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,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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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