import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { startTestDatabase, type TestDatabase } from './harness.js'; type Forum = typeof import('../src/services/forum.js'); type Players = typeof import('../src/services/players.js'); type Characters = typeof import('../src/services/characters.js'); type Scraper = typeof import('../src/services/scraper.js'); type Db = typeof import('../src/db.js'); let database: TestDatabase; let forum: Forum; let players: Players; let characters: Characters; let scraper: Scraper; let db: Db; let ctx: { actorId: number; actorName: string }; before(async () => { database = await startTestDatabase(54337); process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; db = await import('../src/db.js'); forum = await import('../src/services/forum.js'); players = await import('../src/services/players.js'); characters = await import('../src/services/characters.js'); scraper = await import('../src/services/scraper.js'); const user = await db.prisma.user.create({ data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' }, }); ctx = { actorId: user.id, actorName: user.username }; }); after(async () => { await db.prisma.$disconnect(); await database.stop(); }); /** Legt einen Thread an, wie ihn der Abgleich ohne Spieler vorfinden würde. */ async function offenerThread(threadId: number, titel: string, autor: string) { return db.prisma.forumThread.create({ data: { threadId, title: titel, url: `https://forum.example.org/index.php?thread/${threadId}-antrag/`, authorName: autor, currentBoard: 'CC_ARCHIV', }, }); } describe('Anträge ohne zugeordneten Spieler', () => { it('hält sie aus der Arbeitsliste heraus, aber nicht aus der Datenbank', async () => { await offenerThread(9001, 'CC Antrag von einem Fremden', 'Unbekannter'); const arbeitsliste = await forum.listThreads({ onlyOpen: true }); assert.equal( arbeitsliste.some((t) => t.threadId === 9001), false, 'ohne Spieler ist nichts zu entscheiden', ); const wartend = await forum.listUnassignedThreads({}); assert.equal( wartend.items.some((t) => t.threadId === 9001), true, 'auffindbar bleibt er trotzdem', ); }); it('findet sie über Titel und Ersteller', async () => { await offenerThread(9002, 'Feldagent Ersthelfer', 'Darkbrine44'); const nachTitel = await forum.listUnassignedThreads({ query: 'ersthelfer' }); assert.deepEqual( nachTitel.items.map((t) => t.threadId), [9002], 'Grossschreibung darf keine Rolle spielen', ); const nachAutor = await forum.listUnassignedThreads({ query: 'darkbrine' }); assert.deepEqual( nachAutor.items.map((t) => t.threadId), [9002], ); }); it('begrenzt die Ausgabe, nennt aber die volle Zahl', async () => { for (const id of [9010, 9011, 9012]) await offenerThread(id, `Antrag ${id}`, 'Serienbeantrager'); const seite = await forum.listUnassignedThreads({ query: 'Serienbeantrager', take: 2 }); assert.equal(seite.items.length, 2); assert.equal(seite.total, 3, 'sonst wüsste niemand, dass etwas fehlt'); }); }); describe('Nachträgliche Zuordnung', () => { it('zieht wartende Anträge an einen neu angelegten Spieler', async () => { await offenerThread(9101, 'CC Antrag von Monox', 'Monox'); await offenerThread(9102, 'Redesign von Monox', 'monox'); const spieler = await players.createPlayer( { displayName: 'Monox', steamId: '76561198000002222', forumName: 'Monox' }, ctx, ); const zugeordnet = await forum.listThreads({ playerId: spieler.id }); assert.deepEqual( zugeordnet.map((t) => t.threadId).sort(), [9101, 9102], 'auch die abweichende Schreibweise gehört dazu', ); }); it('zieht sie auch nach, wenn der Forum-Name erst später eingetragen wird', async () => { await offenerThread(9201, 'Antrag eines Umbenannten', 'NeuerName'); const spieler = await players.createPlayer( { displayName: 'Ohne Forum', steamId: '76561198000003333' }, ctx, ); assert.equal((await forum.listThreads({ playerId: spieler.id })).length, 0); await players.updatePlayer(spieler.id, { forumName: 'NeuerName' }, ctx); const zugeordnet = await forum.listThreads({ playerId: spieler.id }); assert.deepEqual( zugeordnet.map((t) => t.threadId), [9201], ); }); it('holt über ein früheres Konto auch die alten Anträge', async () => { await offenerThread(9301, 'Antrag unter dem alten Namen', 'AltesKonto'); const spieler = await players.createPlayer( { displayName: 'Umzügler', steamId: '76561198000004444', forumName: 'NeuesKonto' }, ctx, ); await players.addAlias(spieler.id, 'FORUM_NAME', 'AltesKonto', 'Vor dem Wechsel', ctx); const zugeordnet = await forum.listThreads({ playerId: spieler.id }); assert.deepEqual( zugeordnet.map((t) => t.threadId), [9301], ); }); it('nimmt einem Spieler nie einen bereits zugeordneten Antrag weg', async () => { const erster = await players.createPlayer( { displayName: 'Erster', steamId: '76561198000005555', forumName: 'Doppelgänger' }, ctx, ); await offenerThread(9401, 'Strittiger Antrag', 'Doppelgänger'); await forum.assignPlayer(9401, erster.id, ctx); // Ein zweiter Spieler trägt denselben Namen als früheres Konto nach. const zweiter = await players.createPlayer( { displayName: 'Zweiter', steamId: '76561198000006666' }, ctx, ); await players.updatePlayer(zweiter.id, { forumName: 'Doppelgänger' }, ctx); const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9401 } }); assert.equal(thread.playerId, erster.id, 'eine getroffene Entscheidung bleibt stehen'); }); }); describe('Antrag am CC', () => { it('hängt den Thread an den CC, der aus ihm hervorgeht', async () => { const spieler = await players.createPlayer( { displayName: 'Antragsteller', steamId: '76561198000007777', forumName: 'Antragsteller' }, ctx, ); await db.prisma.creditLedger.create({ data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, }); await offenerThread(9501, 'The Duke', 'Antragsteller'); await forum.claimThreadsForPlayer(spieler.id, ['Antragsteller']); const { characterId } = await forum.classifyThread( 9501, { classification: 'NEW_CC', characterName: 'The Duke' }, ctx, ); assert.ok(characterId); const liste = await characters.listCharacters(spieler.id); const cc = liste.find((c) => c.id === characterId); assert.deepEqual( cc?.forumThreads.map((t) => t.threadId), [9501], 'ohne das bliebe der Name auf der Spielerseite ein toter Text', ); assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/); }); }); describe('Problemfälle', () => { it('legt einen Spieler an, obwohl etwas offen ist', async () => { const spieler = await players.createPlayer( { displayName: 'Fall ohne Paket', isProblem: true, problemNote: 'CC-Antrag da, aber kein Paket auffindbar', }, ctx, ); assert.equal(spieler.isProblem, true); assert.equal(spieler.problemNote, 'CC-Antrag da, aber kein Paket auffindbar'); assert.ok(spieler.problemSince, 'wann es aufgefallen ist, gehört dazu'); }); it('markiert auch im Nachhinein', async () => { const spieler = await players.createPlayer( { displayName: 'Erst später auffällig', steamId: '76561198000011111' }, ctx, ); assert.equal(spieler.isProblem, false); const markiert = await players.markProblem( spieler.id, { isProblem: true, note: 'Kein GExtension-Eintrag' }, ctx, ); assert.equal(markiert.isProblem, true); assert.equal(markiert.problemNote, 'Kein GExtension-Eintrag'); }); it('behält den Anfang, wenn die Notiz nachgeschärft wird', async () => { const spieler = await players.createPlayer( { displayName: 'Langer Fall', isProblem: true, problemNote: 'unklar' }, ctx, ); const anfang = spieler.problemSince; const nachgeschaerft = await players.markProblem( spieler.id, { isProblem: true, note: 'Paket liegt unter altem Konto' }, ctx, ); assert.deepEqual(nachgeschaerft.problemSince, anfang, 'der Fall ist derselbe, nur klarer'); }); it('räumt die Markierung restlos ab', async () => { const spieler = await players.createPlayer( { displayName: 'Geklärt', isProblem: true, problemNote: 'war ein Tippfehler' }, ctx, ); const geklaert = await players.markProblem(spieler.id, { isProblem: false }, ctx); assert.equal(geklaert.isProblem, false); assert.equal(geklaert.problemNote, null); assert.equal(geklaert.problemSince, null); }); it('ändert an Guthaben und CCs nichts', async () => { const spieler = await players.createPlayer( { displayName: 'Trotzdem berechtigt', steamId: '76561198000012222' }, ctx, ); await db.prisma.creditLedger.create({ data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, }); const { character } = await characters.createCharacter(spieler.id, { name: 'Bleibt' }, ctx); await players.markProblem(spieler.id, { isProblem: true, note: 'Herkunft unklar' }, ctx); const detail = await players.getPlayerDetail(spieler.id); assert.equal(detail.balances.CC, 0, 'das Paket ist verbraucht, nicht verschwunden'); assert.equal(detail.characters.find((c) => c.id === character.id)?.status, 'ACTIVE'); }); it('listet sie auf Wunsch für sich', async () => { const offen = await players.searchPlayers('', 100, 0, 'problems'); assert.ok(offen.total >= 3); assert.equal( offen.items.every((p) => p.isProblem), true, ); const alle = await players.searchPlayers('', 100, 0, 'all'); assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei'); }); }); describe('Konten beim Abgleich', () => { it('sammelt alle Steam-Kennungen eines Spielers, nicht nur die hinterlegte', async () => { const spieler = await players.createPlayer( { displayName: 'Kontowechsler', steamId: '76561198000013333' }, ctx, ); await players.addAlias(spieler.id, 'STEAM_ID', '76561198000014444', 'Erstes Konto', ctx); await players.addAlias(spieler.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Ganz altes Konto', ctx); const kennungen = await scraper.steamIdsOf(spieler.id); assert.deepEqual( kennungen.sort(), ['76561198000013333', '76561198000014444', '76561198000000999'].sort(), 'auch in der Legacy-Schreibweise nachgetragene Konten gehören dazu', ); }); it('kommt mit einem Spieler ohne jede Kennung klar', async () => { const spieler = await players.createPlayer({ displayName: 'Nur ein Name' }, ctx); assert.deepEqual(await scraper.steamIdsOf(spieler.id), []); }); }); describe('Sortieren, Blättern und Suchen', () => { before(async () => { // Eigener Namensraum, damit die Faelle davor nicht hineinspielen. for (const id of [8801, 8802, 8803, 8804, 8805]) { await offenerThread(id, `Serienantrag ${id}`, 'Vielschreiber'); } }); it('sortiert nach jüngstem und ältestem Antrag', async () => { const neueste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'newest' }); assert.deepEqual( neueste.items.map((t) => t.threadId), [8805, 8804, 8803, 8802, 8801], ); const aelteste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest' }); assert.deepEqual( aelteste.items.map((t) => t.threadId), [8801, 8802, 8803, 8804, 8805], ); }); it('blättert seitenweise durch und nennt die volle Zahl', async () => { const erste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest', take: 2, }); assert.deepEqual( erste.items.map((t) => t.threadId), [8801, 8802], ); assert.equal(erste.total, 5); const zweite = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest', take: 2, skip: 2, }); assert.deepEqual( zweite.items.map((t) => t.threadId), [8803, 8804], 'keine Ueberschneidung mit der ersten Seite', ); }); it('durchsucht auch die Arbeitsliste', async () => { const spieler = await players.createPlayer( { displayName: 'Suchbar', steamId: '76561198000015555', forumName: 'Suchbar' }, ctx, ); await offenerThread(8901, 'Ein ganz bestimmter Antrag', 'Suchbar'); await offenerThread(8902, 'Etwas voellig anderes', 'Suchbar'); await forum.claimThreadsForPlayer(spieler.id, ['Suchbar']); const treffer = await forum.listThreads({ onlyOpen: true, query: 'bestimmter' }); assert.deepEqual( treffer.map((t) => t.threadId), [8901], ); const nachAutor = await forum.listThreads({ onlyOpen: true, query: 'Suchbar' }); assert.equal(nachAutor.length, 2, 'die Suche greift auch auf den Ersteller'); }); }); describe('Ersteller ohne Spieler', () => { it('fasst die Anträge je Ersteller zusammen', async () => { const liste = await forum.listUntrackedAuthors(); const vielschreiber = liste.items.find((a) => a.authorName === 'Vielschreiber'); assert.ok(vielschreiber, 'wer fuenf Antraege gestellt hat, faellt auf'); assert.equal(vielschreiber?.threadCount, 5); assert.equal(vielschreiber?.latestThreadId, 8805); }); it('führt niemanden auf, der längst einem Spieler gehört', async () => { const liste = await forum.listUntrackedAuthors(); assert.equal( liste.items.some((a) => a.authorName === 'Suchbar'), false, ); }); it('verschwindet aus der Liste, sobald der Spieler angelegt ist', async () => { assert.equal( (await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'), true, ); await players.createPlayer( { displayName: 'Vielschreiber', steamId: '76561198000016666', forumName: 'Vielschreiber' }, ctx, ); assert.equal( (await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'), false, 'seine Antraege stehen jetzt in der Arbeitsliste', ); }); }); describe('Spielerliste eingrenzen und sortieren', () => { before(async () => { // Ein Gruppen-CC, ein stillgelegter CC und ein Spieler ohne alles. const wirt = await players.createPlayer( { displayName: 'Filter-Wirt', steamId: '76561198000040001' }, ctx, ); const gast = await players.createPlayer( { displayName: 'Filter-Gast', steamId: '76561198000040002' }, ctx, ); for (const id of [wirt.id, gast.id]) { await db.prisma.creditLedger.create({ data: { playerId: id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, }); } const gruppe = await characters.createCharacter(wirt.id, { name: 'Filter-Runde' }, ctx); await characters.addMember(gruppe.character.id, gast.id, { useKind: 'CC' }, ctx); const einsam = await players.createPlayer( { displayName: 'Filter-Einsam', steamId: '76561198000040003' }, ctx, ); await db.prisma.creditLedger.create({ data: { playerId: einsam.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, }); const stillgelegt = await characters.createCharacter(einsam.id, { name: 'Ruht' }, ctx); await characters.deactivateCharacter(stillgelegt.character.id, 'PLAYER_INACTIVITY', ctx); await players.createPlayer( { displayName: 'Filter-Leer', steamId: '76561198000040004' }, ctx, ); }); const namen = async (filter: Parameters[3]) => (await players.searchPlayers('Filter-', 100, 0, filter)).items.map((p) => p.displayName).sort(); it('findet die Beteiligten eines Gruppen-CCs', async () => { assert.deepEqual(await namen('group'), ['Filter-Gast', 'Filter-Wirt']); }); it('findet, wer einen aktiven CC hat', async () => { assert.deepEqual(await namen('active'), ['Filter-Gast', 'Filter-Wirt']); }); it('findet, wer nur noch stillgelegte CCs hat', async () => { assert.deepEqual( await namen('onlyInactive'), ['Filter-Einsam'], 'wer gar keinen CC hat, gehört nicht dazu', ); }); it('findet, wer gar keinen CC hat', async () => { assert.deepEqual(await namen('none'), ['Filter-Leer']); }); it('sortiert nach der Zahl der CC-Einträge', async () => { const liste = await players.searchPlayers('Filter-', 100, 0, 'all', 'characters'); const zahlen = liste.items.map((p) => p.characterCount); assert.deepEqual( [...zahlen].sort((a, b) => b - a), zahlen, 'die längste Liste steht oben', ); }); }); describe('Eine falsch gelesene Zuordnung geraderücken', () => { it('schlägt den Thread dem richtigen Spieler zu, sobald der Ersteller stimmt', async () => { // So sah es nach dem alten Parser aus: als Ersteller stand der letzte // Antwortende in den Daten, und der Thread hing an ihm. const falsch = await players.createPlayer( { displayName: 'Letzter Antwortender', steamId: '76561198000050001', forumName: 'Antworter' }, ctx, ); const richtig = await players.createPlayer( { displayName: 'Echter Ersteller', steamId: '76561198000050002', forumName: 'Ersteller' }, ctx, ); const thread = await db.prisma.forumThread.create({ data: { threadId: 7301, title: 'Falsch zugeordnet', url: 'https://forum.example.org/index.php?thread/7301-x/', authorName: 'Antworter', playerId: falsch.id, currentBoard: 'CC_ARCHIV', }, }); assert.equal(thread.playerId, falsch.id); // Der berichtigte Parser liest jetzt den echten Ersteller. Beim nächsten // Abgleich zieht die Zuordnung nach - hier direkt geprüft. await db.prisma.forumThread.update({ where: { threadId: 7301 }, data: { authorName: 'Ersteller', playerId: richtig.id }, }); const danach = await forum.listThreads({ playerId: richtig.id }); assert.ok( danach.some((t) => t.threadId === 7301), 'er steht jetzt beim Ersteller', ); assert.equal( (await forum.listThreads({ playerId: falsch.id })).some((t) => t.threadId === 7301), false, 'und nicht mehr beim Antwortenden', ); }); it('lässt eine von Hand gesetzte Zuordnung in Ruhe', async () => { // Wer den Thread von Hand zugeordnet hat, wusste mehr als der Abgleich - // etwa weil der Antrag für jemand anderen geschrieben wurde. const schreiber = await players.createPlayer( { displayName: 'Schreiber', steamId: '76561198000050003', forumName: 'Schreiber' }, ctx, ); const gemeint = await players.createPlayer( { displayName: 'Gemeint', steamId: '76561198000050004' }, ctx, ); await db.prisma.forumThread.create({ data: { threadId: 7302, title: 'Von Hand zugeordnet', url: 'https://forum.example.org/index.php?thread/7302-x/', authorName: 'Schreiber', currentBoard: 'CC_ARCHIV', }, }); await forum.assignPlayer(7302, gemeint.id, ctx); const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7302 } }); assert.equal( thread.playerId, gemeint.id, 'die Zuordnung zeigt bewusst woanders hin als der Ersteller', ); assert.notEqual(thread.playerId, schreiber.id); }); });