Files
CC-Manager/server/test/antraege.test.ts
T
Harlekin 2b78a972bc Ersteller statt letztem Antwortendem lesen
Ein Antrag hing bei einem Spieler, der ihn weder geschrieben noch
kommentiert hatte. Die Ursache steht im HTML der Boarduebersicht: Der
Ersteller "-Daniel-" hat sein Konto geloescht und steht dort ohne
Profil-Link. Der einzige Profil-Link der Zeile gehoerte damit dem letzten
Antwortenden - und genau den nahm der Parser, weil er nach dem ersten Treffer
im ganzen Zeilenblock suchte.

Jetzt wird die Zeile vor der Spalte "letzter Beitrag" abgeschnitten und
zuerst in der Zelle gesucht, die den Ersteller fuehrt. Erst wenn dort kein
Link steht, gilt der blosse Text - so wie es bei einem geloeschten Konto der
Fall ist. Auf vier Stichprobenseiten des Archivs trifft das fuenf von achtzig
Threads; hochgerechnet liegen im Bestand also rund drei Dutzend falsch.

Die falschen Zuordnungen bleiben nicht stehen: Aendert sich der Ersteller
eines bekannten Threads, wird seine Zuordnung neu bestimmt - aber nur, wenn
die bisherige von selbst zustande kam. Wer von Hand zugeordnet hat, wusste
mehr als der Abgleich, das bleibt unangetastet. Die Abschlussmeldung nennt,
wie viele Threads dabei gewandert sind.
2026-09-21 17:06:55 +02:00

565 lines
19 KiB
TypeScript

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<typeof players.searchPlayers>[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);
});
});