diff --git a/__tests__/api/assessments.test.ts b/__tests__/api/assessments.test.ts index a335adb..93535f0 100644 --- a/__tests__/api/assessments.test.ts +++ b/__tests__/api/assessments.test.ts @@ -1,212 +1,206 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -let assessments: any[] = []; -let agentRuns: any[] = []; -let currentUser: any = null; - -function reset() { - assessments = []; - agentRuns = []; - currentUser = null; +/** + * @vitest-environment node + * + * Exercises the real handlers in src/app/api/assessments/route.ts and + * src/app/api/assessments/[id]/route.ts with mocked db/auth. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const findMany = vi.fn(); +const findUnique = vi.fn(); +const agentRunCreate = vi.fn(); +const getUserFromRequest = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + assessment: { + findMany: (...args: unknown[]) => findMany(...args), + findUnique: (...args: unknown[]) => findUnique(...args), + }, + agentRun: { + create: (...args: unknown[]) => agentRunCreate(...args), + }, + }, +})); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +import { GET as list } from '@/app/api/assessments/route'; +import { GET as getById, POST as submit } from '@/app/api/assessments/[id]/route'; + +function listReq(qs = '') { + return new Request(`http://localhost/api/assessments${qs}`); } -function listRequest(role?: string, difficulty?: string) { - let url = 'https://example.com/api/assessments'; - const params: string[] = []; - if (role) params.push('role=' + role); - if (difficulty) params.push('difficulty=' + difficulty); - if (params.length) url += '?' + params.join('&'); - return { url }; +function idReq(body?: unknown) { + return new Request('http://localhost/api/assessments/a1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); } -async function listHandler(request: any) { - try { - const { searchParams } = new URL(request.url); - const role = searchParams.get('role'); - const difficulty = searchParams.get('difficulty'); - let filtered = [...assessments]; - if (role && role !== 'all') filtered = filtered.filter(a => a.role === role); - if (difficulty && difficulty !== 'all') filtered = filtered.filter(a => a.difficulty === difficulty); - return { status: 200, body: { assessments: filtered } }; - } catch { - return { status: 500, body: { error: 'Failed to fetch assessments' } }; - } +function params(id: string) { + return { params: Promise.resolve({ id }) }; } -async function getByIdHandler(request: any, id: string) { - try { - const assessment = assessments.find(a => a.id === id); - if (!assessment) return { status: 404, body: { error: 'Assessment not found' } }; - return { - status: 200, - body: { id: assessment.id, title: assessment.title, role: assessment.role, difficulty: assessment.difficulty, description: assessment.description, datasetInfo: assessment.datasetInfo }, - }; - } catch { - return { status: 500, body: { error: 'Failed to fetch assessment' } }; - } -} - -async function submitHandler(request: any, id: string) { - try { - if (!currentUser) return { status: 401, body: { error: 'Unauthorized' } }; - const assessment = assessments.find(a => a.id === id); - if (!assessment) return { status: 404, body: { error: 'Assessment not found' } }; - const { answers } = await request.json(); - agentRuns.push({ userId: currentUser.id, agentType: 'practical_test', input: JSON.stringify({ assessmentId: id, answers }), output: JSON.stringify({ submitted: true }) }); - return { status: 200, body: { success: true, assessmentId: id } }; - } catch { - return { status: 500, body: { error: 'Failed to submit assessment' } }; - } -} +const fullAssessment = { + id: 'a1', title: 'PPC Test', role: 'PPC VA', difficulty: 'easy', + description: 'd1', datasetInfo: { rows: 100 }, answerKey: { secret: true }, rubric: { weight: 1 }, +}; describe('GET /api/assessments (list)', () => { - beforeEach(() => reset()); - - it('returns all assessments when no filters', async () => { - assessments.push({ id: 'a1', title: 'PPC Test', role: 'PPC VA', difficulty: 'easy', description: 'd1', datasetInfo: {}, answerKey: {}, rubric: {} }, { id: 'a2', title: 'Account Test', role: 'Account VA', difficulty: 'hard', description: 'd2', datasetInfo: {}, answerKey: {}, rubric: {} }); - const res = await listHandler(listRequest()); - expect(res.status).toBe(200); - expect(res.body.assessments.length).toBe(2); + beforeEach(() => { + findMany.mockReset(); }); - it('returns empty array when no assessments', async () => { - const res = await listHandler(listRequest()); + it('returns all assessments when no filters', async () => { + findMany.mockResolvedValue([fullAssessment, { ...fullAssessment, id: 'a2' }]); + const res = await list(listReq()); + const body = await res.json(); expect(res.status).toBe(200); - expect(res.body.assessments).toEqual([]); + expect(body.assessments.length).toBe(2); + expect(findMany).toHaveBeenCalledWith({ where: {}, orderBy: { createdAt: 'desc' } }); }); it('filters by role', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy' }, { id: 'a2', role: 'Account VA', difficulty: 'easy' }, { id: 'a3', role: 'PPC VA', difficulty: 'hard' }); - const res = await listHandler(listRequest('PPC VA')); - expect(res.body.assessments.length).toBe(2); - expect(res.body.assessments.every((a: any) => a.role === 'PPC VA')).toBe(true); + findMany.mockResolvedValue([fullAssessment]); + await list(listReq('?role=PPC%20VA')); + expect(findMany).toHaveBeenCalledWith({ where: { role: 'PPC VA' }, orderBy: { createdAt: 'desc' } }); }); it('filters by difficulty', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy' }, { id: 'a2', role: 'PPC VA', difficulty: 'hard' }); - const res = await listHandler(listRequest(undefined, 'hard')); - expect(res.body.assessments.length).toBe(1); - expect(res.body.assessments[0].difficulty).toBe('hard'); + findMany.mockResolvedValue([]); + await list(listReq('?difficulty=hard')); + expect(findMany).toHaveBeenCalledWith({ where: { difficulty: 'hard' }, orderBy: { createdAt: 'desc' } }); }); - it('filters by both role and difficulty', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy' }, { id: 'a2', role: 'PPC VA', difficulty: 'hard' }, { id: 'a3', role: 'Account VA', difficulty: 'easy' }); - const res = await listHandler(listRequest('PPC VA', 'easy')); - expect(res.body.assessments.length).toBe(1); - expect(res.body.assessments[0].id).toBe('a1'); + it('combines role and difficulty filters', async () => { + findMany.mockResolvedValue([]); + await list(listReq('?role=PPC%20VA&difficulty=easy')); + expect(findMany).toHaveBeenCalledWith({ where: { role: 'PPC VA', difficulty: 'easy' }, orderBy: { createdAt: 'desc' } }); }); it('treats "all" as no filter', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy' }); - const res = await listHandler(listRequest('all', 'all')); - expect(res.body.assessments.length).toBe(1); - }); - - it('returns empty when filter matches nothing', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy' }); - const res = await listHandler(listRequest('Agency VA')); - expect(res.body.assessments).toEqual([]); + findMany.mockResolvedValue([]); + await list(listReq('?role=all&difficulty=all')); + expect(findMany).toHaveBeenCalledWith({ where: {}, orderBy: { createdAt: 'desc' } }); }); - it('includes answerKey in list (no stripping)', async () => { - assessments.push({ id: 'a1', role: 'PPC VA', difficulty: 'easy', answerKey: { secret: true }, rubric: {} }); - const res = await listHandler(listRequest()); - expect(res.body.assessments[0].answerKey).toBeDefined(); + it('does not strip answerKey/rubric in the list response', async () => { + findMany.mockResolvedValue([fullAssessment]); + const res = await list(listReq()); + const body = await res.json(); + expect(body.assessments[0].answerKey).toEqual({ secret: true }); }); - it('returns 500 on URL error', async () => { - const res = await listHandler({ url: undefined }); + it('returns 500 when the db throws', async () => { + findMany.mockRejectedValue(new Error('db down')); + const res = await list(listReq()); expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Failed to fetch assessments'); }); }); describe('GET /api/assessments/[id]', () => { - beforeEach(() => reset()); + beforeEach(() => { + findUnique.mockReset(); + }); it('returns assessment by id', async () => { - assessments.push({ id: 'a1', title: 'Test', role: 'PPC VA', difficulty: 'easy', description: 'desc', datasetInfo: { rows: 100 } }); - const res = await getByIdHandler({ url: 'x' }, 'a1'); + findUnique.mockResolvedValue(fullAssessment); + const res = await getById(idReq(), params('a1')); + const body = await res.json(); expect(res.status).toBe(200); - expect(res.body.id).toBe('a1'); - expect(res.body.title).toBe('Test'); + expect(body.id).toBe('a1'); + expect(body.title).toBe('PPC Test'); }); - it('returns 404 for non-existent id', async () => { - const res = await getByIdHandler({ url: 'x' }, 'nonexistent'); + it('returns 404 for a non-existent id', async () => { + findUnique.mockResolvedValue(null); + const res = await getById(idReq(), params('nonexistent')); expect(res.status).toBe(404); - expect(res.body.error).toBe('Assessment not found'); + expect((await res.json()).error).toBe('Assessment not found'); }); - it('strips answerKey and rubric', async () => { - assessments.push({ id: 'a1', title: 'Test', role: 'PPC VA', difficulty: 'easy', description: 'd', datasetInfo: {}, answerKey: { a: 1 }, rubric: { b: 2 } }); - const res = await getByIdHandler({ url: 'x' }, 'a1'); - expect(res.body).not.toHaveProperty('answerKey'); - expect(res.body).not.toHaveProperty('rubric'); + it('strips answerKey and rubric from the response', async () => { + findUnique.mockResolvedValue(fullAssessment); + const res = await getById(idReq(), params('a1')); + const body = await res.json(); + expect(body).not.toHaveProperty('answerKey'); + expect(body).not.toHaveProperty('rubric'); }); it('includes description and datasetInfo', async () => { - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy', description: 'My desc', datasetInfo: { columns: 5 } }); - const res = await getByIdHandler({ url: 'x' }, 'a1'); - expect(res.body.description).toBe('My desc'); - expect(res.body.datasetInfo).toEqual({ columns: 5 }); + findUnique.mockResolvedValue(fullAssessment); + const res = await getById(idReq(), params('a1')); + const body = await res.json(); + expect(body.description).toBe('d1'); + expect(body.datasetInfo).toEqual({ rows: 100 }); }); - it('returns datasetInfo as empty object when not set', async () => { - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy', description: 'd' }); - const res = await getByIdHandler({ url: 'x' }, 'a1'); - expect(res.body.datasetInfo).toBeUndefined(); + it('returns 500 when the db throws', async () => { + findUnique.mockRejectedValue(new Error('db down')); + const res = await getById(idReq(), params('a1')); + expect(res.status).toBe(500); }); }); describe('POST /api/assessments/[id] (submit)', () => { - beforeEach(() => reset()); + beforeEach(() => { + findUnique.mockReset(); + agentRunCreate.mockReset(); + getUserFromRequest.mockReset(); + }); - it('returns 401 when not authenticated', async () => { - currentUser = null; - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy' }); - const res = await submitHandler({ json: async () => ({ answers: ['a'] }), headers: { get: () => null } }, 'a1'); + it('returns 401 when not authenticated, without touching the db', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await submit(idReq({ answers: ['a'] }), params('a1')); expect(res.status).toBe(401); + expect(findUnique).not.toHaveBeenCalled(); }); - it('returns 404 for non-existent assessment', async () => { - currentUser = { id: 'u1' }; - const res = await submitHandler({ json: async () => ({ answers: [] }), headers: { get: () => null } }, 'nonexistent'); + it('returns 404 for a non-existent assessment', async () => { + getUserFromRequest.mockResolvedValue({ id: 'u1' }); + findUnique.mockResolvedValue(null); + const res = await submit(idReq({ answers: [] }), params('nonexistent')); expect(res.status).toBe(404); }); - it('returns 200 and logs agent run on success', async () => { - currentUser = { id: 'u1' }; - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy' }); - const res = await submitHandler({ json: async () => ({ answers: ['q1', 'q2'] }), headers: { get: () => null } }, 'a1'); + it('returns 200 and logs an agent run on success', async () => { + getUserFromRequest.mockResolvedValue({ id: 'u1' }); + findUnique.mockResolvedValue(fullAssessment); + agentRunCreate.mockResolvedValue({}); + const res = await submit(idReq({ answers: ['q1', 'q2'] }), params('a1')); + const body = await res.json(); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.assessmentId).toBe('a1'); - expect(agentRuns.length).toBe(1); - expect(agentRuns[0].userId).toBe('u1'); - expect(agentRuns[0].agentType).toBe('practical_test'); - }); - - it('stores assessmentId and answers in agent run', async () => { - currentUser = { id: 'u2' }; - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy' }); - await submitHandler({ json: async () => ({ answers: ['a', 'b', 'c'] }), headers: { get: () => null } }, 'a1'); - const run = JSON.parse(agentRuns[0].input); - expect(run.assessmentId).toBe('a1'); - expect(run.answers).toEqual(['a', 'b', 'c']); - }); - - it('allows multiple submissions', async () => { - currentUser = { id: 'u1' }; - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy' }); - await submitHandler({ json: async () => ({ answers: ['a'] }), headers: { get: () => null } }, 'a1'); - await submitHandler({ json: async () => ({ answers: ['b'] }), headers: { get: () => null } }, 'a1'); - expect(agentRuns.length).toBe(2); - }); - - it('returns 500 on json parse error', async () => { - currentUser = { id: 'u1' }; - assessments.push({ id: 'a1', title: 'T', role: 'PPC VA', difficulty: 'easy' }); - const res = await submitHandler({ json: async () => { throw new Error('bad'); }, headers: { get: () => null } }, 'a1'); + expect(body).toEqual({ success: true, assessmentId: 'a1' }); + expect(agentRunCreate).toHaveBeenCalledWith({ + data: { + userId: 'u1', + agentType: 'practical_test', + input: JSON.stringify({ assessmentId: 'a1', answers: ['q1', 'q2'] }), + output: JSON.stringify({ submitted: true }), + }, + }); + }); + + it('returns 500 when the request body cannot be parsed', async () => { + getUserFromRequest.mockResolvedValue({ id: 'u1' }); + findUnique.mockResolvedValue(fullAssessment); + const badReq = { json: async () => { throw new Error('bad json'); } } as unknown as Request; + const res = await submit(badReq, params('a1')); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Failed to submit assessment'); + }); + + it('returns 500 when the agent run write fails', async () => { + getUserFromRequest.mockResolvedValue({ id: 'u1' }); + findUnique.mockResolvedValue(fullAssessment); + agentRunCreate.mockRejectedValue(new Error('db down')); + const res = await submit(idReq({ answers: ['a'] }), params('a1')); expect(res.status).toBe(500); }); }); diff --git a/__tests__/api/auth-login.test.ts b/__tests__/api/auth-login.test.ts index c5cf133..3281aa0 100644 --- a/__tests__/api/auth-login.test.ts +++ b/__tests__/api/auth-login.test.ts @@ -1,284 +1,247 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -// In-memory stubs matching actual route logic -let users: any[] = []; -let rateLimits: any[] = []; -let sessionCreated: any = null; - -function reset() { - users = []; - rateLimits = []; - sessionCreated = null; -} - -// Stubs - -function createRequest(body: any, headers: Record = {}) { - return { - json: async () => body, - headers: { - get: (k: string) => headers[k.toLowerCase()] ?? null, +/** + * @vitest-environment node + * + * Exercises the real route handlers in src/app/api/auth/login and + * src/app/api/auth/logout with mocked db/password/rate-limit so we verify + * the shipped code path (sanitization, bcrypt/legacy password handling, + * rate limiting, session cookie creation) rather than a hand-copied + * reimplementation of it. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { verifyToken } from '@/lib/session'; + +const findUnique = vi.fn(); +const update = vi.fn(); +const verifyPassword = vi.fn(); +const isLegacyHash = vi.fn(); +const hashPassword = vi.fn(); +const checkRateLimit = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => findUnique(...args), + update: (...args: unknown[]) => update(...args), }, - }; -} + }, +})); -// Replicate route logic -async function loginHandler(request: any) { - try { - const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - || request.headers.get('x-real-ip') || 'unknown'; +vi.mock('@/lib/password', () => ({ + verifyPassword: (...args: unknown[]) => verifyPassword(...args), + isLegacyHash: (...args: unknown[]) => isLegacyHash(...args), + hashPassword: (...args: unknown[]) => hashPassword(...args), +})); - const rl = rateLimits.find(r => r.ip === clientIp && r.action === 'auth-login'); - if (rl && rl.count >= 10) { - return { status: 429, body: { error: 'Too many login attempts. Please try again later.' } }; - } - if (!rl) rateLimits.push({ ip: clientIp, action: 'auth-login', count: 1 }); - else rl.count++; +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: (...args: unknown[]) => checkRateLimit(...args), +})); - const { email, password } = await request.json(); - if (!email || !password) { - return { status: 400, body: { error: 'Email and password are required' } }; - } +import { POST as login } from '@/app/api/auth/login/route'; +import { POST as logout } from '@/app/api/auth/logout/route'; - const sanitizedEmail = String(email).trim().toLowerCase().substring(0, 255); - const user = users.find(u => u.email === sanitizedEmail); - - if (!user || !user.passwordHash) { - return { status: 401, body: { error: 'Invalid email or password' } }; - } - - // Simplified password check (always succeeds for test) - const isValid = password === 'correct-password'; - if (!isValid) { - return { status: 401, body: { error: 'Invalid email or password' } }; - } - - sessionCreated = { sub: user.id, email: user.email, tier: user.subscriptionTier, isAdmin: user.isAdmin }; - - return { - status: 200, - body: { - id: user.id, email: user.email, name: user.name, - subscriptionTier: user.subscriptionTier, isAdmin: user.isAdmin, - emailVerified: user.emailVerified, profile: user.profile, - }, - }; - } catch (error) { - return { status: 500, body: { error: 'Login failed' } }; - } +function req(body: unknown, headers: Record = {}) { + return new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); } -// Logout handler -async function logoutHandler() { - const response = { status: 200, body: { success: true } }; - sessionCreated = null; - return response; +function badJsonReq() { + return { + headers: { get: () => null }, + json: async () => { + throw new Error('bad json'); + }, + } as unknown as Request; } +const baseUser = { + id: 'u1', + email: 'user@test.com', + name: 'Test User', + passwordHash: '$2b$12$hashedpassword', + subscriptionTier: 'free', + isAdmin: false, + emailVerified: false, + profile: null, +}; + describe('POST /api/auth/login', () => { - beforeEach(() => reset()); + beforeEach(() => { + findUnique.mockReset(); + update.mockReset(); + verifyPassword.mockReset(); + isLegacyHash.mockReset(); + hashPassword.mockReset(); + checkRateLimit.mockReset(); + checkRateLimit.mockResolvedValue({ allowed: true, remaining: 9 }); + isLegacyHash.mockReturnValue(false); + }); it('returns 400 when email is missing', async () => { - const req = createRequest({ password: 'pass' }); - const res = await loginHandler(req); + const res = await login(req({ password: 'pass' })); expect(res.status).toBe(400); - expect(res.body.error).toContain('Email and password are required'); + expect((await res.json()).error).toContain('Email and password are required'); }); it('returns 400 when password is missing', async () => { - const req = createRequest({ email: 'test@test.com' }); - const res = await loginHandler(req); + const res = await login(req({ email: 'test@test.com' })); expect(res.status).toBe(400); }); it('returns 400 when both fields are missing', async () => { - const req = createRequest({}); - const res = await loginHandler(req); + const res = await login(req({})); expect(res.status).toBe(400); }); - it('returns 400 when body is empty', async () => { - const req = createRequest(undefined); - // This will throw, caught as 500 - const res = await loginHandler(req); + it('returns 500 when the request body cannot be parsed', async () => { + const res = await login(badJsonReq()); expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Login failed'); }); it('returns 401 for non-existent email', async () => { - const req = createRequest({ email: 'noone@test.com', password: 'correct-password' }); - const res = await loginHandler(req); + findUnique.mockResolvedValue(null); + const res = await login(req({ email: 'noone@test.com', password: 'whatever' })); expect(res.status).toBe(401); - expect(res.body.error).toBe('Invalid email or password'); + expect((await res.json()).error).toBe('Invalid email or password'); }); - it('returns 401 for wrong password', async () => { - users.push({ id: 'u1', email: 'user@test.com', name: 'User', passwordHash: 'hashed', subscriptionTier: 'free', isAdmin: false, emailVerified: false, profile: null }); - const req = createRequest({ email: 'user@test.com', password: 'wrong' }); - const res = await loginHandler(req); + it('returns 401 for user without a passwordHash (e.g. oauth-only account)', async () => { + findUnique.mockResolvedValue({ ...baseUser, passwordHash: null }); + const res = await login(req({ email: 'user@test.com', password: 'anything' })); expect(res.status).toBe(401); + expect(verifyPassword).not.toHaveBeenCalled(); }); - it('returns 401 for user without passwordHash', async () => { - users.push({ id: 'u2', email: 'oauth@test.com', name: 'OAuth', passwordHash: null, subscriptionTier: 'free', isAdmin: false, emailVerified: true, profile: null }); - const req = createRequest({ email: 'oauth@test.com', password: 'anything' }); - const res = await loginHandler(req); + it('returns 401 for wrong password', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(false); + const res = await login(req({ email: 'user@test.com', password: 'wrong' })); expect(res.status).toBe(401); }); - it('returns 200 with user data on valid login', async () => { - users.push({ id: 'u1', email: 'user@test.com', name: 'Test User', passwordHash: 'hashed', subscriptionTier: 'starter', isAdmin: false, emailVerified: true, profile: { id: 'p1' } }); - const req = createRequest({ email: 'user@test.com', password: 'correct-password' }); - const res = await loginHandler(req); - expect(res.status).toBe(200); - expect(res.body.id).toBe('u1'); - expect(res.body.email).toBe('user@test.com'); - expect(res.body.name).toBe('Test User'); - expect(res.body.subscriptionTier).toBe('starter'); - expect(res.body.isAdmin).toBe(false); - expect(res.body.emailVerified).toBe(true); - expect(res.body.profile).toEqual({ id: 'p1' }); - }); - - it('creates a session with correct payload', async () => { - users.push({ id: 'u3', email: 'admin@test.com', name: 'Admin', passwordHash: 'hashed', subscriptionTier: 'pro', isAdmin: true, emailVerified: true, profile: null }); - const req = createRequest({ email: 'admin@test.com', password: 'correct-password' }); - await loginHandler(req); - expect(sessionCreated).not.toBeNull(); - expect(sessionCreated.sub).toBe('u3'); - expect(sessionCreated.email).toBe('admin@test.com'); - expect(sessionCreated.tier).toBe('pro'); - expect(sessionCreated.isAdmin).toBe(true); + it('calls verifyPassword with the raw password and stored hash', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + await login(req({ email: 'user@test.com', password: 'correct-password' })); + expect(verifyPassword).toHaveBeenCalledWith('correct-password', baseUser.passwordHash); }); - it('sanitizes email: trims whitespace and lowercases', async () => { - users.push({ id: 'u1', email: 'user@test.com', name: 'User', passwordHash: 'hashed', subscriptionTier: 'free', isAdmin: false, emailVerified: false, profile: null }); - const req = createRequest({ email: ' USER@TEST.COM ', password: 'correct-password' }); - const res = await loginHandler(req); + it('returns 200 with user data (and session cookie) on valid login', async () => { + findUnique.mockResolvedValue({ ...baseUser, subscriptionTier: 'starter', emailVerified: true, profile: { id: 'p1' } }); + verifyPassword.mockResolvedValue(true); + const res = await login(req({ email: 'user@test.com', password: 'correct-password' })); + const body = await res.json(); expect(res.status).toBe(200); - expect(res.body.email).toBe('user@test.com'); - }); - - it('truncates email to 255 chars', async () => { - const longEmail = 'a'.repeat(250) + '@test.com'; // 260 chars - const req = createRequest({ email: longEmail, password: 'correct-password' }); - const res = await loginHandler(req); - // Should not find user (email truncated) - expect(res.status).toBe(401); - }); - - it('returns 429 when rate limit exceeded', async () => { - const ip = 'test-ip'; - rateLimits.push({ ip, action: 'auth-login', count: 10 }); - const req = createRequest({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': ip }); - const res = await loginHandler(req); - expect(res.status).toBe(429); - expect(res.body.error).toContain('Too many login attempts'); - }); - - it('increments rate limit counter on each attempt', async () => { - const ip = '192.168.1.1'; - const req = createRequest({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': ip }); - await loginHandler(req); - await loginHandler(req); - await loginHandler(req); - const rl = rateLimits.find(r => r.ip === ip); - expect(rl!.count).toBe(3); - }); - - it('uses x-real-ip when x-forwarded-for is absent', async () => { - const req = createRequest({ email: 'x@x.com', password: 'p' }, { 'x-real-ip': '10.0.0.1' }); - await loginHandler(req); - const rl = rateLimits.find(r => r.ip === '10.0.0.1'); - expect(rl).toBeDefined(); - }); - - it('falls back to "unknown" when no IP headers', async () => { - const req = createRequest({ email: 'x@x.com', password: 'p' }); - await loginHandler(req); - const rl = rateLimits.find(r => r.ip === 'unknown'); - expect(rl).toBeDefined(); - }); - - it('handles multiple x-forwarded-for IPs (uses first)', async () => { - const req = createRequest({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }); - await loginHandler(req); - const rl = rateLimits.find(r => r.ip === '1.2.3.4'); - expect(rl).toBeDefined(); - }); - - it('handles error thrown during request.json()', async () => { - const badReq = { json: async () => { throw new Error('bad json'); }, headers: { get: () => null } }; - const res = await loginHandler(badReq); - expect(res.status).toBe(500); - expect(res.body.error).toBe('Login failed'); - }); - - it('returns profile data when user has a profile', async () => { - const profile = { id: 'p1', bio: 'I am a VA', experience: '2 years' }; - users.push({ id: 'u5', email: 'pro@test.com', name: 'Pro', passwordHash: 'hashed', subscriptionTier: 'pro', isAdmin: false, emailVerified: true, profile }); - const req = createRequest({ email: 'pro@test.com', password: 'correct-password' }); - const res = await loginHandler(req); + expect(body).toMatchObject({ + id: 'u1', email: 'user@test.com', name: 'Test User', + subscriptionTier: 'starter', isAdmin: false, emailVerified: true, profile: { id: 'p1' }, + }); + expect(res.headers.get('set-cookie')).toContain('interviewlab_session='); + }); + + it('does not leak passwordHash in the response', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + const res = await login(req({ email: 'user@test.com', password: 'correct-password' })); + expect(await res.json()).not.toHaveProperty('passwordHash'); + }); + + it('creates a session cookie whose payload matches the user (sub/email/tier/isAdmin)', async () => { + findUnique.mockResolvedValue({ ...baseUser, id: 'u3', email: 'admin@test.com', subscriptionTier: 'pro', isAdmin: true }); + verifyPassword.mockResolvedValue(true); + const res = await login(req({ email: 'admin@test.com', password: 'correct-password' })); + const setCookie = res.headers.get('set-cookie')!; + const token = setCookie.match(/interviewlab_session=([^;]+)/)![1]; + const payload = await verifyToken(token); + expect(payload).toMatchObject({ sub: 'u3', email: 'admin@test.com', tier: 'pro', isAdmin: true }); + }); + + it('sanitizes email: trims whitespace and lowercases before the db lookup', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + await login(req({ email: ' USER@TEST.COM ', password: 'correct-password' })); + expect(findUnique).toHaveBeenCalledWith({ where: { email: 'user@test.com' }, include: { profile: true } }); + }); + + it('truncates email to 255 chars before lookup', async () => { + findUnique.mockResolvedValue(null); + const longEmail = 'a'.repeat(250) + '@test.com'; // 259 chars + await login(req({ email: longEmail, password: 'x' })); + const calledWith = findUnique.mock.calls[0][0].where.email as string; + expect(calledWith.length).toBe(255); + }); + + it('auto-upgrades a legacy SHA-256 hash to bcrypt on successful login', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + isLegacyHash.mockReturnValue(true); + hashPassword.mockResolvedValue('$2b$12$newbcryptvalue'); + await login(req({ email: 'user@test.com', password: 'correct-password' })); + expect(hashPassword).toHaveBeenCalledWith('correct-password'); + expect(update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { passwordHash: '$2b$12$newbcryptvalue' } }); + }); + + it('does not attempt a hash upgrade for an already-bcrypt hash', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + isLegacyHash.mockReturnValue(false); + await login(req({ email: 'user@test.com', password: 'correct-password' })); + expect(update).not.toHaveBeenCalled(); + }); + + it('login still succeeds even if the legacy hash upgrade write fails', async () => { + findUnique.mockResolvedValue(baseUser); + verifyPassword.mockResolvedValue(true); + isLegacyHash.mockReturnValue(true); + hashPassword.mockResolvedValue('$2b$12$newvalue'); + update.mockRejectedValue(new Error('db write failed')); + const res = await login(req({ email: 'user@test.com', password: 'correct-password' })); expect(res.status).toBe(200); - expect(res.body.profile).toEqual(profile); }); - it('returns null profile when user has no profile', async () => { - users.push({ id: 'u6', email: 'noprop@test.com', name: 'NoPro', passwordHash: 'hashed', subscriptionTier: 'free', isAdmin: false, emailVerified: false, profile: null }); - const req = createRequest({ email: 'noprop@test.com', password: 'correct-password' }); - const res = await loginHandler(req); - expect(res.body.profile).toBeNull(); + it('returns 429 when the persistent rate limiter denies the request', async () => { + checkRateLimit.mockResolvedValue({ allowed: false, remaining: 0 }); + const res = await login(req({ email: 'x@x.com', password: 'p' })); + expect(res.status).toBe(429); + expect((await res.json()).error).toContain('Too many login attempts'); + expect(findUnique).not.toHaveBeenCalled(); }); - it('does not leak passwordHash in response', async () => { - users.push({ id: 'u7', email: 'secure@test.com', name: 'Secure', passwordHash: '$2b$10$hashedpassword', subscriptionTier: 'free', isAdmin: false, emailVerified: false, profile: null }); - const req = createRequest({ email: 'secure@test.com', password: 'correct-password' }); - const res = await loginHandler(req); - expect(res.body).not.toHaveProperty('passwordHash'); + it('keys the rate limiter by x-forwarded-for (first IP) when present', async () => { + checkRateLimit.mockResolvedValue({ allowed: true, remaining: 9 }); + findUnique.mockResolvedValue(null); + await login(req({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' })); + expect(checkRateLimit).toHaveBeenCalledWith('1.2.3.4', 'auth-login', expect.any(Number), expect.any(Number)); }); - it('rate limit is per-IP not global', async () => { - rateLimits.push({ ip: '1.1.1.1', action: 'auth-login', count: 10 }); - const req = createRequest({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': '2.2.2.2' }); - const res = await loginHandler(req); - expect(res.status).not.toBe(429); - }); + it('falls back to x-real-ip, then "unknown", for rate-limit keying', async () => { + findUnique.mockResolvedValue(null); + await login(req({ email: 'x@x.com', password: 'p' }, { 'x-real-ip': '10.0.0.1' })); + expect(checkRateLimit).toHaveBeenCalledWith('10.0.0.1', 'auth-login', expect.any(Number), expect.any(Number)); - it('handles email with leading/trailing spaces', async () => { - users.push({ id: 'u8', email: 'space@test.com', name: 'Space', passwordHash: 'hashed', subscriptionTier: 'free', isAdmin: false, emailVerified: false, profile: null }); - const req = createRequest({ email: ' space@test.com ', password: 'correct-password' }); - const res = await loginHandler(req); - expect(res.status).toBe(200); + checkRateLimit.mockClear(); + await login(req({ email: 'x@x.com', password: 'p' })); + expect(checkRateLimit).toHaveBeenCalledWith('unknown', 'auth-login', expect.any(Number), expect.any(Number)); }); }); describe('POST /api/auth/logout', () => { - beforeEach(() => reset()); - - it('returns 200 with success true', async () => { - sessionCreated = { sub: 'u1', email: 'x@x.com' }; - const res = await logoutHandler(); + it('returns 200 with success true and clears the session cookie', async () => { + const res = await logout(); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('clears the session', async () => { - sessionCreated = { sub: 'u1' }; - await logoutHandler(); - expect(sessionCreated).toBeNull(); + expect(await res.json()).toEqual({ success: true }); + const setCookie = res.headers.get('set-cookie')!; + expect(setCookie).toContain('interviewlab_session='); + expect(setCookie).toMatch(/Max-Age=0|Expires=/i); }); - it('is idempotent (safe to call twice)', async () => { - const r1 = await logoutHandler(); - const r2 = await logoutHandler(); + it('is idempotent — safe to call repeatedly', async () => { + const r1 = await logout(); + const r2 = await logout(); expect(r1.status).toBe(200); expect(r2.status).toBe(200); - expect(sessionCreated).toBeNull(); - }); - - it('works even when no session exists', async () => { - sessionCreated = null; - const res = await logoutHandler(); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); }); }); diff --git a/__tests__/api/auth-register.test.ts b/__tests__/api/auth-register.test.ts index d81266b..93f47d2 100644 --- a/__tests__/api/auth-register.test.ts +++ b/__tests__/api/auth-register.test.ts @@ -1,326 +1,266 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -// --- In-memory store --- -let users: Array<{email: string; name: string; passwordHash: string; emailVerified: boolean}> = []; -let appSettings: Array<{key: string; value: string}> = []; -let rateLimits: Array<{key: string; count: number}> = []; -let _formStart: number = 0; - -function reset() { - users = []; - appSettings = []; - rateLimits = []; - _formStart = Date.now(); -} - -// --- Stubs matching the actual route logic --- -const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function isBot(body: Record): boolean { - if (body.honeypot && body.honeypot !== '') return true; - if (body._formStart) { - const elapsed = Date.now() - Number(body._formStart); - if (elapsed < 2000) return true; - } - return false; -} - -function sanitize(name: string | undefined, email: string): string { - return (name || email.split('@')[0]) - .replace(/<[^>]*>/g, '') - .replace(/[<>"'&]/g, '') - .trim() - .substring(0, 100); -} - -function sanitizeEmail(email: string): string { - return email.trim().toLowerCase().substring(0, 255); -} - -function register(body: Record) { - if (isBot(body)) { - return { status: 201, data: { id: 'bot-trap', email: 'trap@trap.com', message: 'Registration received.' } }; - } - - const email = body.email as string | undefined; - const name = body.name as string | undefined; - const password = body.password as string | undefined; - - if (!email || !password) { - return { status: 400, data: { error: 'Email and password are required' } }; - } - - const sanitizedName = sanitize(name, email); - const sanitizedEmail = sanitizeEmail(email); - - if (!emailRegex.test(sanitizedEmail)) { - return { status: 400, data: { error: 'Invalid email format' } }; - } - - if (password.length < 8) { - return { status: 400, data: { error: 'Password must be at least 8 characters' } }; - } - - // Check max users cap - const maxSetting = appSettings.find(s => s.key === 'max_users'); - const maxUsers = maxSetting ? parseInt(maxSetting.value, 10) : 0; - if (maxUsers > 0 && users.length >= maxUsers) { - return { status: 503, data: { error: 'Registration is temporarily closed.' } }; - } - - if (users.find(u => u.email === sanitizedEmail)) { - return { status: 409, data: { error: 'Email already registered' } }; - } - - const user = { - email: sanitizedEmail, - name: sanitizedName, - passwordHash: 'fake_hash', - emailVerified: false, - }; - users.push(user); - - return { status: 201, data: { id: 'uid_' + users.length, email: user.email, name: user.name, subscriptionTier: 'free', isAdmin: false, emailVerified: false } }; -} - -describe('POST /api/auth/register — bot protection', () => { - beforeEach(() => { reset(); }); - - it('returns fake 201 for honeypot field', () => { - const r = register({ email: 'bot@evil.com', password: 'password123', honeypot: 'filled' }); - expect(r.status).toBe(201); - expect(r.data.email).toBe('trap@trap.com'); - }); - - it('returns fake 201 for fast submission (< 2s)', () => { - // The isBot check uses Date.now() - body._formStart, not module-level _formStart - // This tests the honeypot path instead - const fastBody = { email: 'fast@evil.com', password: 'password123', honeypot: 'filled' }; - const r = register(fastBody); - expect(r.status).toBe(201); - expect(r.data.email).toBe('trap@trap.com'); - }); - - it('registers normally when formStart is old enough', async () => { - // Simulate > 2 seconds have passed - const oldBody = { email: 'test@example.com', password: 'password123', _formStart: Date.now() - 3000 }; - const r = register(oldBody); - expect(r.status).toBe(201); - expect(r.data.email).toBe('test@example.com'); - }); -}); - -describe('POST /api/auth/register — required fields', () => { - beforeEach(() => { reset(); }); - - it('rejects missing email with 400', () => { - const r = register({ password: 'password123' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('required'); - }); - - it('rejects missing password with 400', () => { - const r = register({ email: 'test@example.com' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('required'); - }); - - it('rejects both missing with 400', () => { - const r = register({}); - expect(r.status).toBe(400); - expect(r.data.error).toContain('required'); - }); - - it('rejects null email with 400', () => { - const r = register({ email: null as any, password: 'password123' }); - expect(r.status).toBe(400); - }); - - it('rejects undefined password with 400', () => { - const r = register({ email: 'test@example.com', password: undefined as any }); - expect(r.status).toBe(400); - }); -}); - -describe('POST /api/auth/register — email validation', () => { - beforeEach(() => { reset(); }); - - it('rejects email without @', () => { - const r = register({ email: 'notanemail', password: 'password123' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('Invalid email'); - }); - - it('rejects email without domain', () => { - const r = register({ email: 'test@', password: 'password123' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('Invalid email'); - }); - - it('rejects email without local part', () => { - const r = register({ email: '@example.com', password: 'password123' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('Invalid email'); - }); - - it('rejects email with spaces', () => { - const r = register({ email: 'test @example.com', password: 'password123' }); - expect(r.status).toBe(400); - }); - - it('accepts valid email', () => { - const r = register({ email: 'Test@Example.COM', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.email).toBe('test@example.com'); // lowercase - }); - - it('trims whitespace from email', () => { - const r = register({ email: ' test@example.com ', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.email).toBe('test@example.com'); - }); - - it('truncates email at 255 chars', () => { - const longEmail = 'a'.repeat(240) + '@test.example.com'; // 240 + 18 = 258, truncates to 255 - const r = register({ email: longEmail, password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.email.length).toBeLessThanOrEqual(255); - }); -}); - -describe('POST /api/auth/register — password validation', () => { - beforeEach(() => { reset(); }); - - it('rejects 7-character password', () => { - const r = register({ email: 'test@example.com', password: 'passwor' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('8 characters'); - }); - - it('accepts 8-character password', () => { - const r = register({ email: 'test@example.com', password: 'password' }); - expect(r.status).toBe(201); - }); - - it('accepts long password (100+ chars)', () => { - const r = register({ email: 'test@example.com', password: 'p'.repeat(100) }); - expect(r.status).toBe(201); - }); - - it('accepts password with spaces', () => { - const r = register({ email: 'test@example.com', password: 'my secure password 123' }); - expect(r.status).toBe(201); - }); - - it('accepts Unicode password', () => { - const r = register({ email: 'test@example.com', password: 'mypassword123¥' }); - expect(r.status).toBe(201); - }); -}); - -describe('POST /api/auth/register — duplicate email', () => { - beforeEach(() => { reset(); }); - - it('rejects duplicate email with 409', () => { - register({ email: 'taken@example.com', password: 'password123' }); - const r = register({ email: 'taken@example.com', password: 'password456' }); - expect(r.status).toBe(409); - expect(r.data.error).toContain('already registered'); - }); - - it('rejects duplicate regardless of case', () => { - register({ email: 'Taken@example.com', password: 'password123' }); - const r = register({ email: 'TAKEN@example.com', password: 'password456' }); - expect(r.status).toBe(409); - }); - - it('allows same email after different registration', () => { - // This is expected behavior - the first registration should succeed - // but email comparison is case-insensitive so this tests the boundary - }); -}); - -describe('POST /api/auth/register — name sanitization', () => { - beforeEach(() => { reset(); }); - - it('uses email prefix when name is missing', () => { - const r = register({ email: 'johnny@example.com', password: 'password123' }); - expect(r.data.name).toBe('johnny'); - }); - - it('strips HTML tags from name', () => { - const r = register({ email: 'test@example.com', name: 'Bold John', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.name).not.toContain('<'); - expect(r.data.name).toBe('Bold John'); - }); - - it('removes special chars from name', () => { - const r = register({ email: 'test@example.com', name: 'John<>"&Doe', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.name).toBe('JohnDoe'); - }); - - it('trims whitespace from name', () => { - const r = register({ email: 'test@example.com', name: ' Jane ', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.name).toBe('Jane'); - }); - - it('truncates name at 100 chars', () => { - const r = register({ email: 'test@example.com', name: 'A'.repeat(150), password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.name.length).toBe(100); - }); - - it('handles empty string name', () => { - const r = register({ email: 'test@example.com', name: '', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data.name).toBe('test'); - }); -}); - -describe('POST /api/auth/register — user cap', () => { - beforeEach(() => { reset(); }); - - it('returns 503 when user cap reached', () => { - appSettings.push({ key: 'max_users', value: '1' }); - register({ email: 'first@example.com', password: 'password123' }); - const r = register({ email: 'second@example.com', password: 'password456' }); - expect(r.status).toBe(503); - expect(r.data.error).toContain('closed'); - }); - - it('allows registration when cap not reached', () => { - appSettings.push({ key: 'max_users', value: '5' }); - for (let i = 0; i < 4; i++) { - const r = register({ email: `user${i}@example.com`, password: 'password123' }); - expect(r.status).toBe(201); - } - }); -}); - -describe('POST /api/auth/register — successful registration', () => { - beforeEach(() => { reset(); }); - - it('returns 201 with user data on success', () => { - const r = register({ email: 'newuser@example.com', name: 'New User', password: 'password123' }); - expect(r.status).toBe(201); - expect(r.data).toHaveProperty('id'); - expect(r.data.email).toBe('newuser@example.com'); - expect(r.data.name).toBe('New User'); - expect(r.data.subscriptionTier).toBe('free'); - expect(r.data.isAdmin).toBe(false); - expect(r.data.emailVerified).toBe(false); - }); - - it('does not return passwordHash', () => { - const r = register({ email: 'safe@example.com', password: 'password123' }); - expect(r.data).not.toHaveProperty('passwordHash'); +/** + * @vitest-environment node + * + * Exercises the real POST /api/auth/register handler with mocked db/password + * so bot protection, sanitization, validation, the user cap, and session + * creation are verified against the shipped route code. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { verifyToken } from '@/lib/session'; + +const findUnique = vi.fn(); +const create = vi.fn(); +const count = vi.fn(); +const appSettingFindUnique = vi.fn(); +const hashPassword = vi.fn(); +const createVerificationToken = vi.fn(); +const checkRateLimit = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => findUnique(...args), + create: (...args: unknown[]) => create(...args), + count: (...args: unknown[]) => count(...args), + }, + appSetting: { + findUnique: (...args: unknown[]) => appSettingFindUnique(...args), + }, + }, +})); + +vi.mock('@/lib/password', () => ({ + hashPassword: (...args: unknown[]) => hashPassword(...args), +})); + +vi.mock('@/lib/email-verification', () => ({ + createVerificationToken: (...args: unknown[]) => createVerificationToken(...args), +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: (...args: unknown[]) => checkRateLimit(...args), +})); + +import { POST as register } from '@/app/api/auth/register/route'; + +function req(body: unknown) { + return new Request('http://localhost/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }); +} - it('sets default subscriptionTier to free', () => { - const r = register({ email: 'free@example.com', password: 'password123' }); - expect(r.data.subscriptionTier).toBe('free'); +let nextId = 0; + +describe('POST /api/auth/register', () => { + beforeEach(() => { + delete process.env.MAX_USERS; + nextId = 0; + findUnique.mockReset(); + create.mockReset(); + count.mockReset(); + appSettingFindUnique.mockReset(); + hashPassword.mockReset(); + createVerificationToken.mockReset(); + checkRateLimit.mockReset(); + + checkRateLimit.mockResolvedValue({ allowed: true, remaining: 4 }); + appSettingFindUnique.mockResolvedValue(null); // no max_users cap by default + findUnique.mockResolvedValue(null); // no existing user by default + hashPassword.mockImplementation(async (pw: string) => `hashed:${pw}`); + createVerificationToken.mockResolvedValue('verify-token'); + create.mockImplementation(async ({ data }: { data: Record }) => ({ + id: `uid_${++nextId}`, + email: data.email, + name: data.name, + passwordHash: data.passwordHash, + subscriptionTier: 'free', + isAdmin: false, + emailVerified: false, + })); + }); + + describe('bot protection', () => { + it('returns a fake 201 for a filled honeypot field, without touching the db', async () => { + const res = await register(req({ email: 'bot@evil.com', password: 'password123', honeypot: 'filled' })); + expect(res.status).toBe(201); + expect((await res.json()).email).toBe('trap@trap.com'); + expect(create).not.toHaveBeenCalled(); + }); + + it('returns a fake 201 for a submission faster than 2 seconds', async () => { + const res = await register(req({ email: 'fast@evil.com', password: 'password123', _formStart: Date.now() - 500 })); + expect(res.status).toBe(201); + expect((await res.json()).email).toBe('trap@trap.com'); + expect(create).not.toHaveBeenCalled(); + }); + + it('registers normally when the form was open long enough', async () => { + const res = await register(req({ email: 'test@example.com', password: 'password123', _formStart: Date.now() - 3000 })); + expect(res.status).toBe(201); + expect((await res.json()).email).toBe('test@example.com'); + }); + }); + + describe('required fields', () => { + it('rejects missing email with 400', async () => { + const res = await register(req({ password: 'password123' })); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('required'); + }); + + it('rejects missing password with 400', async () => { + const res = await register(req({ email: 'test@example.com' })); + expect(res.status).toBe(400); + }); + }); + + describe('email validation', () => { + it('rejects email without @', async () => { + const res = await register(req({ email: 'notanemail', password: 'password123' })); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('Invalid email'); + }); + + it('rejects email without a domain', async () => { + const res = await register(req({ email: 'test@', password: 'password123' })); + expect(res.status).toBe(400); + }); + + it('accepts a valid email and lowercases it', async () => { + const res = await register(req({ email: 'Test@Example.COM', password: 'password123' })); + expect(res.status).toBe(201); + expect((await res.json()).email).toBe('test@example.com'); + }); + + it('trims whitespace from email before validation/storage', async () => { + const res = await register(req({ email: ' test@example.com ', password: 'password123' })); + expect(res.status).toBe(201); + expect((await res.json()).email).toBe('test@example.com'); + }); + + it('truncates email at 255 chars', async () => { + const longEmail = 'a'.repeat(240) + '@test.example.com'; + await register(req({ email: longEmail, password: 'password123' })); + const storedEmail = create.mock.calls[0][0].data.email as string; + expect(storedEmail.length).toBeLessThanOrEqual(255); + }); + }); + + describe('password validation', () => { + it('rejects a 7-character password', async () => { + const res = await register(req({ email: 'test@example.com', password: 'passwor' })); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('8 characters'); + }); + + it('accepts an 8-character password', async () => { + const res = await register(req({ email: 'test@example.com', password: 'password' })); + expect(res.status).toBe(201); + }); + + it('hashes the password via hashPassword before storing', async () => { + await register(req({ email: 'test@example.com', password: 'password123' })); + expect(hashPassword).toHaveBeenCalledWith('password123'); + expect(create.mock.calls[0][0].data.passwordHash).toBe('hashed:password123'); + }); + }); + + describe('duplicate email', () => { + it('rejects duplicate email with 409 and does not create/hash', async () => { + findUnique.mockResolvedValue({ id: 'existing', email: 'taken@example.com' }); + const res = await register(req({ email: 'taken@example.com', password: 'password123' })); + expect(res.status).toBe(409); + expect((await res.json()).error).toContain('already registered'); + expect(create).not.toHaveBeenCalled(); + }); + }); + + describe('name sanitization', () => { + it('uses the email prefix when name is missing', async () => { + const res = await register(req({ email: 'johnny@example.com', password: 'password123' })); + expect((await res.json()).name).toBe('johnny'); + }); + + it('strips HTML tags and special characters from name', async () => { + const res = await register(req({ email: 'test@example.com', name: 'Bold John<>"&', password: 'password123' })); + const body = await res.json(); + expect(body.name).not.toContain('<'); + expect(body.name).toBe('Bold John'); + }); + + it('truncates name at 100 chars', async () => { + const res = await register(req({ email: 'test@example.com', name: 'A'.repeat(150), password: 'password123' })); + expect((await res.json()).name.length).toBe(100); + }); + }); + + describe('user cap', () => { + it('returns 503 when the DB-configured user cap is reached', async () => { + appSettingFindUnique.mockResolvedValue({ key: 'max_users', value: '5' }); + count.mockResolvedValue(5); + const res = await register(req({ email: 'second@example.com', password: 'password123' })); + expect(res.status).toBe(503); + expect((await res.json()).error).toContain('closed'); + expect(create).not.toHaveBeenCalled(); + }); + + it('allows registration when the cap is not yet reached', async () => { + appSettingFindUnique.mockResolvedValue({ key: 'max_users', value: '5' }); + count.mockResolvedValue(4); + const res = await register(req({ email: 'user@example.com', password: 'password123' })); + expect(res.status).toBe(201); + }); + + it('MAX_USERS env var takes precedence over the DB setting', async () => { + process.env.MAX_USERS = '1'; + count.mockResolvedValue(1); + const res = await register(req({ email: 'user@example.com', password: 'password123' })); + expect(res.status).toBe(503); + expect(appSettingFindUnique).not.toHaveBeenCalled(); + }); + }); + + describe('successful registration', () => { + it('returns 201 with user data, a session cookie, and no passwordHash', async () => { + const res = await register(req({ email: 'newuser@example.com', name: 'New User', password: 'password123' })); + const body = await res.json(); + expect(res.status).toBe(201); + expect(body).toMatchObject({ email: 'newuser@example.com', name: 'New User', subscriptionTier: 'free', isAdmin: false, emailVerified: false }); + expect(body).not.toHaveProperty('passwordHash'); + expect(res.headers.get('set-cookie')).toContain('interviewlab_session='); + }); + + it('issues a session cookie whose payload matches the new user', async () => { + const res = await register(req({ email: 'newuser@example.com', password: 'password123' })); + const token = res.headers.get('set-cookie')!.match(/interviewlab_session=([^;]+)/)![1]; + const payload = await verifyToken(token); + expect(payload).toMatchObject({ email: 'newuser@example.com', tier: 'free', isAdmin: false }); + }); + + it('creates a profile row alongside the user', async () => { + await register(req({ email: 'newuser@example.com', password: 'password123' })); + expect(create.mock.calls[0][0].data.profile).toEqual({ create: {} }); + }); + + it('creates an email verification token for the sanitized email', async () => { + await register(req({ email: ' NewUser@Example.com ', password: 'password123' })); + expect(createVerificationToken).toHaveBeenCalledWith('newuser@example.com'); + }); + }); + + it('returns 429 when the persistent rate limiter denies the request', async () => { + checkRateLimit.mockResolvedValue({ allowed: false, remaining: 0 }); + const res = await register(req({ email: 'x@x.com', password: 'password123' })); + expect(res.status).toBe(429); + expect(create).not.toHaveBeenCalled(); + }); + + it('returns 500 when the database throws unexpectedly', async () => { + create.mockRejectedValue(new Error('db down')); + const res = await register(req({ email: 'test@example.com', password: 'password123' })); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe('Registration failed'); }); }); diff --git a/__tests__/api/interview-session.test.ts b/__tests__/api/interview-session.test.ts index 020a09c..266946c 100644 --- a/__tests__/api/interview-session.test.ts +++ b/__tests__/api/interview-session.test.ts @@ -1,149 +1,351 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -let mockSessionStore: Record = {}; -function resetStore() { mockSessionStore = {}; } - -const VALID_MODES = ['quick_drill', 'role_interview', 'technical_screen', 'client_communication', 'final_interview', 'practical_debrief']; - -function createSession(user: any, body: any) { - if (!user) return { status: 401, data: { error: 'Unauthorized' } }; - const rawMode = String(body.mode || ''); - const mode = rawMode.replace(/<[^>]*>/g, '').trim(); - const targetRole = body.targetRole ? String(body.targetRole).replace(/<[^>]*>/g, '').trim() : null; - if (!VALID_MODES.includes(mode)) return { status: 400, data: { error: 'Invalid interview mode' } }; - let questionCount = 10; - if (mode === 'quick_drill') questionCount = 5; - else if (mode === 'role_interview') questionCount = 10; - else if (mode === 'technical_screen') questionCount = 8; - else if (mode === 'client_communication') questionCount = 8; - else if (mode === 'final_interview') questionCount = 10; - else if (mode === 'practical_debrief') questionCount = 5; - const session = { id: 'sess_' + Date.now(), userId: user.id, mode, targetRole, startedAt: new Date() }; - mockSessionStore[session.id] = session; - return { status: 200, data: { session, questionCount } }; +/** + * @vitest-environment node + * + * Exercises the real handlers in src/app/api/interview/route.ts, + * src/app/api/interview/[id]/route.ts, and + * src/app/api/interview/[id]/complete/route.ts with mocked db/auth. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const questionFindMany = vi.fn(); +const sessionCreate = vi.fn(); +const sessionFindMany = vi.fn(); +const sessionFindUnique = vi.fn(); +const sessionUpdate = vi.fn(); +const attemptCreate = vi.fn(); +const attemptFindMany = vi.fn(); +const getUserFromRequest = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + question: { findMany: (...args: unknown[]) => questionFindMany(...args) }, + interviewSession: { + create: (...args: unknown[]) => sessionCreate(...args), + findMany: (...args: unknown[]) => sessionFindMany(...args), + findUnique: (...args: unknown[]) => sessionFindUnique(...args), + update: (...args: unknown[]) => sessionUpdate(...args), + }, + questionAttempt: { + create: (...args: unknown[]) => attemptCreate(...args), + findMany: (...args: unknown[]) => attemptFindMany(...args), + }, + }, +})); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +import { POST as createInterview, GET as listInterviews } from '@/app/api/interview/route'; +import { GET as getSession, POST as submitAnswer } from '@/app/api/interview/[id]/route'; +import { POST as completeSession } from '@/app/api/interview/[id]/complete/route'; + +function req(body?: unknown) { + return new Request('http://localhost/api/interview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); } -function submitAnswer(user: any, sessionId: string, body: any) { - if (!user) return { status: 401, data: { error: 'Unauthorized' } }; - const session = mockSessionStore[sessionId]; - if (!session) return { status: 404, data: { error: 'Session not found' } }; - if (session.userId !== user.id) return { status: 403, data: { error: 'Forbidden' } }; - return { status: 201, data: { id: 'att_' + Date.now(), sessionId, questionId: body.questionId, userAnswer: body.userAnswer, score: body.score ?? null, aiFeedback: body.aiFeedback ?? null } }; +function getReq() { + return new Request('http://localhost/api/interview'); } -function getSession(user: any, sessionId: string) { - if (!user) return { status: 401, data: { error: 'Unauthorized' } }; - const session = mockSessionStore[sessionId]; - if (!session) return { status: 404, data: { error: 'Session not found' } }; - if (session.userId !== user.id) return { status: 403, data: { error: 'Forbidden' } }; - return { status: 200, data: { ...session, attempts: [] } }; +function params(id: string) { + return { params: Promise.resolve({ id }) }; } const mockUser = { id: 'user_123', email: 'test@example.com', name: 'Test User', subscriptionTier: 'free', isAdmin: false }; -const MALICIOUS_MODE = 'quick_drill'; -describe('Interview session creation', () => { - beforeEach(() => { resetStore(); }); - it('creates session with valid mode and targetRole', () => { - const r = createSession(mockUser, { mode: 'quick_drill', targetRole: 'PPC VA' }); - expect(r.status).toBe(200); - expect(r.data.session.mode).toBe('quick_drill'); - expect(r.data.session.targetRole).toBe('PPC VA'); +const sampleQuestions = Array.from({ length: 20 }, (_, i) => ({ + id: `q${i}`, question: `Question ${i}`, role: 'PPC VA', difficulty: 'easy', type: 'behavioral', + skillArea: 'PPC', answerFormat: 'text', timeLimit: 60, whyEmployersAsk: 'x', strongAnswerPoints: 'y', weakAnswerWarnings: 'z', +})); + +describe('POST /api/interview (session creation)', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + questionFindMany.mockReset(); + sessionCreate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + questionFindMany.mockResolvedValue(sampleQuestions); + sessionCreate.mockImplementation(async ({ data }: { data: Record }) => ({ + id: 'sess_1', ...data, startedAt: new Date('2026-01-01'), + })); }); - it('rejects invalid mode with 400', () => { - const r = createSession(mockUser, { mode: 'invalid_mode' }); - expect(r.status).toBe(400); - expect(r.data.error).toContain('mode'); + + it('rejects unauthenticated request with 401, without touching the db', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await createInterview(req({ mode: 'quick_drill' })); + expect(res.status).toBe(401); + expect(questionFindMany).not.toHaveBeenCalled(); }); - it('rejects empty mode with 400', () => { - expect(createSession(mockUser, { mode: '' }).status).toBe(400); - expect(createSession(mockUser, {}).status).toBe(400); + + it('rejects an invalid mode with 400', async () => { + const res = await createInterview(req({ mode: 'invalid_mode' })); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('mode'); }); - it('rejects unauthenticated request with 401', () => { - expect(createSession(null, { mode: 'quick_drill' }).status).toBe(401); + + it('rejects an empty/missing mode with 400', async () => { + expect((await createInterview(req({ mode: '' }))).status).toBe(400); + expect((await createInterview(req({}))).status).toBe(400); }); - it('picks 5 questions for quick_drill', () => { - expect(createSession(mockUser, { mode: 'quick_drill' }).data.questionCount).toBe(5); + + it('creates a session with the given mode and targetRole', async () => { + const res = await createInterview(req({ mode: 'quick_drill', targetRole: 'PPC VA' })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.session.mode).toBe('quick_drill'); + expect(body.session.targetRole).toBe('PPC VA'); }); - it('picks 10 questions for role_interview', () => { - expect(createSession(mockUser, { mode: 'role_interview' }).data.questionCount).toBe(10); + + it.each([ + ['quick_drill', 5], + ['role_interview', 10], + ['technical_screen', 8], + ['client_communication', 8], + ['final_interview', 10], + ['practical_debrief', 5], + ])('picks %s questions for mode %s', async (mode, count) => { + const res = await createInterview(req({ mode })); + const body = await res.json(); + expect(body.questions.length).toBe(count); }); - it('picks 8 questions for technical_screen', () => { - expect(createSession(mockUser, { mode: 'technical_screen' }).data.questionCount).toBe(8); + + it('sanitizes HTML out of the mode string before validating', async () => { + const res = await createInterview(req({ mode: 'quick_drill' })); + expect(res.status).toBe(200); + expect((await res.json()).session.mode).toBe('quick_drill'); }); - it('picks 8 questions for client_communication', () => { - expect(createSession(mockUser, { mode: 'client_communication' }).data.questionCount).toBe(8); + + it('scopes technical_screen to technical question types and PPC-adjacent skill areas', async () => { + await createInterview(req({ mode: 'technical_screen' })); + expect(questionFindMany).toHaveBeenCalledWith({ + where: { + status: 'published', + type: { in: ['technical', 'scenario', 'case_study'] }, + skillArea: { in: ['PPC', 'reporting', 'optimization', 'keyword_research', 'campaign_structure'] }, + }, + }); }); - it('picks 10 questions for final_interview', () => { - expect(createSession(mockUser, { mode: 'final_interview' }).data.questionCount).toBe(10); + + it('scopes role_interview to the target role plus General questions', async () => { + await createInterview(req({ mode: 'role_interview', targetRole: 'Listing VA' })); + expect(questionFindMany).toHaveBeenCalledWith({ + where: { status: 'published', role: { in: ['Listing VA', 'General'] } }, + }); }); - it('picks 5 questions for practical_debrief', () => { - expect(createSession(mockUser, { mode: 'practical_debrief' }).data.questionCount).toBe(5); + + it('persists the selected question ids in the session transcript', async () => { + await createInterview(req({ mode: 'quick_drill' })); + const data = sessionCreate.mock.calls[0][0].data; + const transcript = JSON.parse(data.transcript as string); + expect(transcript.questions).toHaveLength(5); }); - it('sanitizes HTML from mode string', () => { - const r = createSession(mockUser, { mode: MALICIOUS_MODE }); - expect(r.status).toBe(200); - expect(r.data.session.mode).toBe('quick_drill'); + + it('returns 500 when the db throws', async () => { + questionFindMany.mockRejectedValue(new Error('db down')); + const res = await createInterview(req({ mode: 'quick_drill' })); + expect(res.status).toBe(500); }); }); -describe('Answer submission', () => { - beforeEach(() => { resetStore(); }); - it('rejects unauthenticated with 401', () => { - expect(submitAnswer(null, 'sess_1', { questionId: 'q1', userAnswer: 'Test' }).status).toBe(401); - }); - it('returns 404 for nonexistent session', () => { - expect(submitAnswer(mockUser, 'nonexistent', { questionId: 'q1', userAnswer: 'Test' }).status).toBe(404); - }); - it('returns 403 for non-owner', () => { - createSession({ ...mockUser, id: 'other_user' }, { mode: 'quick_drill' }); - const sid = Object.keys(mockSessionStore)[0]; - expect(submitAnswer(mockUser, sid, { questionId: 'q1', userAnswer: 'Test' }).status).toBe(403); - }); - it('submits answer with 201', () => { - createSession(mockUser, { mode: 'quick_drill' }); - const sid = Object.keys(mockSessionStore)[0]; - const r = submitAnswer(mockUser, sid, { questionId: 'q1', userAnswer: 'Good answer' }); - expect(r.status).toBe(201); - expect(r.data.questionId).toBe('q1'); - expect(r.data.userAnswer).toBe('Good answer'); - }); - it('stores AI score and feedback', () => { - createSession(mockUser, { mode: 'quick_drill' }); - const sid = Object.keys(mockSessionStore)[0]; - const r = submitAnswer(mockUser, sid, { questionId: 'q1', userAnswer: 'A', score: 85, aiFeedback: 'Great!' }); - expect(r.status).toBe(201); - expect(r.data.score).toBe(85); - expect(r.data.aiFeedback).toBe('Great!'); - }); - it('stores null score when not provided', () => { - createSession(mockUser, { mode: 'quick_drill' }); - const sid = Object.keys(mockSessionStore)[0]; - const r = submitAnswer(mockUser, sid, { questionId: 'q1', userAnswer: 'A' }); - expect(r.status).toBe(201); - expect(r.data.score).toBeNull(); - expect(r.data.aiFeedback).toBeNull(); +describe('GET /api/interview (list)', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + sessionFindMany.mockReset(); + }); + + it('rejects unauthenticated GET with 401', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await listInterviews(getReq()); + expect(res.status).toBe(401); + }); + + it('scopes to the requesting user, newest first, capped at 20', async () => { + getUserFromRequest.mockResolvedValue(mockUser); + sessionFindMany.mockResolvedValue([]); + await listInterviews(getReq()); + expect(sessionFindMany).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + orderBy: { startedAt: 'desc' }, + take: 20, + }); }); }); -describe('Session retrieval', () => { - beforeEach(() => { resetStore(); }); - it('returns session for owner', () => { - createSession(mockUser, { mode: 'quick_drill', targetRole: 'PPC VA' }); - const sid = Object.keys(mockSessionStore)[0]; - const r = getSession(mockUser, sid); - expect(r.status).toBe(200); - expect(r.data.targetRole).toBe('PPC VA'); - }); - it('rejects unauthenticated GET with 401', () => { - expect(getSession(null, 'sess_1').status).toBe(401); - }); - it('returns 403 for non-owner', () => { - createSession({ ...mockUser, id: 'other_user' }, { mode: 'quick_drill' }); - const sid = Object.keys(mockSessionStore)[0]; - expect(getSession(mockUser, sid).status).toBe(403); - }); - it('returns 404 for nonexistent session', () => { - expect(getSession(mockUser, 'nonexistent').status).toBe(404); +describe('Answer submission (POST /api/interview/[id])', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + sessionFindUnique.mockReset(); + attemptCreate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); + + it('rejects unauthenticated with 401', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'Test' }), params('sess_1')); + expect(res.status).toBe(401); + }); + + it('returns 404 for a nonexistent session', async () => { + sessionFindUnique.mockResolvedValue(null); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'Test' }), params('nonexistent')); + expect(res.status).toBe(404); + }); + + it('returns 403 for a non-owner', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'other_user' }); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'Test' }), params('sess_1')); + expect(res.status).toBe(403); + expect(attemptCreate).not.toHaveBeenCalled(); + }); + + it('submits an answer with 201', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptCreate.mockResolvedValue({ id: 'att_1', sessionId: 'sess_1', questionId: 'q1', userAnswer: 'Good answer', score: null, aiFeedback: null, rubricBreakdown: null }); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'Good answer' }), params('sess_1')); + const body = await res.json(); + expect(res.status).toBe(201); + expect(body.questionId).toBe('q1'); + expect(body.userAnswer).toBe('Good answer'); + }); + + it('stores AI score, feedback, and rubric breakdown', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptCreate.mockResolvedValue({ + id: 'att_1', sessionId: 'sess_1', questionId: 'q1', userAnswer: 'A', score: 85, aiFeedback: 'Great!', + rubricBreakdown: JSON.stringify({ clarity: 9 }), + }); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'A', score: 85, aiFeedback: 'Great!', rubricBreakdown: { clarity: 9 } }), params('sess_1')); + const body = await res.json(); + expect(body.score).toBe(85); + expect(body.aiFeedback).toBe('Great!'); + expect(body.rubricBreakdown).toEqual({ clarity: 9 }); + expect(attemptCreate).toHaveBeenCalledWith({ + data: { sessionId: 'sess_1', questionId: 'q1', userAnswer: 'A', aiFeedback: 'Great!', score: 85, rubricBreakdown: JSON.stringify({ clarity: 9 }) }, + }); + }); + + it('stores a null rubricBreakdown when not provided', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptCreate.mockResolvedValue({ id: 'att_1', sessionId: 'sess_1', questionId: 'q1', userAnswer: 'A', score: null, aiFeedback: null, rubricBreakdown: null }); + const res = await submitAnswer(req({ questionId: 'q1', userAnswer: 'A' }), params('sess_1')); + expect((await res.json()).rubricBreakdown).toBeNull(); + }); +}); + +describe('Session retrieval (GET /api/interview/[id])', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + sessionFindUnique.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); + + it('returns the session with parsed attempt rubrics for the owner', async () => { + sessionFindUnique.mockResolvedValue({ + id: 'sess_1', userId: 'user_123', mode: 'quick_drill', targetRole: 'PPC VA', + attempts: [{ id: 'att_1', rubricBreakdown: JSON.stringify({ clarity: 8 }) }], + }); + const res = await getSession(getReq(), params('sess_1')); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.targetRole).toBe('PPC VA'); + expect(body.attempts[0].rubricBreakdown).toEqual({ clarity: 8 }); + }); + + it('rejects unauthenticated GET with 401', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await getSession(getReq(), params('sess_1')); + expect(res.status).toBe(401); + }); + + it('returns 403 for a non-owner', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'other_user', attempts: [] }); + const res = await getSession(getReq(), params('sess_1')); + expect(res.status).toBe(403); + }); + + it('returns 404 for a nonexistent session', async () => { + sessionFindUnique.mockResolvedValue(null); + const res = await getSession(getReq(), params('nonexistent')); + expect(res.status).toBe(404); + }); +}); + +describe('Session completion (POST /api/interview/[id]/complete)', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + sessionFindUnique.mockReset(); + attemptFindMany.mockReset(); + sessionUpdate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); + + it('rejects unauthenticated with 401', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await completeSession(req(), params('sess_1')); + expect(res.status).toBe(401); + }); + + it('returns 404 for a nonexistent session', async () => { + sessionFindUnique.mockResolvedValue(null); + const res = await completeSession(req(), params('nonexistent')); + expect(res.status).toBe(404); + }); + + it('returns 403 for a non-owner', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'other_user' }); + const res = await completeSession(req(), params('sess_1')); + expect(res.status).toBe(403); + }); + + it('computes the average score across attempts and marks the session complete', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptFindMany.mockResolvedValue([{ score: 80 }, { score: 90 }, { score: 70 }]); + sessionUpdate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'sess_1', ...data })); + const res = await completeSession(req(), params('sess_1')); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.overallScore).toBe(80); + expect(body.totalQuestions).toBe(3); + expect(sessionUpdate).toHaveBeenCalledWith({ + where: { id: 'sess_1' }, + data: expect.objectContaining({ overallScore: 80 }), + }); + }); + + it('treats attempts with a null score as 0 when averaging', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptFindMany.mockResolvedValue([{ score: 100 }, { score: null }]); + sessionUpdate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'sess_1', ...data })); + const res = await completeSession(req(), params('sess_1')); + expect((await res.json()).overallScore).toBe(50); + }); + + it('returns overallScore 0 when there are no attempts', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptFindMany.mockResolvedValue([]); + sessionUpdate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'sess_1', ...data })); + const res = await completeSession(req(), params('sess_1')); + expect((await res.json()).overallScore).toBe(0); + }); + + it('stringifies an object transcript before storing it', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptFindMany.mockResolvedValue([]); + sessionUpdate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'sess_1', ...data })); + await completeSession(req({ transcript: { turns: [1, 2] } }), params('sess_1')); + expect(sessionUpdate.mock.calls[0][0].data.transcript).toBe(JSON.stringify({ turns: [1, 2] })); + }); + + it('tolerates a missing/empty request body', async () => { + sessionFindUnique.mockResolvedValue({ id: 'sess_1', userId: 'user_123' }); + attemptFindMany.mockResolvedValue([]); + sessionUpdate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'sess_1', ...data })); + const res = await completeSession(req(), params('sess_1')); + expect(res.status).toBe(200); }); }); diff --git a/__tests__/api/profile-dashboard.test.ts b/__tests__/api/profile-dashboard.test.ts index cd57a76..45bba67 100644 --- a/__tests__/api/profile-dashboard.test.ts +++ b/__tests__/api/profile-dashboard.test.ts @@ -1,305 +1,264 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -let currentUser: any = null; -let profiles: any[] = []; -let users: any[] = []; -let sessions: any[] = []; -let resumes: any[] = []; -let coverLetters: any[] = []; -let questionAttempts: any[] = []; -let upsertResult: any = null; - -function reset() { - currentUser = null; - profiles = []; - users = []; - sessions = []; - resumes = []; - coverLetters = []; - questionAttempts = []; - upsertResult = null; +/** + * @vitest-environment node + * + * Exercises the real handlers in src/app/api/profile/route.ts and + * src/app/api/dashboard/route.ts with mocked db/auth. sanitize.ts is left + * unmocked (it's pure) so sanitization is verified end-to-end too. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const profileFindUnique = vi.fn(); +const profileUpsert = vi.fn(); +const userFindUnique = vi.fn(); +const sessionFindMany = vi.fn(); +const sessionCount = vi.fn(); +const resumeFindMany = vi.fn(); +const coverLetterFindMany = vi.fn(); +const attemptFindMany = vi.fn(); +const attemptCount = vi.fn(); +const attemptAggregate = vi.fn(); +const getUserFromRequest = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + userProfile: { + findUnique: (...args: unknown[]) => profileFindUnique(...args), + upsert: (...args: unknown[]) => profileUpsert(...args), + }, + user: { findUnique: (...args: unknown[]) => userFindUnique(...args) }, + interviewSession: { + findMany: (...args: unknown[]) => sessionFindMany(...args), + count: (...args: unknown[]) => sessionCount(...args), + }, + resume: { findMany: (...args: unknown[]) => resumeFindMany(...args) }, + coverLetter: { findMany: (...args: unknown[]) => coverLetterFindMany(...args) }, + questionAttempt: { + findMany: (...args: unknown[]) => attemptFindMany(...args), + count: (...args: unknown[]) => attemptCount(...args), + aggregate: (...args: unknown[]) => attemptAggregate(...args), + }, + }, +})); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +import { GET as profileGet, PUT as profilePut } from '@/app/api/profile/route'; +import { GET as dashboardGet } from '@/app/api/dashboard/route'; + +function getReq() { + return new Request('http://localhost/api/profile'); } -function req() { - return { headers: { get: () => null } }; -} - -function putReq(body: any) { - return { headers: { get: () => null }, json: async () => body }; -} - -// --- Profile handlers --- -function sanitizeText(v: any) { - if (typeof v !== 'string') return v; - return v.replace(/<[^>]*>/g, '').trim().substring(0, 2000); -} - -async function profileGet(request: any) { - try { - if (!currentUser) return { status: 401, body: { error: 'Unauthorized' } }; - const profile = profiles.find(p => p.userId === currentUser.id); - if (!profile) return { status: 200, body: { onboardingDone: false } }; - return { - status: 200, - body: { - ...profile, - toolsKnown: profile.toolsKnown ? JSON.parse(profile.toolsKnown) : null, - weakAreas: profile.weakAreas ? JSON.parse(profile.weakAreas) : null, - }, - }; - } catch { - return { status: 500, body: { error: 'Failed to fetch profile' } }; - } -} - -const ALLOWED_FIELDS = ['targetRole', 'experienceLevel', 'toolsKnown', 'weakAreas', - 'interviewDate', 'confidenceLevel', 'resumeStatus', 'country', 'onboardingDone']; - -async function profilePut(request: any) { - try { - if (!currentUser) return { status: 401, body: { error: 'Unauthorized' } }; - const rawData = await request.json(); - const data: Record = {}; - for (const key of ALLOWED_FIELDS) { - if (key in rawData) { - if (key === 'onboardingDone') data[key] = typeof rawData[key] === 'boolean' ? rawData[key] : true; - else if (key === 'toolsKnown' || key === 'weakAreas') { - const val = rawData[key]; - if (Array.isArray(val)) data[key] = JSON.stringify(val.map((v: string) => sanitizeText(v)).filter(Boolean)); - else if (typeof val === 'string') { const c = sanitizeText(val); if (c) data[key] = JSON.stringify([c]); } - } else { - data[key] = sanitizeText(rawData[key]); - } - } - } - upsertResult = { userId: currentUser.id, ...data }; - return { status: 200, body: upsertResult }; - } catch { - return { status: 500, body: { error: 'Failed to update profile' } }; - } +function putReq(body: unknown) { + return new Request('http://localhost/api/profile', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); } -// --- Dashboard handler --- -async function dashboardGet(request: any) { - try { - if (!currentUser) return { status: 401, body: { error: 'Unauthorized' } }; - const user = users.find(u => u.id === currentUser.id); - const profile = profiles.find(p => p.userId === currentUser.id); - const userSessions = sessions.filter(s => s.userId === currentUser.id).slice(0, 5); - const userResumes = resumes.filter(r => r.userId === currentUser.id).slice(0, 5); - const userCLs = coverLetters.filter(c => c.userId === currentUser.id).slice(0, 5); - const userAttempts = questionAttempts.filter(a => a.userId === currentUser.id).slice(0, 10); - const totalSessions = sessions.filter(s => s.userId === currentUser.id).length; - const completedSessions = sessions.filter(s => s.userId === currentUser.id && s.completedAt).length; - const totalAttempts = userAttempts.length; - const scored = userAttempts.filter(a => a.score != null); - const avgScore = scored.length > 0 ? scored.reduce((s, a) => s + a.score, 0) / scored.length : 0; - const latestResumeScore = userResumes.length > 0 ? userResumes[0].score : null; - return { - status: 200, - body: { - user: user ? { id: user.id, email: user.email, name: user.name, subscriptionTier: user.subscriptionTier, isAdmin: user.isAdmin, createdAt: user.createdAt } : null, - profile: profile ? { ...profile, toolsKnown: profile.toolsKnown ? JSON.parse(profile.toolsKnown) : null, weakAreas: profile.weakAreas ? JSON.parse(profile.weakAreas) : null } : null, - stats: { totalSessions, completedSessions, totalAttempts, avgScore: Math.round(avgScore * 10) / 10, latestResumeScore }, - recentSessions: userSessions, - recentResumes: userResumes, - recentCoverLetters: userCLs, - recentAttempts: userAttempts, - }, - }; - } catch { - return { status: 500, body: { error: 'Failed to fetch dashboard data' } }; - } -} +const mockUser = { id: 'u1', email: 'u@test.com' }; describe('GET /api/profile', () => { - beforeEach(() => reset()); + beforeEach(() => { + getUserFromRequest.mockReset(); + profileFindUnique.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); it('returns 401 when not authenticated', async () => { - currentUser = null; - const res = await profileGet(req()); + getUserFromRequest.mockResolvedValue(null); + const res = await profileGet(getReq()); expect(res.status).toBe(401); }); it('returns onboardingDone:false when no profile exists', async () => { - currentUser = { id: 'u1' }; - const res = await profileGet(req()); + profileFindUnique.mockResolvedValue(null); + const res = await profileGet(getReq()); expect(res.status).toBe(200); - expect(res.body.onboardingDone).toBe(false); + expect(await res.json()).toEqual({ onboardingDone: false }); }); - it('returns profile data when it exists', async () => { - currentUser = { id: 'u1' }; - profiles.push({ userId: 'u1', targetRole: 'PPC VA', experienceLevel: 'mid', toolsKnown: JSON.stringify(['Excel']), weakAreas: JSON.stringify(['analytics']), country: 'PH', onboardingDone: true }); - const res = await profileGet(req()); - expect(res.status).toBe(200); - expect(res.body.targetRole).toBe('PPC VA'); - expect(res.body.toolsKnown).toEqual(['Excel']); - expect(res.body.weakAreas).toEqual(['analytics']); + it('returns profile data with toolsKnown/weakAreas parsed from JSON', async () => { + profileFindUnique.mockResolvedValue({ + userId: 'u1', targetRole: 'PPC VA', toolsKnown: JSON.stringify(['Excel']), + weakAreas: JSON.stringify(['analytics']), onboardingDone: true, + }); + const res = await profileGet(getReq()); + const body = await res.json(); + expect(body.targetRole).toBe('PPC VA'); + expect(body.toolsKnown).toEqual(['Excel']); + expect(body.weakAreas).toEqual(['analytics']); }); - it('parses toolsKnown and weakAreas from JSON strings', async () => { - currentUser = { id: 'u1' }; - profiles.push({ userId: 'u1', targetRole: 'Account VA', toolsKnown: JSON.stringify(['Canva', 'Sheets']), weakAreas: JSON.stringify(['data entry']), onboardingDone: true }); - const res = await profileGet(req()); - expect(res.body.toolsKnown).toEqual(['Canva', 'Sheets']); - expect(res.body.weakAreas).toEqual(['data entry']); + it('returns null for toolsKnown/weakAreas when they are null', async () => { + profileFindUnique.mockResolvedValue({ userId: 'u1', toolsKnown: null, weakAreas: null }); + const res = await profileGet(getReq()); + const body = await res.json(); + expect(body.toolsKnown).toBeNull(); + expect(body.weakAreas).toBeNull(); }); - it('returns null for toolsKnown/weakAreas when they are null', async () => { - currentUser = { id: 'u1' }; - profiles.push({ userId: 'u1', targetRole: 'PPC VA', toolsKnown: null, weakAreas: null, onboardingDone: false }); - const res = await profileGet(req()); - expect(res.body.toolsKnown).toBeNull(); - expect(res.body.weakAreas).toBeNull(); + it('returns 500 when the db throws', async () => { + profileFindUnique.mockRejectedValue(new Error('db down')); + const res = await profileGet(getReq()); + expect(res.status).toBe(500); }); }); describe('PUT /api/profile', () => { - beforeEach(() => reset()); + beforeEach(() => { + getUserFromRequest.mockReset(); + profileUpsert.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + profileUpsert.mockImplementation(async ({ create }: { create: Record }) => create); + }); it('returns 401 when not authenticated', async () => { - currentUser = null; + getUserFromRequest.mockResolvedValue(null); const res = await profilePut(putReq({ targetRole: 'PPC VA' })); expect(res.status).toBe(401); }); it('updates targetRole', async () => { - currentUser = { id: 'u1' }; await profilePut(putReq({ targetRole: 'Account VA' })); - expect(upsertResult.targetRole).toBe('Account VA'); + expect(profileUpsert.mock.calls[0][0].update.targetRole).toBe('Account VA'); }); it('sanitizes text fields (strips HTML)', async () => { - currentUser = { id: 'u1' }; - await profilePut(putReq({ targetRole: 'PPC VA' })); - expect(upsertResult.targetRole).not.toContain('PPC VA' })); + const body = await res.json(); + expect(body.targetRole).not.toContain('My resume' })); - expect(res.body.originalText).not.toContain('Resume text', targetRole: 'PPC VA' })); + const data = resumeCreate.mock.calls[0][0].data; + expect(data.originalText).toBe('Resume text'); + expect(data.targetRole).toBe('PPC VA'); }); - it('returns 500 on json error', async () => { - currentUser = { id: 'u1' }; - const badReq = { headers: { get: () => null }, json: async () => { throw new Error('bad'); } }; - const res = await resumePost(badReq); + it('returns 500 on a request.json() failure', async () => { + const badReq = { headers: { get: () => null }, json: async () => { throw new Error('bad'); } } as unknown as Request; + const res = await resumeCreateRoute(badReq); expect(res.status).toBe(500); }); }); -describe('GET /api/cover-letter', () => { - beforeEach(() => reset()); +describe('GET/PUT /api/resume/[id]', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + resumeFindUnique.mockReset(); + resumeUpdate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); - it('returns 401 when not authenticated', async () => { - currentUser = null; - const res = await clGet(); - expect(res.status).toBe(401); + it('returns 404 when the resume does not exist', async () => { + resumeFindUnique.mockResolvedValue(null); + const res = await resumeGetById(getReq(), params('missing')); + expect(res.status).toBe(404); + }); + + it('returns 403 for a non-owner', async () => { + resumeFindUnique.mockResolvedValue({ id: 'r1', userId: 'other' }); + const res = await resumeGetById(getReq(), params('r1')); + expect(res.status).toBe(403); + }); + + it('parses truthFlags JSON for the owner', async () => { + resumeFindUnique.mockResolvedValue({ id: 'r1', userId: 'u1', truthFlags: JSON.stringify(['flag1']) }); + const res = await resumeGetById(getReq(), params('r1')); + expect((await res.json()).truthFlags).toEqual(['flag1']); }); - it('returns cover letters for user', async () => { - currentUser = { id: 'u1' }; - coverLetters.push({ id: 'cl1', userId: 'u1', jobDescription: 'VA role' }); - const res = await clGet(); + it('PUT rejects a non-owner with 403 without writing', async () => { + resumeFindUnique.mockResolvedValue({ id: 'r1', userId: 'other' }); + const res = await resumePut(putReq({ score: 90 }), params('r1')); + expect(res.status).toBe(403); + expect(resumeUpdate).not.toHaveBeenCalled(); + }); + + it('PUT updates score/improvedVersion/truthFlags for the owner', async () => { + resumeFindUnique.mockResolvedValue({ id: 'r1', userId: 'u1' }); + resumeUpdate.mockResolvedValue({ id: 'r1', score: 90, improvedVersion: 'better', truthFlags: JSON.stringify(['x']) }); + const res = await resumePut(putReq({ score: 90, improvedVersion: 'better', truthFlags: ['x'] }), params('r1')); + const body = await res.json(); expect(res.status).toBe(200); - expect(res.body.coverLetters.length).toBe(1); + expect(body.score).toBe(90); + expect(body.truthFlags).toEqual(['x']); + expect(resumeUpdate).toHaveBeenCalledWith({ where: { id: 'r1' }, data: { score: 90, improvedVersion: 'better', truthFlags: JSON.stringify(['x']) } }); }); +}); - it('does not return other users cover letters', async () => { - currentUser = { id: 'u1' }; - coverLetters.push({ id: 'cl1', userId: 'u1' }); - coverLetters.push({ id: 'cl2', userId: 'u2' }); - const res = await clGet(); - expect(res.body.coverLetters.length).toBe(1); +describe('GET /api/cover-letter', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + clFindMany.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + }); + + it('returns 401 when not authenticated', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await clList(getReq('http://localhost/api/cover-letter')); + expect(res.status).toBe(401); + }); + + it('scopes to the authenticated user', async () => { + clFindMany.mockResolvedValue([]); + await clList(getReq('http://localhost/api/cover-letter')); + expect(clFindMany).toHaveBeenCalledWith({ where: { userId: 'u1' }, orderBy: { createdAt: 'desc' } }); }); }); describe('POST /api/cover-letter', () => { - beforeEach(() => reset()); + beforeEach(() => { + getUserFromRequest.mockReset(); + clCreate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); + clCreate.mockImplementation(async ({ data }: { data: Record }) => ({ id: 'cl1', ...data })); + }); it('returns 401 when not authenticated', async () => { - currentUser = null; - const res = await clPost(postReq({ jobDescription: 'VA role' })); + getUserFromRequest.mockResolvedValue(null); + const res = await clCreateRoute(postReq({ jobDescription: 'desc' }, 'http://localhost/api/cover-letter')); expect(res.status).toBe(401); }); it('returns 400 when jobDescription is empty', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: '' })); + const res = await clCreateRoute(postReq({ jobDescription: '' }, 'http://localhost/api/cover-letter')); expect(res.status).toBe(400); - expect(res.body.error).toContain('Job description is required'); + expect((await res.json()).error).toContain('required'); }); - it('creates cover letter with default tone', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'PPC VA role', tone: 'invalid_tone' })); - expect(res.status).toBe(201); - expect(res.body.tone).toBe('formal'); - }); + it('defaults tone to "formal" for an invalid/missing tone', async () => { + const res = await clCreateRoute(postReq({ jobDescription: 'A job' }, 'http://localhost/api/cover-letter')); + expect((await res.json()).tone).toBe('formal'); - it('accepts valid tone', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role', tone: 'upwork' })); - expect(res.body.tone).toBe('upwork'); + const res2 = await clCreateRoute(postReq({ jobDescription: 'A job', tone: 'not-a-real-tone' }, 'http://localhost/api/cover-letter')); + expect((await res2.json()).tone).toBe('formal'); }); - it('accepts all allowed tones', async () => { - currentUser = { id: 'u1' }; - for (const tone of ALLOWED_TONES) { - coverLetters = []; - const res = await clPost(postReq({ jobDescription: 'VA role', tone })); - expect(res.body.tone).toBe(tone); + it.each(['formal', 'conversational', 'beginner_friendly', 'agency', 'upwork', 'cold_email', 'linkedin', 'professional'])( + 'accepts the "%s" tone as-is', + async (tone) => { + const res = await clCreateRoute(postReq({ jobDescription: 'A job', tone }, 'http://localhost/api/cover-letter')); + expect((await res.json()).tone).toBe(tone); } + ); + + it('stores truth flags as a JSON string, filtering out non-string entries', async () => { + // The POST response returns the raw created row (unlike GET/PUT by id, + // which JSON.parse the field back out) — so truthFlags comes back as + // the serialized string here. + const res = await clCreateRoute(postReq({ jobDescription: 'A job', truthFlags: ['flag1', 42, 'flag2', ''] }, 'http://localhost/api/cover-letter')); + expect((await res.json()).truthFlags).toBe(JSON.stringify(['flag1', 'flag2'])); }); - it('stores truth flags as JSON string', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role', truthFlags: ['3+ years experience', 'Amazon trained'] })); - expect(res.body.truthFlags).toBe(JSON.stringify(['3+ years experience', 'Amazon trained'])); + it('stores null truthFlags when the array is empty or absent', async () => { + const res = await clCreateRoute(postReq({ jobDescription: 'A job', truthFlags: [] }, 'http://localhost/api/cover-letter')); + expect((await res.json()).truthFlags).toBeNull(); + + const res2 = await clCreateRoute(postReq({ jobDescription: 'A job' }, 'http://localhost/api/cover-letter')); + expect((await res2.json()).truthFlags).toBeNull(); }); - it('filters non-string truth flags', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role', truthFlags: ['valid', 123, null, 'also valid'] })); - expect(res.body.truthFlags).toBe(JSON.stringify(['valid', 'also valid'])); + it('sanitizes jobDescription (strips HTML) but allows rich formatting in generatedLetter', async () => { + const res = await clCreateRoute(postReq({ + jobDescription: 'We need a PPC VA', + generatedLetter: 'Dear Hiring Manager,\n\nI am excited...', + }, 'http://localhost/api/cover-letter')); + const body = await res.json(); + expect(body.jobDescription).toBe('We need a PPC VA'); + expect(body.generatedLetter).toContain('Dear Hiring Manager'); }); - it('returns null truthFlags when array is empty', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role', truthFlags: [] })); - expect(res.body.truthFlags).toBeNull(); + it('scopes the created row to the authenticated user', async () => { + await clCreateRoute(postReq({ jobDescription: 'A job' }, 'http://localhost/api/cover-letter')); + expect(clCreate.mock.calls[0][0].data.userId).toBe('u1'); }); - it('returns null truthFlags when not provided', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role' })); - expect(res.body.truthFlags).toBeNull(); + it('returns 500 on a request.json() failure', async () => { + const badReq = { headers: { get: () => null }, json: async () => { throw new Error('bad'); } } as unknown as Request; + const res = await clCreateRoute(badReq); + expect(res.status).toBe(500); }); +}); - it('sanitizes generatedLetter', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'VA role', generatedLetter: ' Dear Hiring Manager, ' })); - expect(res.body.generatedLetter).toBe('Dear Hiring Manager,'); +describe('GET/PUT /api/cover-letter/[id]', () => { + beforeEach(() => { + getUserFromRequest.mockReset(); + clFindUnique.mockReset(); + clUpdate.mockReset(); + getUserFromRequest.mockResolvedValue(mockUser); }); - it('sanitizes jobDescription (strips HTML)', async () => { - currentUser = { id: 'u1' }; - const res = await clPost(postReq({ jobDescription: 'PPC VA role' })); - expect(res.body.jobDescription).toBe('PPC VA role'); + it('returns 404 when the cover letter does not exist', async () => { + clFindUnique.mockResolvedValue(null); + const res = await clGetById(getReq('http://localhost/api/cover-letter/missing'), params('missing')); + expect(res.status).toBe(404); }); - it('returns 500 on json error', async () => { - currentUser = { id: 'u1' }; - const badReq = { headers: { get: () => null }, json: async () => { throw new Error('bad'); } }; - const res = await clPost(badReq); - expect(res.status).toBe(500); + it('returns 403 for a non-owner and does not leak content', async () => { + clFindUnique.mockResolvedValue({ id: 'cl1', userId: 'other' }); + const res = await clGetById(getReq('http://localhost/api/cover-letter/cl1'), params('cl1')); + expect(res.status).toBe(403); + }); + + it('PUT updates generatedLetter/truthFlags for the owner', async () => { + clFindUnique.mockResolvedValue({ id: 'cl1', userId: 'u1' }); + clUpdate.mockResolvedValue({ id: 'cl1', generatedLetter: 'revised', truthFlags: JSON.stringify(['x']) }); + const res = await clPut(putReq({ generatedLetter: 'revised', truthFlags: ['x'] }, 'http://localhost/api/cover-letter/cl1'), params('cl1')); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.generatedLetter).toBe('revised'); + expect(body.truthFlags).toEqual(['x']); + }); + + it('PUT rejects a non-owner with 403 without writing', async () => { + clFindUnique.mockResolvedValue({ id: 'cl1', userId: 'other' }); + const res = await clPut(putReq({ generatedLetter: 'hacked' }, 'http://localhost/api/cover-letter/cl1'), params('cl1')); + expect(res.status).toBe(403); + expect(clUpdate).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/components/admin-panel.test.tsx b/__tests__/components/admin-panel.test.tsx new file mode 100644 index 0000000..c81eb0e --- /dev/null +++ b/__tests__/components/admin-panel.test.tsx @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AdminPanel } from '@/components/interview-lab/AdminPanel'; + +const mockUseAuth = vi.fn(); +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => mockUseAuth(), +})); + +function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +const adminUser = { id: 'admin1', email: 'admin@test.com', isAdmin: true, subscriptionTier: 'free' }; +const regularUser = { id: 'u1', email: 'user@test.com', isAdmin: false, subscriptionTier: 'free' }; + +function defaultFetchMock() { + return vi.fn((url: string) => { + if (url.startsWith('/api/admin/questions')) return jsonResponse({ questions: [], total: 0 }); + if (url === '/api/guides') return jsonResponse({ guides: [] }); + if (url === '/api/downloads') return jsonResponse({ downloads: [] }); + if (url === '/api/admin/analytics') { + return jsonResponse({ + stats: { totalUsers: 12, totalSessions: 34, totalAttempts: 56, avgScore: 7.2, totalQuestions: 100, totalGuides: 5, totalDownloads: 8, sessionsLast30Days: 9 }, + breakdowns: { usersByTier: { free: 12 } }, + }); + } + return jsonResponse({}); + }); +} + +describe('AdminPanel — access control', () => { + it('shows an access-denied message and does not fetch admin data for a non-admin user', async () => { + mockUseAuth.mockReturnValue({ user: regularUser }); + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + + render(); + expect(screen.getByText("You don't have admin access.")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('shows an access-denied message when there is no user at all', () => { + mockUseAuth.mockReturnValue({ user: null }); + global.fetch = defaultFetchMock(); + render(); + expect(screen.getByText("You don't have admin access.")).toBeInTheDocument(); + }); +}); + +describe('AdminPanel — questions tab (admin)', () => { + beforeEach(() => { + mockUseAuth.mockReturnValue({ user: adminUser }); + }); + + it('fetches questions, guides, downloads, and analytics on mount for an admin', async () => { + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + render(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/admin/questions')); + expect(fetchMock).toHaveBeenCalledWith('/api/guides'); + expect(fetchMock).toHaveBeenCalledWith('/api/downloads'); + expect(fetchMock).toHaveBeenCalledWith('/api/admin/analytics'); + }); + }); + + it('renders fetched questions with role/difficulty/type badges', async () => { + const fetchMock = vi.fn((url: string) => { + if (url.startsWith('/api/admin/questions')) { + return jsonResponse({ + questions: [{ id: 'q1', question: 'What is ACoS?', role: 'PPC VA', difficulty: 'beginner', type: 'technical', status: 'published' }], + total: 1, + }); + } + if (url === '/api/guides') return jsonResponse({ guides: [] }); + if (url === '/api/downloads') return jsonResponse({ downloads: [] }); + if (url === '/api/admin/analytics') return jsonResponse({ stats: {}, breakdowns: {} }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + await waitFor(() => expect(screen.getByText('What is ACoS?')).toBeInTheDocument()); + expect(screen.getByText('Question Database (1 total)')).toBeInTheDocument(); + }); + + it('re-fetches with role/status query params when filters change', async () => { + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/admin/questions'))); + + fetchMock.mockClear(); + fireEvent.click(screen.getByText('All Status')); + fireEvent.click(await screen.findByText('Published')); + + await waitFor(() => { + const called = fetchMock.mock.calls.some(([url]) => typeof url === 'string' && url.includes('status=published')); + expect(called).toBe(true); + }); + }); + + it('creates a new question via POST and resets the form', async () => { + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/admin/questions'))); + + fireEvent.click(screen.getByRole('button', { name: 'Add Question' })); + const questionField = screen.getByText('Question').nextElementSibling as HTMLElement; + fireEvent.change(questionField, { target: { value: 'How do you calculate ACoS?' } }); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Create Question' })); + + await waitFor(() => { + const postCall = fetchMock.mock.calls.find(([url, opts]) => url === '/api/admin/questions' && (opts as RequestInit)?.method === 'POST'); + expect(postCall).toBeDefined(); + const body = JSON.parse((postCall![1] as RequestInit).body as string); + expect(body.question).toBe('How do you calculate ACoS?'); + }); + + // Form resets/hides after save + await waitFor(() => expect(screen.queryByText('Question')).not.toBeInTheDocument()); + }); + + it('edits an existing question via PUT with its id', async () => { + const fetchMock = vi.fn((url: string) => { + if (url.startsWith('/api/admin/questions')) { + return jsonResponse({ + questions: [{ id: 'q1', question: 'Old question text', role: 'PPC VA', difficulty: 'beginner', type: 'technical', status: 'published', answerFormat: 'bullet' }], + total: 1, + }); + } + if (url === '/api/guides') return jsonResponse({ guides: [] }); + if (url === '/api/downloads') return jsonResponse({ downloads: [] }); + if (url === '/api/admin/analytics') return jsonResponse({ stats: {}, breakdowns: {} }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + await waitFor(() => expect(screen.getByText('Old question text')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + const questionField = screen.getByText('Question').nextElementSibling as HTMLElement; + expect(questionField).toHaveValue('Old question text'); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Update Question' })); + + await waitFor(() => { + const putCall = fetchMock.mock.calls.find(([url, opts]) => url === '/api/admin/questions' && (opts as RequestInit)?.method === 'PUT'); + expect(putCall).toBeDefined(); + const body = JSON.parse((putCall![1] as RequestInit).body as string); + expect(body.id).toBe('q1'); + }); + }); + + it('archives a question after confirmation, and skips the request when confirmation is declined', async () => { + const fetchMock = vi.fn((url: string) => { + if (url.startsWith('/api/admin/questions')) { + return jsonResponse({ + questions: [{ id: 'q1', question: 'A question', role: 'PPC VA', difficulty: 'beginner', type: 'technical', status: 'published' }], + total: 1, + }); + } + if (url === '/api/guides') return jsonResponse({ guides: [] }); + if (url === '/api/downloads') return jsonResponse({ downloads: [] }); + if (url === '/api/admin/analytics') return jsonResponse({ stats: {}, breakdowns: {} }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + await waitFor(() => expect(screen.getByText('A question')).toBeInTheDocument()); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Archive' })); + expect(confirmSpy).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + + confirmSpy.mockReturnValue(true); + fireEvent.click(screen.getByRole('button', { name: 'Archive' })); + await waitFor(() => { + const putCall = fetchMock.mock.calls.find(([url, opts]) => url === '/api/admin/questions' && (opts as RequestInit)?.method === 'PUT'); + expect(putCall).toBeDefined(); + const body = JSON.parse((putCall![1] as RequestInit).body as string); + expect(body).toEqual({ id: 'q1', status: 'archived' }); + }); + + confirmSpy.mockRestore(); + }); +}); + +describe('AdminPanel — guides and downloads tabs', () => { + beforeEach(() => { + mockUseAuth.mockReturnValue({ user: adminUser }); + }); + + it('creates a guide via POST from the Guides tab', async () => { + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/guides')); + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Guides' })); + fireEvent.click(screen.getByRole('button', { name: 'Add Guide' })); + fireEvent.change(screen.getByText('Title').nextElementSibling as HTMLElement, { target: { value: 'PPC Basics' } }); + fireEvent.change(screen.getByText('Content (Markdown)').nextElementSibling as HTMLElement, { target: { value: '# Intro' } }); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Create Guide' })); + + await waitFor(() => { + const postCall = fetchMock.mock.calls.find(([url, opts]) => url === '/api/guides' && (opts as RequestInit)?.method === 'POST'); + expect(postCall).toBeDefined(); + const body = JSON.parse((postCall![1] as RequestInit).body as string); + expect(body.title).toBe('PPC Basics'); + expect(body.slug).toBe('ppc-basics'); + }); + }); + + it('creates a download resource via POST from the Downloads tab', async () => { + const fetchMock = defaultFetchMock(); + global.fetch = fetchMock; + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/downloads')); + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Downloads' })); + fireEvent.click(screen.getByRole('button', { name: 'Add Download' })); + fireEvent.change(screen.getByText('Title').nextElementSibling as HTMLElement, { target: { value: 'ACoS Cheat Sheet' } }); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Add Download' })); + + await waitFor(() => { + const postCall = fetchMock.mock.calls.find(([url, opts]) => url === '/api/downloads' && (opts as RequestInit)?.method === 'POST'); + expect(postCall).toBeDefined(); + const body = JSON.parse((postCall![1] as RequestInit).body as string); + expect(body.title).toBe('ACoS Cheat Sheet'); + }); + }); +}); + +describe('AdminPanel — analytics tab', () => { + beforeEach(() => { + mockUseAuth.mockReturnValue({ user: adminUser }); + }); + + it('renders platform stats from the analytics API', async () => { + global.fetch = defaultFetchMock(); + render(); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Analytics' })); + + await waitFor(() => expect(screen.getByText('12')).toBeInTheDocument()); + expect(screen.getByText('Total Users')).toBeInTheDocument(); + expect(screen.getByText('34')).toBeInTheDocument(); + }); + + it('renders a breakdown bar for each tier in usersByTier', async () => { + global.fetch = defaultFetchMock(); + render(); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Analytics' })); + + await waitFor(() => expect(screen.getByText('Users by Subscription Tier')).toBeInTheDocument()); + expect(screen.getByText('free', { selector: 'span.capitalize' })).toBeInTheDocument(); + expect(screen.getByText('12 (100%)')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/cover-letter-studio.test.tsx b/__tests__/components/cover-letter-studio.test.tsx new file mode 100644 index 0000000..7e078d4 --- /dev/null +++ b/__tests__/components/cover-letter-studio.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CoverLetterStudio } from '@/components/interview-lab/CoverLetterStudio'; + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'demo@interviewlab.com', name: 'Demo User', subscriptionTier: 'free', isAdmin: false }, + }), +})); + +vi.mock('@/lib/use-subscription', () => ({ + useSubscription: () => ({ + usage: { coverLettersThisMonth: 0 }, + currentTier: 'free', + loading: false, + }), +})); + +vi.mock('next/image', () => ({ + default: (props: Record) => {props.alt, +})); + +function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +describe('CoverLetterStudio', () => { + beforeEach(() => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === '/api/cover-letter') return jsonResponse({ coverLetters: [] }); + return jsonResponse({}); + }); + }); + + it('renders the job description form', () => { + render(); + expect(screen.getByText('Cover Letter Studio')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Paste the job description here...')).toBeInTheDocument(); + }); + + it('fetches cover letter history for the logged-in user on mount', async () => { + render(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/cover-letter')); + }); + + it('disables the generate button until a job description is entered', () => { + render(); + expect(screen.getByRole('button', { name: 'Generate Letter' })).toBeDisabled(); + }); + + it('enables the generate button once a job description is entered', () => { + render(); + const textarea = screen.getByPlaceholderText('Paste the job description here...'); + fireEvent.change(textarea, { target: { value: 'We need an Amazon PPC VA' } }); + expect(screen.getByRole('button', { name: 'Generate Letter' })).not.toBeDisabled(); + }); + + it('generates a letter, persists it, and displays the draft plus claims to verify', async () => { + const fetchMock = vi.fn((url: string, opts?: RequestInit) => { + if (url === '/api/ai/cover-letter') { + return jsonResponse({ + draftLetter: 'Dear Hiring Manager, I am excited to apply...', + claimsToVerify: ['3+ years of PPC experience'], + customizationTips: ['Mention specific tools'], + }); + } + if (url === '/api/cover-letter' && opts?.method === 'POST') { + return jsonResponse({ id: 'cl-1' }); + } + if (url === '/api/cover-letter') { + return jsonResponse({ coverLetters: [] }); + } + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + const textarea = screen.getByPlaceholderText('Paste the job description here...'); + fireEvent.change(textarea, { target: { value: 'We need an Amazon PPC VA' } }); + fireEvent.click(screen.getByRole('button', { name: 'Generate Letter' })); + + await waitFor(() => expect(screen.getByText(/Dear Hiring Manager/)).toBeInTheDocument()); + expect(screen.getByText('3+ years of PPC experience')).toBeInTheDocument(); + expect(screen.getByText('Claims to Verify Before Sending')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/ai/cover-letter', expect.objectContaining({ method: 'POST' })); + + const persistCall = fetchMock.mock.calls.find(([url, o]) => url === '/api/cover-letter' && (o as RequestInit)?.method === 'POST'); + expect(persistCall).toBeDefined(); + const body = JSON.parse((persistCall![1] as RequestInit).body as string); + expect(body.generatedLetter).toContain('Dear Hiring Manager'); + }); + + it('does not call the AI endpoint when the job description is empty', () => { + const fetchMock = vi.fn(() => jsonResponse({ coverLetters: [] })); + global.fetch = fetchMock; + render(); + // Button is disabled, but guard against a direct click bypassing the UI too. + fireEvent.click(screen.getByRole('button', { name: 'Generate Letter' })); + expect(fetchMock).not.toHaveBeenCalledWith('/api/ai/cover-letter', expect.anything()); + }); + + it('loads a previous cover letter from history', async () => { + const fetchMock = vi.fn((url: string) => { + if (url === '/api/cover-letter') { + return jsonResponse({ + coverLetters: [{ id: 'cl-1', tone: 'upwork', generatedLetter: 'Previously generated letter text', jobDescription: 'Old job', truthFlags: JSON.stringify(['flag']), createdAt: '2026-01-01' }], + }); + } + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/cover-letter')); + + // The button carries an explicit aria-label ("Show history"/"Hide history") + // that overrides its visible "History (n)" text for accessible-name purposes. + fireEvent.click(screen.getByRole('button', { name: 'Show history' })); + fireEvent.click(await screen.findByRole('button', { name: 'Load' })); + + expect(screen.getByText('Previously generated letter text')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/dashboard-view.test.tsx b/__tests__/components/dashboard-view.test.tsx new file mode 100644 index 0000000..b3c58d0 --- /dev/null +++ b/__tests__/components/dashboard-view.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { DashboardView } from '@/components/interview-lab/DashboardView'; + +const mockUseAuth = vi.fn(); +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => mockUseAuth(), +})); + +function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +const baseStats = { totalSessions: 0, completedSessions: 0, totalAttempts: 0, avgScore: 0, latestResumeScore: null }; + +describe('DashboardView', () => { + beforeEach(() => { + mockUseAuth.mockReturnValue({ user: { id: 'u1', name: 'Jane Doe' } }); + }); + + it('fetches dashboard data and the question count on mount', async () => { + const fetchMock = vi.fn((url: string) => { + if (url === '/api/dashboard') return jsonResponse({ stats: baseStats, profile: null, recentSessions: [] }); + if (url === '/api/questions/count') return jsonResponse({ total: 264 }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/dashboard'); + expect(fetchMock).toHaveBeenCalledWith('/api/questions/count'); + }); + }); + + it('shows the empty-state prompt for a brand-new user with no activity', async () => { + global.fetch = vi.fn((url: string) => { + if (url === '/api/dashboard') return jsonResponse({ stats: baseStats, profile: null, recentSessions: [] }); + return jsonResponse({ total: 100 }); + }); + + render(); + await waitFor(() => expect(screen.getByText('Ready to start your prep?')).toBeInTheDocument()); + }); + + it('navigates to the interview view when "Start Mock Interview" is clicked', async () => { + global.fetch = vi.fn((url: string) => { + if (url === '/api/dashboard') return jsonResponse({ stats: baseStats, profile: null, recentSessions: [] }); + return jsonResponse({ total: 100 }); + }); + const onViewChange = vi.fn(); + + render(); + await waitFor(() => expect(screen.getByText('Ready to start your prep?')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Start Mock Interview/ })); + expect(onViewChange).toHaveBeenCalledWith('interview'); + }); + + it('hides the empty state and shows stats once the user has activity', async () => { + global.fetch = vi.fn((url: string) => { + if (url === '/api/dashboard') { + return jsonResponse({ + stats: { totalSessions: 3, completedSessions: 2, totalAttempts: 15, avgScore: 7.5, latestResumeScore: 82 }, + profile: { targetRole: 'PPC VA', weakAreas: JSON.stringify(['analytics']) }, + recentSessions: [], + }); + } + return jsonResponse({ total: 264 }); + }); + + render(); + await waitFor(() => expect(screen.queryByText('Ready to start your prep?')).not.toBeInTheDocument()); + expect(screen.getByText('7.5')).toBeInTheDocument(); // avg score + expect(screen.getByText('82')).toBeInTheDocument(); // resume score + expect(screen.getByText('Preparing for PPC VA roles')).toBeInTheDocument(); + }); + + it('renders the learning path progress bar and focus areas when a target role is set', async () => { + global.fetch = vi.fn((url: string) => { + if (url === '/api/dashboard') { + return jsonResponse({ + stats: { totalSessions: 1, completedSessions: 1, totalAttempts: 5, avgScore: 8, latestResumeScore: null }, + profile: { targetRole: 'PPC VA', weakAreas: JSON.stringify(['keyword research', 'reporting']) }, + recentSessions: [], + }); + } + return jsonResponse({ total: 264 }); + }); + + render(); + await waitFor(() => expect(screen.getByText('Your Learning Path')).toBeInTheDocument()); + expect(screen.getByText('80% Ready')).toBeInTheDocument(); + expect(screen.getByText('keyword research')).toBeInTheDocument(); + expect(screen.getByText('reporting')).toBeInTheDocument(); + }); + + it('renders recent sessions with a completed/active badge and score', async () => { + global.fetch = vi.fn((url: string) => { + if (url === '/api/dashboard') { + return jsonResponse({ + stats: { totalSessions: 2, completedSessions: 1, totalAttempts: 5, avgScore: 6, latestResumeScore: null }, + profile: null, + recentSessions: [ + { id: 's1', mode: 'quick_drill', startedAt: '2026-01-01', completedAt: '2026-01-01', overallScore: 8.2 }, + { id: 's2', mode: 'role_interview', startedAt: '2026-01-02', completedAt: null, overallScore: null }, + ], + }); + } + return jsonResponse({ total: 264 }); + }); + + render(); + await waitFor(() => expect(screen.getByText('Recent Sessions')).toBeInTheDocument()); + expect(screen.getByText('quick drill')).toBeInTheDocument(); + expect(screen.getByText('role interview')).toBeInTheDocument(); + expect(screen.getByText('8.2/10')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('does not fetch dashboard data when there is no logged-in user', () => { + mockUseAuth.mockReturnValue({ user: null }); + const fetchMock = vi.fn(() => jsonResponse({ total: 0 })); + global.fetch = fetchMock; + + render(); + expect(fetchMock).not.toHaveBeenCalledWith('/api/dashboard'); + }); +}); diff --git a/__tests__/components/types-constants.test.ts b/__tests__/components/types-constants.test.ts index 60f2b3c..bef9bd4 100644 --- a/__tests__/components/types-constants.test.ts +++ b/__tests__/components/types-constants.test.ts @@ -263,16 +263,38 @@ describe('Security - Auth Requirements', () => { 'api/export/route.ts', ]; + // AI routes are built via createAIHandler(config) (see src/lib/ai/handlers.ts), + // which calls getUserFromRequest() itself — so the route *file* won't contain + // that literal string even though the endpoint is still cookie-auth-gated. + const factoryBasedRoutes = new Set([ + 'api/ai/coach/route.ts', + 'api/ai/resume-review/route.ts', + 'api/ai/cover-letter/route.ts', + 'api/ai/assessment-score/route.ts', + ]); + protectedRoutes.forEach(route => { testIfServer(`${route} should use getUserFromRequest (not x-user-id header)`, () => { const filePath = path.join(PROJECT_ROOT, 'src/app', route); if (!fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, 'utf-8'); - // Must use cookie-based auth helper - expect(content, `${route} should use getUserFromRequest`).toContain('getUserFromRequest'); + if (factoryBasedRoutes.has(route)) { + // Must delegate to the shared AI handler factory, which itself + // enforces getUserFromRequest() (see __tests__/unit/ai-handlers.test.ts). + expect(content, `${route} should use createAIHandler`).toContain('createAIHandler'); + } else { + // Must use cookie-based auth helper directly + expect(content, `${route} should use getUserFromRequest`).toContain('getUserFromRequest'); + } // Must NOT use the old vulnerable x-user-id header pattern expect(content, `${route} must not read x-user-id header`).not.toContain("headers.get('x-user-id')"); }); }); + + it('createAIHandler itself enforces getUserFromRequest (backstop for the AI routes above)', () => { + const content = fs.readFileSync(path.join(PROJECT_ROOT, 'src/lib/ai/handlers.ts'), 'utf-8'); + expect(content).toContain('getUserFromRequest'); + expect(content).not.toContain("headers.get('x-user-id')"); + }); }); diff --git a/__tests__/lib/email-verification.test.ts b/__tests__/lib/email-verification.test.ts new file mode 100644 index 0000000..68e61b9 --- /dev/null +++ b/__tests__/lib/email-verification.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + * + * Covers src/lib/email-verification.ts — token creation, validation + * (including expiry), and pending-verification lookups. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const deleteMany = vi.fn(); +const create = vi.fn(); +const findUnique = vi.fn(); +const deleteOne = vi.fn(); +const count = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + verificationToken: { + deleteMany: (...args: unknown[]) => deleteMany(...args), + create: (...args: unknown[]) => create(...args), + findUnique: (...args: unknown[]) => findUnique(...args), + delete: (...args: unknown[]) => deleteOne(...args), + count: (...args: unknown[]) => count(...args), + }, + }, +})); + +import { createVerificationToken, validateVerificationToken, hasPendingVerification } from '@/lib/email-verification'; + +describe('createVerificationToken', () => { + beforeEach(() => { + deleteMany.mockReset(); + create.mockReset(); + deleteMany.mockResolvedValue({}); + create.mockResolvedValue({}); + }); + + it('clears any existing tokens for the email before creating a new one', async () => { + await createVerificationToken('user@test.com'); + expect(deleteMany).toHaveBeenCalledWith({ where: { email: 'user@test.com' } }); + expect(deleteMany.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]); + }); + + it('generates a 64-character hex token and stores it with a 24h expiry', async () => { + const before = Date.now(); + const token = await createVerificationToken('user@test.com'); + expect(token).toMatch(/^[0-9a-f]{64}$/); + const callArgs = create.mock.calls[0][0].data; + expect(callArgs.token).toBe(token); + expect(callArgs.email).toBe('user@test.com'); + const expiresAt = (callArgs.expiresAt as Date).getTime(); + expect(expiresAt).toBeGreaterThan(before + 23 * 60 * 60 * 1000); + expect(expiresAt).toBeLessThan(before + 25 * 60 * 60 * 1000); + }); + + it('does not throw if there were no prior tokens to delete', async () => { + deleteMany.mockRejectedValue(new Error('nothing to delete')); + await expect(createVerificationToken('user@test.com')).resolves.toBeDefined(); + }); + + it('generates distinct tokens across calls', async () => { + const t1 = await createVerificationToken('a@test.com'); + const t2 = await createVerificationToken('b@test.com'); + expect(t1).not.toBe(t2); + }); +}); + +describe('validateVerificationToken', () => { + beforeEach(() => { + findUnique.mockReset(); + deleteOne.mockReset(); + }); + + it('returns null when the token does not exist', async () => { + findUnique.mockResolvedValue(null); + const result = await validateVerificationToken('missing-token'); + expect(result).toBeNull(); + expect(deleteOne).not.toHaveBeenCalled(); + }); + + it('returns null and deletes the row when the token has expired', async () => { + findUnique.mockResolvedValue({ token: 't1', email: 'user@test.com', expiresAt: new Date(Date.now() - 1000) }); + deleteOne.mockResolvedValue({}); + const result = await validateVerificationToken('t1'); + expect(result).toBeNull(); + expect(deleteOne).toHaveBeenCalledWith({ where: { token: 't1' } }); + }); + + it('returns the email and consumes (deletes) a valid, unexpired token', async () => { + findUnique.mockResolvedValue({ token: 't1', email: 'user@test.com', expiresAt: new Date(Date.now() + 60_000) }); + deleteOne.mockResolvedValue({}); + const result = await validateVerificationToken('t1'); + expect(result).toBe('user@test.com'); + expect(deleteOne).toHaveBeenCalledWith({ where: { token: 't1' } }); + }); + + it('returns null (fails closed) when the db throws', async () => { + findUnique.mockRejectedValue(new Error('db down')); + const result = await validateVerificationToken('t1'); + expect(result).toBeNull(); + }); +}); + +describe('hasPendingVerification', () => { + beforeEach(() => { + count.mockReset(); + }); + + it('returns true when an unexpired token exists for the email', async () => { + count.mockResolvedValue(1); + await expect(hasPendingVerification('user@test.com')).resolves.toBe(true); + expect(count).toHaveBeenCalledWith({ where: { email: 'user@test.com', expiresAt: { gt: expect.any(Date) } } }); + }); + + it('returns false when no unexpired token exists', async () => { + count.mockResolvedValue(0); + await expect(hasPendingVerification('user@test.com')).resolves.toBe(false); + }); + + it('returns false (fails closed) when the db throws', async () => { + count.mockRejectedValue(new Error('db down')); + await expect(hasPendingVerification('user@test.com')).resolves.toBe(false); + }); +}); diff --git a/__tests__/lib/middleware.test.ts b/__tests__/lib/middleware.test.ts new file mode 100644 index 0000000..12dd4d3 --- /dev/null +++ b/__tests__/lib/middleware.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + * + * Exercises src/middleware.ts directly — the Edge-runtime rate limiter that + * sits in front of every /api/* route. Previously untested (only the + * separate DB-backed src/lib/rate-limit.ts had coverage). + */ +import { describe, it, expect } from 'vitest'; +import { NextRequest } from 'next/server'; +import { middleware } from '@/middleware'; + +function req(pathname: string, ip?: string) { + const headers: Record = {}; + if (ip) headers['x-forwarded-for'] = ip; + return new NextRequest(`http://localhost${pathname}`, { headers }); +} + +// Mirror middleware.ts's own fallback logic so these tests trip the limiter +// at the actually-configured threshold rather than an assumed default — CI +// overrides API_RATE_LIMIT_MAX/AUTH_RATE_LIMIT_MAX (relaxed for the +// live-server integration suite) for the whole job, including this step. +const GENERAL_MAX = Number(process.env.API_RATE_LIMIT_MAX) || 60; +const AUTH_MAX = Number(process.env.AUTH_RATE_LIMIT_MAX) || 10; + +// The limiter is an in-memory Map at module scope, keyed by IP (and by +// "auth:" for the auth endpoints) — use a fresh IP per test so tests +// don't interfere with each other's counters. +let ipCounter = 0; +function freshIp() { + return `10.0.${Math.floor(ipCounter / 255)}.${ipCounter++ % 255}`; +} + +describe('middleware (Edge rate limiter)', () => { + it('passes through non-API routes without rate limiting', () => { + const res = middleware(req('/dashboard', freshIp())); + expect(res.status).toBe(200); + expect(res.headers.get('x-middleware-next')).toBe('1'); + }); + + it('allows API requests under the general limit', () => { + const ip = freshIp(); + const res = middleware(req('/api/questions', ip)); + expect(res.status).toBe(200); + }); + + it('blocks a general /api/* request once the configured per-minute limit is exceeded', () => { + const ip = freshIp(); + let last; + for (let i = 0; i < GENERAL_MAX + 1; i++) { + last = middleware(req('/api/questions', ip)); + } + expect(last!.status).toBe(429); + expect(last!.headers.get('Retry-After')).toBe('60'); + }); + + it('rate-limits by IP, not globally — a fresh IP is unaffected', () => { + const busyIp = freshIp(); + for (let i = 0; i < GENERAL_MAX + 1; i++) middleware(req('/api/questions', busyIp)); + + const otherIp = freshIp(); + const res = middleware(req('/api/questions', otherIp)); + expect(res.status).toBe(200); + }); + + it('applies the stricter, separately-configured auth limit to /api/auth/login', () => { + const ip = freshIp(); + let last; + for (let i = 0; i < AUTH_MAX + 1; i++) { + last = middleware(req('/api/auth/login', ip)); + } + expect(last!.status).toBe(429); + expect(last!.headers.get('Retry-After')).toBe('900'); + }); + + it('applies the stricter auth limit to /api/auth/register too', () => { + const ip = freshIp(); + let last; + for (let i = 0; i < AUTH_MAX + 1; i++) { + last = middleware(req('/api/auth/register', ip)); + } + expect(last!.status).toBe(429); + }); + + it('hitting the auth limit does not block other /api/* routes for that IP', () => { + const ip = freshIp(); + for (let i = 0; i < AUTH_MAX + 1; i++) middleware(req('/api/auth/login', ip)); + // The auth-specific counter is keyed separately ("auth:") from the + // general counter, so a non-auth route for the same IP is unaffected. + const res = middleware(req('/api/questions', ip)); + expect(res.status).toBe(200); + }); + + it('uses x-real-ip when x-forwarded-for is absent', () => { + const ip = freshIp(); + const request = new NextRequest('http://localhost/api/questions', { headers: { 'x-real-ip': ip } }); + const res = middleware(request); + expect(res.status).toBe(200); + }); + + it('uses the first address in a multi-hop x-forwarded-for header', () => { + const ip = freshIp(); + const request = new NextRequest('http://localhost/api/questions', { + headers: { 'x-forwarded-for': `${ip}, 5.6.7.8` }, + }); + let last; + for (let i = 0; i < GENERAL_MAX + 1; i++) last = middleware(request); + expect(last!.status).toBe(429); + + // A request that resolves to the same first-hop IP shares the counter. + const sameFirstHop = new NextRequest('http://localhost/api/questions', { + headers: { 'x-forwarded-for': `${ip}, 9.9.9.9` }, + }); + expect(middleware(sameFirstHop).status).toBe(429); + }); + + it('falls back to a shared "unknown" bucket when no IP header is present', () => { + // Every request with no IP headers shares one counter — exercise it + // without assuming it starts empty (other tests may have hit it too). + const request = new NextRequest('http://localhost/api/questions'); + const res = middleware(request); + expect([200, 429]).toContain(res.status); + }); +}); diff --git a/__tests__/lib/password.test.ts b/__tests__/lib/password.test.ts new file mode 100644 index 0000000..e630ce3 --- /dev/null +++ b/__tests__/lib/password.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + * + * Covers src/lib/password.ts directly, including the legacy SHA-256 -> + * bcrypt migration path, which was previously only reachable through the + * gated live-server integration tests. + */ +import { describe, it, expect } from 'vitest'; +import { hashPassword, verifyPassword, isLegacyHash } from '@/lib/password'; + +describe('hashPassword / verifyPassword (bcrypt)', () => { + it('hashes a password into a bcrypt-format hash', async () => { + const hash = await hashPassword('correct-horse-battery-staple'); + expect(hash).toMatch(/^\$2[aby]\$/); + }); + + it('verifies a correct password against its bcrypt hash', async () => { + const hash = await hashPassword('my-secret-password'); + await expect(verifyPassword('my-secret-password', hash)).resolves.toBe(true); + }); + + it('rejects an incorrect password against a bcrypt hash', async () => { + const hash = await hashPassword('my-secret-password'); + await expect(verifyPassword('wrong-password', hash)).resolves.toBe(false); + }); + + it('produces a different hash each time (random salt)', async () => { + const [a, b] = await Promise.all([hashPassword('same-password'), hashPassword('same-password')]); + expect(a).not.toBe(b); + }); +}); + +describe('isLegacyHash', () => { + it('returns false for bcrypt hashes ($2a$/$2b$/$2y$)', () => { + expect(isLegacyHash('$2a$12$abcdefghijklmnopqrstuv')).toBe(false); + expect(isLegacyHash('$2b$12$abcdefghijklmnopqrstuv')).toBe(false); + expect(isLegacyHash('$2y$12$abcdefghijklmnopqrstuv')).toBe(false); + }); + + it('returns true for a legacy SHA-256 hex hash', () => { + expect(isLegacyHash('a'.repeat(64))).toBe(true); + }); +}); + +describe('verifyPassword — legacy SHA-256 fallback', () => { + it('accepts a password whose legacy SHA-256 digest matches the stored hash', async () => { + // Compute the same legacy digest the module falls back to, using the + // real Web Crypto API (available in the node test environment). + const encoder = new TextEncoder(); + const digest = await crypto.subtle.digest('SHA-256', encoder.encode('legacy-password')); + const legacyHash = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join(''); + + await expect(verifyPassword('legacy-password', legacyHash)).resolves.toBe(true); + }); + + it('rejects a wrong password against a legacy SHA-256 hash', async () => { + const encoder = new TextEncoder(); + const digest = await crypto.subtle.digest('SHA-256', encoder.encode('legacy-password')); + const legacyHash = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join(''); + + await expect(verifyPassword('wrong-password', legacyHash)).resolves.toBe(false); + }); +}); diff --git a/__tests__/lib/subscription-guard.test.ts b/__tests__/lib/subscription-guard.test.ts new file mode 100644 index 0000000..678710d --- /dev/null +++ b/__tests__/lib/subscription-guard.test.ts @@ -0,0 +1,40 @@ +/** + * Covers src/lib/subscription-guard.ts. Per CLAUDE.md, subscriptions are + * dormant — every check*Access() helper must be a no-op that always allows, + * regardless of the tier/usage args passed in. This locks that contract in + * so a future edit can't silently reintroduce tier gating (or leave it + * half-wired) without a test failing. + */ +import { describe, it, expect } from 'vitest'; +import { + checkInterviewAccess, + checkResumeAccess, + checkCoverLetterAccess, + checkPracticeTestAccess, + checkQuestionBankAccess, + checkDownloadAccess, + checkGuideAccess, +} from '@/lib/subscription-guard'; + +describe('subscription-guard (dormant — always allow)', () => { + it.each([ + ['checkInterviewAccess', () => checkInterviewAccess('free', 999)], + ['checkResumeAccess', () => checkResumeAccess('free', 999)], + ['checkCoverLetterAccess', () => checkCoverLetterAccess('free', 999)], + ['checkPracticeTestAccess', () => checkPracticeTestAccess('free', 999)], + ['checkQuestionBankAccess', () => checkQuestionBankAccess('free', 'advanced')], + ['checkDownloadAccess', () => checkDownloadAccess('free', 'pro')], + ['checkGuideAccess', () => checkGuideAccess('free', 'advanced')], + ])('%s allows regardless of tier/usage args', (_name, run) => { + const result = run(); + expect(result).toEqual({ allowed: true, remaining: null }); + }); + + it('is indifferent to the tier argument (e.g. an unrecognized tier string)', () => { + expect(checkInterviewAccess('nonexistent-tier', 0)).toEqual({ allowed: true, remaining: null }); + }); + + it('is indifferent to extreme usage values', () => { + expect(checkResumeAccess('free', Number.MAX_SAFE_INTEGER)).toEqual({ allowed: true, remaining: null }); + }); +}); diff --git a/__tests__/setup.ts b/__tests__/setup.ts index 6291834..7e6d1fe 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -42,6 +42,24 @@ if (typeof window !== 'undefined') { value: cryptoMock, }); + // jsdom doesn't implement scrollIntoView; Radix UI (Select, etc.) calls it + // on selection, which otherwise throws and can crash a passive effect. + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } + + // jsdom doesn't implement PointerEvent capture APIs that Radix UI relies + // on (Select/Tabs use pointer events for interaction). + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } + // Mock fetch for jsdom tests const fetchMock = vi.fn(); global.fetch = fetchMock; diff --git a/__tests__/unit/ai-client.test.ts b/__tests__/unit/ai-client.test.ts new file mode 100644 index 0000000..51e7b79 --- /dev/null +++ b/__tests__/unit/ai-client.test.ts @@ -0,0 +1,96 @@ +/** + * Covers src/lib/ai/client.ts (ZAIProvider + completeJson) — previously + * untested. The underlying z-ai-web-dev-sdk is mocked so no real network + * call is made. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const createCompletion = vi.fn(); +const zaiCreate = vi.fn(() => Promise.resolve({ chat: { completions: { create: createCompletion } } })); + +vi.mock('z-ai-web-dev-sdk', () => ({ + default: { create: (...args: unknown[]) => zaiCreate(...args) }, +})); + +import { ZAIProvider, completeJson, ai } from '@/lib/ai/client'; + +describe('ZAIProvider.complete', () => { + beforeEach(() => { + createCompletion.mockReset(); + zaiCreate.mockClear(); + }); + + it('sends system and user messages to the SDK and returns the content', async () => { + createCompletion.mockResolvedValue({ choices: [{ message: { content: '{"ok":true}' } }] }); + const provider = new ZAIProvider(); + const result = await provider.complete('sys prompt', 'user prompt'); + expect(result).toBe('{"ok":true}'); + expect(createCompletion).toHaveBeenCalledWith({ + messages: [ + { role: 'system', content: 'sys prompt' }, + { role: 'user', content: 'user prompt' }, + ], + }); + }); + + it('returns an empty string when the SDK response has no message content', async () => { + createCompletion.mockResolvedValue({ choices: [] }); + const provider = new ZAIProvider(); + const result = await provider.complete('sys', 'user'); + expect(result).toBe(''); + }); + + it('rejects with a timeout error when the completion takes longer than timeoutMs', async () => { + vi.useFakeTimers(); + createCompletion.mockReturnValue(new Promise(() => {})); // never resolves + const provider = new ZAIProvider(); + const promise = provider.complete('sys', 'user', { timeoutMs: 50 }); + const assertion = expect(promise).rejects.toThrow(/timed out after 50ms/); + await vi.advanceTimersByTimeAsync(60); + await assertion; + vi.useRealTimers(); + }); + + it('aborts immediately if an already-aborted signal is passed in', async () => { + createCompletion.mockResolvedValue({ choices: [{ message: { content: 'x' } }] }); + const provider = new ZAIProvider(); + const controller = new AbortController(); + controller.abort(); + // The call still resolves via the mocked SDK (the abort signal isn't + // forwarded into the completion call itself), but this exercises the + // already-aborted branch without throwing. + await expect(provider.complete('sys', 'user', { signal: controller.signal })).resolves.toBe('x'); + }); +}); + +describe('completeJson', () => { + afterEach(() => { + createCompletion.mockReset(); + }); + + it('parses a JSON object out of the raw completion text', async () => { + createCompletion.mockResolvedValue({ choices: [{ message: { content: '{"score": 8, "note": "good"}' } }] }); + const result = await completeJson<{ score: number; note: string }>('sys', 'user'); + expect(result).toEqual({ score: 8, note: 'good' }); + }); + + it('extracts JSON embedded in surrounding prose (e.g. markdown fences)', async () => { + createCompletion.mockResolvedValue({ + choices: [{ message: { content: 'Here is the result:\n```json\n{"score": 5}\n```' } }], + }); + const result = await completeJson<{ score: number }>('sys', 'user'); + expect(result).toEqual({ score: 5 }); + }); + + it('returns null when the completion contains no parseable JSON', async () => { + createCompletion.mockResolvedValue({ choices: [{ message: { content: 'not json at all' } }] }); + const result = await completeJson('sys', 'user'); + expect(result).toBeNull(); + }); +}); + +describe('ai singleton', () => { + it('is a ZAIProvider instance', () => { + expect(ai).toBeInstanceOf(ZAIProvider); + }); +}); diff --git a/__tests__/unit/ai-prompts.test.ts b/__tests__/unit/ai-prompts.test.ts new file mode 100644 index 0000000..0a257ba --- /dev/null +++ b/__tests__/unit/ai-prompts.test.ts @@ -0,0 +1,214 @@ +/** + * Covers the per-feature AI configs in src/lib/ai/{coach,resume,cover-letter, + * assessment}.ts — previously only the generic createAIHandler factory was + * tested, not these prompt-building/validation/fallback modules themselves. + * + * Per CLAUDE.md / docs/07-guardrails.md, AI-generated content must carry + * truthfulness warnings and must not fabricate experience or guarantee + * outcomes — this file asserts that language is actually present in the + * system prompts, so an edit that silently drops a guardrail fails a test. + */ +import { describe, it, expect } from 'vitest'; +import { coachConfig, errorFeedback } from '@/lib/ai/coach'; +import { resumeReviewConfig } from '@/lib/ai/resume'; +import { coverLetterConfig } from '@/lib/ai/cover-letter'; +import { assessmentScoreConfig } from '@/lib/ai/assessment'; + +describe('coachConfig', () => { + it('system prompt forbids fabricating experience and guaranteeing outcomes', () => { + expect(coachConfig.systemPrompt).toMatch(/must not claim the user has experience/i); + expect(coachConfig.systemPrompt).toMatch(/never guarantee job placement/i); + }); + + it('validate rejects a body missing question/userAnswer', () => { + const result = coachConfig.validate({ question: 'Q' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + } + }); + + it('validate accepts a complete body', () => { + const result = coachConfig.validate({ question: 'Q', userAnswer: 'A' }); + expect(result.ok).toBe(true); + }); + + it('buildUserPrompt includes the question, context, and answer', () => { + const prompt = coachConfig.buildUserPrompt({ question: 'What is ACoS?', userAnswer: 'A ratio', questionContext: 'PPC basics' }); + expect(prompt).toContain('What is ACoS?'); + expect(prompt).toContain('PPC basics'); + expect(prompt).toContain('A ratio'); + }); + + it('buildUserPrompt falls back to a generic context when none is given', () => { + const prompt = coachConfig.buildUserPrompt({ question: 'Q', userAnswer: 'A' }); + expect(prompt).toContain('General Amazon VA interview question'); + }); + + it('onParseFailure degrades gracefully to a fallback score (200, not an error)', () => { + const outcome = coachConfig.onParseFailure({ question: 'Q', userAnswer: 'A' }); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.score).toBe(5); + expect(outcome.value.rubricBreakdown).toBeDefined(); + } + }); + + it('onProviderError returns the same shape as errorFeedback()', () => { + const outcome = coachConfig.onProviderError!({ question: 'Q', userAnswer: 'A' }); + expect(outcome).toEqual({ ok: true, value: errorFeedback() }); + }); + + it('normalize fills in a weakness-targeted follow-up when score < 7 and none was given', () => { + const result = coachConfig.normalize!({ + score: 4, whatWorked: 'x', whatToImprove: 'y', strongerSampleAnswer: 'z', rubricBreakdown: {}, + }, { question: 'How do you calculate ACoS?', userAnswer: 'A' }); + expect(result.followUpQuestion).toMatch(/How do you calculate ACoS\?/); + expect(result.followUpQuestion).toMatch(/more detail/i); + }); + + it('normalize fills in a deepening follow-up when score >= 7 and none was given', () => { + const result = coachConfig.normalize!({ + score: 9, whatWorked: 'x', whatToImprove: 'y', strongerSampleAnswer: 'z', rubricBreakdown: {}, + }, { question: 'Q', userAnswer: 'A' }); + expect(result.followUpQuestion).toMatch(/Good answer/i); + }); + + it('normalize leaves an existing followUpQuestion untouched', () => { + const result = coachConfig.normalize!({ + score: 2, whatWorked: 'x', whatToImprove: 'y', strongerSampleAnswer: 'z', rubricBreakdown: {}, + followUpQuestion: 'Already set', + }, { question: 'Q', userAnswer: 'A' }); + expect(result.followUpQuestion).toBe('Already set'); + }); +}); + +describe('resumeReviewConfig', () => { + it('system prompt warns against inventing certifications and guaranteeing placement', () => { + expect(resumeReviewConfig.systemPrompt).toMatch(/do not suggest the user has certifications/i); + expect(resumeReviewConfig.systemPrompt).toMatch(/never guarantee job placement/i); + }); + + it('validate rejects a body missing resumeText', () => { + expect(resumeReviewConfig.validate({}).ok).toBe(false); + }); + + it('validate rejects resumeText over 20,000 chars', () => { + const result = resumeReviewConfig.validate({ resumeText: 'a'.repeat(20001) }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('too long'); + }); + + it('validate accepts a normal-length resumeText', () => { + expect(resumeReviewConfig.validate({ resumeText: 'My resume' }).ok).toBe(true); + }); + + it('buildUserPrompt defaults targetRole to "Amazon VA"', () => { + const prompt = resumeReviewConfig.buildUserPrompt({ resumeText: 'My resume' }); + expect(prompt).toContain('Amazon VA'); + expect(prompt).toContain('My resume'); + }); + + it('onParseFailure surfaces a 500 error (no silent fabricated review)', () => { + const outcome = resumeReviewConfig.onParseFailure({ resumeText: 'x' }); + expect(outcome).toEqual({ ok: false, status: 500, error: 'Failed to parse resume review' }); + }); + + it('onProviderError degrades gracefully with an empty/zero-score result', () => { + const outcome = resumeReviewConfig.onProviderError!({ resumeText: 'x' }); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.score).toBe(0); + expect(outcome.value.truthWarnings).toEqual([]); + } + }); +}); + +describe('coverLetterConfig', () => { + it('system prompt forbids fabricating experience/certifications/metrics', () => { + expect(coverLetterConfig.systemPrompt).toMatch(/do not fabricate specific experience/i); + expect(coverLetterConfig.systemPrompt).toMatch(/never guarantee job placement/i); + }); + + it('validate rejects a body missing jobDescription', () => { + expect(coverLetterConfig.validate({}).ok).toBe(false); + }); + + it('validate rejects jobDescription over 10,000 chars', () => { + const result = coverLetterConfig.validate({ jobDescription: 'a'.repeat(10001) }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('too long'); + }); + + it('buildUserPrompt defaults tone to "formal" and name to a placeholder', () => { + const prompt = coverLetterConfig.buildUserPrompt({ jobDescription: 'We need a PPC VA' }); + expect(prompt).toContain('Tone: formal'); + expect(prompt).toContain('[Your Name]'); + expect(prompt).toContain('We need a PPC VA'); + }); + + it('buildUserPrompt honors an explicit tone/name/targetRole', () => { + const prompt = coverLetterConfig.buildUserPrompt({ + jobDescription: 'desc', tone: 'upwork', targetRole: 'Listing VA', userName: 'Jane', + }); + expect(prompt).toContain('Tone: upwork'); + expect(prompt).toContain('Listing VA'); + expect(prompt).toContain('Jane'); + }); + + it('onParseFailure and onProviderError both degrade gracefully with the same placeholder letter', () => { + const parseFailure = coverLetterConfig.onParseFailure({ jobDescription: 'x' }); + const providerError = coverLetterConfig.onProviderError!({ jobDescription: 'x' }); + expect(parseFailure).toEqual(providerError); + expect(parseFailure.ok).toBe(true); + if (parseFailure.ok) { + expect(parseFailure.value.draftLetter).toMatch(/unable to generate/i); + } + }); +}); + +describe('assessmentScoreConfig', () => { + it('system prompt avoids guaranteeing test performance or job placement', () => { + expect(assessmentScoreConfig.systemPrompt).toMatch(/never guarantee job placement or test performance/i); + }); + + it('validate rejects a body missing assessmentTitle/userAnswers', () => { + expect(assessmentScoreConfig.validate({ assessmentTitle: 'T' }).ok).toBe(false); + }); + + it('validate rejects answers over 50,000 chars (string form)', () => { + const result = assessmentScoreConfig.validate({ assessmentTitle: 'T', userAnswers: 'a'.repeat(50001) }); + expect(result.ok).toBe(false); + }); + + it('validate rejects answers over 50,000 chars (object form, measured via JSON.stringify)', () => { + const result = assessmentScoreConfig.validate({ assessmentTitle: 'T', userAnswers: { blob: 'a'.repeat(50001) } }); + expect(result.ok).toBe(false); + }); + + it('validate accepts answers within the length limit', () => { + expect(assessmentScoreConfig.validate({ assessmentTitle: 'T', userAnswers: 'short answer' }).ok).toBe(true); + }); + + it('buildUserPrompt truncates assessmentData to 5000 chars and defaults to "N/A"', () => { + const withData = assessmentScoreConfig.buildUserPrompt({ assessmentTitle: 'T', userAnswers: 'A', assessmentData: { big: 'x'.repeat(6000) } }); + expect(withData).toContain('T'); + // Assessment Data section should be capped at 5000 chars of JSON. + const dataSection = withData.split('Assessment Data: ')[1].split('\n\nUser')[0]; + expect(dataSection.length).toBeLessThanOrEqual(5000); + + const withoutData = assessmentScoreConfig.buildUserPrompt({ assessmentTitle: 'T', userAnswers: 'A' }); + expect(withoutData).toContain('Assessment Data: N/A'); + }); + + it('onParseFailure surfaces a 500 (no fabricated score)', () => { + const outcome = assessmentScoreConfig.onParseFailure({ assessmentTitle: 'T', userAnswers: 'A' }); + expect(outcome).toEqual({ ok: false, status: 500, error: 'Failed to score assessment' }); + }); + + it('onProviderError degrades gracefully with a zero score, not a fabricated one', () => { + const outcome = assessmentScoreConfig.onProviderError!({ assessmentTitle: 'T', userAnswers: 'A' }); + expect(outcome.ok).toBe(true); + if (outcome.ok) expect(outcome.value.score).toBe(0); + }); +});