From 10fd06887f92bd7848e00b56f8d95461615db20f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 24 Feb 2026 08:58:01 -0600 Subject: [PATCH] Add comprehensive tests for profile-redirect and auth features 19 new tests across 5 test files covering the protected layout redirect, account page load, registration action (documenting the redirect bug), and login/register component rendering. Includes shared test utilities. Co-Authored-By: Claude Opus 4.6 --- src/lib/test-utils.ts | 56 +++++++++++++ .../(protected)/account/page.server.spec.ts | 46 +++++++++++ src/routes/(protected)/layout.server.spec.ts | 67 ++++++++++++++++ src/routes/login/page.svelte.spec.ts | 64 +++++++++++++++ src/routes/register/page.server.spec.ts | 78 +++++++++++++++++++ src/routes/register/page.svelte.spec.ts | 59 ++++++++++++++ 6 files changed, 370 insertions(+) create mode 100644 src/lib/test-utils.ts create mode 100644 src/routes/(protected)/account/page.server.spec.ts create mode 100644 src/routes/(protected)/layout.server.spec.ts create mode 100644 src/routes/login/page.svelte.spec.ts create mode 100644 src/routes/register/page.server.spec.ts create mode 100644 src/routes/register/page.svelte.spec.ts diff --git a/src/lib/test-utils.ts b/src/lib/test-utils.ts new file mode 100644 index 0000000..9dc9d4f --- /dev/null +++ b/src/lib/test-utils.ts @@ -0,0 +1,56 @@ +/** + * Shared mock data factories for tests. + * + * NOTE: `vi.mock()` calls must live in each test file (hoisting requirement). + * These helpers only build data objects — no mocking side-effects. + */ + +/** Build a minimal `App.Locals` object. Pass a user to simulate an authenticated request. */ +export function createLocals(user?: { id: string; name: string; email: string }): App.Locals { + return { + session: user + ? ({ + id: 'session-1', + userId: user.id, + expiresAt: new Date(Date.now() + 86_400_000) + } as App.Locals['session']) + : (undefined as unknown as App.Locals['session']), + user: user as unknown as App.Locals['user'] + }; +} + +/** Build a `Request` whose `.formData()` resolves to the given key/value pairs. */ +export function createFormRequest(fields: Record): Request { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.append(k, v); + return new Request('http://localhost', { method: 'POST', body: fd }); +} + +/** Minimal user object with sensible defaults. */ +export function mockUser(overrides?: Record) { + return { + id: 'user-1', + name: 'Jane Doe', + email: 'jane@example.com', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + roleId: null, + ...overrides + }; +} + +/** User that also has a profile (and optionally a role). */ +export function mockUserWithProfile(overrides?: Record) { + return mockUser({ + profile: { + id: 'profile-1', + userId: 'user-1', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-0100' + }, + ...overrides + }); +} diff --git a/src/routes/(protected)/account/page.server.spec.ts b/src/routes/(protected)/account/page.server.spec.ts new file mode 100644 index 0000000..aad102f --- /dev/null +++ b/src/routes/(protected)/account/page.server.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isHttpError } from '@sveltejs/kit'; +import { createLocals, mockUser, mockUserWithProfile } from '$lib/test-utils'; + +vi.mock('$lib/db', () => ({ + prisma: { + user: { + findUnique: vi.fn() + } + } +})); + +import { prisma } from '$lib/db'; +import { load } from './+page.server'; + +const findUnique = prisma.user.findUnique as ReturnType; + +describe('(protected)/account page server load', () => { + beforeEach(() => { + findUnique.mockReset(); + }); + + it('throws 403 when locals.user is undefined', async () => { + findUnique.mockResolvedValue(null); + + const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e); + expect(isHttpError(err, 403)).toBe(true); + }); + + it('throws 403 when user is not found in the database', async () => { + findUnique.mockResolvedValue(null); + + const locals = createLocals(mockUser() as any); + const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e); + expect(isHttpError(err, 403)).toBe(true); + }); + + it('returns user with profile when found', async () => { + const user = mockUserWithProfile(); + findUnique.mockResolvedValue(user); + + const locals = createLocals(mockUser() as any); + const result = (await load({ locals } as any)) as any; + expect(result.user).toEqual(user); + }); +}); diff --git a/src/routes/(protected)/layout.server.spec.ts b/src/routes/(protected)/layout.server.spec.ts new file mode 100644 index 0000000..094304a --- /dev/null +++ b/src/routes/(protected)/layout.server.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isHttpError, isRedirect } from '@sveltejs/kit'; +import { createLocals, mockUser, mockUserWithProfile } from '$lib/test-utils'; + +// Mock the database module — must be at the top level for vi.mock hoisting. +vi.mock('$lib/db', () => ({ + prisma: { + user: { + findUnique: vi.fn() + } + } +})); + +// Import *after* the mock so we get the mocked version. +import { prisma } from '$lib/db'; +import { load } from './+layout.server'; + +const findUnique = prisma.user.findUnique as ReturnType; + +describe('(protected) layout server load', () => { + beforeEach(() => { + findUnique.mockReset(); + }); + + it('throws 403 when locals.user is undefined', async () => { + findUnique.mockResolvedValue(null); + + const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e); + expect(isHttpError(err, 403)).toBe(true); + }); + + it('throws 403 when user is not found in the database', async () => { + findUnique.mockResolvedValue(null); + + const locals = createLocals(mockUser() as any); + const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e); + expect(isHttpError(err, 403)).toBe(true); + }); + + it('redirects to /account/setup when user has no profile', async () => { + findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null }); + + const locals = createLocals(mockUser() as any); + const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e); + expect(isRedirect(err)).toBe(true); + if (isRedirect(err)) { + expect(err.status).toBe(303); + expect(err.location).toBe('/account/setup'); + } + }); + + it('returns userRoleId when user has a profile', async () => { + findUnique.mockResolvedValue(mockUserWithProfile({ roleId: 2 })); + + const locals = createLocals(mockUser() as any); + const result = await load({ locals } as any); + expect(result).toEqual({ userRoleId: 2 }); + }); + + it('returns userRoleId as null when user has no role', async () => { + findUnique.mockResolvedValue(mockUserWithProfile({ roleId: null })); + + const locals = createLocals(mockUser() as any); + const result = await load({ locals } as any); + expect(result).toEqual({ userRoleId: null }); + }); +}); diff --git a/src/routes/login/page.svelte.spec.ts b/src/routes/login/page.svelte.spec.ts new file mode 100644 index 0000000..ebc0b58 --- /dev/null +++ b/src/routes/login/page.svelte.spec.ts @@ -0,0 +1,64 @@ +import { page } from 'vitest/browser'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const { signIn } = vi.hoisted(() => { + const signIn = { + email: vi.fn(), + social: vi.fn() + }; + return { signIn }; +}); + +vi.mock('$lib/auth-client', () => ({ + authClient: { signIn } +})); + +import Login from './+page.svelte'; + +describe('login page', () => { + it('renders login form with email, password, and submit button', async () => { + render(Login); + + await expect.element(page.getByPlaceholder('Email Address')).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Password')).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Sign In', exact: true })) + .toBeInTheDocument(); + }); + + it('renders Google sign-in button', async () => { + render(Login); + + await expect.element(page.getByAltText('Sign In with Google')).toBeInTheDocument(); + }); + + it('shows error message on failed login', async () => { + signIn.email.mockResolvedValue({ + data: null, + error: { message: 'Invalid credentials' } + }); + + render(Login); + + await page.getByPlaceholder('Email Address').fill('bad@example.com'); + await page.getByPlaceholder('Password').fill('wrong'); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + + await expect.element(page.getByText('Invalid credentials')).toBeInTheDocument(); + }); + + it('calls signIn.social with Google provider and /account callbackURL', async () => { + signIn.social.mockResolvedValue({}); + + render(Login); + + const googleButton = page.getByAltText('Sign In with Google'); + await googleButton.click(); + + expect(signIn.social).toHaveBeenCalledWith({ + provider: 'google', + callbackURL: '/account' + }); + }); +}); diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts new file mode 100644 index 0000000..03757df --- /dev/null +++ b/src/routes/register/page.server.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isActionFailure } from '@sveltejs/kit'; +import { createFormRequest } from '$lib/test-utils'; + +const { signUpEmail } = vi.hoisted(() => ({ + signUpEmail: vi.fn() +})); + +vi.mock('$lib/db', () => ({ + prisma: {} +})); + +vi.mock('$lib/auth', () => ({ + auth: { + api: { + signUpEmail + } + } +})); + +import { actions } from './+page.server'; + +const invokeDefault = (request: Request) => actions.default({ request } as any); + +describe('register page server action', () => { + beforeEach(() => { + signUpEmail.mockReset(); + }); + + it('returns fail(400) when passwords do not match', async () => { + const request = createFormRequest({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'password1', + password2: 'different' + }); + + const result = await invokeDefault(request); + expect(isActionFailure(result)).toBe(true); + expect(signUpEmail).not.toHaveBeenCalled(); + }); + + // BUG: The `throw redirect(303, '/account/setup')` on line 29 is inside a + // try/catch that catches it and returns `fail(400, err)`. The redirect never + // propagates. Compare with account/setup/+page.server.ts which correctly + // uses `isRedirect()` to re-throw. This test documents the current (broken) + // behaviour — a successful signup should redirect, not fail. + it('returns fail(400) on successful signup instead of redirecting (bug)', async () => { + signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); + + 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); + }); + + it('returns fail(400) when signUpEmail throws', async () => { + signUpEmail.mockRejectedValue(new Error('email already in use')); + + 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); + }); +}); diff --git a/src/routes/register/page.svelte.spec.ts b/src/routes/register/page.svelte.spec.ts new file mode 100644 index 0000000..e2a97aa --- /dev/null +++ b/src/routes/register/page.svelte.spec.ts @@ -0,0 +1,59 @@ +import { page } from 'vitest/browser'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const { signIn } = vi.hoisted(() => { + const signIn = { + social: vi.fn() + }; + return { signIn }; +}); + +vi.mock('$lib/auth-client', () => ({ + authClient: { signIn } +})); + +import Register from './+page.svelte'; + +describe('register page', () => { + it('renders all form fields and submit button', async () => { + render(Register); + + await expect.element(page.getByPlaceholder('First Name')).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Last Name')).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Email Address')).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Password', { exact: true })).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('Verify Password')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + it('all input fields are required', async () => { + render(Register); + + for (const placeholder of ['First Name', 'Last Name', 'Email Address', 'Verify Password']) { + await expect.element(page.getByPlaceholder(placeholder)).toHaveAttribute('required'); + } + await expect + .element(page.getByPlaceholder('Password', { exact: true })) + .toHaveAttribute('required'); + }); + + it('renders Google sign-up button', async () => { + render(Register); + + await expect.element(page.getByAltText('Sign Up with Google')).toBeInTheDocument(); + }); + + it('calls signIn.social with Google provider and /account/setup callbackURL', async () => { + signIn.social.mockResolvedValue({}); + + render(Register); + + await page.getByAltText('Sign Up with Google').click(); + + expect(signIn.social).toHaveBeenCalledWith({ + provider: 'google', + callbackURL: '/account/setup' + }); + }); +});