From 6ff8e878a406a338ff9e92965aa16e136c248f5f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 7 Jul 2026 09:22:56 -0500 Subject: [PATCH] test: add unit tests for all auth backend logic - Register: edge cases for non-Error throws, error message assertions, and verify signUpEmail was called with correct payload - Forgot-password: empty/missing email, Error/non-Error throws - Reset-password: load (missing/expired/valid token) and action (short password, mismatch, throw, success redirect) - Account setup: load (unauthorized, returns user), action (redirect, falsy create, throws, isRedirect passthrough) - Email-verification: correct URL construction, sendMail delegation, throw propagation - Hooks: sets/omits locals, passes building/auth to svelteKitHandler - Fix Svelte 5 warning on setup page by using with one-time guard instead of top-level prop read --- src/hooks.server.spec.ts | 109 ++++++++++ src/lib/server/email-verification.spec.ts | 64 ++++++ .../(user)/account/setup/+page.svelte | 15 +- .../(user)/account/setup/page.server.spec.ts | 133 ++++++++++++ .../forgot-password/page.server.spec.ts | 102 +++++++++ src/routes/register/page.server.spec.ts | 51 ++++- src/routes/reset-password/page.server.spec.ts | 201 ++++++++++++++++++ 7 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 src/hooks.server.spec.ts create mode 100644 src/lib/server/email-verification.spec.ts create mode 100644 src/routes/(protected)/(user)/account/setup/page.server.spec.ts create mode 100644 src/routes/forgot-password/page.server.spec.ts create mode 100644 src/routes/reset-password/page.server.spec.ts diff --git a/src/hooks.server.spec.ts b/src/hooks.server.spec.ts new file mode 100644 index 0000000..c8acb97 --- /dev/null +++ b/src/hooks.server.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { getSession } = vi.hoisted(() => ({ + getSession: vi.fn() +})); + +const { svelteKitHandler } = vi.hoisted(() => ({ + svelteKitHandler: vi.fn() +})); + +vi.mock('$app/environment', () => ({ + building: false +})); + +vi.mock('better-auth/svelte-kit', () => ({ + svelteKitHandler +})); + +vi.mock('$lib/auth', () => ({ + auth: { + api: { + getSession + } + } +})); + +import { handle } from './hooks.server'; + +function createMockEvent() { + return { + request: new Request('http://localhost/dashboard'), + locals: {} as Record, + resolve: vi.fn() + } as any; +} + +describe('hooks.server handle', () => { + beforeEach(() => { + getSession.mockReset(); + svelteKitHandler.mockReset(); + }); + + it('sets session and user on locals when authenticated', async () => { + const sessionData = { + session: { id: 'sess-1', userId: 'user-1', expiresAt: new Date() }, + user: { id: 'user-1', name: 'Jane', email: 'jane@example.com' } + }; + getSession.mockResolvedValue(sessionData); + + const event = createMockEvent(); + await handle({ event, resolve: event.resolve }); + + expect(event.locals.session).toEqual(sessionData.session); + expect(event.locals.user).toEqual(sessionData.user); + expect(svelteKitHandler).toHaveBeenCalledWith( + expect.objectContaining({ + event, + resolve: event.resolve, + building: false + }) + ); + }); + + it('does not set locals when not authenticated', async () => { + getSession.mockResolvedValue(null); + + const event = createMockEvent(); + await handle({ event, resolve: event.resolve }); + + expect(event.locals.session).toBeUndefined(); + expect(event.locals.user).toBeUndefined(); + }); + + it('does not set locals when getSession returns undefined', async () => { + getSession.mockResolvedValue(undefined); + + const event = createMockEvent(); + await handle({ event, resolve: event.resolve }); + + expect(event.locals.session).toBeUndefined(); + expect(event.locals.user).toBeUndefined(); + }); + + it('passes building flag from $app/environment to svelteKitHandler', async () => { + getSession.mockResolvedValue(null); + + const event = createMockEvent(); + await handle({ event, resolve: event.resolve }); + + expect(svelteKitHandler).toHaveBeenCalledWith( + expect.objectContaining({ building: false }) + ); + }); + + it('calls svelteKitHandler with auth instance', async () => { + getSession.mockResolvedValue(null); + + const event = createMockEvent(); + await handle({ event, resolve: event.resolve }); + + expect(svelteKitHandler).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + api: expect.objectContaining({ getSession }) + }) + }) + ); + }); +}); diff --git a/src/lib/server/email-verification.spec.ts b/src/lib/server/email-verification.spec.ts new file mode 100644 index 0000000..ab09509 --- /dev/null +++ b/src/lib/server/email-verification.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { sendMail } = vi.hoisted(() => ({ + sendMail: vi.fn() +})); + +vi.mock('$env/static/private', () => ({ + BETTER_AUTH_URL: 'http://localhost:5173' +})); + +vi.mock('./resend', () => ({ + sendMail +})); + +import { sendVerificationEmail } from './email-verification'; + +describe('sendVerificationEmail', () => { + beforeEach(() => { + sendMail.mockReset(); + }); + + it('calls sendMail with correct email and template', async () => { + sendMail.mockResolvedValue(undefined); + + await sendVerificationEmail({ + email: 'jane@example.com', + token: 'abc123' + }); + + expect(sendMail).toHaveBeenCalledWith({ + to: 'jane@example.com', + templateId: 'email-verification-email', + data: { + target_email_address: 'jane@example.com', + verification_link: + 'http://localhost:5173/api/auth/verify-email?token=abc123&callbackURL=/login?verified=true' + } + }); + }); + + it('constructs verification URL with the given token', async () => { + sendMail.mockResolvedValue(undefined); + + await sendVerificationEmail({ + email: 'test@example.com', + token: 'xyz789' + }); + + const call = sendMail.mock.calls[0][0]; + expect(call.data.verification_link).toContain('xyz789'); + expect(call.data.verification_link).toContain('/login?verified=true'); + }); + + it('throws when sendMail throws', async () => { + sendMail.mockRejectedValue(new Error('Resend error')); + + await expect( + sendVerificationEmail({ + email: 'jane@example.com', + token: 'abc123' + }) + ).rejects.toThrow('Resend error'); + }); +}); diff --git a/src/routes/(protected)/(user)/account/setup/+page.svelte b/src/routes/(protected)/(user)/account/setup/+page.svelte index 113af29..d0158b0 100644 --- a/src/routes/(protected)/(user)/account/setup/+page.svelte +++ b/src/routes/(protected)/(user)/account/setup/+page.svelte @@ -18,10 +18,8 @@ let { data }: PageProps = $props(); - const userFullName = data.user?.name; - let firstName = $state(splitName(userFullName).first); - let lastName = $state(splitName(userFullName).last); - let phoneNumber = $state(''); + let firstName = $state(''); + let lastName = $state(''); function splitName(fullName: string | null | undefined): { first: string; last: string } { if (!fullName) return { first: '', last: '' }; @@ -29,6 +27,15 @@ return { first: parts[0] ?? '', last: parts.slice(1).join(' ') }; } + $effect(() => { + if (data.user?.name && !firstName) { + const names = splitName(data.user.name); + firstName = names.first; + lastName = names.last; + } + }); + let phoneNumber = $state(''); + function handlePhoneInput(event: Event) { const input = event.target as HTMLInputElement; phoneNumber = clientFormatPhoneNumber(input.value); diff --git a/src/routes/(protected)/(user)/account/setup/page.server.spec.ts b/src/routes/(protected)/(user)/account/setup/page.server.spec.ts new file mode 100644 index 0000000..250c0ef --- /dev/null +++ b/src/routes/(protected)/(user)/account/setup/page.server.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isHttpError, isRedirect, isActionFailure, redirect } from '@sveltejs/kit'; +import { createLocals, mockUser, createFormRequest } from '$lib/test-utils'; +import { serverFormatPhoneNumber } from '$lib/utils'; + +const { create, findUnique } = vi.hoisted(() => ({ + create: vi.fn(), + findUnique: vi.fn() +})); + +vi.mock('$lib/db', () => ({ + prisma: { + user: { + findUnique + }, + profile: { + create + } + } +})); + +vi.mock('$lib/utils', () => ({ + serverFormatPhoneNumber: vi.fn((val: string) => `formatted-${val}`) +})); + +import { load, actions } from './+page.server'; + +const invokeDefault = (request: Request) => actions.default({ request } as any); + +describe('account setup page server load', () => { + beforeEach(() => { + create.mockReset(); + }); + + it('throws 403 when user is not logged in', async () => { + const err = await Promise.resolve( + load({ locals: createLocals() } as any) + ).catch((e: unknown) => e); + expect(isHttpError(err, 403)).toBe(true); + }); + + it('returns user data when logged in', async () => { + const user = mockUser({ id: 'user-1', name: 'Jane Doe' }); + findUnique.mockResolvedValue(user); + + const result = await load({ locals: createLocals(user as any) } as any); + expect(result).toEqual({ user }); + }); +}); + +describe('account setup page server action', () => { + beforeEach(() => { + create.mockReset(); + }); + + it('redirects to /dashboard on successful profile creation', async () => { + create.mockResolvedValue({ id: 'profile-1', userId: 'user-1' }); + + const request = createFormRequest({ + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-0100' + }); + + const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.status).toBe(303); + expect(err.location).toBe('/dashboard'); + } + expect(serverFormatPhoneNumber).toHaveBeenCalledWith('555-0100'); + expect(create).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: 'formatted-555-0100' + } + }); + }); + + it('returns fail(400) when profile.create returns falsy', async () => { + create.mockResolvedValue(null); + + const request = createFormRequest({ + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-0100' + }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + }); + + it('throws 400 error when profile.create throws', async () => { + create.mockRejectedValue(new Error('db error')); + + const request = createFormRequest({ + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-0100' + }); + + const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e); + expect(isHttpError(err, 400)).toBe(true); + if (isHttpError(err)) { + expect(err.body.message).toBe('bad request'); + } + }); + + it('re-throws redirect errors from profile.create', async () => { + let redirectError: unknown; + try { + redirect(303, '/dashboard'); + } catch (e) { + redirectError = e; + } + create.mockRejectedValue(redirectError); + + const request = createFormRequest({ + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-0100' + }); + + const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e); + expect(err).toBe(redirectError); + }); +}); diff --git a/src/routes/forgot-password/page.server.spec.ts b/src/routes/forgot-password/page.server.spec.ts new file mode 100644 index 0000000..eb28b76 --- /dev/null +++ b/src/routes/forgot-password/page.server.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isActionFailure } from '@sveltejs/kit'; +import { createFormRequest } from '$lib/test-utils'; + +const { requestPasswordReset } = vi.hoisted(() => ({ + requestPasswordReset: vi.fn() +})); + +vi.mock('$lib/db', () => ({ + prisma: {} +})); + +vi.mock('$lib/auth', () => ({ + auth: { + api: { + requestPasswordReset + } + } +})); + +import { actions } from './+page.server'; + +const invokeDefault = (request: Request) => actions.default({ request } as any); + +describe('forgot-password page server action', () => { + beforeEach(() => { + requestPasswordReset.mockReset(); + }); + + it('returns fail(400) when email is empty', async () => { + const request = createFormRequest({ email: '' }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Email is required.' }); + } + expect(requestPasswordReset).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when email is missing from form', async () => { + const request = createFormRequest({}); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Email is required.' }); + } + expect(requestPasswordReset).not.toHaveBeenCalled(); + }); + + it('returns success on successful request', async () => { + requestPasswordReset.mockResolvedValue(undefined); + + const request = createFormRequest({ email: 'jane@example.com' }); + + const result = await invokeDefault(request); + expect(result).toEqual({ success: true }); + expect(requestPasswordReset).toHaveBeenCalledWith({ + body: { + email: 'jane@example.com', + redirectTo: '/reset-password' + } + }); + }); + + it('returns fail(400) with error message when requestPasswordReset throws', async () => { + requestPasswordReset.mockRejectedValue(new Error('User not found')); + + const request = createFormRequest({ email: 'jane@example.com' }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'User not found' }); + } + }); + + it('returns fail(400) with default message when non-Error is thrown', async () => { + requestPasswordReset.mockRejectedValue('unexpected'); + + const request = createFormRequest({ email: 'jane@example.com' }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Failed to send reset email.' }); + } + }); + + it('returns fail(400) with default message when thrown value is null', async () => { + requestPasswordReset.mockRejectedValue(null); + + const request = createFormRequest({ email: 'jane@example.com' }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Failed to send reset email.' }); + } + }); +}); diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts index 8699c53..f20f03e 100644 --- a/src/routes/register/page.server.spec.ts +++ b/src/routes/register/page.server.spec.ts @@ -38,6 +38,9 @@ describe('register page server action', () => { const result = await invokeDefault(request); expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'passwords do not match' }); + } expect(signUpEmail).not.toHaveBeenCalled(); }); @@ -54,9 +57,16 @@ describe('register page server action', () => { const result = await invokeDefault(request); expect(result).toEqual({ success: true }); + expect(signUpEmail).toHaveBeenCalledWith({ + body: { + name: 'Jane Doe', + email: 'jane@example.com', + password: 'secret123' + } + }); }); - it('returns fail(400) when signUpEmail throws', async () => { + it('returns fail(400) with error message when signUpEmail throws', async () => { signUpEmail.mockRejectedValue(new Error('email already in use')); const request = createFormRequest({ @@ -69,5 +79,44 @@ describe('register page server action', () => { const result = await invokeDefault(request); expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'email already in use' }); + } + }); + + it('returns fail(400) with generic message when non-Error is thrown', async () => { + signUpEmail.mockRejectedValue('string error'); + + const request = createFormRequest({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'secret123', + password2: 'secret123' + }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'An unexpected error occurred' }); + } + }); + + it('returns fail(400) with generic message when thrown value is null', async () => { + signUpEmail.mockRejectedValue(null); + + const request = createFormRequest({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'secret123', + password2: 'secret123' + }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'An unexpected error occurred' }); + } }); }); diff --git a/src/routes/reset-password/page.server.spec.ts b/src/routes/reset-password/page.server.spec.ts new file mode 100644 index 0000000..1dbcb5a --- /dev/null +++ b/src/routes/reset-password/page.server.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isRedirect, isActionFailure } from '@sveltejs/kit'; +import { createFormRequest, createUrl } from '$lib/test-utils'; + +const { resetPassword } = vi.hoisted(() => ({ + resetPassword: vi.fn() +})); + +const { findFirst } = vi.hoisted(() => ({ + findFirst: vi.fn() +})); + +vi.mock('$lib/db', () => ({ + prisma: { + verification: { + findFirst + } + } +})); + +vi.mock('$lib/auth', () => ({ + auth: { + api: { + resetPassword + } + } +})); + +import { load, actions } from './+page.server'; + +describe('reset-password page server load', () => { + beforeEach(() => { + findFirst.mockReset(); + }); + + it('redirects to / when token is missing', async () => { + const url = createUrl('/reset-password'); + + const err = await Promise.resolve(load({ url } as any)).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.location).toBe('/'); + } + expect(findFirst).not.toHaveBeenCalled(); + }); + + it('redirects to / when verification record is not found', async () => { + findFirst.mockResolvedValue(null); + + const url = createUrl('/reset-password', { token: 'invalid-token' }); + + const err = await Promise.resolve(load({ url } as any)).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.location).toBe('/'); + } + expect(findFirst).toHaveBeenCalledWith({ + where: { + value: 'invalid-token', + expiresAt: { gt: expect.any(Date) } + } + }); + }); + + it('returns undefined when token is valid (verification found)', async () => { + findFirst.mockResolvedValue({ id: 'v-1', value: 'valid-token' }); + + const url = createUrl('/reset-password', { token: 'valid-token' }); + + const result = await load({ url } as any); + expect(result).toBeUndefined(); + }); + + it('redirects to / when verification is expired', async () => { + findFirst.mockResolvedValue(null); + + const url = createUrl('/reset-password', { token: 'expired-token' }); + + const err = await Promise.resolve(load({ url } as any)).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.location).toBe('/'); + } + }); +}); + +describe('reset-password page server action', () => { + beforeEach(() => { + resetPassword.mockReset(); + }); + + const invokeDefault = (url: URL, request: Request) => + actions.default({ request, url } as any); + + it('returns fail(400) when token is missing', async () => { + const url = createUrl('/reset-password'); + + const result = await invokeDefault(url, createFormRequest({})); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Invalid or missing reset token.' }); + } + expect(resetPassword).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when password is too short', async () => { + const url = createUrl('/reset-password', { token: 'token-1' }); + + const result = await invokeDefault( + url, + createFormRequest({ password: 'short', password2: 'short' }) + ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Password must be at least 8 characters.' }); + } + expect(resetPassword).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when password is empty', async () => { + const url = createUrl('/reset-password', { token: 'token-1' }); + + const result = await invokeDefault( + url, + createFormRequest({ password: '', password2: '' }) + ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Password must be at least 8 characters.' }); + } + expect(resetPassword).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when passwords do not match', async () => { + const url = createUrl('/reset-password', { token: 'token-1' }); + + const result = await invokeDefault( + url, + createFormRequest({ password: 'longenough1', password2: 'longenough2' }) + ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Passwords do not match.' }); + } + expect(resetPassword).not.toHaveBeenCalled(); + }); + + it('returns fail(400) when resetPassword throws', async () => { + resetPassword.mockRejectedValue(new Error('Token expired')); + + const url = createUrl('/reset-password', { token: 'token-1' }); + + const result = await invokeDefault( + url, + createFormRequest({ password: 'longenough1', password2: 'longenough1' }) + ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Token expired' }); + } + }); + + it('returns fail(400) with default message when non-Error is thrown', async () => { + resetPassword.mockRejectedValue(null); + + const url = createUrl('/reset-password', { token: 'token-1' }); + + const result = await invokeDefault( + url, + createFormRequest({ password: 'longenough1', password2: 'longenough1' }) + ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'Failed to reset password.' }); + } + }); + + it('redirects to /login on successful reset', async () => { + resetPassword.mockResolvedValue(undefined); + + const url = createUrl('/reset-password', { token: 'token-1' }); + + const err = await Promise.resolve( + invokeDefault( + url, + createFormRequest({ password: 'longenough1', password2: 'longenough1' }) + ) + ).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.status).toBe(303); + expect(err.location).toBe('/login'); + } + expect(resetPassword).toHaveBeenCalledWith({ + body: { + newPassword: 'longenough1', + token: 'token-1' + } + }); + }); +});