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
This commit is contained in:
NeilClack
2026-07-07 09:22:56 -05:00
parent 1e391c9252
commit 6ff8e878a4
7 changed files with 670 additions and 5 deletions
+109
View File
@@ -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<string, unknown>,
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 })
})
})
);
});
});
+64
View File
@@ -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');
});
});
@@ -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);
@@ -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);
});
});
@@ -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.' });
}
});
});
+50 -1
View File
@@ -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' });
}
});
});
@@ -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'
}
});
});
});