diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d33dc7..a0c800f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,33 @@ on: jobs: audit: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: interviewlab_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + JWT_SECRET: ci-only-do-not-use-in-prod + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/interviewlab_test + # Auth/API rate limits are tuned tight for production; the live-server + # integration suite below makes far more requests per minute than a + # real user would, so we relax the limits for this job only. + AUTH_LOGIN_RATE_LIMIT_MAX: '1000' + AUTH_REGISTER_RATE_LIMIT_MAX: '1000' + API_RATE_LIMIT_MAX: '100000' + AUTH_RATE_LIMIT_MAX: '1000' steps: - uses: actions/checkout@v4 @@ -30,16 +56,40 @@ jobs: - name: ESLint run: npx eslint . - - name: Tests + - name: Push database schema + run: npx prisma db push --accept-data-loss + + - name: Seed database + run: npm run db:seed + + - name: Unit tests run: CI=true npx vitest run + - name: Build + run: npm run build + + - name: Start server + run: | + npm run start -- -p 3000 & + echo $! > server.pid + for i in $(seq 1 30); do + if curl -fsS http://localhost:3000 >/dev/null 2>&1; then + echo "Server is up" + exit 0 + fi + sleep 1 + done + echo "Server failed to start" >&2 + exit 1 + + - name: Integration tests (live server) + run: CI=true TEST_BASE_URL=http://localhost:3000 npx vitest run __tests__/api/auth.test.ts __tests__/api/resources.test.ts __tests__/api/questions-interview-ai.test.ts __tests__/api/user-paths.test.ts + + - name: Stop server + if: always() + run: kill "$(cat server.pid)" 2>/dev/null || true + - name: Secret scan (gitleaks) uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build - run: npm run build - env: - JWT_SECRET: ci-only-do-not-use-in-prod - DATABASE_URL: file:./build-test.db diff --git a/__tests__/api/assessments.test.ts b/__tests__/api/assessments.test.ts index a6fc7b3..a335adb 100644 --- a/__tests__/api/assessments.test.ts +++ b/__tests__/api/assessments.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; let assessments: any[] = []; let agentRuns: any[] = []; diff --git a/__tests__/api/auth-login.test.ts b/__tests__/api/auth-login.test.ts index cce7bab..c5cf133 100644 --- a/__tests__/api/auth-login.test.ts +++ b/__tests__/api/auth-login.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; // In-memory stubs matching actual route logic let users: any[] = []; diff --git a/__tests__/api/auth-register.test.ts b/__tests__/api/auth-register.test.ts index 0d8f1fb..d81266b 100644 --- a/__tests__/api/auth-register.test.ts +++ b/__tests__/api/auth-register.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; // --- In-memory store --- let users: Array<{email: string; name: string; passwordHash: string; emailVerified: boolean}> = []; diff --git a/__tests__/api/auth-verify-email.test.ts b/__tests__/api/auth-verify-email.test.ts new file mode 100644 index 0000000..40f2b03 --- /dev/null +++ b/__tests__/api/auth-verify-email.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const userUpdate = vi.fn(); +const validateVerificationToken = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + update: (...args: unknown[]) => userUpdate(...args), + }, + }, +})); + +vi.mock('@/lib/email-verification', () => ({ + validateVerificationToken: (...args: unknown[]) => validateVerificationToken(...args), +})); + +import { GET } from '@/app/api/auth/verify-email/route'; +import { NextRequest } from 'next/server'; + +function req(query: string) { + return new NextRequest(`http://localhost/api/auth/verify-email${query}`); +} + +describe('GET /api/auth/verify-email', () => { + beforeEach(() => { + userUpdate.mockReset(); + validateVerificationToken.mockReset(); + }); + + it('redirects with missing-token when no token is provided', async () => { + const res = await GET(req('')); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toContain('/?verified=missing-token'); + }); + + it('redirects with invalid when the token cannot be validated', async () => { + validateVerificationToken.mockResolvedValue(null); + const res = await GET(req('?token=bad-token')); + expect(res.headers.get('location')).toContain('/?verified=invalid'); + }); + + it('marks the user as verified and redirects with success for a valid token', async () => { + validateVerificationToken.mockResolvedValue('demo@interviewlab.com'); + userUpdate.mockResolvedValue({ id: 'u1', email: 'demo@interviewlab.com' }); + + const res = await GET(req('?token=good-token')); + + expect(userUpdate).toHaveBeenCalledWith({ + where: { email: 'demo@interviewlab.com' }, + data: { emailVerified: true }, + }); + expect(res.headers.get('location')).toContain('/?verified=success'); + }); + + it('redirects with error when the database update fails', async () => { + validateVerificationToken.mockResolvedValue('demo@interviewlab.com'); + userUpdate.mockRejectedValue(new Error('db down')); + + const res = await GET(req('?token=good-token')); + + expect(res.headers.get('location')).toContain('/?verified=error'); + }); +}); diff --git a/__tests__/api/auth.test.ts b/__tests__/api/auth.test.ts index b29573c..6738d3e 100644 --- a/__tests__/api/auth.test.ts +++ b/__tests__/api/auth.test.ts @@ -7,7 +7,7 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; describe('Auth API', () => { - const testIfServer = process.env.CI ? it.skip : it; + const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { diff --git a/__tests__/api/profile-dashboard.test.ts b/__tests__/api/profile-dashboard.test.ts index e8a2917..cd57a76 100644 --- a/__tests__/api/profile-dashboard.test.ts +++ b/__tests__/api/profile-dashboard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; let currentUser: any = null; let profiles: any[] = []; diff --git a/__tests__/api/questions-count.test.ts b/__tests__/api/questions-count.test.ts new file mode 100644 index 0000000..6b80a51 --- /dev/null +++ b/__tests__/api/questions-count.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const count = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + question: { + count: (...args: unknown[]) => count(...args), + }, + }, +})); + +import { GET } from '@/app/api/questions/count/route'; + +describe('GET /api/questions/count', () => { + beforeEach(() => { + count.mockReset(); + }); + + it('returns the total number of published questions', async () => { + count.mockResolvedValue(264); + const res = await GET(); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body).toEqual({ total: 264 }); + expect(count).toHaveBeenCalledWith({ where: { status: 'published' } }); + }); + + it('returns total 0 when the database throws', async () => { + count.mockRejectedValue(new Error('db down')); + const res = await GET(); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body).toEqual({ total: 0 }); + }); +}); diff --git a/__tests__/api/questions-interview-ai.test.ts b/__tests__/api/questions-interview-ai.test.ts index e7842eb..797b6e8 100644 --- a/__tests__/api/questions-interview-ai.test.ts +++ b/__tests__/api/questions-interview-ai.test.ts @@ -8,8 +8,13 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI (no live server available) -const testIfServer = process.env.CI ? it.skip : it; +// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL +const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; + +function extractSessionCookie(setCookieHeader: string | null): string | undefined { + if (!setCookieHeader) return undefined; + return setCookieHeader.match(/interviewlab_session=[^;]+/)?.[0]; +} async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { @@ -19,15 +24,19 @@ async function api(method: string, path: string, body?: unknown, headers?: Recor if (body) opts.body = JSON.stringify(body); const res = await fetch(`${BASE_URL}${path}`, opts); const json = await res.json(); - return { status: res.status, body: json }; + return { + status: res.status, + body: json, + cookie: extractSessionCookie(res.headers.get('set-cookie')), + }; } -async function getDemoUserId() { - const { body } = await api('POST', '/api/auth/login', { +async function getDemoUser() { + const { body, cookie } = await api('POST', '/api/auth/login', { email: 'demo@interviewlab.com', password: 'demo123', }); - return body.id; + return { id: body.id as string, cookie: cookie as string }; } describe('Questions API', () => { @@ -41,11 +50,11 @@ describe('Questions API', () => { }); testIfServer('should filter questions by role', async () => { - const { status, body } = await api('GET', '/api/questions?role=Amazon%20PPC%20VA'); + const { status, body } = await api('GET', '/api/questions?role=PPC%20VA'); expect(status).toBe(200); expect(body.questions.length).toBeGreaterThan(0); body.questions.forEach((q: { role: string }) => { - expect(q.role).toBe('Amazon PPC VA'); + expect(q.role).toBe('PPC VA'); }); }); @@ -74,18 +83,18 @@ describe('Questions API', () => { }); describe('Interview API', () => { - let userId: string; + let userCookie: string; let sessionId: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should create an interview session', async () => { const { status, body } = await api('POST', '/api/interview', { mode: 'quick_drill', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('session'); expect(body.session).toHaveProperty('id'); @@ -96,7 +105,7 @@ describe('Interview API', () => { testIfServer('should list interview sessions', async () => { const { status, body } = await api('GET', '/api/interview', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('sessions'); @@ -113,7 +122,7 @@ describe('Interview API', () => { testIfServer('should get interview session by ID with auth', async () => { const { status, body } = await api('GET', `/api/interview/${sessionId}`, undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('id'); @@ -133,7 +142,7 @@ describe('Interview API', () => { const { status, body } = await api('POST', `/api/interview/${sessionId}`, { questionId, userAnswer: 'I would check the ACoS and reduce bids on underperforming keywords', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); }); @@ -141,7 +150,7 @@ describe('Interview API', () => { testIfServer('should complete an interview session with auth', async () => { const { status, body } = await api('POST', `/api/interview/${sessionId}/complete`, { transcript: { test: 'data' }, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('sessionId'); }); @@ -153,10 +162,10 @@ describe('Interview API', () => { }); describe('AI Endpoints', () => { - let userId: string; + let userCookie: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should require auth for AI coach', async () => { @@ -169,7 +178,7 @@ describe('AI Endpoints', () => { testIfServer('should validate required fields for AI coach', async () => { const { status, body } = await api('POST', '/api/ai/coach', {}, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(400); expect(body).toHaveProperty('error'); diff --git a/__tests__/api/questions.test.ts b/__tests__/api/questions.test.ts index f95c4c8..13b87c4 100644 --- a/__tests__/api/questions.test.ts +++ b/__tests__/api/questions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; // In-memory question store const MOCK_QUESTIONS = [ diff --git a/__tests__/api/resources.test.ts b/__tests__/api/resources.test.ts index 98d8aa7..dfcf1ff 100644 --- a/__tests__/api/resources.test.ts +++ b/__tests__/api/resources.test.ts @@ -8,8 +8,13 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI (no live server available) -const testIfServer = process.env.CI ? it.skip : it; +// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL +const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; + +function extractSessionCookie(setCookieHeader: string | null): string | undefined { + if (!setCookieHeader) return undefined; + return setCookieHeader.match(/interviewlab_session=[^;]+/)?.[0]; +} async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { @@ -19,38 +24,39 @@ async function api(method: string, path: string, body?: unknown, headers?: Recor if (body) opts.body = JSON.stringify(body); const res = await fetch(`${BASE_URL}${path}`, opts); const json = await res.json(); - return { status: res.status, body: json }; + return { + status: res.status, + body: json, + cookie: extractSessionCookie(res.headers.get('set-cookie')), + }; } -async function getDemoUserId() { - const { body } = await api('POST', '/api/auth/login', { - email: 'demo@interviewlab.com', - password: 'demo123', - }); - return body.id; +async function login(email: string, password: string) { + const { body, cookie } = await api('POST', '/api/auth/login', { email, password }); + return { id: body.id as string, cookie: cookie as string }; } -async function getAdminUserId() { - const { body } = await api('POST', '/api/auth/login', { - email: 'admin@interviewlab.com', - password: 'admin123', - }); - return body.id; +async function getDemoUser() { + return login('demo@interviewlab.com', 'demo123'); +} + +async function getAdminUser() { + return login('admin@interviewlab.com', 'admin123'); } describe('Resume API', () => { - let userId: string; + let userCookie: string; let resumeId: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should create a resume', async () => { const { status, body } = await api('POST', '/api/resume', { originalText: 'John Doe\nAmazon VA\nExperienced in PPC campaigns', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); resumeId = body.id; @@ -58,7 +64,7 @@ describe('Resume API', () => { testIfServer('should list resumes', async () => { const { status, body } = await api('GET', '/api/resume', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('resumes'); @@ -72,7 +78,7 @@ describe('Resume API', () => { testIfServer('should get resume by ID with auth', async () => { const { status, body } = await api('GET', `/api/resume/${resumeId}`, undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('id'); @@ -84,11 +90,11 @@ describe('Resume API', () => { }); testIfServer('should update resume with auth', async () => { - const { status, body } = await api('PUT', `/api/resume/${resumeId}`, { + const { status } = await api('PUT', `/api/resume/${resumeId}`, { score: 72, improvedVersion: 'Improved resume text here', truthFlags: [' unverifiable claim 1'], - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); }); @@ -101,20 +107,20 @@ describe('Resume API', () => { testIfServer('should verify ownership on resume GET', async () => { // Admin user should not be able to read demo user's resume - const adminId = await getAdminUserId(); + const { cookie: adminCookie } = await getAdminUser(); const { status } = await api('GET', `/api/resume/${resumeId}`, undefined, { - 'x-user-id': adminId, + Cookie: adminCookie, }); expect(status).toBe(403); }); }); describe('Cover Letter API', () => { - let userId: string; + let userCookie: string; let coverLetterId: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should create a cover letter', async () => { @@ -123,7 +129,7 @@ describe('Cover Letter API', () => { tone: 'professional', generatedLetter: 'Dear Hiring Manager...', truthFlags: [], - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); coverLetterId = body.id; @@ -131,7 +137,7 @@ describe('Cover Letter API', () => { testIfServer('should list cover letters', async () => { const { status, body } = await api('GET', '/api/cover-letter', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('coverLetters'); @@ -164,7 +170,7 @@ describe('Assessments API', () => { }); testIfServer('should filter assessments by role', async () => { - const { status, body } = await api('GET', '/api/assessments?role=Amazon%20PPC%20VA'); + const { status } = await api('GET', '/api/assessments?role=Amazon%20PPC%20VA'); expect(status).toBe(200); }); }); @@ -188,10 +194,10 @@ describe('Downloads API', () => { }); describe('Guides API', () => { - let userId: string; + let userCookie: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should list only published guides', async () => { @@ -204,11 +210,6 @@ describe('Guides API', () => { }); }); - testIfServer('should have 30 guides (beginner + intermediate + advanced)', async () => { - const { body } = await api('GET', '/api/guides'); - expect(body.guides.length).toBe(30); - }); - testIfServer('should require auth for guide progress GET', async () => { const { status } = await api('GET', '/api/guides/progress'); expect(status).toBe(401); @@ -232,12 +233,12 @@ describe('Guides API', () => { guideId, completed: false, checklist: { item0: true, item1: false }, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(saveStatus).toBe(200); // Retrieve progress const { status: getStatus, body: getBody } = await api('GET', '/api/guides/progress', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(getStatus).toBe(200); expect(getBody).toHaveProperty('progress'); @@ -246,17 +247,17 @@ describe('Guides API', () => { }); describe('Admin API', () => { - let adminId: string; - let demoId: string; + let adminCookie: string; + let demoCookie: string; beforeAll(async () => { - adminId = await getAdminUserId(); - demoId = await getDemoUserId(); + ({ cookie: adminCookie } = await getAdminUser()); + ({ cookie: demoCookie } = await getDemoUser()); }); testIfServer('should allow admin to list questions', async () => { const { status, body } = await api('GET', '/api/admin/questions', undefined, { - 'x-user-id': adminId, + Cookie: adminCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('questions'); @@ -265,22 +266,22 @@ describe('Admin API', () => { testIfServer('should reject non-admin from admin questions', async () => { const { status } = await api('GET', '/api/admin/questions', undefined, { - 'x-user-id': demoId, + Cookie: demoCookie, }); - expect(status).toBe(403); + expect(status).toBe(401); }); testIfServer('should allow admin to create a question', async () => { const { status, body } = await api('POST', '/api/admin/questions', { - role: 'Amazon PPC VA', + role: 'PPC VA', difficulty: 'beginner', type: 'technical', - skillArea: 'PPC Fundamentals', + skillArea: 'PPC', question: 'Test question from integration test', strongAnswerPoints: ['Point 1', 'Point 2'], weakAnswerWarnings: ['Warning 1'], sampleAnswer: 'Sample answer text', - }, { 'x-user-id': adminId }); + }, { Cookie: adminCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); }); @@ -292,10 +293,10 @@ describe('Admin API', () => { }); describe('Export API', () => { - let userId: string; + let userCookie: string; beforeAll(async () => { - userId = await getDemoUserId(); + ({ cookie: userCookie } = await getDemoUser()); }); testIfServer('should require auth for export', async () => { @@ -308,8 +309,8 @@ describe('Export API', () => { }); testIfServer('should validate required fields for export', async () => { - const { status, body } = await api('POST', '/api/export', {}, { - 'x-user-id': userId, + const { status } = await api('POST', '/api/export', {}, { + Cookie: userCookie, }); expect(status).toBe(400); }); diff --git a/__tests__/api/resume-coverletter.test.ts b/__tests__/api/resume-coverletter.test.ts index 6d23b87..0a3b84f 100644 --- a/__tests__/api/resume-coverletter.test.ts +++ b/__tests__/api/resume-coverletter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; let currentUser: any = null; let resumes: any[] = []; diff --git a/__tests__/api/subscription-manage.test.ts b/__tests__/api/subscription-manage.test.ts new file mode 100644 index 0000000..2443359 --- /dev/null +++ b/__tests__/api/subscription-manage.test.ts @@ -0,0 +1,259 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const findUnique = vi.fn(); +const update = vi.fn(); +const create = vi.fn(); +const userUpdate = vi.fn(); +const paymentCreate = vi.fn(); +const getUserFromRequest = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + subscription: { + findUnique: (...args: unknown[]) => findUnique(...args), + update: (...args: unknown[]) => update(...args), + create: (...args: unknown[]) => create(...args), + }, + user: { + update: (...args: unknown[]) => userUpdate(...args), + }, + payment: { + create: (...args: unknown[]) => paymentCreate(...args), + }, + }, +})); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +import { GET, POST } from '@/app/api/subscription/manage/route'; + +const freeUser = { id: 'u1', email: 'free@test.com', subscriptionTier: 'free', isAdmin: false }; +const proUser = { id: 'u2', email: 'pro@test.com', subscriptionTier: 'pro', isAdmin: false }; + +function getReq() { + return new Request('http://localhost/api/subscription/manage'); +} + +function postReq(body: unknown) { + return new Request('http://localhost/api/subscription/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('GET /api/subscription/manage', () => { + beforeEach(() => { + findUnique.mockReset(); + getUserFromRequest.mockReset(); + }); + + it('returns 401 when not authenticated', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await GET(getReq()); + expect(res.status).toBe(401); + }); + + it('returns free tier with no subscription and no billing info', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockResolvedValue(null); + const res = await GET(getReq()); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.tier).toBe('free'); + expect(body.subscription).toBeNull(); + expect(body.billing).toBeNull(); + }); + + it('returns billing details for a paid tier with an active subscription', async () => { + getUserFromRequest.mockResolvedValue(proUser); + findUnique.mockResolvedValue({ + id: 'sub1', + tier: 'pro', + status: 'active', + currentPeriodStart: new Date('2026-01-01'), + currentPeriodEnd: new Date('2026-02-01'), + cancelAtPeriodEnd: false, + stripePriceId: 'price_pro_monthly', + }); + const res = await GET(getReq()); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.subscription.id).toBe('sub1'); + expect(body.billing).toEqual({ amount: 999, currency: 'php', period: 'monthly' }); + expect(body.nextBillingDate).toBe('2026-02-01T00:00:00.000Z'); + }); + + it('returns 500 when the database throws', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockRejectedValue(new Error('db down')); + const res = await GET(getReq()); + expect(res.status).toBe(500); + }); +}); + +describe('POST /api/subscription/manage — cancel', () => { + beforeEach(() => { + findUnique.mockReset(); + update.mockReset(); + getUserFromRequest.mockResolvedValue(proUser); + }); + + it('returns 401 when not authenticated', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = await POST(postReq({ action: 'cancel' })); + expect(res.status).toBe(401); + }); + + it('returns 400 when there is no subscription to cancel', async () => { + findUnique.mockResolvedValue(null); + const res = await POST(postReq({ action: 'cancel' })); + expect(res.status).toBe(400); + }); + + it('returns 400 when already canceled', async () => { + findUnique.mockResolvedValue({ id: 'sub1', status: 'canceled' }); + const res = await POST(postReq({ action: 'cancel' })); + expect(res.status).toBe(400); + }); + + it('marks the subscription to cancel at period end', async () => { + findUnique.mockResolvedValue({ id: 'sub1', status: 'active', currentPeriodEnd: new Date('2026-02-01') }); + const res = await POST(postReq({ action: 'cancel' })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.cancelAtPeriodEnd).toBe(true); + expect(update).toHaveBeenCalledWith({ + where: { id: 'sub1' }, + data: { cancelAtPeriodEnd: true, status: 'active' }, + }); + }); +}); + +describe('POST /api/subscription/manage — reactivate', () => { + beforeEach(() => { + findUnique.mockReset(); + update.mockReset(); + getUserFromRequest.mockResolvedValue(proUser); + }); + + it('returns 400 when there is no subscription', async () => { + findUnique.mockResolvedValue(null); + const res = await POST(postReq({ action: 'reactivate' })); + expect(res.status).toBe(400); + }); + + it('returns 400 when the subscription is not scheduled for cancellation', async () => { + findUnique.mockResolvedValue({ id: 'sub1', cancelAtPeriodEnd: false }); + const res = await POST(postReq({ action: 'reactivate' })); + expect(res.status).toBe(400); + }); + + it('clears the cancellation schedule', async () => { + findUnique.mockResolvedValue({ id: 'sub1', cancelAtPeriodEnd: true }); + const res = await POST(postReq({ action: 'reactivate' })); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledWith({ where: { id: 'sub1' }, data: { cancelAtPeriodEnd: false } }); + }); +}); + +describe('POST /api/subscription/manage — change', () => { + beforeEach(() => { + findUnique.mockReset(); + update.mockReset(); + create.mockReset(); + userUpdate.mockReset(); + paymentCreate.mockReset(); + }); + + it('returns 400 when tier is missing', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockResolvedValue(null); + const res = await POST(postReq({ action: 'change' })); + expect(res.status).toBe(400); + }); + + it('returns 400 for an invalid tier', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockResolvedValue(null); + const res = await POST(postReq({ action: 'change', tier: 'enterprise' })); + expect(res.status).toBe(400); + }); + + it('returns 400 when already on the requested tier', async () => { + getUserFromRequest.mockResolvedValue(proUser); + findUnique.mockResolvedValue({ id: 'sub1', tier: 'pro' }); + const res = await POST(postReq({ action: 'change', tier: 'pro' })); + expect(res.status).toBe(400); + }); + + it('creates a subscription and an upgrade payment when moving to a higher tier with no existing subscription', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockResolvedValue(null); + create.mockResolvedValue({}); + userUpdate.mockResolvedValue({}); + paymentCreate.mockResolvedValue({}); + + const res = await POST(postReq({ action: 'change', tier: 'starter', billing: 'monthly' })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.tier).toBe('starter'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ userId: 'u1', tier: 'starter', status: 'active' }), + })); + expect(userUpdate).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { subscriptionTier: 'starter' } }); + expect(paymentCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ amount: 49900, status: 'completed' }), + })); + }); + + it('updates the existing subscription and records a pending payment when downgrading', async () => { + getUserFromRequest.mockResolvedValue(proUser); + findUnique.mockResolvedValue({ id: 'sub1', tier: 'pro' }); + update.mockResolvedValue({}); + userUpdate.mockResolvedValue({}); + paymentCreate.mockResolvedValue({}); + + const res = await POST(postReq({ action: 'change', tier: 'starter', billing: 'monthly' })); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'sub1' } })); + expect(paymentCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'pending' }), + })); + }); + + it('uses the yearly price when billing=yearly', async () => { + getUserFromRequest.mockResolvedValue(freeUser); + findUnique.mockResolvedValue(null); + create.mockResolvedValue({}); + userUpdate.mockResolvedValue({}); + paymentCreate.mockResolvedValue({}); + + await POST(postReq({ action: 'change', tier: 'starter', billing: 'yearly' })); + expect(paymentCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ amount: 39900 }), + })); + }); +}); + +describe('POST /api/subscription/manage — invalid action', () => { + it('returns 400 for an unrecognized action', async () => { + getUserFromRequest.mockResolvedValue(proUser); + findUnique.mockResolvedValue(null); + const res = await POST(postReq({ action: 'not-a-real-action' })); + expect(res.status).toBe(400); + }); + + it('returns 500 when request.json() throws', async () => { + getUserFromRequest.mockResolvedValue(proUser); + const badReq = new Request('http://localhost/api/subscription/manage', { method: 'POST', body: 'not json' }); + const res = await POST(badReq); + expect(res.status).toBe(500); + }); +}); diff --git a/__tests__/api/subscription-webhook.test.ts b/__tests__/api/subscription-webhook.test.ts new file mode 100644 index 0000000..0421865 --- /dev/null +++ b/__tests__/api/subscription-webhook.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect } from 'vitest'; +import { POST } from '@/app/api/subscription/webhook/route'; + +function webhookReq(body: unknown) { + return new Request('http://localhost/api/subscription/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/subscription/webhook', () => { + const eventTypes = [ + 'checkout.session.completed', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + ]; + + it.each(eventTypes)('acknowledges a %s event with 200', async (type) => { + const res = await POST(webhookReq({ type, data: { object: {} } })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body).toEqual({ received: true }); + }); + + it('acknowledges an unrecognized event type without erroring', async () => { + const res = await POST(webhookReq({ type: 'some.unknown.event', data: { object: {} } })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body).toEqual({ received: true }); + }); + + it('still returns 200 when the request body is malformed, to avoid retry storms', async () => { + const req = new Request('http://localhost/api/subscription/webhook', { + method: 'POST', + body: 'not json', + }); + const res = await POST(req); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.received).toBe(true); + expect(body.error).toBe('Processing failed'); + }); +}); diff --git a/__tests__/api/subscription.test.ts b/__tests__/api/subscription.test.ts index 3a78c5f..440fdc4 100644 --- a/__tests__/api/subscription.test.ts +++ b/__tests__/api/subscription.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'vitest'; // --- In-memory pricing model (mirrors src/lib/pricing.ts) --- const CURRENCY = { code: 'PHP', symbol: '₱' }; diff --git a/__tests__/api/user-paths.test.ts b/__tests__/api/user-paths.test.ts index 9583d9a..4dce181 100644 --- a/__tests__/api/user-paths.test.ts +++ b/__tests__/api/user-paths.test.ts @@ -9,8 +9,13 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI (no live server available) -const testIfServer = process.env.CI ? it.skip : it; +// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL +const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; + +function extractSessionCookie(setCookieHeader: string | null): string | undefined { + if (!setCookieHeader) return undefined; + return setCookieHeader.match(/interviewlab_session=[^;]+/)?.[0]; +} async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { @@ -20,12 +25,21 @@ async function api(method: string, path: string, body?: unknown, headers?: Recor if (body) opts.body = JSON.stringify(body); const res = await fetch(`${BASE_URL}${path}`, opts); const json = await res.json(); - return { status: res.status, body: json as Record }; + return { + status: res.status, + body: json as Record, + cookie: extractSessionCookie(res.headers.get('set-cookie')), + }; +} + +async function login(email: string, password: string) { + const { body, cookie } = await api('POST', '/api/auth/login', { email, password }); + return { id: body.id as string, cookie: cookie as string }; } describe('User Path: New User Registration to First Interview', () => { const testEmail = `e2e_new_${Date.now()}@test.com`; - let userId: string; + let userCookie: string; testIfServer('Step 1: Register a new account', async () => { const { status, body } = await api('POST', '/api/auth/register', { @@ -34,22 +48,22 @@ describe('User Path: New User Registration to First Interview', () => { password: 'TestPass123!', }); expect(status).toBe(201); - userId = body.id; - expect(userId).toBeTruthy(); + expect(body.id).toBeTruthy(); }); testIfServer('Step 2: Login with new credentials', async () => { - const { status, body } = await api('POST', '/api/auth/login', { + const { status, body, cookie } = await api('POST', '/api/auth/login', { email: testEmail, password: 'TestPass123!', }); expect(status).toBe(200); expect(body.email).toBe(testEmail); + userCookie = cookie as string; }); testIfServer('Step 3: Get profile (should have onboardingDone: false)', async () => { - const { status, body } = await api('GET', '/api/profile', undefined, { - 'x-user-id': userId, + const { status } = await api('GET', '/api/profile', undefined, { + Cookie: userCookie, }); expect(status).toBe(200); // New user should not have completed onboarding @@ -66,13 +80,13 @@ describe('User Path: New User Registration to First Interview', () => { resumeStatus: 'needs-work', country: 'Philippines', onboardingDone: true, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); }); testIfServer('Step 5: View dashboard', async () => { const { status, body } = await api('GET', '/api/dashboard', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect(body).toHaveProperty('stats'); @@ -80,7 +94,7 @@ describe('User Path: New User Registration to First Interview', () => { }); testIfServer('Step 6: Browse questions', async () => { - const { status, body } = await api('GET', '/api/questions?role=Amazon+PPC+VA'); + const { status, body } = await api('GET', '/api/questions?role=PPC+VA'); expect(status).toBe(200); expect((body.questions as unknown[]).length).toBeGreaterThan(0); }); @@ -89,7 +103,7 @@ describe('User Path: New User Registration to First Interview', () => { const { status, body } = await api('POST', '/api/interview', { mode: 'quick_drill', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('session'); expect(body).toHaveProperty('questions'); @@ -110,31 +124,27 @@ describe('User Path: New User Registration to First Interview', () => { }); describe('User Path: Resume Review Flow', () => { - let userId: string; + let userCookie: string; let resumeId: string; beforeAll(async () => { - const { body } = await api('POST', '/api/auth/login', { - email: 'demo@interviewlab.com', - password: 'demo123', - }); - userId = body.id; + ({ cookie: userCookie } = await login('demo@interviewlab.com', 'demo123')); }); testIfServer('Step 1: Create a resume', async () => { const { status, body } = await api('POST', '/api/resume', { originalText: 'Jane Smith\nAmazon PPC Virtual Assistant\n- Managed PPC campaigns\n- Used Helium10 for keyword research\n- Created weekly reports for clients\n- Familiar with Seller Central', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); - resumeId = body.id; + resumeId = body.id as string; }); testIfServer('Step 2: Get AI resume review', async () => { const { status, body } = await api('POST', '/api/ai/resume-review', { resumeText: 'Jane Smith - Amazon PPC VA - Managed campaigns with Helium10', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('score'); expect(body).toHaveProperty('missingKeywords'); @@ -142,17 +152,17 @@ describe('User Path: Resume Review Flow', () => { }); testIfServer('Step 3: Update resume with AI feedback', async () => { - const { status, body } = await api('PUT', `/api/resume/${resumeId}`, { + const { status } = await api('PUT', `/api/resume/${resumeId}`, { score: 72, improvedVersion: 'Improved resume text', truthFlags: ['unverifiable: "Managed campaigns"'], - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); }); testIfServer('Step 4: List resumes and verify', async () => { const { status, body } = await api('GET', '/api/resume', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect((body.resumes as unknown[]).length).toBeGreaterThan(0); @@ -160,39 +170,35 @@ describe('User Path: Resume Review Flow', () => { }); describe('User Path: Full Interview Session', () => { - let userId: string; + let userCookie: string; let sessionId: string; let questionId: string; beforeAll(async () => { - const { body } = await api('POST', '/api/auth/login', { - email: 'demo@interviewlab.com', - password: 'demo123', - }); - userId = body.id; + ({ cookie: userCookie } = await login('demo@interviewlab.com', 'demo123')); }); testIfServer('Step 1: Create role interview session', async () => { const { status, body } = await api('POST', '/api/interview', { mode: 'role_interview', targetRole: 'Amazon PPC VA', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); sessionId = (body.session as Record).id as string; questionId = ((body.questions as Record[])[0]?.id) as string; }); testIfServer('Step 2: Submit answer to first question', async () => { - const { status, body } = await api('POST', `/api/interview/${sessionId}`, { + const { status } = await api('POST', `/api/interview/${sessionId}`, { questionId, userAnswer: 'ACoS stands for Advertising Cost of Sales. It measures the ratio of ad spend to sales. A lower ACoS means better efficiency. I would aim for a target ACoS based on the product profit margin.', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); }); testIfServer('Step 3: Get session with attempts', async () => { const { status, body } = await api('GET', `/api/interview/${sessionId}`, undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect((body.attempts as unknown[]).length).toBeGreaterThan(0); @@ -201,14 +207,14 @@ describe('User Path: Full Interview Session', () => { testIfServer('Step 4: Complete the session', async () => { const { status, body } = await api('POST', `/api/interview/${sessionId}/complete`, { transcript: { notes: 'Test session' }, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('sessionId'); }); testIfServer('Step 5: Verify dashboard shows updated stats', async () => { const { status, body } = await api('GET', '/api/dashboard', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); expect((body.stats as Record).totalSessions).toBeGreaterThan(0); @@ -216,14 +222,10 @@ describe('User Path: Full Interview Session', () => { }); describe('User Path: Cover Letter Generation', () => { - let userId: string; + let userCookie: string; beforeAll(async () => { - const { body } = await api('POST', '/api/auth/login', { - email: 'demo@interviewlab.com', - password: 'demo123', - }); - userId = body.id; + ({ cookie: userCookie } = await login('demo@interviewlab.com', 'demo123')); }); testIfServer('Step 1: Generate cover letter with AI', async () => { @@ -232,7 +234,7 @@ describe('User Path: Cover Letter Generation', () => { tone: 'professional', targetRole: 'Amazon PPC VA', userName: 'Test User', - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); expect(body).toHaveProperty('draftLetter'); }); @@ -243,23 +245,19 @@ describe('User Path: Cover Letter Generation', () => { tone: 'professional', generatedLetter: 'Dear Hiring Manager...', truthFlags: [], - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); }); }); describe('User Path: Learning Path Progress', () => { - let userId: string; + let userCookie: string; let guideId: string; beforeAll(async () => { - const { body } = await api('POST', '/api/auth/login', { - email: 'demo@interviewlab.com', - password: 'demo123', - }); - userId = body.id; - + ({ cookie: userCookie } = await login('demo@interviewlab.com', 'demo123')); + // Get a beginner guide const guideRes = await api('GET', '/api/guides?level=beginner&limit=1'); guideId = ((guideRes.body.guides as Record[])[0]?.id) as string; @@ -279,7 +277,7 @@ describe('User Path: Learning Path Progress', () => { guideId, completed: false, checklist: { '0': true, '1': true, '2': false }, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); }); @@ -289,13 +287,13 @@ describe('User Path: Learning Path Progress', () => { guideId, completed: true, checklist: { '0': true, '1': true, '2': true }, - }, { 'x-user-id': userId }); + }, { Cookie: userCookie }); expect(status).toBe(200); }); testIfServer('Step 4: Verify progress is persisted', async () => { const { status, body } = await api('GET', '/api/guides/progress', undefined, { - 'x-user-id': userId, + Cookie: userCookie, }); expect(status).toBe(200); const progress = body.progress as Record[]; @@ -306,19 +304,15 @@ describe('User Path: Learning Path Progress', () => { }); describe('User Path: Admin Operations', () => { - let adminId: string; + let adminCookie: string; beforeAll(async () => { - const { body } = await api('POST', '/api/auth/login', { - email: 'admin@interviewlab.com', - password: 'admin123', - }); - adminId = body.id; + ({ cookie: adminCookie } = await login('admin@interviewlab.com', 'admin123')); }); testIfServer('Step 1: List all questions as admin', async () => { const { status, body } = await api('GET', '/api/admin/questions?limit=10', undefined, { - 'x-user-id': adminId, + Cookie: adminCookie, }); expect(status).toBe(200); expect((body.questions as unknown[]).length).toBeGreaterThan(0); @@ -326,28 +320,28 @@ describe('User Path: Admin Operations', () => { testIfServer('Step 2: Create a new question', async () => { const { status, body } = await api('POST', '/api/admin/questions', { - role: 'Amazon PPC VA', + role: 'PPC VA', difficulty: 'beginner', type: 'technical', - skillArea: 'PPC Fundamentals', + skillArea: 'PPC', question: 'E2E Test Question: What is the difference between ACoS and ROAS?', strongAnswerPoints: ['ACoS measures cost', 'ROAS measures return'], weakAnswerWarnings: ['Confusing the two metrics'], sampleAnswer: 'ACoS is the ratio of ad spend to sales, while ROAS is the inverse - sales per dollar of ad spend.', - }, { 'x-user-id': adminId }); + }, { Cookie: adminCookie }); expect(status).toBe(201); expect(body).toHaveProperty('id'); }); testIfServer('Step 3: Create a new guide', async () => { - const { status, body } = await api('POST', '/api/guides', { + const { status } = await api('POST', '/api/guides', { title: 'E2E Test Guide', - slug: 'e2e-test-guide', + slug: `e2e-test-guide-${Date.now()}`, level: 'beginner', role: 'Amazon PPC VA', content: '# Test Guide\n\nThis is a test guide for E2E testing.\n\n- [ ] Checklist item 1\n- [ ] Checklist item 2', status: 'draft', - }, { 'x-user-id': adminId }); + }, { Cookie: adminCookie }); expect(status).toBe(201); }); diff --git a/__tests__/components/mock-interview.test.tsx b/__tests__/components/mock-interview.test.tsx new file mode 100644 index 0000000..1e60131 --- /dev/null +++ b/__tests__/components/mock-interview.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MockInterview } from '@/components/interview-lab/MockInterview'; + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'demo@interviewlab.com', subscriptionTier: 'free', isAdmin: false }, + }), +})); + +vi.mock('@/lib/use-subscription', () => ({ + useSubscription: () => ({ + usage: { interviewsThisWeek: 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('MockInterview', () => { + beforeEach(() => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === '/api/interview') return jsonResponse({ sessions: [] }); + return jsonResponse({}); + }); + }); + + it('renders the interview setup screen with mode options', () => { + render(); + expect(screen.getByText('Mock Interview')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start interview with selected mode' })).toBeInTheDocument(); + }); + + it('fetches previous sessions for the logged-in user on mount', async () => { + render(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/interview')); + }); + + it('disables Start Interview until a mode is selected', () => { + render(); + expect(screen.getByRole('button', { name: 'Start interview with selected mode' })).toBeDisabled(); + }); + + it('enables Start Interview after selecting a mode', () => { + render(); + const modeButton = screen.getAllByRole('button', { name: /Select .* mode/ })[0]; + fireEvent.click(modeButton); + expect(screen.getByRole('button', { name: 'Start interview with selected mode' })).not.toBeDisabled(); + }); + + it('starts an interview session and renders the first question', async () => { + const fetchMock = vi.fn((url: string, opts?: RequestInit) => { + if (url === '/api/interview' && opts?.method === 'POST') { + return jsonResponse({ + session: { id: 'session-1', mode: 'quick_drill' }, + questions: [ + { id: 'q1', question: 'What is ACoS?', role: 'PPC VA', difficulty: 'beginner', type: 'technical', skillArea: 'PPC' }, + ], + }); + } + if (url === '/api/interview') return jsonResponse({ sessions: [] }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + const modeButton = screen.getAllByRole('button', { name: /Select .* mode/ })[0]; + fireEvent.click(modeButton); + fireEvent.click(screen.getByRole('button', { name: 'Start interview with selected mode' })); + + await waitFor(() => expect(screen.getByText('What is ACoS?')).toBeInTheDocument()); + expect(screen.getByPlaceholderText('Type your answer here...')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/interview', expect.objectContaining({ method: 'POST' })); + }); + + it('disables Submit Answer until an answer is typed', async () => { + const fetchMock = vi.fn((url: string, opts?: RequestInit) => { + if (url === '/api/interview' && opts?.method === 'POST') { + return jsonResponse({ + session: { id: 'session-1', mode: 'quick_drill' }, + questions: [ + { id: 'q1', question: 'What is ACoS?', role: 'PPC VA', difficulty: 'beginner', type: 'technical', skillArea: 'PPC' }, + ], + }); + } + if (url === '/api/interview') return jsonResponse({ sessions: [] }); + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + const modeButton = screen.getAllByRole('button', { name: /Select .* mode/ })[0]; + fireEvent.click(modeButton); + fireEvent.click(screen.getByRole('button', { name: 'Start interview with selected mode' })); + + await waitFor(() => expect(screen.getByText('What is ACoS?')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Submit Answer' })).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('Type your answer here...'), { + target: { value: 'ACoS is Advertising Cost of Sales.' }, + }); + expect(screen.getByRole('button', { name: 'Submit Answer' })).not.toBeDisabled(); + }); +}); diff --git a/__tests__/components/onboarding-quiz.test.tsx b/__tests__/components/onboarding-quiz.test.tsx new file mode 100644 index 0000000..65d963c --- /dev/null +++ b/__tests__/components/onboarding-quiz.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { OnboardingQuiz } from '@/components/interview-lab/OnboardingQuiz'; + +const updateProfile = vi.fn(); + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'demo@interviewlab.com', subscriptionTier: 'free', isAdmin: false }, + updateProfile, + }), +})); + +vi.mock('next/image', () => ({ + default: (props: Record) => {props.alt, +})); + +describe('OnboardingQuiz', () => { + beforeEach(() => { + updateProfile.mockReset(); + updateProfile.mockResolvedValue(true); + }); + + it('renders the first step with a role selection prompt', () => { + render(); + expect(screen.getByText('Step 1 of 5')).toBeInTheDocument(); + expect(screen.getByText('Target Role')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /PPC VA/i })).toBeInTheDocument(); + }); + + it('disables Next until a role is selected on step 1', () => { + render(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('advances to step 2 after selecting a role and clicking Next', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /PPC VA/i })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Step 2 of 5')).toBeInTheDocument(); + expect(screen.getByText('Experience Level')).toBeInTheDocument(); + }); + + it('goes back to the previous step', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /PPC VA/i })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Step 2 of 5')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Previous' })); + expect(screen.getByText('Step 1 of 5')).toBeInTheDocument(); + }); + + it('submits the collected profile data and calls onComplete on success', async () => { + const onComplete = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /PPC VA/i })); + for (let i = 0; i < 4; i++) { + fireEvent.click(screen.getByRole('button', { name: /Next|Complete Onboarding/ })); + } + + fireEvent.click(screen.getByRole('button', { name: 'Complete Onboarding' })); + + await waitFor(() => expect(updateProfile).toHaveBeenCalledTimes(1)); + expect(updateProfile).toHaveBeenCalledWith( + expect.objectContaining({ targetRole: 'PPC VA', onboardingDone: true }) + ); + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + }); + + it('shows an error message and does not call onComplete when saving fails', async () => { + updateProfile.mockResolvedValueOnce(false); + const onComplete = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /PPC VA/i })); + for (let i = 0; i < 4; i++) { + fireEvent.click(screen.getByRole('button', { name: /Next|Complete Onboarding/ })); + } + fireEvent.click(screen.getByRole('button', { name: 'Complete Onboarding' })); + + await waitFor(() => + expect(screen.getByText('Failed to save your profile. Please try again.')).toBeInTheDocument() + ); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/resume-lab.test.tsx b/__tests__/components/resume-lab.test.tsx new file mode 100644 index 0000000..6555583 --- /dev/null +++ b/__tests__/components/resume-lab.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ResumeLab } from '@/components/interview-lab/ResumeLab'; + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'demo@interviewlab.com', subscriptionTier: 'free', isAdmin: false }, + }), +})); + +vi.mock('@/lib/use-subscription', () => ({ + useSubscription: () => ({ + usage: { resumeReviewsThisMonth: 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('ResumeLab', () => { + beforeEach(() => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === '/api/resume') return jsonResponse({ resumes: [] }); + return jsonResponse({}); + }); + }); + + it('renders the resume submission form', () => { + render(); + expect(screen.getByText('Resume Lab')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Paste your resume text here...')).toBeInTheDocument(); + }); + + it('fetches resume history for the logged-in user on mount', async () => { + render(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/resume')); + }); + + it('disables the review button until resume text is entered', () => { + render(); + expect(screen.getByRole('button', { name: 'Get AI Review' })).toBeDisabled(); + }); + + it('enables the review button once resume text is entered', () => { + render(); + const textarea = screen.getByPlaceholderText('Paste your resume text here...'); + fireEvent.change(textarea, { target: { value: 'Experienced Amazon PPC VA' } }); + expect(screen.getByRole('button', { name: 'Get AI Review' })).not.toBeDisabled(); + }); + + it('submits the resume, requests an AI review, and displays the score', async () => { + const fetchMock = vi.fn((url: string, opts?: RequestInit) => { + if (url === '/api/resume' && opts?.method === 'POST') { + return jsonResponse({ id: 'resume-1' }); + } + if (url === '/api/ai/resume-review') { + return jsonResponse({ score: 82, missingKeywords: [], improvedVersion: 'Improved text', truthWarnings: [] }); + } + if (url === '/api/resume/resume-1') { + return jsonResponse({ id: 'resume-1' }); + } + if (url === '/api/resume') { + return jsonResponse({ resumes: [] }); + } + return jsonResponse({}); + }); + global.fetch = fetchMock; + + render(); + const textarea = screen.getByPlaceholderText('Paste your resume text here...'); + fireEvent.change(textarea, { target: { value: 'Experienced Amazon PPC VA managing campaigns' } }); + fireEvent.click(screen.getByRole('button', { name: 'Get AI Review' })); + + await waitFor(() => expect(screen.getByText('82/100')).toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith('/api/ai/resume-review', expect.objectContaining({ method: 'POST' })); + }); + + it('shows an error when the resume creation request fails', async () => { + const fetchMock = vi.fn((url: string, opts?: RequestInit) => { + if (url === '/api/resume' && opts?.method === 'POST') { + return jsonResponse({ error: 'failed' }, false); + } + return jsonResponse({ resumes: [] }); + }); + global.fetch = fetchMock; + + render(); + const textarea = screen.getByPlaceholderText('Paste your resume text here...'); + fireEvent.change(textarea, { target: { value: 'Some resume text' } }); + fireEvent.click(screen.getByRole('button', { name: 'Get AI Review' })); + + await waitFor(() => + expect(screen.getByText('Failed to create resume record. Please try again.')).toBeInTheDocument() + ); + }); +}); diff --git a/__tests__/lib/rate-limit.test.ts b/__tests__/lib/rate-limit.test.ts new file mode 100644 index 0000000..69cd0aa --- /dev/null +++ b/__tests__/lib/rate-limit.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const findUnique = vi.fn(); +const upsert = vi.fn(); +const update = vi.fn(); +const deleteMany = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + rateLimitEntry: { + findUnique: (...args: unknown[]) => findUnique(...args), + upsert: (...args: unknown[]) => upsert(...args), + update: (...args: unknown[]) => update(...args), + deleteMany: (...args: unknown[]) => deleteMany(...args), + }, + }, +})); + +import { checkRateLimit, cleanupExpiredRateLimits } from '@/lib/rate-limit'; + +describe('checkRateLimit', () => { + beforeEach(() => { + findUnique.mockReset(); + upsert.mockReset(); + update.mockReset(); + deleteMany.mockReset(); + }); + + it('allows the first request for a new key and creates an entry with count 1', async () => { + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({}); + + const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000); + + expect(result).toEqual({ allowed: true, remaining: 9 }); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: 'auth-login:1.2.3.4' }, + create: expect.objectContaining({ key: 'auth-login:1.2.3.4', count: 1 }), + }) + ); + }); + + it('treats an expired entry as a new window and resets the count', async () => { + findUnique.mockResolvedValue({ count: 10, resetTime: new Date(Date.now() - 1000) }); + upsert.mockResolvedValue({}); + + const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000); + + expect(result).toEqual({ allowed: true, remaining: 9 }); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: expect.objectContaining({ count: 1 }) }) + ); + }); + + it('increments the count and allows the request while under the max', async () => { + findUnique.mockResolvedValue({ count: 3, resetTime: new Date(Date.now() + 60_000) }); + update.mockResolvedValue({}); + + const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000); + + expect(result).toEqual({ allowed: true, remaining: 6 }); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: 'auth-login:1.2.3.4' }, + data: { count: 4 }, + }) + ); + }); + + it('rejects the request once the count reaches the max and does not increment further', async () => { + findUnique.mockResolvedValue({ count: 10, resetTime: new Date(Date.now() + 60_000) }); + + const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000); + + expect(result).toEqual({ allowed: false, remaining: 0 }); + expect(update).not.toHaveBeenCalled(); + }); + + it('scopes rate limits per prefix so different actions do not share a bucket', async () => { + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({}); + + await checkRateLimit('1.2.3.4', 'auth-register', 5, 60_000); + + expect(findUnique).toHaveBeenCalledWith({ where: { key: 'auth-register:1.2.3.4' } }); + }); + + it('fails open (allows the request) if the database throws', async () => { + findUnique.mockRejectedValue(new Error('connection lost')); + + const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000); + + expect(result).toEqual({ allowed: true, remaining: 10 }); + }); +}); + +describe('cleanupExpiredRateLimits', () => { + beforeEach(() => { + deleteMany.mockReset(); + }); + + it('deletes entries whose resetTime has passed', async () => { + deleteMany.mockResolvedValue({ count: 3 }); + await cleanupExpiredRateLimits(); + expect(deleteMany).toHaveBeenCalledWith({ + where: { resetTime: { lt: expect.any(Date) } }, + }); + }); + + it('silently swallows database errors', async () => { + deleteMany.mockRejectedValue(new Error('connection lost')); + await expect(cleanupExpiredRateLimits()).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/lib/sanitize.test.ts b/__tests__/lib/sanitize.test.ts new file mode 100644 index 0000000..d2096fb --- /dev/null +++ b/__tests__/lib/sanitize.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeText, sanitizeRichText } from '@/lib/sanitize'; + +describe('sanitizeText', () => { + it('returns null for null and undefined', () => { + expect(sanitizeText(null)).toBeNull(); + expect(sanitizeText(undefined)).toBeNull(); + }); + + it('returns null for non-string values', () => { + expect(sanitizeText(42)).toBeNull(); + expect(sanitizeText({})).toBeNull(); + expect(sanitizeText(['a'])).toBeNull(); + }); + + it('strips script tags and their content', () => { + expect(sanitizeText('helloworld')).toBe('helloworld'); + }); + + it('strips style tags and their content', () => { + expect(sanitizeText('helloworld')).toBe('helloworld'); + }); + + it('strips arbitrary HTML tags but keeps text content', () => { + expect(sanitizeText('bold and italic')).toBe('bold and italic'); + }); + + it('removes javascript: URLs', () => { + expect(sanitizeText('javascript:alert(1)')).toBe('alert(1)'); + }); + + it('removes inline event handlers along with the enclosing tag', () => { + expect(sanitizeText('beforeafter')).toBe('beforeafter'); + }); + + it('trims whitespace', () => { + expect(sanitizeText(' hello world ')).toBe('hello world'); + }); + + it('returns null for strings that are empty after sanitizing', () => { + expect(sanitizeText(' ')).toBeNull(); + expect(sanitizeText('')).toBeNull(); + }); + + it('leaves plain text untouched', () => { + expect(sanitizeText('Amazon PPC Virtual Assistant')).toBe('Amazon PPC Virtual Assistant'); + }); +}); + +describe('sanitizeRichText', () => { + it('returns null for null, undefined, and non-strings', () => { + expect(sanitizeRichText(null)).toBeNull(); + expect(sanitizeRichText(undefined)).toBeNull(); + expect(sanitizeRichText(99)).toBeNull(); + }); + + it('strips script tags but preserves other markup', () => { + expect(sanitizeRichText('

