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, playerToken, body } = {}) => { const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...(playerToken ? { 'x-player-token': playerToken } : {}), }, 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; // Match-Übersicht nur für dieses Match, ohne Zuschauercode assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`)); assert.ok(!orgView.organizer.links.overview.includes(codes.spectator)); // 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('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async () => { const { call, close } = await setup({ requireFullTeams: false }); try { const { id, organizerToken: org } = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' }, })).body; const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes; const me = (await call('POST', `/matches/${id}/join`, { body: { code: codes.A, nickname: 'orga' } })).body; const b1 = (await call('POST', `/matches/${id}/join`, { body: { code: codes.B, nickname: 'b1' } })).body; const both = { token: org, playerToken: me.playerToken }; const view = (await call('GET', `/matches/${id}`, both)).body; assert.equal(view.viewer.role, 'organizer'); assert.equal(view.viewer.isOrganizer, true); assert.equal(view.viewer.isCaptain, true); assert.equal(view.viewer.team, 'A'); assert.ok(view.organizer, 'Organisator-Daten bleiben sichtbar'); assert.equal((await call('POST', `/matches/${id}/ready`, { ...both, body: { ready: true } })).status, 200); await call('POST', `/matches/${id}/ready`, { token: b1.playerToken, body: { ready: true } }); assert.equal((await call('POST', `/matches/${id}/veto/start`, both)).status, 200); // Nur mit Organisator-Zugang ist er kein Captain und darf nicht bannen const ban = { stepIndex: 0, map: 'de_inferno' }; assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: org, body: ban })).status, 403); assert.equal((await call('POST', `/matches/${id}/veto/action`, { ...both, body: ban })).status, 200); // Ein fremder Spieler-Token macht niemanden zum Organisator const fake = (await call('GET', `/matches/${id}`, { token: b1.playerToken, playerToken: me.playerToken })).body; assert.equal(fake.viewer.isOrganizer, false); } finally { await close(); } }); test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => { const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } }); try { const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); 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.getMatch(id).status === 'live', { label: 'live' }); const { config_key: key } = app.service.serverRow(id); const map1 = app.service.seriesMaps(id)[0]; const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`; const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text }); assert.equal((await post('falsch', 'x')).status, 403); const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo'; const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha'; const res = await post(key, [ `L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`, `L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`, `L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`, 'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', 'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")', ].join('\n')); assert.equal(res.status, 200); const snap = (await call('GET', `/matches/${id}`, { token: org })).body; const ctTeam = map1.ct_team; assert.equal(snap.series[0].score[ctTeam], 1); assert.equal(snap.series[0].live.rounds.length, 2); assert.equal(snap.series[0].live.rounds[1].reason, 'bomb'); await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`); // Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0 const after = (await call('GET', `/matches/${id}`, { token: org })).body; assert.equal(after.series[0].status, 'done'); assert.deepEqual(after.seriesScore, { A: 0, B: 0 }); // Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen await call('POST', `/matches/${id}/series/next`, { token: org }); await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' }); assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200); await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 }); const final = (await call('GET', `/matches/${id}`, { token: org })).body; const winner = final.series[1].winner; assert.ok(['A', 'B'].includes(winner)); assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg'); assert.equal(final.seriesScore[winner], 1); const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id); assert.equal(list.seriesScore[winner], 1); } finally { await close(); } }); test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => { const { app, call, close } = await setup({ autoAdvanceSeconds: 0 }); try { const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); 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.getMatch(id).status === 'live', { label: 'live' }); const key = app.service.serverRow(id).config_key; const winMapForAlpha = async (map) => { const body = [ 'Team playing "CT": Alpha', 'Team playing "TERRORIST": Bravo', 'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', 'World triggered "Round_End"', `Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`, ].join('\n'); const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body }); assert.equal(res.status, 200); }; await winMapForAlpha(app.service.seriesMaps(id)[0].map); // Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' }); assert.equal(app.service.getMatch(id).status, 'live'); await winMapForAlpha(app.service.seriesMaps(id)[1].map); // 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' }); await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' }); const snap = (await call('GET', `/matches/${id}`, { token: org })).body; assert.deepEqual(snap.seriesScore, { A: 2, B: 0 }); assert.equal(snap.series[2].winner, null); } finally { await close(); } }); test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => { const { app, call, close } = await setup({ streamKey: 'geheim123' }); try { const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body; const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); await call('POST', `/matches/${id}/veto/start`, { token: org }); await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } }); assert.equal((await call('GET', '/stream/overview')).status, 403); assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403); const res = await call('GET', '/stream/overview?key=geheim123'); assert.equal(res.status, 200); const { active, upcoming } = res.body; assert.equal(active.length, 1); assert.equal(active[0].status, 'veto'); assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned'); assert.equal(active[0].veto.current.team, 'B'); assert.ok(upcoming.some((m) => m.id === lobby.id)); const json = JSON.stringify(res.body); const row = app.service.getMatch(id); for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) { assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht'); } } finally { await close(); } }); test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => { const { app, call, close } = await setup({ adminPassword: 'Admin123' }); try { const { id } = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' }, })).body; const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich'); assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401); const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body; const admin = { token: login.token }; assert.ok(login.token); const list = (await call('GET', '/admin/matches', admin)).body.matches; const mine = list.find((m) => m.id === id); assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen'); assert.ok(mine.organizer.codes.A, 'Codes sichtbar'); // Ein Match abbrechen wie ein Admin assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200); assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause'); assert.equal(app.service.getMatch(id).status, 'cancelled'); // Endgültig löschen assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200); assert.equal((await call('GET', `/matches/${id}`)).status, 404); // Abmelden: Token ist danach ungültig await call('POST', '/admin/logout', admin); assert.equal((await call('GET', '/admin/session', admin)).status, 401); } finally { await close(); } }); test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => { const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } }); try { const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); 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.getMatch(id).status === 'live', { label: 'live' }); await call('POST', `/matches/${id}/server/warmup-end`, { token: org }); // Organisator sieht keine Rollback-Funktion assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401); assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404); const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body; const admin = { token: login.token }; const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points; assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map'); const map = app.service.seriesMaps(id)[0].map; assert.ok(points.every((p) => p.map === map)); // Dateien, die nicht zu diesem Match gehören, werden abgelehnt assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400); const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } }); assert.equal(ok.status, 200); const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id); assert.equal(log.length, 1); assert.match(log[0].message, /zurückgespielt/); } finally { await close(); } }); test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => { const { call, close } = await setup({ adminPassword: '' }); try { const res = await call('POST', '/admin/login', { body: { password: 'x' } }); assert.equal(res.status, 404); assert.equal(res.body.code, 'admin_disabled'); } 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(); } }); test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die volle Frist', async () => { const { app, call, close } = await setup({ idleTimeoutMinutes: 30 }); 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.getMatch(id).status === 'live', { label: 'live' }); // Lange nichts los, dann stoppt der Server const hourAgo = Date.now() - 60 * 60_000; app.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(hourAgo, id); await call('POST', `/matches/${id}/server/stop`, { token: org }); await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'gestoppt' }); // Neustart durch den Organisator: Server fährt hoch und lädt die Map wieder (Match ist weiter "live") const res = await call('POST', `/matches/${id}/server/retry`, { token: org }); assert.equal(res.status, 200, JSON.stringify(res.body)); await waitFor(async () => { await app.manager.poll(); return app.service.serverRow(id).status === 'configured'; }, { label: 'neu konfiguriert' }); assert.equal(app.service.getMatch(id).status, 'live'); // Die Abschaltung darf den frisch gestarteten Server nicht sofort wieder stoppen await app.manager.reap(); assert.equal(app.service.serverRow(id).status, 'configured'); // Nach Ablauf der Frist ab dem Neustart dagegen schon await app.manager.reap(Date.now() + 31 * 60_000); assert.equal(app.service.serverRow(id).status, 'stopped'); } finally { await close(); } }); test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => { const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 }); try { const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token; const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body; const open = await mk('Offen A', 'Offen B'); const done = await mk('Fertig A', 'Fertig B'); const old = await mk('Alt A', 'Alt B'); // Laufende Matches lassen sich nicht archivieren assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409); await call('POST', `/admin/matches/${done.id}/finish`, { token }); assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200); let list = (await call('GET', '/admin/matches', { token })).body; assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste'); assert.equal(list.archivedCount, 1); const archive = (await call('GET', '/admin/matches?archived=1', { token })).body; assert.deepEqual(archive.matches.map((m) => m.id), [done.id]); assert.ok(archive.matches[0].archivedAt > 0); // Zurückholen await call('POST', `/admin/matches/${done.id}/unarchive`, { token }); list = (await call('GET', '/admin/matches', { token })).body; assert.ok(list.matches.some((m) => m.id === done.id)); // Automatisch: abgebrochenes Match, älter als 3 Tage await call('POST', `/admin/matches/${old.id}/cancel`, { token }); assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht'); await app.manager.reap(Date.now() + 4 * 86_400_000); assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert'); assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert'); assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben'); } finally { await close(); } }); test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => { const { app, call, close } = await setup({ adminPassword: 'admin-geheim' }); try { assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401); const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token; const info = (await call('GET', '/admin/knife', { token })).body; assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']); const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } }); assert.equal(created.status, 200, JSON.stringify(created.body)); // Noch nicht bereit: Startseite zeigt ihn nicht assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0); await app.knife.poll(); await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' }); const pub = (await call('GET', '/knife-servers')).body.servers; assert.equal(pub.length, 1); assert.equal(pub[0].map, 'de_inferno'); assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password)); // Ports des Messer-Servers sind für Matches belegt const row = app.knife.row(created.body.id); assert.ok(app.manager.usedPortsOn(row.host).has(row.port)); assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200); assert.equal(app.knife.list().length, 0); } finally { await close(); } }); test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => { const { app, call, close } = await setup({ adminPassword: 'admin-geheim' }); try { const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token; const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body; const view = (await call('GET', '/admin/map-pools', { token })).body; const comp = view.pools.find((p) => p.mode === 'competitive'); assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active))); // Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke']; const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } }); assert.equal(saved.status, 200, JSON.stringify(saved.body)); const pool = saved.body.pools.find((p) => p.mode === 'competitive'); assert.equal(pool.maps.filter((m) => m.active).length, 5); assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']); // Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body; assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5); assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10); // Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate const cfg = (await call('GET', '/config')).body; assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5); // Ungültig: zu wenige oder unbekannte Maps assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400); assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400); assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401); } finally { await close(); } }); test('Seitenwahl per Messerrunde: Organisator legt fest, wer das Veto beginnt', async () => { const { app, call, close } = await setup(); try { const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'knife' } }); assert.equal(created.status, 200, JSON.stringify(created.body)); const { id, organizerToken: org } = created.body; const snap = (await call('GET', `/matches/${id}`, { token: org })).body; assert.equal(snap.vetoStart, 'knife'); const codes = snap.organizer.codes; for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) { const r = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } }); await call('POST', `/matches/${id}/ready`, { token: r.body.playerToken, body: { ready: true } }); } // Ohne Angabe, wer beginnt, startet das Veto nicht const missing = await call('POST', `/matches/${id}/veto/start`, { token: org }); assert.equal(missing.status, 400); assert.equal(missing.body.code, 'first_team_required'); assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'X' } })).status, 400); const ok = await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } }); assert.equal(ok.status, 200, JSON.stringify(ok.body)); const after = (await call('GET', `/matches/${id}`, { token: org })).body; assert.equal(after.status, 'veto'); assert.equal(after.veto.current.team, 'B', 'das gewählte Team beginnt'); assert.ok(app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND message LIKE '%Messerrunde%'").get(id)); } finally { await close(); } }); test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => { const { call, close } = await setup(); try { const { id, org, tokens } = await readyWingmanMatch(call, 'bo1'); await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } }); const snap = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body; assert.equal(snap.veto.current.team, 'A', 'feste Startregel A gilt'); } finally { await close(); } }); test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => { const { call, close } = await setup(); try { const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1'); const spectator = codes.spectator; const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } }); assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200); assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200); assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200); // Fremder Team-Chat und Zuschauer: verboten assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403); assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403); assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403); assert.equal((await post(tokens.a1, 'match', ' ')).status, 400); assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400); const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat; assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']); assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']); assert.equal(a.messages.match[1].organizer, true); assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']); const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat; assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']); assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht'); assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']); assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null); assert.equal((await call('GET', `/matches/${id}`)).body.chat, null); // Spam-Schutz und nach Matchende nur lesbar for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`); assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429); await call('POST', `/matches/${id}/finish`, { token: org }); assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409); const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat; assert.equal(closed.canWrite, false); assert.equal(closed.messages.match.length, 7); } finally { await close(); } }); test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', async () => { const { app, call, close } = await setup({ adminPassword: 'admin-geheim' }); try { const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token; const m = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' } })).body; await waitFor(() => app.service.serverRow(m.id)?.handle, { label: 'Server gestartet' }); const base = `http://127.0.0.1:${app.server.address().port}/api/admin/logs`; assert.equal((await fetch(`${base}/match/${m.id}`)).status, 401); assert.equal((await fetch(`${base}/match/gibtsnicht`, { headers: { authorization: `Bearer ${token}` } })).status, 404); assert.equal((await fetch(`${base}/sonstwas/${m.id}`, { headers: { authorization: `Bearer ${token}` } })).status, 404); const ctl = new AbortController(); const res = await fetch(`${base}/match/${m.id}`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal }); assert.equal(res.status, 200); const reader = res.body.getReader(); const { value } = await reader.read(); assert.match(new TextDecoder().decode(value), /Demo-Adapter/); ctl.abort(); } finally { await close(); } }); test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, danach derselbe Server fürs Match', async () => { const { app, call, close } = await setup(); try { const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } }); const { id, organizerToken: org } = created.body; const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes; const tokens = {}; for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) { tokens[name] = (await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } })).body.playerToken; } // Vor dem Start: Aktionen gehen noch nicht assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 409); await app.manager.poll(); await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' }); const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body; assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup' }); assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches'); const pub = (await call('GET', `/matches/${id}`)).body; assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten'); // Nur der Organisator startet die Runde assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403); assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200); assert.equal(app.manager.row(id).knife_phase, 'live'); assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200); assert.equal(app.manager.row(id).knife_phase, 'warmup'); // Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen const handle = app.manager.row(id).handle; const plans = []; const configure = app.adapter.configure.bind(app.adapter); app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); }; for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } }); assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200); const snap = (await call('GET', `/matches/${id}`, { token: org })).body; const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1'; const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1'; await playVeto(call, id, { A: tokens[capA], B: tokens[capB] }); await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' }); const row = app.manager.row(id); assert.equal(row.handle, handle, 'derselbe Server'); assert.equal(row.knife_at, null); assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1')); assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined); } finally { await close(); } });