Ein CC, der nicht redesignt wurde, existiert unveraendert weiter und kommt
deshalb immer gratis zurueck - egal ob der Spieler inaktiv war oder die
Einheit voruebergehend aus dem Spiel genommen wurde. Bisher kostete der Fall
OTHER ein Guthaben, was die Regel unnoetig verkompliziert hat.
Damit der zweite Fall brauchbar wird, haelt inactiveNote den Grund im Klartext
fest ("Einheit bis Maerz entfernt"); die Notiz steht an der CC-Karte und wird
beim Reaktivieren wieder geleert.
317 lines
12 KiB
TypeScript
317 lines
12 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('auch ein aus anderem Grund entfernter CC kommt gratis zurueck', async () => {
|
|
const playerId = await freshPlayer('Einheit-weg');
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
|
|
|
|
await characters.deactivateCharacter(
|
|
created.character.id,
|
|
'OTHER',
|
|
ctx,
|
|
'Einheit bis Maerz entfernt',
|
|
);
|
|
|
|
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: created.character.id },
|
|
});
|
|
assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz entfernt', 'der Grund bleibt lesbar');
|
|
|
|
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.inactiveNote, null, 'die Notiz wird beim Zurueckholen geloescht');
|
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
|
});
|
|
|
|
it('eine bezahlte Reaktivierung wird bei jedem Gratis-Fall verweigert', async () => {
|
|
const playerId = await freshPlayer('Gratis-Fall');
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
|
|
for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) {
|
|
const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx);
|
|
await characters.deactivateCharacter(created.character.id, reason, ctx);
|
|
|
|
await assert.rejects(
|
|
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'IS_FREE',
|
|
`Grund ${reason} darf nichts kosten`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|