Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion __tests__/api/assessments.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down
2 changes: 1 addition & 1 deletion __tests__/api/auth-login.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down
2 changes: 1 addition & 1 deletion __tests__/api/auth-register.test.ts
Original file line number Diff line number Diff line change
@@ -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}> = [];
Expand Down
67 changes: 67 additions & 0 deletions __tests__/api/auth-verify-email.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion __tests__/api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) {
const opts: RequestInit = {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/api/profile-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down
39 changes: 39 additions & 0 deletions __tests__/api/questions-count.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
45 changes: 27 additions & 18 deletions __tests__/api/questions-interview-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) {
const opts: RequestInit = {
Expand All @@ -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', () => {
Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -133,15 +142,15 @@ 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');
});

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');
});
Expand All @@ -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 () => {
Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion __tests__/api/questions.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
Loading
Loading