import { describe, it, expect, beforeEach, afterAll } from 'vitest'; import express from 'express'; import fs from 'fs'; import path from 'path'; import { apiRouter } from '../routes.js'; const DATA_DIR = path.join(process.cwd(), 'server', 'data'); const SESSION_FILE = path.join(DATA_DIR, 'session.json'); const SEED_FILE = path.join(DATA_DIR, 'seed.json'); const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak'); function createApp() { const app = express(); app.use(express.json()); app.use(apiRouter); return app; } function resetStore() { const seedData = fs.readFileSync(SEED_FILE, 'utf-8'); fs.writeFileSync(SESSION_FILE, seedData, 'utf-8'); if (fs.existsSync(BACKUP_FILE)) { fs.unlinkSync(BACKUP_FILE); } } // Helper to make requests without supertest (using native fetch with app.listen) // We'll use a simple approach: call the route handlers directly via the express app import { type Server } from 'http'; let server: Server; let baseUrl: string; beforeEach(() => { resetStore(); }); afterAll(() => { resetStore(); if (server) server.close(); }); describe('server/routes', () => { const app = createApp(); // Start server on a random port beforeEach(async () => { await new Promise((resolve) => { if (server) server.close(); server = app.listen(0, () => { const addr = server.address(); if (addr && typeof addr !== 'string') { baseUrl = `http://localhost:${addr.port}`; } resolve(); }); }); }); afterAll(() => { if (server) server.close(); }); // ─── Session Endpoints ─────────────────────────────────────────────── describe('GET /api/session', () => { it('returns current session state', async () => { const res = await fetch(`${baseUrl}/api/session`); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe('unikat-2026'); expect(data.status).toBe('setup'); }); }); describe('POST /api/session/start', () => { it('starts the session', async () => { const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe('active'); expect(data.startedAt).toBeTruthy(); }); it('rejects if already active', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain('already active'); }); }); describe('POST /api/session/end', () => { it('ends an active session', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' }); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe('ended'); expect(data.endedAt).toBeTruthy(); }); it('rejects if not active', async () => { const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain('not active'); }); }); // ─── Team and Juror Endpoints ──────────────────────────────────────── describe('GET /api/teams', () => { it('returns all 11 teams', async () => { const res = await fetch(`${baseUrl}/api/teams`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveLength(11); expect(data[0]).toHaveProperty('id'); expect(data[0]).toHaveProperty('name'); }); }); describe('GET /api/jurors', () => { it('returns all 6 jurors', async () => { const res = await fetch(`${baseUrl}/api/jurors`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveLength(6); expect(data[0]).toHaveProperty('id'); expect(data[0]).toHaveProperty('name'); }); }); // ─── Score Endpoints ───────────────────────────────────────────────── describe('POST /api/scores', () => { it('rejects when session is not active (403)', async () => { const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }), }); expect(res.status).toBe(403); }); it('accepts a valid score when session is active', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.jurorId).toBe('finke'); expect(data.value).toBe(8); }); it('rejects value out of range (400)', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 11 }), }); expect(res.status).toBe(400); }); it('rejects unknown jurorId (400)', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'unknown', teamId: 'dual', criterionId: 'gesamtidee', value: 5 }), }); expect(res.status).toBe(400); }); it('rejects unknown teamId (400)', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'unknown', criterionId: 'gesamtidee', value: 5 }), }); expect(res.status).toBe(400); }); it('rejects unknown criterionId (400)', async () => { await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); const res = await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'unknown', value: 5 }), }); expect(res.status).toBe(400); }); }); describe('GET /api/scores', () => { it('returns all scores', async () => { const res = await fetch(`${baseUrl}/api/scores`); expect(res.status).toBe(200); const data = await res.json(); expect(Array.isArray(data)).toBe(true); }); }); describe('GET /api/scores/:jurorId', () => { it('returns scores for a specific juror', async () => { // Add a score first await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 7 }), }); const res = await fetch(`${baseUrl}/api/scores/finke`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveLength(1); expect(data[0].jurorId).toBe('finke'); }); }); // ─── Results and Progress Endpoints ────────────────────────────────── describe('GET /api/results', () => { it('returns aggregated results for all teams', async () => { const res = await fetch(`${baseUrl}/api/results`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveLength(11); expect(data[0]).toHaveProperty('teamId'); expect(data[0]).toHaveProperty('total'); expect(data[0]).toHaveProperty('average'); expect(data[0]).toHaveProperty('rank'); }); }); describe('GET /api/progress', () => { it('returns juror completion matrix', async () => { const res = await fetch(`${baseUrl}/api/progress`); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty('finke'); expect(data.finke).toHaveProperty('completed'); expect(data.finke).toHaveProperty('total'); expect(data.finke.total).toBe(11); }); it('does not include individual score values', async () => { // Add a score await fetch(`${baseUrl}/api/session/start`, { method: 'POST' }); await fetch(`${baseUrl}/api/scores`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }), }); const res = await fetch(`${baseUrl}/api/progress`); const data = await res.json(); const json = JSON.stringify(data); // Should not contain any score value or the word "value" expect(json).not.toContain('"value"'); expect(json).not.toContain('"scores"'); }); }); describe('GET /api/export/csv', () => { it('returns CSV with correct headers', async () => { const res = await fetch(`${baseUrl}/api/export/csv`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/csv'); expect(res.headers.get('content-disposition')).toContain('attachment'); const csv = await res.text(); const lines = csv.split('\n'); expect(lines[0]).toContain('Team'); expect(lines[0]).toContain('Total'); expect(lines[0]).toContain('Average'); expect(lines[0]).toContain('Rank'); }); it('has one row per team plus header', async () => { const res = await fetch(`${baseUrl}/api/export/csv`); const csv = await res.text(); const lines = csv.split('\n'); // 1 header + 11 team rows expect(lines).toHaveLength(12); }); }); });