Beim Durcharbeiten von Spieler A stellt sich heraus, dass er schon vor drei
Redesigns bei einem Gruppen-CC von Spieler B dabei war. Das ging bisher nicht:
"Einem inaktiven CC kann niemand beitreten" - richtig fuer den laufenden
Betrieb, falsch fuers Nacherfassen. Dort geht es nicht um jetzt, sondern um
eine Geschichte, die laengst gelaufen ist.
An einem stillgelegten CC wird deshalb nachgetragen statt beigetreten: mit
Zeitraum, und die Guthaben-Angabe ist freigestellt, weil die Buchung im Konto
stehen kann. Wer weiter unten in derselben Kette schon steht, kommt auch an
einem laufenden CC ohne neue Buchung dazu - sein Platz wurde einmal bezahlt,
und ein Redesign tauscht ihn aus. Fuer alle anderen bleibt die Angabe Pflicht.
Damit vier Kettenglieder nicht vier Klicks bedeuten, traegt ein Aufruf einen
Spieler ab einem Glied in die ganze weitere Kette ein; gebucht wird einmal.
Ein Test spielt den ganzen Fall durch: B fertig, A nachgetragen, C steigt in
der Mitte ein und wieder aus, D kommt erst am Ende dazu - und am Schluss sieht
jeder seine eigene Geschichte.
Leere Import-Vorschauen blieben in der Liste stehen, sobald alle Zeilen beim
Spieler gebucht waren. Bestaetigen liess sich so eine Huelle nicht ("nichts zu
buchen"), sie war nur im Weg. Jetzt gilt sie als erledigt - sie ist es ja.
Auch der Bestand wird beim naechsten Aufruf der Liste abgeraeumt.
Und die Spielerliste laesst sich eingrenzen: nur mit aktivem CC, nur mit
ausschliesslich stillgelegten, nur Beteiligte eines Gruppen-CCs, nur ohne
jeden CC, nur Problemfaelle. Sortiert wird nach Namen, nach der Zahl der
CC-Eintraege oder nach Anlage.
488 lines
17 KiB
TypeScript
488 lines
17 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',
|
|
);
|
|
});
|
|
});
|