import { test } from 'node:test'; import assert from 'node:assert/strict'; import { loadConfig } from '../src/config.js'; import { createApp } from '../src/app.js'; const quiet = { log() {}, warn() {}, error() {} }; async function setup(overrides = {}) { const config = loadConfig({ databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '', requireFullTeams: true, idleTimeoutMinutes: 30, demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides, }); const app = createApp(config, { logger: quiet }); await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${app.server.address().port}/api`; const call = async (method, path, { token, body } = {}) => { const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json() }; }; return { app, call, close: () => app.close() }; } async function waitFor(fn, { timeoutMs = 8000, label = 'Bedingung' } = {}) { const deadline = Date.now() + timeoutMs; for (;;) { const value = await fn(); if (value) return value; if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: ${label}`); await new Promise((r) => setTimeout(r, 50)); } } /** Erstellt ein Wingman-Match, füllt beide Teams und setzt alle bereit. */ async function readyWingmanMatch(call, format = 'bo3') { const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format, vetoStart: 'A' }, }); assert.equal(created.status, 200, JSON.stringify(created.body)); const { id, organizerToken: org } = created.body; const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; const codes = orgView.organizer.codes; const tokens = {}; for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) { const res = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } }); assert.equal(res.status, 200, JSON.stringify(res.body)); tokens[name] = res.body.playerToken; } for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } }); return { id, org, codes, tokens }; } async function playVeto(call, id, captains) { for (let guard = 0; guard < 20; guard++) { const snap = (await call('GET', `/matches/${id}`, { token: captains.A })).body; if (snap.veto.done) return snap; const { current } = snap.veto; const body = { stepIndex: current.index }; if (current.type === 'side') body.side = 'CT'; else body.map = snap.veto.maps.find((m) => m.status === 'available').id; const res = await call('POST', `/matches/${id}/veto/action`, { token: captains[current.team], body }); assert.equal(res.status, 200, JSON.stringify(res.body)); } throw new Error('Veto endet nicht'); } test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', async () => { const { app, call, close } = await setup(); try { const cfg = (await call('GET', '/config')).body; assert.equal(cfg.server.simulated, true); const wingman = cfg.pools.find((p) => p.mode === 'wingman'); assert.equal(wingman.formats.bo5.supported, false); const unsupported = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo5' }, }); assert.equal(unsupported.status, 400); assert.equal(unsupported.body.code, 'unsupported_format'); const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'A' }, }); const { id, organizerToken: org } = created.body; // Server wird direkt beim Erstellen der Lobby angefordert await waitFor(() => app.service.serverRow(id), { label: 'Server-Datensatz' }); const publicView = (await call('GET', `/matches/${id}`)).body; assert.equal(publicView.viewer.role, 'public'); assert.equal(publicView.organizer, undefined); assert.equal(publicView.veto, null); const orgView = (await call('GET', `/matches/${id}`, { token: org })).body; assert.equal(orgView.viewer.role, 'organizer'); const { codes } = orgView.organizer; // Zuschauer- und fremde Codes erlauben keinen Teambeitritt assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403); assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: 'AAAA-AAAA', nickname: 'spy' } })).status, 404); const join = async (team, nickname) => call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname } }); const a1 = (await join('A', 'a1')).body; const a2 = (await join('A', 'a2')).body; assert.equal((await join('A', 'a3')).body.code, 'team_full'); assert.equal((await join('B', 'A1')).body.code, 'name_taken'); const b1 = (await join('B', 'b1')).body; const b2 = (await join('B', 'b2')).body; const a1View = (await call('GET', `/matches/${id}`, { token: a1.playerToken })).body; assert.equal(a1View.viewer.role, 'captain'); assert.equal((await call('GET', `/matches/${id}`, { token: a2.playerToken })).body.viewer.role, 'player'); // Veto-Start nur durch Organisator und nur wenn alle bereit sind assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: a1.playerToken })).status, 403); assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'teams_not_ready'); for (const p of [a1, a2, b1, b2]) await call('POST', `/matches/${id}/ready`, { token: p.playerToken, body: { ready: true } }); assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).status, 200); assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'wrong_phase'); // Nur der Captain des Teams am Zug darf handeln const firstMap = { stepIndex: 0, map: 'de_inferno' }; assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a2.playerToken, body: firstMap })).status, 403); assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: codes.spectator, body: firstMap })).status, 403); assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: b1.playerToken, body: firstMap })).body.code, 'not_your_turn'); assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).status, 200); // Doppelte Aktion (gleicher Schritt) wird abgelehnt assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).body.code, 'stale_step'); const done = await playVeto(call, id, { A: a1.playerToken, B: b1.playerToken }); assert.equal(done.series.length, 3); // Demo-Server wird nach dem Veto automatisch konfiguriert await app.manager.poll(); await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.status === 'live', { label: 'Match live' }); const player = (await call('GET', `/matches/${id}`, { token: a2.playerToken })).body; const spectator = (await call('GET', `/matches/${id}`, { token: codes.spectator })).body; const organizer = (await call('GET', `/matches/${id}`, { token: org })).body; assert.equal(player.server.simulated, true); assert.ok(player.server.connect); assert.equal(player.server.gotv, undefined); assert.ok(spectator.server.gotv); assert.equal(spectator.server.connect, undefined); assert.ok(organizer.server.connect && organizer.server.gotv); assert.equal(organizer.series[0].status, 'live'); // RCON-Passwort und interne Handles verlassen nie das Backend const row = app.service.serverRow(id); for (const view of [player, spectator, organizer, publicView]) { const json = JSON.stringify(view); assert.ok(!json.includes(row.rcon_password)); assert.ok(!json.includes('handle')); } // Nächste Map der Serie laden assert.equal((await call('POST', `/matches/${id}/series/next`, { token: a1.playerToken })).status, 403); assert.equal((await call('POST', `/matches/${id}/series/next`, { token: org })).status, 200); await waitFor(async () => { const s = (await call('GET', `/matches/${id}`, { token: org })).body; return s.status === 'live' && s.currentMapNumber === 2; }, { label: 'Map 2' }); assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200); await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' }); const list = (await call('GET', '/matches')).body.matches; assert.equal(list.find((m) => m.id === id).group, 'closed'); } finally { await close(); } }); test('fehlgeschlagene Konfiguration kann vom Organisator wiederholt werden', async () => { const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: true } }); try { const { id, org, tokens } = await readyWingmanMatch(call, 'bo1'); await call('POST', `/matches/${id}/veto/start`, { token: org }); await playVeto(call, id, { A: tokens.a1, B: tokens.b1 }); await app.manager.poll(); await waitFor(() => app.service.serverRow(id).status === 'config_failed', { label: 'config_failed' }); assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: tokens.a1 })).status, 403); assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200); await waitFor(() => app.service.serverRow(id).status === 'configured', { label: 'configured' }); assert.equal(app.service.getMatch(id).status, 'live'); } finally { await close(); } }); test('ungenutzte Server werden nach der Frist heruntergefahren', async () => { const { app, call, close } = await setup({ idleTimeoutMinutes: 10 }); try { const { id } = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'competitive', format: 'bo1' }, })).body; await waitFor(() => app.service.serverRow(id)?.handle, { label: 'Server gestartet' }); await app.manager.reap(Date.now() + 5 * 60_000); assert.notEqual(app.service.serverRow(id).status, 'stopped'); await app.manager.reap(Date.now() + 11 * 60_000); assert.equal(app.service.serverRow(id).status, 'stopped'); assert.match(app.service.serverRow(id).message, /ungenutzt/); } finally { await close(); } }); test('Spieler verlassen die Lobby, Captain geht über', async () => { const { call, close } = await setup(); try { const { id, org, tokens } = await readyWingmanMatch(call); assert.equal((await call('POST', `/matches/${id}/leave`, { token: tokens.a1 })).status, 200); const snap = (await call('GET', `/matches/${id}`, { token: org })).body; const teamA = snap.teams.A.players; assert.equal(teamA.length, 1); assert.equal(teamA[0].nickname, 'a2'); assert.equal(teamA[0].isCaptain, true); // Alter Token ist ungültig assert.equal((await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.viewer.role, 'public'); } finally { await close(); } });