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-Toepfe', () => { 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 zaehlt 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 ueberzogenes 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 behaelt 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 Inaktivitaet entfernter CC kommt gratis zurueck', 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 zurueck', 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 zurueck', 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 zurueck', 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 Maerz entfernt', ); const deactivated = await db.prisma.character.findUniqueOrThrow({ where: { id: created.character.id }, }); assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz 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 Zurueckholen geloescht'); 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 zurueck', 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 laesst 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 Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => { const playerId = await freshPlayer('Ruecknehmer'); 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 Ruecknahme 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 spaet', ctx), (error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS', ); }); });