import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { startTestDatabase, type TestDatabase } from './harness.js'; // Die Module lesen beim Laden die Umgebung, deshalb werden sie erst nach dem // Start der Testdatenbank dynamisch importiert. type Credits = typeof import('../src/services/credits.js'); type Characters = typeof import('../src/services/characters.js'); type Db = typeof import('../src/db.js'); let database: TestDatabase; let credits: Credits; let characters: Characters; let db: Db; let ctx: { actorId: number; actorName: string }; async function freshPlayer(name: string): Promise { const player = await db.prisma.player.create({ data: { displayName: name } }); return player.id; } before(async () => { database = await startTestDatabase(54329); 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'); credits = await import('../src/services/credits.js'); characters = await import('../src/services/characters.js'); const user = await db.prisma.user.create({ data: { username: 'tester', passwordHash: 'x', role: 'ADMIN' }, }); ctx = { actorId: user.id, actorName: user.username }; }); after(async () => { await db.prisma.$disconnect(); await database.stop(); }); describe('Guthaben-Töpfe', () => { it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => { const playerId = await freshPlayer('Paket-Spieler'); const { balances } = await credits.bookCcPackage(playerId, ctx); assert.equal(balances.CC, 1); assert.equal(balances.REDESIGN_PAID, 1); assert.equal(balances.REDESIGN_KULANZ, 0); }); it('ein Redesign-Paket erzeugt nur Redesign-Guthaben', async () => { const playerId = await freshPlayer('Redesign-Spieler'); const { balances } = await credits.bookRedesignPackage(playerId, ctx); assert.equal(balances.CC, 0); assert.equal(balances.REDESIGN_PAID, 1); }); it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => { const playerId = await freshPlayer('Kulanz-Spieler'); const { balances } = await credits.bookKulanz(playerId, ctx); assert.equal(balances.REDESIGN_KULANZ, 1); assert.equal(balances.REDESIGN_PAID, 0); assert.equal(balances.redesignTotal, 1); }); it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => { const playerId = await freshPlayer('Minus-Spieler'); const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx); assert.equal(result.balances.CC, -1); assert.equal(result.overdrawn, true); }); }); describe('CC-Lebenszyklus', () => { it('ein neuer CC verbraucht genau ein CC-Guthaben', async () => { const playerId = await freshPlayer('Ersteller'); await credits.bookCcPackage(playerId, ctx); const result = await characters.createCharacter(playerId, { name: 'Dr. Bright' }, ctx); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.balances.CC, 0); assert.equal(result.balances.REDESIGN_PAID, 1, 'das inklusive Redesign bleibt erhalten'); assert.equal(result.overdrawn, false); }); it('ein Redesign setzt den alten CC auf REWORKED und verkettet den neuen', async () => { const playerId = await freshPlayer('Redesigner'); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx); const result = await characters.redesignCharacter( first.character.id, { name: 'Neu', useKind: 'REDESIGN_PAID' }, ctx, ); assert.equal(result.predecessor.status, 'INACTIVE'); assert.equal(result.predecessor.inactiveReason, 'REWORKED'); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.character.predecessorId, first.character.id); assert.equal( result.character.lineageId, first.character.lineageId, 'die Kette behält ihre lineageId', ); assert.equal(result.balances.REDESIGN_PAID, 0); }); it('ein Redesign kann auch aus dem Kulanz-Topf bezahlt werden', async () => { const playerId = await freshPlayer('Kulanz-Redesigner'); await credits.bookCcPackage(playerId, ctx); await credits.bookKulanz(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx); const result = await characters.redesignCharacter( first.character.id, { name: 'Neu', useKind: 'REDESIGN_KULANZ' }, ctx, ); assert.equal(result.balances.REDESIGN_KULANZ, 0); assert.equal(result.balances.REDESIGN_PAID, 1, 'der bezahlte Topf bleibt unangetastet'); }); }); describe('Reaktivierung', () => { it('ein wegen Inaktivität entfernter CC kommt gratis zurück', async () => { const playerId = await freshPlayer('Inaktiver'); await credits.bookCcPackage(playerId, ctx); const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx); await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx); const before = await credits.getBalances(playerId); const result = await characters.reactivateFree(created.character.id, ctx); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.character.inactiveReason, null); assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt'); }); it('ein reworkter CC kommt NICHT gratis zurück', async () => { const playerId = await freshPlayer('Rework-Spieler'); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx); await characters.redesignCharacter( first.character.id, { name: 'Neu', useKind: 'REDESIGN_PAID' }, ctx, ); await assert.rejects( () => characters.reactivateFree(first.character.id, ctx), (error: Error & { code?: string }) => error.code === 'NOT_FREE', ); }); it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => { const playerId = await freshPlayer('Wiederbeleber'); await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx); await characters.redesignCharacter( first.character.id, { name: 'Neu', useKind: 'REDESIGN_PAID' }, ctx, ); const before = await credits.getBalances(playerId); const result = await characters.reactivatePaid(first.character.id, 'CC', ctx); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.balances.CC, before.CC - 1); }); it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => { const playerId = await freshPlayer('Einheit-weg'); await credits.bookCcPackage(playerId, ctx); const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx); await characters.deactivateCharacter( created.character.id, 'OTHER', ctx, 'Einheit bis März entfernt', ); const deactivated = await db.prisma.character.findUniqueOrThrow({ where: { id: created.character.id }, }); assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar'); const before = await credits.getBalances(playerId); const result = await characters.reactivateFree(created.character.id, ctx); assert.equal(result.character.status, 'ACTIVE'); assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht'); assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt'); }); it('eine bezahlte Reaktivierung wird bei jedem Gratis-Fall verweigert', async () => { const playerId = await freshPlayer('Gratis-Fall'); await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx); for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) { const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx); await characters.deactivateCharacter(created.character.id, reason, ctx); await assert.rejects( () => characters.reactivatePaid(created.character.id, 'CC', ctx), (error: Error & { code?: string }) => error.code === 'IS_FREE', `Grund ${reason} darf nichts kosten`, ); } }); }); describe('Korrekturen', () => { it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => { const playerId = await freshPlayer('Storno-Spieler'); const booked = await credits.bookCcPackage(playerId, ctx); const entry = await db.prisma.creditLedger.findFirstOrThrow({ where: { groupId: booked.groupId }, }); const result = await credits.reverseGroup(entry.id, 'Doppelt erfasst', ctx); assert.equal(result.reversed, 2, 'beide Zeilen des Pakets'); assert.equal(result.balances.CC, 0); assert.equal(result.balances.REDESIGN_PAID, 0); }); it('dieselbe Buchung lässt sich kein zweites Mal stornieren', async () => { const playerId = await freshPlayer('Doppel-Storno'); const booked = await credits.bookKulanz(playerId, ctx); const entry = await db.prisma.creditLedger.findFirstOrThrow({ where: { groupId: booked.groupId }, }); await credits.reverseGroup(entry.id, 'Erste Korrektur', ctx); await assert.rejects( () => credits.reverseGroup(entry.id, 'Nochmal', ctx), (error: Error & { code?: string }) => error.code === 'ALREADY_REVERSED', ); }); it('eine Verbrauchsbuchung wird nicht direkt storniert', async () => { const playerId = await freshPlayer('Verbrauch-Storno'); await credits.bookCcPackage(playerId, ctx); const created = await characters.createCharacter(playerId, { name: 'CC' }, ctx); const consume = await db.prisma.creditLedger.findFirstOrThrow({ where: { characterId: created.character.id, source: 'CONSUME' }, }); await assert.rejects( () => credits.reverseGroup(consume.id, 'Versuch', ctx), (error: Error & { code?: string }) => error.code === 'CONSUME_NOT_REVERSIBLE', ); }); it('eine Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => { const playerId = await freshPlayer('Rücknehmer'); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx); const redesigned = await characters.redesignCharacter( first.character.id, { name: 'Versehen', useKind: 'REDESIGN_PAID' }, ctx, ); const result = await characters.revertCharacter( redesigned.character.id, 'Falscher Spieler', ctx, ); assert.equal(result.restoredPredecessorId, first.character.id); assert.equal(result.balances.REDESIGN_PAID, 1, 'das Redesign-Guthaben ist wieder da'); const restored = await db.prisma.character.findUniqueOrThrow({ where: { id: first.character.id }, }); assert.equal(restored.status, 'ACTIVE'); assert.equal(restored.inactiveReason, null); }); it('eine Rücknahme mitten in der Kette wird abgelehnt', async () => { const playerId = await freshPlayer('Ketten-Spieler'); await credits.bookCcPackage(playerId, ctx); await credits.bookRedesignPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'A' }, ctx); const second = await characters.redesignCharacter( first.character.id, { name: 'B', useKind: 'REDESIGN_PAID' }, ctx, ); await characters.redesignCharacter( second.character.id, { name: 'C', useKind: 'REDESIGN_PAID' }, ctx, ); await assert.rejects( () => characters.revertCharacter(second.character.id, 'Zu spät', ctx), (error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS', ); }); }); describe('Von Hand eingetragene Guthaben zurücknehmen', () => { it('zählt, wie viel jemals in den Topf kam', async () => { const playerId = await freshPlayer('Zaehler'); await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx); await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx); const stand = await credits.getBalances(playerId); assert.equal(stand.CC, 1, 'eines ist noch offen'); assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein'); assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit'); }); it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => { // Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus. const playerId = await freshPlayer('Stornierer'); await credits.bookCcPackage(playerId, ctx); const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx); await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx); const stand = await credits.getBalances(playerId); assert.equal(stand.CC, 1, 'das Guthaben ist zurück'); assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines'); }); it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => { const playerId = await freshPlayer('Vertipper'); await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx); const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx); assert.equal(balances.CC, 1); assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück'); assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten'); }); it('nimmt eine Kulanz zurück', async () => { const playerId = await freshPlayer('Kulanzfall'); await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' }); const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx); assert.equal(balances.REDESIGN_KULANZ, 0); assert.equal(balances.granted.REDESIGN_KULANZ, 0); }); it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => { const playerId = await freshPlayer('Leerer Topf'); await assert.rejects( () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', ); }); it('lässt gebuchte Import-Pakete in Ruhe', async () => { // Ein Paket aus einer Bestellung gehört in die Historie, nicht unter // einen Knopf, der "das letzte weg" bedeutet. const playerId = await freshPlayer('Importiert'); const importRecord = await db.prisma.import.create({ data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 }, }); await db.prisma.creditLedger.create({ data: { playerId, kind: 'CC', amount: 1, source: 'CC_PACKAGE', importId: importRecord.id, }, }); await assert.rejects( () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', ); }); it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => { const playerId = await freshPlayer('Doppelklicker'); await credits.bookKulanz(playerId, ctx); await credits.removeLastGrant(playerId, 'KULANZ', ctx); await assert.rejects( () => credits.removeLastGrant(playerId, 'KULANZ', ctx), (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', ); }); });