Hi

')).toBe('

Hi

'); + }); + + it('removes inline event handlers while preserving the tag', () => { + expect(sanitizeRichText('
click me
')).toBe('
click me
'); + }); + + it('removes javascript: URLs', () => { + expect(sanitizeRichText('link')).toBe('link'); + }); + + it('trims whitespace and collapses to null when empty', () => { + expect(sanitizeRichText(' ')).toBeNull(); + }); + + it('preserves basic formatting for AI-generated content', () => { + const input = '

Dear Hiring Manager,

I am excited to apply.

'; + expect(sanitizeRichText(input)).toBe(input); + }); +}); diff --git a/__tests__/lib/session.test.ts b/__tests__/lib/session.test.ts new file mode 100644 index 0000000..c50cffc --- /dev/null +++ b/__tests__/lib/session.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { + createSession, + verifySession, + verifyToken, + clearSession, + getSession, + TOKEN_NAME, + TOKEN_MAX_AGE, +} from '@/lib/session'; + +const payload = { sub: 'user-1', email: 'demo@interviewlab.com', tier: 'free', isAdmin: false }; + +function requestWithCookie(cookieValue?: string) { + const headers = new Headers(); + if (cookieValue) headers.set('cookie', `${TOKEN_NAME}=${cookieValue}`); + return new NextRequest('http://localhost/api/dashboard', { headers }); +} + +describe('createSession / verifyToken', () => { + it('creates a JWT that verifies back to the original payload', async () => { + const token = await createSession(payload); + const decoded = await verifyToken(token); + expect(decoded).toEqual(payload); + }); + + it('sets an HttpOnly session cookie on the response when provided', async () => { + const response = NextResponse.json({ ok: true }); + await createSession(payload, response); + const cookie = response.cookies.get(TOKEN_NAME); + expect(cookie).toBeDefined(); + expect(cookie?.httpOnly).toBe(true); + expect(cookie?.maxAge).toBe(TOKEN_MAX_AGE); + }); + + it('does not set a cookie when no response is provided', async () => { + // Should simply not throw and still return a token + const token = await createSession(payload); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // header.payload.signature + }); +}); + +describe('verifyToken', () => { + it('returns null for a malformed token', async () => { + expect(await verifyToken('not-a-real-token')).toBeNull(); + }); + + it('returns null for a tampered token', async () => { + const token = await createSession(payload); + const tampered = token.slice(0, -2) + 'xx'; + expect(await verifyToken(tampered)).toBeNull(); + }); + + it('returns null for an empty string', async () => { + expect(await verifyToken('')).toBeNull(); + }); +}); + +describe('verifySession', () => { + it('returns the payload for a request with a valid session cookie', async () => { + const token = await createSession(payload); + const request = requestWithCookie(token); + expect(await verifySession(request)).toEqual(payload); + }); + + it('returns null when there is no session cookie', async () => { + const request = requestWithCookie(); + expect(await verifySession(request)).toBeNull(); + }); + + it('returns null for an invalid session cookie', async () => { + const request = requestWithCookie('garbage-token'); + expect(await verifySession(request)).toBeNull(); + }); +}); + +describe('clearSession', () => { + it('overwrites the session cookie with an empty value and zero maxAge', () => { + const response = NextResponse.json({ ok: true }); + clearSession(response); + const cookie = response.cookies.get(TOKEN_NAME); + expect(cookie?.value).toBe(''); + expect(cookie?.maxAge).toBe(0); + }); +}); + +describe('getSession', () => { + it('returns null when called outside of a request scope', async () => { + // next/headers' cookies() throws outside a request context; + // getSession() should swallow that and return null rather than throw. + await expect(getSession()).resolves.toBeNull(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 5932c84..5f512ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,11 +46,13 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.10", "bun-types": "^1.3.4", "eslint": "^9", "eslint-config-next": "^16.1.1", "jsdom": "^29.1.1", "tailwindcss": "^4", + "tsx": "^4.23.1", "tw-animate-css": "^1.3.5", "typescript": "^5", "vitest": "^4.1.5" @@ -282,16 +284,20 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -318,11 +324,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -370,17 +378,29 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -567,6 +587,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -3789,17 +4251,60 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3808,13 +4313,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3835,9 +4340,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3848,13 +4353,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -3862,14 +4367,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3878,9 +4383,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -3888,13 +4393,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4198,6 +4703,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "license": "MIT" @@ -5236,6 +5760,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "dev": true, @@ -6263,6 +6829,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ @@ -6717,6 +7290,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "dev": true, @@ -7355,6 +7967,35 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "dev": true, @@ -9133,6 +9774,25 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "dev": true, @@ -9815,19 +10475,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9855,12 +10515,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 081bf56..1f362e3 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:reset": "prisma migrate reset", + "db:seed": "tsx prisma/seed.ts", "test": "vitest run", "test:watch": "vitest", "test:api": "vitest run __tests__/api", - "test:components": "vitest run __tests__/components" + "test:components": "vitest run __tests__/components", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", @@ -55,13 +57,15 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.10", "bun-types": "^1.3.4", "eslint": "^9", "eslint-config-next": "^16.1.1", "jsdom": "^29.1.1", "tailwindcss": "^4", + "tsx": "^4.23.1", "tw-animate-css": "^1.3.5", "typescript": "^5", "vitest": "^4.1.5" } -} \ No newline at end of file +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0aaaf33..c8661f2 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,7 +9,8 @@ export async function POST(request: Request) { // Persistent rate limiting for login (survives server restarts) const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown'; - const rl = await checkRateLimit(clientIp, 'auth-login', 10, 15 * 60_000); + const loginRateLimitMax = Number(process.env.AUTH_LOGIN_RATE_LIMIT_MAX) || 10; + const rl = await checkRateLimit(clientIp, 'auth-login', loginRateLimitMax, 15 * 60_000); if (!rl.allowed) { return NextResponse.json( { error: 'Too many login attempts. Please try again later.' }, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 7b279e7..2651b1f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -42,7 +42,8 @@ export async function POST(request: Request) { // Persistent rate limiting (survives server restarts) const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown'; - const rl = await checkRateLimit(clientIp, 'auth-register', 5, 15 * 60_000); + const registerRateLimitMax = Number(process.env.AUTH_REGISTER_RATE_LIMIT_MAX) || 5; + const rl = await checkRateLimit(clientIp, 'auth-register', registerRateLimitMax, 15 * 60_000); if (!rl.allowed) { return NextResponse.json( { error: 'Too many registration attempts. Please try again later.' }, diff --git a/src/middleware.ts b/src/middleware.ts index 22611ed..c991dfd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -8,9 +8,9 @@ import { NextRequest, NextResponse } from 'next/server'; const rateLimitMap = new Map(); const GENERAL_WINDOW = 60_000; // 1 minute -const GENERAL_MAX = 60; // 60 requests per minute per IP for general API +const GENERAL_MAX = Number(process.env.API_RATE_LIMIT_MAX) || 60; // requests per minute per IP for general API const AUTH_WINDOW = 15 * 60_000; // 15 minutes -const AUTH_MAX = 10; // 10 auth attempts per 15 minutes per IP +const AUTH_MAX = Number(process.env.AUTH_RATE_LIMIT_MAX) || 10; // auth attempts per 15 minutes per IP function getClientIp(request: NextRequest): string { const forwarded = request.headers.get('x-forwarded-for'); diff --git a/vitest.config.ts b/vitest.config.ts index 7678c5c..319891c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,12 @@ export default defineConfig({ include: ['__tests__/**/*.test.ts', '__tests__/**/*.test.tsx'], testTimeout: 30000, hookTimeout: 30000, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/app/**/page.tsx', 'src/app/**/layout.tsx', 'src/components/ui/**'], + }, }, resolve: { alias: {