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 <noreply@anthropic.com>
This commit is contained in:
NeilClack
2026-02-24 08:58:01 -06:00
co-authored by Claude Opus 4.6
parent b8d1ba11e7
commit 10fd06887f
6 changed files with 370 additions and 0 deletions
+56
View File
@@ -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<string, string>): 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<string, unknown>) {
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<string, unknown>) {
return mockUser({
profile: {
id: 'profile-1',
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-0100'
},
...overrides
});
}
@@ -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<typeof vi.fn>;
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);
});
});
@@ -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<typeof vi.fn>;
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 });
});
});
+64
View File
@@ -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'
});
});
});
+78
View File
@@ -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);
});
});
+59
View File
@@ -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'
});
});
});