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); }); });