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.
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|