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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user