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:
Natalie 2026-06-30 15:07:25 -04:00
parent bdea96dfe9
commit 457add9be3
4 changed files with 166 additions and 14 deletions

View file

@ -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": "*",

View file

@ -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
View 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
View 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',
}
}