diff --git a/src/app/api/admin/feature-flags/__tests__/route.test.ts b/src/app/api/admin/feature-flags/__tests__/route.test.ts new file mode 100644 index 00000000..0b4e28ff --- /dev/null +++ b/src/app/api/admin/feature-flags/__tests__/route.test.ts @@ -0,0 +1,228 @@ +/** + * Colocated tests for the feature flags route handler + * (src/app/api/admin/feature-flags/route.ts). + * + * The handler normalises whatever it is handed into a stored flag, so these + * tests pin the defaults it applies (disabled, `all`, 0%), the bounds it clamps + * percentage to, the actor it attributes the flag to, and the 400 it returns + * when the flag cannot be named. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET, POST } from '../route'; + +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +vi.mock('@/middleware/audit', () => ({ + logAuditMutation: vi.fn(), +})); + +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +vi.mock('@/lib/feature-flags/store', () => { + let counter = 0; + return { + flagStore: new Map(), + createAuditEntry: vi.fn(), + generateId: vi.fn((prefix: string) => `${prefix}_${++counter}`), + }; +}); + +import { createAuditEntry, flagStore } from '@/lib/feature-flags/store'; +import { logAuditMutation } from '@/middleware/audit'; + +const FLAGS_URL = 'http://localhost/api/admin/feature-flags'; + +/** The handlers are declared for `NextRequest`; these tests drive them with `Request`. */ +type Handler = (request: Request) => Promise; + +function invoke(handler: unknown, request: Request): Promise { + return (handler as Handler)(request); +} + +function postRequest(body: unknown, headers: Record = {}): Request { + return new Request(FLAGS_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +function flag(updatedAt: string, overrides: Record = {}) { + return { + id: `flag_${updatedAt}`, + name: `flag-${updatedAt}`, + description: '', + enabled: false, + strategy: 'all', + percentage: 0, + rules: [], + tags: [], + createdAt: updatedAt, + updatedAt, + createdBy: 'admin', + ...overrides, + }; +} + +describe('GET /api/admin/feature-flags', () => { + beforeEach(() => { + vi.clearAllMocks(); + flagStore.clear(); + }); + + it('returns an empty list when no flags exist', async () => { + const response = await invoke(GET, new Request(FLAGS_URL)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ flags: [] }); + }); + + it('returns flags sorted by most recently updated first', async () => { + const older = flag('2026-01-01T00:00:00.000Z', { name: 'older' }); + const newer = flag('2026-02-01T00:00:00.000Z', { name: 'newer' }); + flagStore.set(older.id, older); + flagStore.set(newer.id, newer); + + const response = await invoke(GET, new Request(FLAGS_URL)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.flags.map((entry: { name: string }) => entry.name)).toEqual(['newer', 'older']); + }); +}); + +describe('POST /api/admin/feature-flags', () => { + beforeEach(() => { + vi.clearAllMocks(); + flagStore.clear(); + }); + + it('creates a flag from a name alone, defaulting to disabled for everyone', async () => { + const response = await invoke(POST, postRequest({ name: 'new-feature' })); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.flag.name).toBe('new-feature'); + expect(body.flag.enabled).toBe(false); + expect(body.flag.strategy).toBe('all'); + expect(body.flag.percentage).toBe(0); + expect(body.flag.rules).toEqual([]); + expect(body.flag.tags).toEqual([]); + expect(body.flag.description).toBe(''); + expect(body.flag.createdBy).toBe('anonymous'); + expect(flagStore.size).toBe(1); + }); + + it('trims the name and description it stores', async () => { + const response = await invoke( + POST, + postRequest({ name: ' spaced ', description: ' note ' }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.flag.name).toBe('spaced'); + expect(body.flag.description).toBe('note'); + }); + + it('keeps a recognised strategy, rules and tags', async () => { + const rules = [{ attribute: 'role', operator: 'equals', value: 'admin' }]; + const response = await invoke( + POST, + postRequest({ name: 'targeted', strategy: 'targeting', rules, tags: ['experiment', 42] }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.flag.strategy).toBe('targeting'); + expect(body.flag.rules).toEqual(rules); + expect(body.flag.tags).toEqual(['experiment', '42']); + }); + + it('clamps the percentage into the 0-100 range', async () => { + const high = await invoke(POST, postRequest({ name: 'too-high', percentage: 150 })); + const highBody = await high.json(); + expect(highBody.flag.percentage).toBe(100); + + const low = await invoke(POST, postRequest({ name: 'too-low', percentage: -20 })); + const lowBody = await low.json(); + expect(lowBody.flag.percentage).toBe(0); + }); + + it('falls back to the all strategy for an unrecognised strategy', async () => { + const response = await invoke(POST, postRequest({ name: 'odd', strategy: 'sometimes' })); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.flag.strategy).toBe('all'); + }); + + it('attributes the flag to the acting admin', async () => { + const response = await invoke( + POST, + postRequest({ name: 'attributed' }, { 'x-admin-user': 'root@teachlink.com' }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.flag.createdBy).toBe('root@teachlink.com'); + }); + + it('records the creation in the audit trail', async () => { + const request = postRequest({ name: 'audited' }); + const response = await invoke(POST, request); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(createAuditEntry).toHaveBeenCalledWith('created', 'anonymous', null, body.flag); + expect(logAuditMutation).toHaveBeenCalledWith(request, { + action: 'create', + targetType: 'feature-flag', + targetId: body.flag.id, + statusCode: 201, + metadata: { name: 'audited' }, + }); + }); + + it('returns 400 when the name is missing', async () => { + const response = await invoke(POST, postRequest({ description: 'no name' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('name is required'); + expect(flagStore.size).toBe(0); + expect(createAuditEntry).not.toHaveBeenCalled(); + }); + + it('returns 400 when the name is only whitespace', async () => { + const response = await invoke(POST, postRequest({ name: ' ' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('name is required'); + expect(flagStore.size).toBe(0); + }); + + it('returns 400 when the body cannot be parsed as JSON', async () => { + const request = new Request(FLAGS_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: 'not-json', + }); + + const response = await invoke(POST, request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('name is required'); + expect(flagStore.size).toBe(0); + }); +}); diff --git a/src/app/api/admin/network-policies/__tests__/route.test.ts b/src/app/api/admin/network-policies/__tests__/route.test.ts new file mode 100644 index 00000000..a944a1c9 --- /dev/null +++ b/src/app/api/admin/network-policies/__tests__/route.test.ts @@ -0,0 +1,203 @@ +/** + * Colocated tests for the network policies route handler + * (src/app/api/admin/network-policies/route.ts). + * + * The handler validates scope, value and action before it stores anything, and + * identifies policies by a generated id, so these tests pin the accepted + * combinations, each rejection, and the delete lifecycle. + */ +import { describe, expect, it } from 'vitest'; +import { DELETE, GET, POST } from '../route'; + +const POLICIES_URL = 'http://localhost/api/admin/network-policies'; + +/** The handlers are declared for `NextRequest`; these tests drive them with `Request`. */ +type Handler = (request: Request) => Promise; + +function invoke(handler: unknown, request: Request): Promise { + return (handler as Handler)(request); +} + +/** + * `DELETE` reads the policy id from `request.nextUrl`, which a plain `Request` + * does not carry, so the parsed URL is attached before the handler runs. + */ +function createRequest(url: string, init?: RequestInit): Request { + const request = new Request(url, init); + Object.defineProperty(request, 'nextUrl', { value: new URL(url) }); + return request; +} + +function postRequest(body: unknown): Request { + return createRequest(POLICIES_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function deleteRequest(id?: string): Request { + const url = id === undefined ? POLICIES_URL : `${POLICIES_URL}?id=${id}`; + return createRequest(url, { method: 'DELETE' }); +} + +async function createPolicy(overrides: Record = {}) { + const response = await invoke( + POST, + postRequest({ scope: 'IP', value: '192.168.1.1', action: 'ALLOW', ...overrides }), + ); + const body = await response.json(); + return body.data as { id: string; scope: string; value: string; action: string }; +} + +describe('GET /api/admin/network-policies', () => { + it('returns the policy list in a success envelope', async () => { + const response = await invoke(GET, new Request(POLICIES_URL)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + }); +}); + +describe('POST /api/admin/network-policies', () => { + it('creates a policy for each accepted scope', async () => { + const response = await invoke( + POST, + postRequest({ scope: 'CIDR', value: '10.0.0.0/8', action: 'DENY', description: 'private' }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.success).toBe(true); + expect(body.data.id).toEqual(expect.any(String)); + expect(body.data.scope).toBe('CIDR'); + expect(body.data.value).toBe('10.0.0.0/8'); + expect(body.data.action).toBe('DENY'); + expect(body.data.description).toBe('private'); + expect(body.data.createdAt).toEqual(expect.any(String)); + }); + + it('trims the stored value and caps the description at 200 characters', async () => { + const response = await invoke( + POST, + postRequest({ + scope: 'IP', + value: ' 192.168.1.1 ', + action: 'ALLOW', + description: 'a'.repeat(250), + }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.data.value).toBe('192.168.1.1'); + expect(body.data.description).toHaveLength(200); + }); + + it('gives every policy a distinct id', async () => { + const first = await createPolicy({ value: '10.0.0.1' }); + const second = await createPolicy({ value: '10.0.0.2' }); + + expect(first.id).not.toBe(second.id); + }); + + it('returns 400 for an unrecognised scope', async () => { + const response = await invoke( + POST, + postRequest({ scope: 'ASN', value: 'AS12345', action: 'ALLOW' }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe('Invalid scope'); + }); + + it('returns 400 when the value is missing or blank', async () => { + const missing = await invoke(POST, postRequest({ scope: 'IP', action: 'ALLOW' })); + expect(missing.status).toBe(400); + expect((await missing.json()).message).toBe('Value is required'); + + const blank = await invoke(POST, postRequest({ scope: 'IP', value: ' ', action: 'ALLOW' })); + expect(blank.status).toBe(400); + expect((await blank.json()).message).toBe('Value is required'); + }); + + it('returns 400 for an unrecognised action', async () => { + const response = await invoke( + POST, + postRequest({ scope: 'IP', value: '192.168.1.1', action: 'LOG' }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe('Invalid action'); + }); + + it('returns 400 when the body cannot be parsed as JSON', async () => { + const request = createRequest(POLICIES_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: 'not-json', + }); + + const response = await invoke(POST, request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe('Invalid request body'); + }); +}); + +describe('DELETE /api/admin/network-policies', () => { + it('returns 400 when no id is supplied', async () => { + const response = await invoke(DELETE, deleteRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe('id is required'); + }); + + it('returns 404 for an id that does not exist', async () => { + const response = await invoke(DELETE, deleteRequest('np_missing')); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.success).toBe(false); + expect(body.message).toBe('Policy not found'); + }); + + it('removes a stored policy and leaves it out of the list', async () => { + const policy = await createPolicy({ value: '203.0.113.7' }); + + const before = await (await invoke(GET, new Request(POLICIES_URL))).json(); + expect(before.data.map((entry: { id: string }) => entry.id)).toContain(policy.id); + + const response = await invoke(DELETE, deleteRequest(policy.id)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + + const after = await (await invoke(GET, new Request(POLICIES_URL))).json(); + expect(after.data.map((entry: { id: string }) => entry.id)).not.toContain(policy.id); + }); + + it('returns 404 when the same policy is deleted twice', async () => { + const policy = await createPolicy({ value: '198.51.100.4' }); + + const first = await invoke(DELETE, deleteRequest(policy.id)); + expect(first.status).toBe(200); + + const second = await invoke(DELETE, deleteRequest(policy.id)); + const body = await second.json(); + + expect(second.status).toBe(404); + expect(body.message).toBe('Policy not found'); + }); +}); diff --git a/src/app/api/auth/email-verification/resend/__tests__/route.test.ts b/src/app/api/auth/email-verification/resend/__tests__/route.test.ts new file mode 100644 index 00000000..76d5b3a4 --- /dev/null +++ b/src/app/api/auth/email-verification/resend/__tests__/route.test.ts @@ -0,0 +1,197 @@ +/** + * Colocated tests for the verification resend route handler + * (src/app/api/auth/email-verification/resend/route.ts). + * + * The handler turns one library result into four different HTTP contracts + * (pending resend, already verified, cooldown, unknown request) and validates + * the body before it does, so both the success and the validation-failure + * responses are pinned here. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { POST } from '../route'; + +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +vi.mock('@/lib/auth/email-verification', () => ({ + resendVerificationEmail: vi.fn(), + buildVerificationMailContext: vi.fn(() => ({ + email: 'student@teachlink.com', + name: 'Student', + verificationUrl: 'https://teachlink.test/verify-email?token=fresh-token', + backupCode: 'BACKUP123', + expiresInMinutes: 15, + })), + getVerificationTokenTtlMinutes: vi.fn(() => 15), +})); + +vi.mock('@/services/notifications', () => ({ + notificationService: { + sendEmailVerificationEmail: vi.fn().mockResolvedValue({ success: true, provider: 'mock' }), + }, +})); + +import { + buildVerificationMailContext, + getVerificationTokenTtlMinutes, + resendVerificationEmail, +} from '@/lib/auth/email-verification'; +import { notificationService } from '@/services/notifications'; + +const RESEND_URL = 'http://localhost/api/auth/email-verification/resend'; + +/** The handler is declared for `NextRequest`; this test drives it with `Request`. */ +type Handler = (request: Request) => Promise; + +function invoke(handler: unknown, request: Request): Promise { + return (handler as Handler)(request); +} + +/** + * The mocked result union carries more members than these cases assert on, so the + * mock is driven through a narrow structural type instead of a cast per value. + */ +type Stub = { + mockResolvedValue(value: unknown): void; + mockRejectedValue(reason: unknown): void; +}; + +function stub(module: unknown): Stub { + return module as Stub; +} + +function postRequest(body: unknown): Request { + return new Request(RESEND_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const pendingResult = { + record: { email: 'student@teachlink.com', name: 'Student' }, + verificationToken: 'fresh-token', + backupCode: 'BACKUP123', +}; + +describe('POST /api/auth/email-verification/resend', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends a new verification email and reports the token lifetime', async () => { + stub(resendVerificationEmail).mockResolvedValue(pendingResult); + + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.verification).toEqual({ status: 'pending' }); + expect(body.message).toBe( + `Verification email resent. It expires in ${getVerificationTokenTtlMinutes()} minutes.`, + ); + expect(resendVerificationEmail).toHaveBeenCalledWith('student@teachlink.com'); + }); + + it('builds the mail context from the fresh token and backup code', async () => { + stub(resendVerificationEmail).mockResolvedValue(pendingResult); + + await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + + expect(buildVerificationMailContext).toHaveBeenCalledWith( + pendingResult.record, + 'fresh-token', + 'BACKUP123', + ); + expect(notificationService.sendEmailVerificationEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: 'student@teachlink.com' }), + ); + }); + + it('reports an already verified email without sending another email', async () => { + stub(resendVerificationEmail).mockResolvedValue({ status: 'already_verified' }); + + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe('Email already verified'); + expect(body.verification).toEqual({ status: 'already_verified' }); + expect(notificationService.sendEmailVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 429 while the resend cooldown is still active', async () => { + stub(resendVerificationEmail).mockResolvedValue({ status: 'cooldown' }); + + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(body.verification).toEqual({ status: 'cooldown' }); + expect(body.message).toBe('Please wait before requesting another verification email'); + }); + + it('returns 410 when there is no verification request to resend', async () => { + stub(resendVerificationEmail).mockResolvedValue({ status: 'not_found' }); + + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body.message).toBe('Verification request not found'); + expect(body.verification).toEqual({ status: 'expired' }); + }); + + it('returns 400 when the email is missing', async () => { + const response = await invoke(POST, postRequest({})); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(body.errors).toEqual([{ field: 'email', message: 'Email is required' }]); + expect(resendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 400 when the email is not a valid address', async () => { + const response = await invoke(POST, postRequest({ email: 'not-an-email' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(body.errors).toEqual([{ field: 'email', message: 'Invalid email address' }]); + expect(resendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 500 when the body cannot be parsed as JSON', async () => { + const request = new Request(RESEND_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: 'not-json', + }); + + const response = await invoke(POST, request); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.message).toBe('Internal server error'); + expect(resendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 500 when the resend lookup throws', async () => { + stub(resendVerificationEmail).mockRejectedValue(new Error('database unavailable')); + + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.message).toBe('Internal server error'); + }); +}); diff --git a/src/app/api/auth/email-verification/restore/__tests__/route.test.ts b/src/app/api/auth/email-verification/restore/__tests__/route.test.ts new file mode 100644 index 00000000..b50dc9cc --- /dev/null +++ b/src/app/api/auth/email-verification/restore/__tests__/route.test.ts @@ -0,0 +1,202 @@ +/** + * Colocated tests for the verification restore route handler + * (src/app/api/auth/email-verification/restore/route.ts). + * + * Restoring trades a backup code for a fresh verification token, so the handler + * has four success-shaped outcomes plus a validation failure path; each is + * pinned here together with the email that is sent on a successful restore. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { POST } from '../route'; + +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +vi.mock('@/lib/auth/email-verification', () => ({ + restoreVerificationEmail: vi.fn(), + buildVerificationMailContext: vi.fn(() => ({ + email: 'student@teachlink.com', + name: 'Student', + verificationUrl: 'https://teachlink.test/verify-email?token=restored-token', + backupCode: 'BACKUP123', + expiresInMinutes: 15, + })), + getVerificationTokenTtlMinutes: vi.fn(() => 15), +})); + +vi.mock('@/services/notifications', () => ({ + notificationService: { + sendEmailVerificationEmail: vi.fn().mockResolvedValue({ success: true, provider: 'mock' }), + }, +})); + +import { + buildVerificationMailContext, + getVerificationTokenTtlMinutes, + restoreVerificationEmail, +} from '@/lib/auth/email-verification'; +import { notificationService } from '@/services/notifications'; + +const RESTORE_URL = 'http://localhost/api/auth/email-verification/restore'; + +/** The handler is declared for `NextRequest`; this test drives it with `Request`. */ +type Handler = (request: Request) => Promise; + +function invoke(handler: unknown, request: Request): Promise { + return (handler as Handler)(request); +} + +/** + * The mocked result union carries more members than these cases assert on, so the + * mock is driven through a narrow structural type instead of a cast per value. + */ +type Stub = { + mockResolvedValue(value: unknown): void; + mockRejectedValue(reason: unknown): void; +}; + +function stub(module: unknown): Stub { + return module as Stub; +} + +function postRequest(body: unknown): Request { + return new Request(RESTORE_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const pendingResult = { + record: { email: 'student@teachlink.com', name: 'Student' }, + verificationToken: 'restored-token', + backupCode: 'BACKUP123', +}; + +describe('POST /api/auth/email-verification/restore', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('restores access with a backup code and emails the new token', async () => { + stub(restoreVerificationEmail).mockResolvedValue(pendingResult); + + const response = await invoke( + POST, + postRequest({ email: 'student@teachlink.com', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.verification).toEqual({ status: 'pending' }); + expect(body.message).toBe( + `Verification restored. It expires in ${getVerificationTokenTtlMinutes()} minutes.`, + ); + expect(restoreVerificationEmail).toHaveBeenCalledWith({ + email: 'student@teachlink.com', + backupCode: 'BACKUP123', + }); + }); + + it('builds the mail context from the restored token and backup code', async () => { + stub(restoreVerificationEmail).mockResolvedValue(pendingResult); + + await invoke(POST, postRequest({ email: 'student@teachlink.com', backupCode: 'BACKUP123' })); + + expect(buildVerificationMailContext).toHaveBeenCalledWith( + pendingResult.record, + 'restored-token', + 'BACKUP123', + ); + expect(notificationService.sendEmailVerificationEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: 'student@teachlink.com' }), + ); + }); + + it('reports an already verified email without sending another email', async () => { + stub(restoreVerificationEmail).mockResolvedValue({ status: 'already_verified' }); + + const response = await invoke( + POST, + postRequest({ email: 'student@teachlink.com', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe('Email already verified'); + expect(body.verification).toEqual({ status: 'already_verified' }); + expect(notificationService.sendEmailVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 404 when no verification record matches the email', async () => { + stub(restoreVerificationEmail).mockResolvedValue({ status: 'not_found' }); + + const response = await invoke( + POST, + postRequest({ email: 'unknown@teachlink.com', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.message).toBe('Verification record not found'); + expect(body.verification).toEqual({ status: 'not_found' }); + }); + + it('returns 410 when the backup code itself has expired', async () => { + stub(restoreVerificationEmail).mockResolvedValue({ status: 'expired' }); + + const response = await invoke( + POST, + postRequest({ email: 'student@teachlink.com', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body.message).toBe('Backup code expired'); + expect(body.verification).toEqual({ status: 'expired' }); + }); + + it('returns 400 when the backup code is missing', async () => { + const response = await invoke(POST, postRequest({ email: 'student@teachlink.com' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(body.errors).toEqual([{ field: 'backupCode', message: 'Backup code is required' }]); + expect(restoreVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 400 when the email is not a valid address', async () => { + const response = await invoke( + POST, + postRequest({ email: 'not-an-email', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(body.errors).toEqual([{ field: 'email', message: 'Invalid email address' }]); + expect(restoreVerificationEmail).not.toHaveBeenCalled(); + }); + + it('returns 500 when the restore lookup throws', async () => { + stub(restoreVerificationEmail).mockRejectedValue(new Error('database unavailable')); + + const response = await invoke( + POST, + postRequest({ email: 'student@teachlink.com', backupCode: 'BACKUP123' }), + ); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.message).toBe('Internal server error'); + }); +});