test(ui-auth): add unit tests for waitlist validation + payload helpers
Extract the inline email-validation and POST /api/waitlist payload logic out of RegisterForm into a dependency-free src/waitlist.ts module (validateEmail / isValidEmail / normalizeEmail / buildWaitlistPayload) and cover it with 23 vitest cases. RegisterForm now routes through the shared helpers instead of an inline regex, keeping a single source of truth for the email rule. Adds vitest as a devDependency + `test` script. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bdea96dfe9
commit
457add9be3
4 changed files with 166 additions and 14 deletions
|
|
@ -9,6 +9,12 @@
|
|||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lilith/ui-primitives": "*",
|
||||
"@lilith/ui-styled-components": "*",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ import styled, { css } from '@lilith/ui-styled-components'
|
|||
import { Button, Alert, Spinner } from '@lilith/ui-primitives'
|
||||
|
||||
import type { AuthHandler, User, RegistrationRole } from './index'
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
import { validateEmail, buildWaitlistPayload, normalizeEmail } from './waitlist'
|
||||
|
||||
interface RegisterFormProps {
|
||||
authHandler?: AuthHandler
|
||||
|
|
@ -244,12 +243,9 @@ export function RegisterForm({
|
|||
e.preventDefault()
|
||||
setFormError(null)
|
||||
|
||||
if (!email.trim()) {
|
||||
setFormError('Email address is required')
|
||||
return
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
setFormError('Please enter a valid email address')
|
||||
const validationError = validateEmail(email)
|
||||
if (validationError) {
|
||||
setFormError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -259,11 +255,7 @@ export function RegisterForm({
|
|||
const response = await fetch('/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.trim().toLowerCase(),
|
||||
source: 'register-modal',
|
||||
userType: selectedRole ?? 'unknown',
|
||||
}),
|
||||
body: JSON.stringify(buildWaitlistPayload({ email, role: selectedRole })),
|
||||
})
|
||||
|
||||
// 201 = created, 409 = already on list — both are success states
|
||||
|
|
@ -271,7 +263,7 @@ export function RegisterForm({
|
|||
setIsSuccess(true)
|
||||
const placeholderUser: User = {
|
||||
id: '',
|
||||
email: email.trim().toLowerCase(),
|
||||
email: normalizeEmail(email),
|
||||
role: 'user',
|
||||
userTypes: selectedRole ? [selectedRole] : [],
|
||||
}
|
||||
|
|
|
|||
101
src/waitlist.test.ts
Normal file
101
src/waitlist.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
EMAIL_REGEX,
|
||||
normalizeEmail,
|
||||
isValidEmail,
|
||||
validateEmail,
|
||||
buildWaitlistPayload,
|
||||
} from './waitlist'
|
||||
|
||||
describe('normalizeEmail', () => {
|
||||
it('trims surrounding whitespace and lowercases', () => {
|
||||
expect(normalizeEmail(' Quinn@Example.COM ')).toBe('quinn@example.com')
|
||||
})
|
||||
|
||||
it('leaves an already-normalized address untouched', () => {
|
||||
expect(normalizeEmail('a@b.com')).toBe('a@b.com')
|
||||
})
|
||||
|
||||
it('does not strip internal characters, only edges', () => {
|
||||
expect(normalizeEmail('First.Last+tag@Mail.Co')).toBe('first.last+tag@mail.co')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidEmail / EMAIL_REGEX', () => {
|
||||
it.each([
|
||||
'user@example.com',
|
||||
'first.last@sub.domain.io',
|
||||
'name+tag@example.co.uk',
|
||||
'x@y.z',
|
||||
])('accepts well-formed address %s', (email) => {
|
||||
expect(isValidEmail(email)).toBe(true)
|
||||
expect(EMAIL_REGEX.test(email)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'plainaddress',
|
||||
'no-at-sign.com',
|
||||
'missing@domain',
|
||||
'@no-local.com',
|
||||
'spaces in@email.com',
|
||||
'two@@example.com',
|
||||
'trailing@dot.',
|
||||
])('rejects malformed address %s', (email) => {
|
||||
expect(isValidEmail(email)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a valid address that has surrounding whitespace', () => {
|
||||
// isValidEmail trims first, so padding does not make it invalid
|
||||
expect(isValidEmail(' user@example.com ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEmail', () => {
|
||||
it('flags an empty / whitespace-only value as required', () => {
|
||||
expect(validateEmail('')).toBe('Email address is required')
|
||||
expect(validateEmail(' ')).toBe('Email address is required')
|
||||
})
|
||||
|
||||
it('flags a structurally invalid address', () => {
|
||||
expect(validateEmail('not-an-email')).toBe('Please enter a valid email address')
|
||||
})
|
||||
|
||||
it('returns null for an acceptable address', () => {
|
||||
expect(validateEmail('user@example.com')).toBeNull()
|
||||
expect(validateEmail(' user@example.com ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWaitlistPayload', () => {
|
||||
it('normalizes the email and defaults source + userType', () => {
|
||||
expect(buildWaitlistPayload({ email: ' Quinn@Example.COM ' })).toEqual({
|
||||
email: 'quinn@example.com',
|
||||
source: 'register-modal',
|
||||
userType: 'unknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the selected role through as userType', () => {
|
||||
expect(buildWaitlistPayload({ email: 'a@b.com', role: 'provider' })).toEqual({
|
||||
email: 'a@b.com',
|
||||
source: 'register-modal',
|
||||
userType: 'provider',
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicit source override', () => {
|
||||
const payload = buildWaitlistPayload({
|
||||
email: 'a@b.com',
|
||||
role: 'creator',
|
||||
source: 'hero-cta',
|
||||
})
|
||||
expect(payload.source).toBe('hero-cta')
|
||||
expect(payload.userType).toBe('creator')
|
||||
})
|
||||
|
||||
it('falls back to "unknown" when role is undefined', () => {
|
||||
expect(buildWaitlistPayload({ email: 'a@b.com', role: undefined }).userType).toBe('unknown')
|
||||
})
|
||||
})
|
||||
53
src/waitlist.ts
Normal file
53
src/waitlist.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* waitlist — pure helpers for the .live waitlist signup flow.
|
||||
*
|
||||
* Extracted from RegisterForm so the validation + payload-shaping logic
|
||||
* is dependency-free (no React, no @lilith UI) and unit-testable on its own.
|
||||
*/
|
||||
|
||||
import type { RegistrationRole } from './index'
|
||||
|
||||
/** Single source of truth for client-side email shape validation. */
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/** Trim + lowercase so the same address always lands as one waitlist row. */
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** True when the (trimmed) email looks structurally valid. */
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email field exactly as the form needs it.
|
||||
* Returns a user-facing error message, or null when the value is acceptable.
|
||||
*/
|
||||
export function validateEmail(email: string): string | null {
|
||||
if (!email.trim()) return 'Email address is required'
|
||||
if (!isValidEmail(email)) return 'Please enter a valid email address'
|
||||
return null
|
||||
}
|
||||
|
||||
export interface WaitlistPayload {
|
||||
email: string
|
||||
source: string
|
||||
userType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the POST /api/waitlist body. Email is normalized; an absent role
|
||||
* falls back to 'unknown' so the backend always receives a string userType.
|
||||
*/
|
||||
export function buildWaitlistPayload(params: {
|
||||
email: string
|
||||
role?: RegistrationRole
|
||||
source?: string
|
||||
}): WaitlistPayload {
|
||||
return {
|
||||
email: normalizeEmail(params.email),
|
||||
source: params.source ?? 'register-modal',
|
||||
userType: params.role ?? 'unknown',
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue