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