Skip to content

Commit 3377687

Browse files
Fix broken CI test suite and close major test coverage gaps (#3)
- Fix 7 test files importing from 'bun:test' instead of 'vitest', which made npx vitest run (what CI actually runs) fail to load them entirely — CI was passing on a fraction of the suite. - Rewrite the 4 live-server integration suites (auth, resources, questions/interview/AI, user-paths) to authenticate via the real JWT session cookie flow; they still used the x-user-id header shortcut that was removed as a security fix, so they were exercising an auth path that no longer exists. - Wire those integration suites into CI: spin up a Postgres service, push + seed the schema, build, start the app, and run them against the live server instead of skipping unconditionally under CI=true. - Make login/register/general API rate limits configurable via env vars (defaults unchanged) so the integration suite's request volume doesn't trip production-tuned limits. - Add real render tests (MockInterview, ResumeLab, OnboardingQuiz), direct unit tests (session, rate-limit, sanitize), and route tests (subscription webhook/manage, verify-email, questions/count) that import the actual implementation instead of reimplementing it. - Add @vitest/coverage-v8 and a test:coverage script. Claude-Session: https://claude.ai/code/session_01CyerEYcFhnBf8oaqqSSCSg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e07910 commit 3377687

28 files changed

Lines changed: 1952 additions & 215 deletions

.github/workflows/ci.yml

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,33 @@ on:
99
jobs:
1010
audit:
1111
runs-on: ubuntu-latest
12-
timeout-minutes: 10
12+
timeout-minutes: 15
13+
14+
services:
15+
postgres:
16+
image: postgres:16
17+
env:
18+
POSTGRES_USER: postgres
19+
POSTGRES_PASSWORD: postgres
20+
POSTGRES_DB: interviewlab_test
21+
ports:
22+
- 5432:5432
23+
options: >-
24+
--health-cmd pg_isready
25+
--health-interval 10s
26+
--health-timeout 5s
27+
--health-retries 5
28+
29+
env:
30+
JWT_SECRET: ci-only-do-not-use-in-prod
31+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/interviewlab_test
32+
# Auth/API rate limits are tuned tight for production; the live-server
33+
# integration suite below makes far more requests per minute than a
34+
# real user would, so we relax the limits for this job only.
35+
AUTH_LOGIN_RATE_LIMIT_MAX: '1000'
36+
AUTH_REGISTER_RATE_LIMIT_MAX: '1000'
37+
API_RATE_LIMIT_MAX: '100000'
38+
AUTH_RATE_LIMIT_MAX: '1000'
1339

1440
steps:
1541
- uses: actions/checkout@v4
@@ -30,16 +56,40 @@ jobs:
3056
- name: ESLint
3157
run: npx eslint .
3258

33-
- name: Tests
59+
- name: Push database schema
60+
run: npx prisma db push --accept-data-loss
61+
62+
- name: Seed database
63+
run: npm run db:seed
64+
65+
- name: Unit tests
3466
run: CI=true npx vitest run
3567

68+
- name: Build
69+
run: npm run build
70+
71+
- name: Start server
72+
run: |
73+
npm run start -- -p 3000 &
74+
echo $! > server.pid
75+
for i in $(seq 1 30); do
76+
if curl -fsS http://localhost:3000 >/dev/null 2>&1; then
77+
echo "Server is up"
78+
exit 0
79+
fi
80+
sleep 1
81+
done
82+
echo "Server failed to start" >&2
83+
exit 1
84+
85+
- name: Integration tests (live server)
86+
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
87+
88+
- name: Stop server
89+
if: always()
90+
run: kill "$(cat server.pid)" 2>/dev/null || true
91+
3692
- name: Secret scan (gitleaks)
3793
uses: gitleaks/gitleaks-action@v2
3894
env:
3995
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40-
41-
- name: Build
42-
run: npm run build
43-
env:
44-
JWT_SECRET: ci-only-do-not-use-in-prod
45-
DATABASE_URL: file:./build-test.db

__tests__/api/assessments.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from 'bun:test';
1+
import { describe, it, expect, beforeEach } from 'vitest';
22

33
let assessments: any[] = [];
44
let agentRuns: any[] = [];

__tests__/api/auth-login.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from 'bun:test';
1+
import { describe, it, expect, beforeEach } from 'vitest';
22

33
// In-memory stubs matching actual route logic
44
let users: any[] = [];

__tests__/api/auth-register.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from 'bun:test';
1+
import { describe, it, expect, beforeEach } from 'vitest';
22

33
// --- In-memory store ---
44
let users: Array<{email: string; name: string; passwordHash: string; emailVerified: boolean}> = [];
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
6+
const userUpdate = vi.fn();
7+
const validateVerificationToken = vi.fn();
8+
9+
vi.mock('@/lib/db', () => ({
10+
db: {
11+
user: {
12+
update: (...args: unknown[]) => userUpdate(...args),
13+
},
14+
},
15+
}));
16+
17+
vi.mock('@/lib/email-verification', () => ({
18+
validateVerificationToken: (...args: unknown[]) => validateVerificationToken(...args),
19+
}));
20+
21+
import { GET } from '@/app/api/auth/verify-email/route';
22+
import { NextRequest } from 'next/server';
23+
24+
function req(query: string) {
25+
return new NextRequest(`http://localhost/api/auth/verify-email${query}`);
26+
}
27+
28+
describe('GET /api/auth/verify-email', () => {
29+
beforeEach(() => {
30+
userUpdate.mockReset();
31+
validateVerificationToken.mockReset();
32+
});
33+
34+
it('redirects with missing-token when no token is provided', async () => {
35+
const res = await GET(req(''));
36+
expect(res.status).toBe(307);
37+
expect(res.headers.get('location')).toContain('/?verified=missing-token');
38+
});
39+
40+
it('redirects with invalid when the token cannot be validated', async () => {
41+
validateVerificationToken.mockResolvedValue(null);
42+
const res = await GET(req('?token=bad-token'));
43+
expect(res.headers.get('location')).toContain('/?verified=invalid');
44+
});
45+
46+
it('marks the user as verified and redirects with success for a valid token', async () => {
47+
validateVerificationToken.mockResolvedValue('demo@interviewlab.com');
48+
userUpdate.mockResolvedValue({ id: 'u1', email: 'demo@interviewlab.com' });
49+
50+
const res = await GET(req('?token=good-token'));
51+
52+
expect(userUpdate).toHaveBeenCalledWith({
53+
where: { email: 'demo@interviewlab.com' },
54+
data: { emailVerified: true },
55+
});
56+
expect(res.headers.get('location')).toContain('/?verified=success');
57+
});
58+
59+
it('redirects with error when the database update fails', async () => {
60+
validateVerificationToken.mockResolvedValue('demo@interviewlab.com');
61+
userUpdate.mockRejectedValue(new Error('db down'));
62+
63+
const res = await GET(req('?token=good-token'));
64+
65+
expect(res.headers.get('location')).toContain('/?verified=error');
66+
});
67+
});

__tests__/api/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
88

99
describe('Auth API', () => {
10-
const testIfServer = process.env.CI ? it.skip : it;
10+
const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it;
1111

1212
async function api(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
1313
const opts: RequestInit = {

__tests__/api/profile-dashboard.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from 'bun:test';
1+
import { describe, it, expect, beforeEach } from 'vitest';
22

33
let currentUser: any = null;
44
let profiles: any[] = [];
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
6+
const count = vi.fn();
7+
8+
vi.mock('@/lib/db', () => ({
9+
db: {
10+
question: {
11+
count: (...args: unknown[]) => count(...args),
12+
},
13+
},
14+
}));
15+
16+
import { GET } from '@/app/api/questions/count/route';
17+
18+
describe('GET /api/questions/count', () => {
19+
beforeEach(() => {
20+
count.mockReset();
21+
});
22+
23+
it('returns the total number of published questions', async () => {
24+
count.mockResolvedValue(264);
25+
const res = await GET();
26+
const body = await res.json();
27+
expect(res.status).toBe(200);
28+
expect(body).toEqual({ total: 264 });
29+
expect(count).toHaveBeenCalledWith({ where: { status: 'published' } });
30+
});
31+
32+
it('returns total 0 when the database throws', async () => {
33+
count.mockRejectedValue(new Error('db down'));
34+
const res = await GET();
35+
const body = await res.json();
36+
expect(res.status).toBe(200);
37+
expect(body).toEqual({ total: 0 });
38+
});
39+
});

__tests__/api/questions-interview-ai.test.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@
88

99
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
1010

11-
// Skip integration tests in CI (no live server available)
12-
const testIfServer = process.env.CI ? it.skip : it;
11+
// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL
12+
const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it;
13+
14+
function extractSessionCookie(setCookieHeader: string | null): string | undefined {
15+
if (!setCookieHeader) return undefined;
16+
return setCookieHeader.match(/interviewlab_session=[^;]+/)?.[0];
17+
}
1318

1419
async function api(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
1520
const opts: RequestInit = {
@@ -19,15 +24,19 @@ async function api(method: string, path: string, body?: unknown, headers?: Recor
1924
if (body) opts.body = JSON.stringify(body);
2025
const res = await fetch(`${BASE_URL}${path}`, opts);
2126
const json = await res.json();
22-
return { status: res.status, body: json };
27+
return {
28+
status: res.status,
29+
body: json,
30+
cookie: extractSessionCookie(res.headers.get('set-cookie')),
31+
};
2332
}
2433

25-
async function getDemoUserId() {
26-
const { body } = await api('POST', '/api/auth/login', {
34+
async function getDemoUser() {
35+
const { body, cookie } = await api('POST', '/api/auth/login', {
2736
email: 'demo@interviewlab.com',
2837
password: 'demo123',
2938
});
30-
return body.id;
39+
return { id: body.id as string, cookie: cookie as string };
3140
}
3241

3342
describe('Questions API', () => {
@@ -41,11 +50,11 @@ describe('Questions API', () => {
4150
});
4251

4352
testIfServer('should filter questions by role', async () => {
44-
const { status, body } = await api('GET', '/api/questions?role=Amazon%20PPC%20VA');
53+
const { status, body } = await api('GET', '/api/questions?role=PPC%20VA');
4554
expect(status).toBe(200);
4655
expect(body.questions.length).toBeGreaterThan(0);
4756
body.questions.forEach((q: { role: string }) => {
48-
expect(q.role).toBe('Amazon PPC VA');
57+
expect(q.role).toBe('PPC VA');
4958
});
5059
});
5160

@@ -74,18 +83,18 @@ describe('Questions API', () => {
7483
});
7584

7685
describe('Interview API', () => {
77-
let userId: string;
86+
let userCookie: string;
7887
let sessionId: string;
7988

8089
beforeAll(async () => {
81-
userId = await getDemoUserId();
90+
({ cookie: userCookie } = await getDemoUser());
8291
});
8392

8493
testIfServer('should create an interview session', async () => {
8594
const { status, body } = await api('POST', '/api/interview', {
8695
mode: 'quick_drill',
8796
targetRole: 'Amazon PPC VA',
88-
}, { 'x-user-id': userId });
97+
}, { Cookie: userCookie });
8998
expect(status).toBe(200);
9099
expect(body).toHaveProperty('session');
91100
expect(body.session).toHaveProperty('id');
@@ -96,7 +105,7 @@ describe('Interview API', () => {
96105

97106
testIfServer('should list interview sessions', async () => {
98107
const { status, body } = await api('GET', '/api/interview', undefined, {
99-
'x-user-id': userId,
108+
Cookie: userCookie,
100109
});
101110
expect(status).toBe(200);
102111
expect(body).toHaveProperty('sessions');
@@ -113,7 +122,7 @@ describe('Interview API', () => {
113122

114123
testIfServer('should get interview session by ID with auth', async () => {
115124
const { status, body } = await api('GET', `/api/interview/${sessionId}`, undefined, {
116-
'x-user-id': userId,
125+
Cookie: userCookie,
117126
});
118127
expect(status).toBe(200);
119128
expect(body).toHaveProperty('id');
@@ -133,15 +142,15 @@ describe('Interview API', () => {
133142
const { status, body } = await api('POST', `/api/interview/${sessionId}`, {
134143
questionId,
135144
userAnswer: 'I would check the ACoS and reduce bids on underperforming keywords',
136-
}, { 'x-user-id': userId });
145+
}, { Cookie: userCookie });
137146
expect(status).toBe(201);
138147
expect(body).toHaveProperty('id');
139148
});
140149

141150
testIfServer('should complete an interview session with auth', async () => {
142151
const { status, body } = await api('POST', `/api/interview/${sessionId}/complete`, {
143152
transcript: { test: 'data' },
144-
}, { 'x-user-id': userId });
153+
}, { Cookie: userCookie });
145154
expect(status).toBe(200);
146155
expect(body).toHaveProperty('sessionId');
147156
});
@@ -153,10 +162,10 @@ describe('Interview API', () => {
153162
});
154163

155164
describe('AI Endpoints', () => {
156-
let userId: string;
165+
let userCookie: string;
157166

158167
beforeAll(async () => {
159-
userId = await getDemoUserId();
168+
({ cookie: userCookie } = await getDemoUser());
160169
});
161170

162171
testIfServer('should require auth for AI coach', async () => {
@@ -169,7 +178,7 @@ describe('AI Endpoints', () => {
169178

170179
testIfServer('should validate required fields for AI coach', async () => {
171180
const { status, body } = await api('POST', '/api/ai/coach', {}, {
172-
'x-user-id': userId,
181+
Cookie: userCookie,
173182
});
174183
expect(status).toBe(400);
175184
expect(body).toHaveProperty('error');

__tests__/api/questions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from 'bun:test';
1+
import { describe, it, expect, beforeEach } from 'vitest';
22

33
// In-memory question store
44
const MOCK_QUESTIONS = [

0 commit comments

Comments
 (0)