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.
This commit is contained in:
2026-09-18 11:37:43 +02:00
parent 245666d363
commit b988a82765
4 changed files with 813 additions and 0 deletions
+36
View File
@@ -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<void>;
}
/**
* 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<TestDatabase> {
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();
},
};
}
+288
View File
@@ -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<Credentials> {
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');
});
});
+203
View File
@@ -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',
);
});
});
+286
View File
@@ -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<number> {
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',
);
});
});