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.
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
|
|
|
type Imports = typeof import('../src/services/imports.js');
|
|
type Credits = typeof import('../src/services/credits.js');
|
|
type Db = typeof import('../src/db.js');
|
|
|
|
let database: TestDatabase;
|
|
let imports: Imports;
|
|
let credits: Credits;
|
|
let db: Db;
|
|
let ctx: { actorId: number; actorName: string };
|
|
|
|
const CSV = [
|
|
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
|
'A-1001;76561198000000001;Rabe;CC-Paket Groß;2025-01-15',
|
|
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
|
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
|
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
|
].join('\n');
|
|
|
|
const MAPPING = {
|
|
playerKey: 'Steam-ID',
|
|
playerName: 'Spielername',
|
|
product: 'Produkt',
|
|
orderId: 'Bestellnummer',
|
|
purchasedAt: 'Kaufdatum',
|
|
createMissingPlayers: true,
|
|
};
|
|
|
|
before(async () => {
|
|
database = await startTestDatabase(54332);
|
|
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');
|
|
imports = await import('../src/services/imports.js');
|
|
credits = await import('../src/services/credits.js');
|
|
|
|
const user = await db.prisma.user.create({
|
|
data: { username: 'importeur', passwordHash: 'x', role: 'ADMIN' },
|
|
});
|
|
ctx = { actorId: user.id, actorName: user.username };
|
|
|
|
await db.prisma.productMapping.createMany({
|
|
data: [
|
|
{ matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
|
|
{ matchPattern: 'redesign', target: 'REDESIGN_PACKAGE', priority: 20 },
|
|
{ matchPattern: 'spielerfarbe', target: 'IGNORE', priority: 30 },
|
|
],
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
await db.prisma.$disconnect();
|
|
await database.stop();
|
|
});
|
|
|
|
describe('Datei einlesen', () => {
|
|
it('erkennt Semikolon als Trennzeichen', () => {
|
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
|
|
|
assert.equal(parsed.rows.length, 4);
|
|
assert.deepEqual(parsed.columns, [
|
|
'Bestellnummer',
|
|
'Steam-ID',
|
|
'Spielername',
|
|
'Produkt',
|
|
'Kaufdatum',
|
|
]);
|
|
});
|
|
|
|
it('liest auch JSON-Listen', () => {
|
|
const json = JSON.stringify([{ order: 'B-1', product: 'CC-Paket', user: '765' }]);
|
|
const parsed = imports.parseUpload('export.json', Buffer.from(json, 'utf8'));
|
|
|
|
assert.equal(parsed.rows.length, 1);
|
|
assert.equal(parsed.rows[0]?.product, 'CC-Paket');
|
|
});
|
|
|
|
it('weist eine leere Datei ab', () => {
|
|
assert.throws(() => imports.parseUpload('leer.csv', Buffer.from('', 'utf8')));
|
|
});
|
|
});
|
|
|
|
describe('Vorschau und Buchung', () => {
|
|
it('ordnet jede Zeile ihrem Plan zu', async () => {
|
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
|
const importId = await imports.createImport('export.csv', parsed, ctx);
|
|
|
|
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
|
|
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt');
|
|
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
|
assert.equal(summary.SKIP_DUPLICATE, 0);
|
|
});
|
|
|
|
it('bucht CC-Pakete mit beiden Guthaben und legt Spieler an', async () => {
|
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
|
const importId = await imports.createImport('lauf1.csv', parsed, ctx);
|
|
await imports.applyMapping(importId, MAPPING, ctx);
|
|
|
|
const result = await imports.commitImport(importId, ctx);
|
|
|
|
assert.equal(result.booked, 3);
|
|
assert.equal(result.createdPlayers, 3);
|
|
|
|
const rabe = await db.prisma.player.findUniqueOrThrow({
|
|
where: { steamId: '76561198000000001' },
|
|
});
|
|
const balances = await credits.getBalances(rabe.id);
|
|
|
|
assert.equal(balances.CC, 1, 'CC-Paket gibt einen CC');
|
|
assert.equal(balances.REDESIGN_PAID, 1, 'und das inklusive Redesign');
|
|
|
|
const fuchs = await db.prisma.player.findUniqueOrThrow({
|
|
where: { steamId: '76561198000000002' },
|
|
});
|
|
const fuchsBalances = await credits.getBalances(fuchs.id);
|
|
|
|
assert.equal(fuchsBalances.CC, 0, 'ein Redesign-Paket gibt keinen CC');
|
|
assert.equal(fuchsBalances.REDESIGN_PAID, 1);
|
|
});
|
|
|
|
it('bucht dieselbe Bestell-ID kein zweites Mal', async () => {
|
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
|
const importId = await imports.createImport('lauf2.csv', parsed, ctx);
|
|
|
|
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
|
|
|
assert.equal(summary.SKIP_DUPLICATE, 3, 'alle bereits gebuchten Bestellungen');
|
|
assert.equal(summary.BOOK, 0);
|
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 0);
|
|
|
|
await assert.rejects(
|
|
() => imports.commitImport(importId, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_BOOK',
|
|
);
|
|
});
|
|
|
|
it('erkennt Dubletten auch innerhalb einer Datei', async () => {
|
|
const doubled = [
|
|
'Bestellnummer;Steam-ID;Produkt',
|
|
'C-1;76561198000000009;CC-Paket',
|
|
'C-1;76561198000000009;CC-Paket',
|
|
].join('\n');
|
|
|
|
const parsed = imports.parseUpload('doppelt.csv', Buffer.from(doubled, 'utf8'));
|
|
const importId = await imports.createImport('doppelt.csv', parsed, ctx);
|
|
|
|
const summary = await imports.applyMapping(
|
|
importId,
|
|
{
|
|
playerKey: 'Steam-ID',
|
|
product: 'Produkt',
|
|
orderId: 'Bestellnummer',
|
|
createMissingPlayers: true,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 1);
|
|
assert.equal(summary.SKIP_DUPLICATE, 1);
|
|
});
|
|
|
|
it('überspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
|
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
|
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
|
|
|
const summary = await imports.applyMapping(
|
|
importId,
|
|
{
|
|
playerKey: 'Steam-ID',
|
|
product: 'Produkt',
|
|
orderId: 'Bestellnummer',
|
|
createMissingPlayers: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
|
});
|
|
|
|
it('ein abgeschlossener Import lässt sich nicht erneut buchen', async () => {
|
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
|
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
|
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
|
await imports.applyMapping(
|
|
importId,
|
|
{ playerKey: 'Steam-ID', product: 'Produkt', orderId: 'Bestellnummer', createMissingPlayers: false },
|
|
ctx,
|
|
);
|
|
await imports.commitImport(importId, ctx);
|
|
|
|
await assert.rejects(
|
|
() => imports.commitImport(importId, ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOT_PENDING',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Kollisionen beim Buchen', () => {
|
|
it('überspringt eine Bestellung, die inzwischen anderswo gebucht wurde', async () => {
|
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'K-1;76561198000000077;CC-Paket'].join('\n');
|
|
const mapping = {
|
|
playerKey: 'Steam-ID',
|
|
product: 'Produkt',
|
|
orderId: 'Bestellnummer',
|
|
createMissingPlayers: true,
|
|
};
|
|
|
|
// Zwei Vorschauen mit derselben Bestellung - so entstanden durch mehrfachen Abgleich.
|
|
const ersteVorschau = await imports.createImport(
|
|
'erste.csv',
|
|
imports.parseUpload('erste.csv', Buffer.from(csv, 'utf8')),
|
|
ctx,
|
|
);
|
|
const zweiteVorschau = await imports.createImport(
|
|
'zweite.csv',
|
|
imports.parseUpload('zweite.csv', Buffer.from(csv, 'utf8')),
|
|
ctx,
|
|
);
|
|
await imports.applyMapping(ersteVorschau, mapping, ctx);
|
|
await imports.applyMapping(zweiteVorschau, mapping, ctx);
|
|
|
|
const erste = await imports.commitImport(ersteVorschau, ctx);
|
|
assert.equal(erste.booked, 1);
|
|
|
|
// Früher scheiterte hier der gesamte Import an der eindeutigen Bestell-ID.
|
|
const zweite = await imports.commitImport(zweiteVorschau, ctx);
|
|
assert.equal(zweite.booked, 0);
|
|
assert.equal(zweite.skipped, 1, 'die Zeile wird übersprungen, nicht der Import abgebrochen');
|
|
|
|
const player = await db.prisma.player.findUniqueOrThrow({
|
|
where: { steamId: '76561198000000077' },
|
|
});
|
|
const balances = await credits.getBalances(player.id);
|
|
assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt');
|
|
});
|
|
});
|
|
|
|
describe('Einzelne Zeilen beim Spieler buchen', () => {
|
|
// Jeder Fall bekommt eigene Bestellnummern: ein gebuchter Dedup-Anker gilt
|
|
// dauerhaft und wuerde sonst den naechsten Fall stillschweigend leerlaufen
|
|
// lassen. Die Spieler stehen fest - beim Weg ueber die Spielerseite geht es
|
|
// genau darum, einen Bekannten abzuarbeiten.
|
|
let eule = 0;
|
|
let specht = 0;
|
|
|
|
function datei(praefix: string): Buffer {
|
|
return Buffer.from(
|
|
[
|
|
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
|
`${praefix}-1;76561198000000011;Eule;CC-Paket Groß;2025-02-01`,
|
|
`${praefix}-2;76561198000000011;Eule;Redesign Gutschein;2025-02-02`,
|
|
`${praefix}-3;76561198000000012;Specht;CC-Paket Klein;2025-02-03`,
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
async function vorschau(praefix: string, name = `${praefix}.csv`) {
|
|
const parsed = imports.parseUpload(name, datei(praefix));
|
|
const id = await imports.createImport(name, parsed, ctx);
|
|
await imports.applyMapping(id, MAPPING, ctx);
|
|
return id;
|
|
}
|
|
|
|
before(async () => {
|
|
eule = (
|
|
await db.prisma.player.create({
|
|
data: { displayName: 'Eule', steamId: '76561198000000011' },
|
|
})
|
|
).id;
|
|
specht = (
|
|
await db.prisma.player.create({
|
|
data: { displayName: 'Specht', steamId: '76561198000000012' },
|
|
})
|
|
).id;
|
|
});
|
|
|
|
it('bucht nur die gewählte Zeile und lässt den Rest der Vorschau offen', async () => {
|
|
const importId = await vorschau('EINZ');
|
|
|
|
const offen = await imports.listPendingRowsFor(eule);
|
|
assert.equal(offen.length, 2, 'ein CC-Paket und ein Redesign-Gutschein');
|
|
assert.equal(offen[0]?.productName, 'CC-Paket Groß', 'ältester Kauf zuerst');
|
|
|
|
const ergebnis = await imports.bookRows([offen[0]!.id], ctx);
|
|
assert.equal(ergebnis.booked, 1);
|
|
|
|
// Ein CC-Paket schreibt zwei Zeilen - dieselbe Regel wie beim Import.
|
|
const stand = await credits.getBalances(eule);
|
|
assert.equal(stand.CC, 1);
|
|
assert.equal(stand.REDESIGN_PAID, 1);
|
|
|
|
assert.equal(
|
|
(await imports.listPendingRowsFor(eule)).length,
|
|
1,
|
|
'die gebuchte Zeile ist aus der Liste, die andere wartet weiter',
|
|
);
|
|
|
|
const nachher = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
|
|
assert.equal(nachher.status, 'PENDING', 'der Import als Ganzes bleibt offen');
|
|
|
|
await imports.discardImport(importId, ctx);
|
|
});
|
|
|
|
it('bucht dieselbe Bestellung nicht ein zweites Mal über den Import', async () => {
|
|
const importId = await vorschau('ZWEI');
|
|
|
|
const offen = await imports.listPendingRowsFor(specht);
|
|
assert.equal(offen.length, 1);
|
|
await imports.bookRows([offen[0]!.id], ctx);
|
|
|
|
const vorher = await credits.getBalances(specht);
|
|
|
|
// Der Import wird danach ganz normal bestätigt - die eben gebuchte Zeile
|
|
// steht dabei gar nicht mehr zur Buchung an, sie ist ja abgehakt.
|
|
const ergebnis = await imports.commitImport(importId, ctx);
|
|
assert.equal(ergebnis.booked, 2, 'nur die beiden Zeilen der Eule bleiben übrig');
|
|
|
|
assert.deepEqual(
|
|
await credits.getBalances(specht),
|
|
vorher,
|
|
'sonst stünde das Paket doppelt im Konto',
|
|
);
|
|
});
|
|
|
|
it('hakt dieselbe Bestellung auch in einem zweiten offenen Import ab', async () => {
|
|
// Zweimal dieselbe Datei eingelesen, etwa weil der Abgleich doppelt lief.
|
|
const ersterImport = await vorschau('DREI', 'lauf-a.csv');
|
|
const zweiterImport = await vorschau('DREI', 'lauf-b.csv');
|
|
|
|
const offen = await imports.listPendingRowsFor(eule);
|
|
const redesigns = offen.filter((row) => row.externalId === 'DREI-2');
|
|
assert.equal(redesigns.length, 2, 'dieselbe Bestellung liegt in beiden Vorschauen');
|
|
|
|
const vorher = await credits.getBalances(eule);
|
|
await imports.bookRows([redesigns[0]!.id], ctx);
|
|
|
|
assert.equal(
|
|
(await imports.listPendingRowsFor(eule)).filter((row) => row.externalId === 'DREI-2').length,
|
|
0,
|
|
'die Zwillingszeile im anderen Import ist mit abgehakt',
|
|
);
|
|
assert.equal(
|
|
(await credits.getBalances(eule)).REDESIGN_PAID,
|
|
vorher.REDESIGN_PAID + 1,
|
|
'einmal gekauft, einmal gebucht',
|
|
);
|
|
|
|
await imports.discardImport(ersterImport, ctx);
|
|
await imports.discardImport(zweiterImport, ctx);
|
|
});
|
|
|
|
it('lässt keine leere Vorschau in der Liste zurück', async () => {
|
|
// Genau der Fall aus dem Betrieb: alle Zeilen beim Spieler gebucht, die
|
|
// Vorschau bleibt als leere Hülle stehen und lässt sich nicht bestätigen.
|
|
const importId = await vorschau('LEER');
|
|
|
|
const offen = [
|
|
...(await imports.listPendingRowsFor(eule)),
|
|
...(await imports.listPendingRowsFor(specht)),
|
|
].filter((row) => row.importId === importId);
|
|
assert.equal(offen.length, 3, 'alle drei Zeilen dieses Imports');
|
|
|
|
await imports.bookRows(offen.map((row) => row.id), ctx);
|
|
|
|
const danach = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
|
|
assert.equal(danach.status, 'COMMITTED', 'nichts mehr zu tun heisst erledigt');
|
|
|
|
const liste = await imports.listImports();
|
|
assert.equal(
|
|
liste.some((i) => i.id === importId && i.status === 'PENDING'),
|
|
false,
|
|
'und damit aus der Liste offener Importe verschwunden',
|
|
);
|
|
});
|
|
|
|
it('räumt auch Vorschauen ab, die schon leer herumlagen', async () => {
|
|
const importId = await vorschau('ALTLAST');
|
|
const offen = [
|
|
...(await imports.listPendingRowsFor(eule)),
|
|
...(await imports.listPendingRowsFor(specht)),
|
|
].filter((row) => row.importId === importId);
|
|
|
|
// Von Hand leergeräumt, ohne den Import anzufassen - so sieht der
|
|
// Bestand aus, der vor dieser Korrektur entstanden ist.
|
|
await db.prisma.importRow.updateMany({
|
|
where: { id: { in: offen.map((row) => row.id) } },
|
|
data: { plan: 'SKIP_DUPLICATE' },
|
|
});
|
|
assert.equal(
|
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
|
'PENDING',
|
|
);
|
|
|
|
await imports.listImports();
|
|
|
|
assert.equal(
|
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
|
'COMMITTED',
|
|
);
|
|
});
|
|
|
|
it('weist eine leere Auswahl ab', async () => {
|
|
await assert.rejects(
|
|
() => imports.bookRows([], ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_SELECTED',
|
|
);
|
|
});
|
|
|
|
it('nimmt Zeilen auch ohne Buchung aus der Vorschau', async () => {
|
|
const importId = await vorschau('VIER');
|
|
|
|
const offen = await imports.listPendingRowsFor(specht);
|
|
assert.equal(offen.length, 1);
|
|
const vorher = await credits.getBalances(specht);
|
|
|
|
const ergebnis = await imports.dismissRows(
|
|
offen.map((row) => row.id),
|
|
ctx,
|
|
);
|
|
assert.equal(ergebnis.dismissed, 1);
|
|
|
|
assert.deepEqual(await imports.listPendingRowsFor(specht), []);
|
|
assert.deepEqual(await credits.getBalances(specht), vorher, 'weggelegt ist nicht gebucht');
|
|
|
|
await imports.discardImport(importId, ctx);
|
|
});
|
|
});
|