297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
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<void>((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);
|
|
});
|
|
});
|
|
});
|