In den Kaesten steht jetzt neben dem offenen Stand, wie viel jemals in den Topf kam: "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht, das andere nie etwas gehabt. Gezaehlt werden dabei nur echte Zugaenge: Die Ruecknahme eines Verbrauchs zaehlt dorthin, wo die stornierte Zeile zaehlte, sonst saehe ein zurueckgenommenes Redesign wie ein zusaetzliches Paket aus. Neben jedem Plus steht ein Minus, das die zuletzt von Hand eingetragene Zugabe dieser Art zurueckholt - fuer den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. Zurueckgenommen wird ueber eine Gegenbuchung; bei einem CC-Paket gehen beide Zeilen mit. Aus einem Import gebuchte Pakete bleiben aussen vor: die gehoeren zu einer Bestellung und werden dort storniert, wo der Zusammenhang sichtbar ist. Die Rechte folgen dem Eintragen - Kulanz bleibt bei den Admins. Nebenbei zwei wacklige Tests festgezurrt: Die Testdatenbanken weichen auf den naechsten Port aus, wenn einer aus einem frueheren Lauf noch haengt, und der Sperr-Test des Abgleichs klopft nicht mehr bei GExtension an, sondern laeuft gegen eine tote Adresse.
407 lines
15 KiB
TypeScript
407 lines
15 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-Töpfe', () => {
|
|
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 zählt 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 überzogenes 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 behält 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 Inaktivität entfernter CC kommt gratis zurück', 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 zurück', 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 zurück', 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 zurück', 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 März entfernt',
|
|
);
|
|
|
|
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
|
where: { id: created.character.id },
|
|
});
|
|
assert.equal(deactivated.inactiveNote, 'Einheit bis März 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 Zurückholen gelöscht');
|
|
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 zurück', 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 lässt 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 Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => {
|
|
const playerId = await freshPlayer('Rücknehmer');
|
|
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 Rücknahme 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 spät', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Von Hand eingetragene Guthaben zurücknehmen', () => {
|
|
it('zählt, wie viel jemals in den Topf kam', async () => {
|
|
const playerId = await freshPlayer('Zaehler');
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx);
|
|
|
|
const stand = await credits.getBalances(playerId);
|
|
assert.equal(stand.CC, 1, 'eines ist noch offen');
|
|
assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein');
|
|
assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit');
|
|
});
|
|
|
|
it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => {
|
|
// Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus.
|
|
const playerId = await freshPlayer('Stornierer');
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx);
|
|
await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx);
|
|
|
|
const stand = await credits.getBalances(playerId);
|
|
assert.equal(stand.CC, 1, 'das Guthaben ist zurück');
|
|
assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines');
|
|
});
|
|
|
|
it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => {
|
|
const playerId = await freshPlayer('Vertipper');
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
await credits.bookCcPackage(playerId, ctx);
|
|
|
|
const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx);
|
|
|
|
assert.equal(balances.CC, 1);
|
|
assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück');
|
|
assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten');
|
|
});
|
|
|
|
it('nimmt eine Kulanz zurück', async () => {
|
|
const playerId = await freshPlayer('Kulanzfall');
|
|
await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' });
|
|
|
|
const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx);
|
|
assert.equal(balances.REDESIGN_KULANZ, 0);
|
|
assert.equal(balances.granted.REDESIGN_KULANZ, 0);
|
|
});
|
|
|
|
it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => {
|
|
const playerId = await freshPlayer('Leerer Topf');
|
|
|
|
await assert.rejects(
|
|
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
|
);
|
|
});
|
|
|
|
it('lässt gebuchte Import-Pakete in Ruhe', async () => {
|
|
// Ein Paket aus einer Bestellung gehört in die Historie, nicht unter
|
|
// einen Knopf, der "das letzte weg" bedeutet.
|
|
const playerId = await freshPlayer('Importiert');
|
|
const importRecord = await db.prisma.import.create({
|
|
data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 },
|
|
});
|
|
await db.prisma.creditLedger.create({
|
|
data: {
|
|
playerId,
|
|
kind: 'CC',
|
|
amount: 1,
|
|
source: 'CC_PACKAGE',
|
|
importId: importRecord.id,
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
|
);
|
|
});
|
|
|
|
it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => {
|
|
const playerId = await freshPlayer('Doppelklicker');
|
|
await credits.bookKulanz(playerId, ctx);
|
|
|
|
await credits.removeLastGrant(playerId, 'KULANZ', ctx);
|
|
await assert.rejects(
|
|
() => credits.removeLastGrant(playerId, 'KULANZ', ctx),
|
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
|
);
|
|
});
|
|
});
|