test: add admin routes tests
Made-with: Cursor
This commit is contained in:
51
tests/helpers/build-admin-test-app.ts
Normal file
51
tests/helpers/build-admin-test-app.ts
Normal file
@@ -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<FastifyInstance> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
140
tests/integration/admin.routes.test.ts
Normal file
140
tests/integration/admin.routes.test.ts
Normal file
@@ -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<ReturnType<typeof buildAdminTestApp>>;
|
||||||
|
let mockDb: ReturnType<typeof createMockDb>;
|
||||||
|
|
||||||
|
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<typeof vi.fn>)
|
||||||
|
.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<typeof vi.fn>).mockReturnValueOnce(
|
||||||
|
updateChainReturning([{ id: 'q-1' }])
|
||||||
|
);
|
||||||
|
(mockDb.insert as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValueOnce(
|
||||||
|
updateChainReturning([{ id: 'q-1' }])
|
||||||
|
);
|
||||||
|
(mockDb.insert as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValueOnce(
|
||||||
|
updateChainReturning([updatedQuestion])
|
||||||
|
);
|
||||||
|
(mockDb.insert as ReturnType<typeof vi.fn>).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?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() */
|
/** Build an insert chain that resolves at .returning() or .values() */
|
||||||
export function insertChain(resolveAtReturning: unknown[] = []) {
|
export function insertChain(resolveAtReturning: unknown[] = []) {
|
||||||
const returningFn = vi.fn().mockResolvedValue(resolveAtReturning);
|
const returningFn = vi.fn().mockResolvedValue(resolveAtReturning);
|
||||||
|
|||||||
Reference in New Issue
Block a user