Files
Harlekin ae5c67eea6 Von Hand vergebenes CC-Guthaben ohne Redesign
Der Knopf trug bisher die Regel des Shops: ein CC-Paket ist ein Platz plus
ein Redesign. Fuer Einzelfaelle - jemand bekommt einen Platz als Ausgleich,
aus einer Absprache, weil etwas schiefging - war das zu viel.

"+ CC-Guthaben" schreibt jetzt nur den Platz gut. Wer ein volles Paket
nachtragen will, klickt zusaetzlich auf "+ Redesign-Paket". Der Abgleich mit
dem Shop bleibt unberuehrt: dort ist ein Paket weiterhin beides, und die
Regel dafuer steht unveraendert an einer Stelle.

In der Historie steht "CC-Guthaben (von Hand)" statt "CC-Paket" - eine eigene
Quelle, damit zu sehen ist, dass kein Kauf dahintersteht. Das Minus daneben
nimmt entsprechend diese Zugabe zurueck.
2026-09-21 23:43:26 +02:00

455 lines
17 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',
);
});
});
describe('CC-Guthaben von Hand vergeben', () => {
it('schreibt nur das CC-Guthaben gut, kein Redesign', async () => {
// Ein gekauftes Paket bringt beides mit. Von Hand gibt es Einzelfälle,
// in denen jemand nur einen Platz bekommen soll.
const playerId = await freshPlayer('Einzelfall');
const { balances } = await credits.grantCcSlot(playerId, ctx, { note: 'Ausgleich' });
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 0, 'kein Redesign obendrauf');
assert.equal(balances.granted.CC, 1);
});
it('macht in der Historie sichtbar, dass kein Kauf dahintersteht', async () => {
const playerId = await freshPlayer('Nachvollziehbar');
await credits.grantCcSlot(playerId, ctx);
const zeile = await db.prisma.creditLedger.findFirstOrThrow({ where: { playerId } });
assert.equal(zeile.source, 'CC_GRANT');
});
it('lässt den Abgleich mit dem Shop unberührt', async () => {
// Ein Paket aus dem Shop bleibt, was es ist: Platz plus Redesign.
const playerId = await freshPlayer('Aus dem Shop');
const { balances } = await credits.bookCcPackage(playerId, ctx);
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 1, 'das Redesign gehört zum Paket');
});
it('nimmt einen von Hand vergebenen Platz wieder zurück', async () => {
const playerId = await freshPlayer('Doch nicht');
await credits.grantCcSlot(playerId, ctx);
const { balances } = await credits.removeLastGrant(playerId, 'CC_GRANT', ctx);
assert.equal(balances.CC, 0);
assert.equal(balances.granted.CC, 0);
});
it('reicht für einen neuen CC', async () => {
const playerId = await freshPlayer('Baut damit');
await credits.grantCcSlot(playerId, ctx);
const ergebnis = await characters.createCharacter(playerId, { name: 'Daraus gebaut' }, ctx);
assert.equal(ergebnis.balances.CC, 0);
assert.equal(ergebnis.overdrawn, false, 'der Platz war gedeckt');
});
});