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:
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
const userFullName = data.user?.name;
|
let firstName = $state('');
|
||||||
let firstName = $state(splitName(userFullName).first);
|
let lastName = $state('');
|
||||||
let lastName = $state(splitName(userFullName).last);
|
|
||||||
let phoneNumber = $state('');
|
|
||||||
|
|
||||||
function splitName(fullName: string | null | undefined): { first: string; last: string } {
|
function splitName(fullName: string | null | undefined): { first: string; last: string } {
|
||||||
if (!fullName) return { first: '', last: '' };
|
if (!fullName) return { first: '', last: '' };
|
||||||
@@ -29,6 +27,15 @@
|
|||||||
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
|
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) {
|
function handlePhoneInput(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
phoneNumber = clientFormatPhoneNumber(input.value);
|
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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,9 @@ describe('register page server action', () => {
|
|||||||
|
|
||||||
const result = await invokeDefault(request);
|
const result = await invokeDefault(request);
|
||||||
expect(isActionFailure(result)).toBe(true);
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
if (isActionFailure(result)) {
|
||||||
|
expect(result.data).toEqual({ error: 'passwords do not match' });
|
||||||
|
}
|
||||||
expect(signUpEmail).not.toHaveBeenCalled();
|
expect(signUpEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,9 +57,16 @@ describe('register page server action', () => {
|
|||||||
|
|
||||||
const result = await invokeDefault(request);
|
const result = await invokeDefault(request);
|
||||||
expect(result).toEqual({ success: true });
|
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'));
|
signUpEmail.mockRejectedValue(new Error('email already in use'));
|
||||||
|
|
||||||
const request = createFormRequest({
|
const request = createFormRequest({
|
||||||
@@ -69,5 +79,44 @@ describe('register page server action', () => {
|
|||||||
|
|
||||||
const result = await invokeDefault(request);
|
const result = await invokeDefault(request);
|
||||||
expect(isActionFailure(result)).toBe(true);
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user