diff --git a/tests/helpers/build-admin-test-app.ts b/tests/helpers/build-admin-test-app.ts new file mode 100644 index 0000000..3ac234c --- /dev/null +++ b/tests/helpers/build-admin-test-app.ts @@ -0,0 +1,51 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import { AppError } from '../../src/utils/errors.js'; +import { adminQuestionsRoutes } from '../../src/routes/admin/questions.js'; +import type { MockDb } from '../test-utils.js'; +import { createMockDb } from '../test-utils.js'; + +const mockAdminUser = { id: 'admin-1', email: 'admin@test.com' }; + +/** + * Build a minimal Fastify app for admin route integration tests. + * Bypasses real auth - preHandlers set req.user to mock admin. + */ +export async function buildAdminTestApp(mockDb?: MockDb): Promise { + const db = mockDb ?? createMockDb(); + + const app = Fastify({ + logger: false, + requestIdHeader: 'x-request-id', + requestIdLogLabel: 'requestId', + }); + + app.setErrorHandler((err: unknown, _request, reply) => { + const error = err as Error & { statusCode?: number; validation?: unknown }; + if (err instanceof AppError) { + return reply.status(err.statusCode).send(err.toJSON()); + } + if (error.validation) { + return reply.status(422).send({ + error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: error.validation }, + }); + } + return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: error.message } }); + }); + + app.decorate('db', db); + app.decorate('rateLimitOptions', { + apiAuthed: { max: 100, timeWindow: '1 minute' }, + }); + app.decorateRequest('user', undefined); + app.decorate('authenticate', async (req: { user?: { id: string; email: string } }) => { + req.user = mockAdminUser; + }); + app.decorate('authenticateAdmin', async (req: { user?: { id: string; email: string } }) => { + if (!req.user) req.user = mockAdminUser; + (req.user as { role?: string }).role = 'admin'; + }); + + await app.register(adminQuestionsRoutes, { prefix: '/admin' }); + + return app; +} diff --git a/tests/integration/admin.routes.test.ts b/tests/integration/admin.routes.test.ts new file mode 100644 index 0000000..38b9ad2 --- /dev/null +++ b/tests/integration/admin.routes.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { buildAdminTestApp } from '../helpers/build-admin-test-app.js'; +import { + createMockDb, + selectChainOrderedLimitOffset, + selectChainWhere, + updateChainReturning, + insertChain, +} from '../test-utils.js'; + +describe('Admin routes integration', () => { + let app: Awaited>; + let mockDb: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDb = createMockDb(); + app = await buildAdminTestApp(mockDb as never); + }); + + describe('GET /admin/questions/pending', () => { + it('returns pending questions list', async () => { + const pendingQuestions = [ + { + id: 'q-1', + stack: 'js', + level: 'beginner', + type: 'single_choice', + questionText: 'Test question?', + options: [{ key: 'a', text: 'A' }], + correctAnswer: 'a', + explanation: 'Exp', + source: 'manual', + createdAt: new Date(), + }, + ]; + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChainOrderedLimitOffset(pendingQuestions)) + .mockReturnValueOnce(selectChainWhere([{ count: 1 }])); + + const res = await app.inject({ + method: 'GET', + url: '/admin/questions/pending', + headers: { authorization: 'Bearer any-token' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.questions).toHaveLength(1); + expect(body.questions[0].questionText).toBe('Test question?'); + expect(body.total).toBe(1); + }); + }); + + describe('POST /admin/questions/:questionId/approve', () => { + it('returns 204 on success', async () => { + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChainReturning([{ id: 'q-1' }]) + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/admin/questions/11111111-1111-1111-1111-111111111111/approve', + headers: { authorization: 'Bearer any-token' }, + }); + + expect(res.statusCode).toBe(204); + }); + + it('returns 404 when question not found', async () => { + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChainReturning([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/admin/questions/11111111-1111-1111-1111-111111111111/approve', + headers: { authorization: 'Bearer any-token' }, + }); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /admin/questions/:questionId/reject', () => { + it('returns 204 on success', async () => { + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChainReturning([{ id: 'q-1' }]) + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/admin/questions/11111111-1111-1111-1111-111111111111/reject', + headers: { authorization: 'Bearer any-token' }, + }); + + expect(res.statusCode).toBe(204); + }); + }); + + describe('PATCH /admin/questions/:questionId', () => { + it('returns updated question', async () => { + const updatedQuestion = { + id: 'q-1', + stack: 'js', + level: 'intermediate', + type: 'single_choice', + questionText: 'Updated?', + options: [{ key: 'a', text: 'A' }], + correctAnswer: 'a', + explanation: 'Updated exp', + source: 'manual', + createdAt: new Date(), + }; + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChainReturning([updatedQuestion]) + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'PATCH', + url: '/admin/questions/11111111-1111-1111-1111-111111111111', + headers: { authorization: 'Bearer any-token' }, + payload: { questionText: 'Updated?', explanation: 'Updated exp' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.questionText).toBe('Updated?'); + }); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6a61121..1559492 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -58,6 +58,22 @@ export function selectChainWhere(resolveAtWhere: unknown[] = []) { }; } +/** Build a select chain for .from().where().orderBy().limit().offset() */ +export function selectChainOrderedLimitOffset(resolveRows: unknown[] = []) { + const offsetFn = vi.fn().mockResolvedValue(resolveRows); + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + offset: offsetFn, + }), + }), + }), + }), + }; +} + /** Build an insert chain that resolves at .returning() or .values() */ export function insertChain(resolveAtReturning: unknown[] = []) { const returningFn = vi.fn().mockResolvedValue(resolveAtReturning);