Files
CC-Manager/server/test/nacherfassung.test.ts
T
Harlekin 367f1aace4 Jedes Glied einer Liste kostet, auch in einem Gruppen-CC
Bisher zahlte nur, wer neu einstieg: wer in einer Kette mitgezogen
wurde oder einen Gruppen-CC mitbenutzte, stand darin ohne eigene
Buchung. Damit ging die Rechnung nicht auf - verbrauchte Redesigns sind
alle Glieder aller Listen minus die Anzahl der Listen, und das gilt fuer
jeden Nutzer einzeln.

Jetzt bucht jedes Glied: das unterste einen Platz, jedes weitere ein
Redesign. Wo eine Buchung fehlt, traegt der Abgleich sie nach, und das
Redesign eines Gruppen-CCs kostet jeden Nutzer sein eigenes.

Am Pfeil stehen alle offenen Buchungen eines Eintrags, nicht nur die
juengste - sonst bleibt eine zweite unsichtbar.
2026-09-22 13:01:52 +02:00

193 lines
6.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54341);
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');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'nacherfasser', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
async function spieler(name: string, pakete = 2) {
const p = await db.prisma.player.create({ data: { displayName: name } });
for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(p.id, ctx);
return p.id;
}
/** Die aktiven Mitglieder eines CCs, nach Namen. */
async function mitglieder(characterId: number): Promise<string[]> {
const liste = await db.prisma.characterMember.findMany({
where: { characterId, leftAt: null },
include: { player: { select: { displayName: true } } },
orderBy: { id: 'asc' },
});
return liste.map((m) => m.player.displayName).sort();
}
/**
* Der Fall aus dem Betrieb: Spieler B ist längst eingetragen, und erst beim
* Durcharbeiten von Spieler A stellt sich heraus, dass die beiden einen
* Gruppen-CC teilten - über mehrere Redesigns hinweg, mit wechselnder
* Besetzung.
*/
describe('Nachträglich aufräumen, was längst passiert ist', () => {
let b = 0;
let a = 0;
let c = 0;
let d = 0;
let alpha = 0;
let beta = 0;
let gamma = 0;
before(async () => {
b = await spieler('Spieler B');
a = await spieler('Spieler A');
c = await spieler('Spieler C');
d = await spieler('Spieler D');
// B wurde zuerst durchgearbeitet: ein CC, zweimal redesignt.
const ersterCC = await characters.createCharacter(b, { name: 'Alpha' }, ctx);
alpha = ersterCC.character.id;
await credits.bookRedesignPackage(b, ctx);
const zweiter = await characters.redesignCharacter(
alpha,
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
ctx,
);
beta = zweiter.character.id;
await credits.bookRedesignPackage(b, ctx);
const dritter = await characters.redesignCharacter(
beta,
{ name: 'Gamma', useKind: 'REDESIGN_PAID' },
ctx,
);
gamma = dritter.character.id;
});
it('trägt A rückwirkend in ein längst redesigntes Glied ein', async () => {
// Alpha ist inaktiv - im laufenden Betrieb tritt dort niemand mehr bei.
// Beim Nachtragen geht es aber nicht um jetzt, sondern um damals.
const alt = await db.prisma.character.findUniqueOrThrow({ where: { id: alpha } });
assert.equal(alt.status, 'INACTIVE');
await characters.addMember(
alpha,
a,
{ useKind: 'CC', joinedAt: new Date('2024-03-01') },
ctx,
);
assert.deepEqual(await mitglieder(alpha), ['Spieler A', 'Spieler B']);
});
it('zieht ihn durch die Kette und bucht jedes Glied einzeln', async () => {
// Für ihn ist jedes Glied ein eigener Eintrag in seiner Liste: der
// unterste kostet den Platz, jedes weitere ein Redesign.
const vorher = await credits.getBalances(a);
const ergebnis = await characters.addMemberToChain(beta, a, {}, ctx);
assert.equal(ergebnis.added, 2, 'Beta und Gamma kommen dazu');
const danach = await credits.getBalances(a);
assert.equal(danach.CC, vorher.CC, 'sein Platz wurde einmal bezahlt');
assert.equal(
danach.REDESIGN_PAID,
vorher.REDESIGN_PAID - 2,
'für Beta und Gamma je ein Redesign',
);
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B']);
});
it('belegt dabei genau einen Platz', async () => {
const belegt = (await characters.listCharacters(a)).filter(
(cc) => cc.status === 'ACTIVE' && cc.members.some((m) => m.playerId === a && !m.leftAt),
);
assert.deepEqual(
belegt.map((cc) => cc.name),
['Gamma'],
'die früheren Glieder sind inaktiv und belegen nichts',
);
});
it('lässt C mittendrin ein- und wieder aussteigen', async () => {
// C war nur bei Beta dabei - davor und danach nicht.
await characters.addMember(beta, c, { useKind: 'CC', joinedAt: new Date('2024-06-01') }, ctx);
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B', 'Spieler C']);
await characters.removeMember(beta, c, 'Ausgestiegen vor dem nächsten Redesign', ctx);
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B']);
// Bei Gamma steht er gar nicht erst.
assert.equal((await mitglieder(gamma)).includes('Spieler C'), false);
});
it('nimmt D erst beim letzten Glied auf', async () => {
await characters.addMember(gamma, d, { useKind: 'CC' }, ctx);
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B', 'Spieler D']);
assert.equal((await mitglieder(alpha)).includes('Spieler D'), false, 'damals war er nicht da');
});
it('zeigt jedem Beteiligten seine eigene Geschichte', async () => {
const seins = async (playerId: number) =>
(await characters.listCharacters(playerId)).map((cc) => cc.name).sort();
assert.deepEqual(await seins(a), ['Alpha', 'Beta', 'Gamma'], 'A war von Anfang an dabei');
assert.deepEqual(await seins(b), ['Alpha', 'Beta', 'Gamma']);
assert.deepEqual(await seins(c), ['Beta'], 'C nur in der Mitte');
assert.deepEqual(await seins(d), ['Gamma'], 'D nur am Ende');
});
it('verlangt an einem laufenden CC weiterhin eine Guthaben-Angabe', async () => {
const fremder = await spieler('Fremder', 1);
await assert.rejects(
() => characters.addMember(gamma, fremder, {}, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
'wer neu dazukommt, zahlt - die Ausnahme gilt nur der eigenen Kette',
);
});
it('weist einen verdrehten Zeitraum ab', async () => {
const spaeter = await spieler('Verdreht', 1);
await assert.rejects(
() =>
characters.addMember(
alpha,
spaeter,
{ joinedAt: new Date('2025-01-01'), leftAt: new Date('2024-01-01') },
ctx,
),
(error: Error & { code?: string }) => error.code === 'BAD_RANGE',
);
});
});