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:
co-authored by
Claude Opus 4.6
parent
b8d1ba11e7
commit
10fd06887f
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user