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.
455 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|