From b988a82765ee7194d56b23728ae3599a8c89263b Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:37:43 +0200 Subject: [PATCH] Integrationstests gegen eine Postgres-Instanz im Speicher Die Tests starten Postgres per PGlite und sprechen es ueber einen lokalen Port an, laufen also ohne Docker und ohne installierte Datenbank. Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierung, Stornos und Ruecknahmen, Anmeldung mit CSRF- und Rollenpruefung sowie der Import mit Dedup. --- server/test/harness.ts | 36 +++++ server/test/http.test.ts | 288 ++++++++++++++++++++++++++++++++++++ server/test/imports.test.ts | 203 +++++++++++++++++++++++++ server/test/rules.test.ts | 286 +++++++++++++++++++++++++++++++++++ 4 files changed, 813 insertions(+) create mode 100644 server/test/harness.ts create mode 100644 server/test/http.test.ts create mode 100644 server/test/imports.test.ts create mode 100644 server/test/rules.test.ts diff --git a/server/test/harness.ts b/server/test/harness.ts new file mode 100644 index 0000000..ed7a500 --- /dev/null +++ b/server/test/harness.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { PGLiteSocketServer } from '@electric-sql/pglite-socket'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql'); + +export interface TestDatabase { + url: string; + stop: () => Promise; +} + +/** + * Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen + * Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet. + * So laufen die Tests ohne Docker oder installiertes Postgres. + */ +export async function startTestDatabase(port: number): Promise { + const db = await PGlite.create(); + await db.exec(readFileSync(MIGRATION, 'utf8')); + + const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' }); + await server.start(); + + return { + // connection_limit=1, weil die Testinstanz immer nur eine Verbindung + // gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht. + url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`, + stop: async () => { + await server.stop(); + await db.close(); + }, + }; +} diff --git a/server/test/http.test.ts b/server/test/http.test.ts new file mode 100644 index 0000000..f0da7e8 --- /dev/null +++ b/server/test/http.test.ts @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { FastifyInstance } from 'fastify'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let app: FastifyInstance; +let db: Db; + +const PASSWORD = 'TestPasswort123'; + +interface Credentials { + session: string; + csrf: string; +} + +async function login(username: string, password = PASSWORD): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username, password }, + }); + assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`); + + const session = response.cookies.find((c) => c.name === 'ccm_sid'); + const csrf = response.cookies.find((c) => c.name === 'ccm_csrf'); + assert.ok(session && csrf, 'Sitzungs-Cookies fehlen'); + + return { session: session.value, csrf: csrf.value }; +} + +function authHeaders(credentials: Credentials, withCsrf = true) { + return { + cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`, + ...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}), + }; +} + +before(async () => { + database = await startTestDatabase(54330); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + process.env.COOKIE_SECURE = 'false'; + + db = await import('../src/db.js'); + const { hashPassword } = await import('../src/auth/password.js'); + const { buildApp } = await import('../src/app.js'); + + const passwordHash = await hashPassword(PASSWORD); + await db.prisma.user.createMany({ + data: [ + { username: 'chef', passwordHash, role: 'ADMIN' }, + { username: 'team', passwordHash, role: 'EDITOR' }, + { username: 'gast', passwordHash, role: 'VIEWER' }, + { username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true }, + { username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false }, + ], + }); + + app = await buildApp(); + await app.ready(); +}); + +after(async () => { + await app.close(); + await db.prisma.$disconnect(); + await database.stop(); +}); + +describe('Anmeldung', () => { + it('weist ein falsches Passwort ab', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'chef', password: 'falsch' }, + }); + + assert.equal(response.statusCode, 401); + }); + + it('verraet nicht, ob es den Benutzernamen gibt', async () => { + const unknownUser = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'gibtesnicht', password: 'falsch' }, + }); + const wrongPassword = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'chef', password: 'falsch' }, + }); + + assert.equal(unknownUser.statusCode, wrongPassword.statusCode); + assert.equal(unknownUser.json().message, wrongPassword.json().message); + }); + + it('laesst ein gesperrtes Konto nicht herein', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'gesperrt', password: PASSWORD }, + }); + + assert.equal(response.statusCode, 401); + }); + + it('setzt bei Erfolg beide Cookies', async () => { + const credentials = await login('chef'); + assert.ok(credentials.session.length > 20); + assert.ok(credentials.csrf.length > 20); + }); +}); + +describe('CSRF-Schutz', () => { + it('blockt eine schreibende Anfrage ohne Token', async () => { + const credentials = await login('team'); + + const response = await app.inject({ + method: 'POST', + url: '/api/players', + headers: authHeaders(credentials, false), + payload: { displayName: 'Ohne Token' }, + }); + + assert.equal(response.statusCode, 403); + assert.equal(response.json().error, 'FORBIDDEN'); + }); + + it('blockt einen gefaelschten Token', async () => { + const credentials = await login('team'); + + const response = await app.inject({ + method: 'POST', + url: '/api/players', + headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) }, + payload: { displayName: 'Falscher Token' }, + }); + + assert.equal(response.statusCode, 403); + }); + + it('laesst die Anfrage mit passendem Token durch', async () => { + const credentials = await login('team'); + + const response = await app.inject({ + method: 'POST', + url: '/api/players', + headers: authHeaders(credentials), + payload: { displayName: 'Mit Token' }, + }); + + assert.equal(response.statusCode, 201); + }); + + it('laesst lesende Anfragen ohne Token zu', async () => { + const credentials = await login('gast'); + + const response = await app.inject({ + method: 'GET', + url: '/api/players', + headers: authHeaders(credentials, false), + }); + + assert.equal(response.statusCode, 200); + }); +}); + +describe('Rollen', () => { + it('ein Viewer darf lesen, aber nichts anlegen', async () => { + const credentials = await login('gast'); + + const read = await app.inject({ + method: 'GET', + url: '/api/players', + headers: authHeaders(credentials), + }); + const write = await app.inject({ + method: 'POST', + url: '/api/players', + headers: authHeaders(credentials), + payload: { displayName: 'Verboten' }, + }); + + assert.equal(read.statusCode, 200); + assert.equal(write.statusCode, 403); + }); + + it('ein Editor kommt nicht in den Admin-Bereich', async () => { + const credentials = await login('team'); + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/users', + headers: authHeaders(credentials), + }); + + assert.equal(response.statusCode, 403); + }); + + it('ein Admin kommt in den Admin-Bereich', async () => { + const credentials = await login('chef'); + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/users', + headers: authHeaders(credentials), + }); + + assert.equal(response.statusCode, 200); + assert.ok(Array.isArray(response.json().items)); + }); + + it('ohne Anmeldung ist alles zu', async () => { + const response = await app.inject({ method: 'GET', url: '/api/players' }); + assert.equal(response.statusCode, 401); + }); +}); + +describe('Erstpasswort', () => { + it('sperrt schreibende Aktionen bis zum Wechsel', async () => { + const credentials = await login('neu'); + + const response = await app.inject({ + method: 'POST', + url: '/api/players', + headers: authHeaders(credentials), + payload: { displayName: 'Zu frueh' }, + }); + + assert.equal(response.statusCode, 403); + assert.match(response.json().message, /Passwort/); + }); + + it('erlaubt genau den Passwortwechsel', async () => { + const credentials = await login('neu'); + + const response = await app.inject({ + method: 'POST', + url: '/api/account/password', + headers: authHeaders(credentials), + payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' }, + }); + + assert.equal(response.statusCode, 200); + + const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } }); + assert.equal(user.mustChangePassword, false); + }); +}); + +describe('Guthaben ueber die API', () => { + it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => { + const credentials = await login('team'); + + const created = await app.inject({ + method: 'POST', + url: '/api/players', + headers: authHeaders(credentials), + payload: { displayName: 'API-Spieler' }, + }); + const playerId = created.json().id; + + const booked = await app.inject({ + method: 'POST', + url: `/api/players/${playerId}/credits/cc-package`, + headers: authHeaders(credentials), + payload: {}, + }); + + assert.equal(booked.statusCode, 200); + assert.deepEqual(booked.json().balances, { + CC: 1, + REDESIGN_PAID: 1, + REDESIGN_KULANZ: 0, + redesignTotal: 1, + }); + }); + + it('eine unbekannte API-Route liefert sauberes JSON', async () => { + const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' }); + + assert.equal(response.statusCode, 404); + assert.equal(response.json().error, 'NOT_FOUND'); + }); +}); diff --git a/server/test/imports.test.ts b/server/test/imports.test.ts new file mode 100644 index 0000000..cfcb904 --- /dev/null +++ b/server/test/imports.test.ts @@ -0,0 +1,203 @@ +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 Gross;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 gueltigem 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('ueberspringt 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 laesst 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', + ); + }); +}); diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts new file mode 100644 index 0000000..6f45fc4 --- /dev/null +++ b/server/test/rules.test.ts @@ -0,0 +1,286 @@ +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('eine bezahlte Reaktivierung wird bei Gratis-Faellen verweigert', async () => { + const playerId = await freshPlayer('Gratis-Fall'); + await credits.bookCcPackage(playerId, ctx); + const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx); + await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx); + + await assert.rejects( + () => characters.reactivatePaid(created.character.id, 'CC', ctx), + (error: Error & { code?: string }) => error.code === 'IS_FREE', + ); + }); +}); + +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', + ); + }); +});