Files
CC-Manager/server/test/gruppen.test.ts
T
Harlekin e65fdfc0ad Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle
Vier Punkte aus dem Betrieb.

Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie
angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt
reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch
einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde
schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer
Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet.

Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID
ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit
unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt
hat. Jetzt werden alle Kennungen des Spielers abgefragt.

Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben.
Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade
nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden,
welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv
gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt
weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt
am Beitritt und bleibt nachvollziehbar.

Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne
auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben
- koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit
Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist
folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die
Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen.

Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim
Test.
2026-09-21 00:19:48 +02:00

350 lines
14 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 };
/** Ein Spieler mit einem CC-Paket im Rücken. */
async function spielerMitPaket(name: string, pakete = 1) {
const player = await db.prisma.player.create({ data: { displayName: name } });
for (let i = 0; i < pakete; i += 1) {
await credits.bookCcPackage(player.id, ctx);
}
return player.id;
}
before(async () => {
database = await startTestDatabase(54336);
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: 'gruppenchef', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Gruppen-CCs', () => {
it('führt den Ersteller als erstes Mitglied', async () => {
const playerId = await spielerMitPaket('Gründer');
const { character } = await characters.createCharacter(playerId, { name: 'Die Bande' }, ctx);
const mitglieder = await characters.listMembers(character.id);
assert.equal(mitglieder.length, 1);
assert.equal(mitglieder[0]?.playerId, playerId);
assert.equal(mitglieder[0]?.leftAt, null);
});
it('kostet jeden Beitretenden ein eigenes Guthaben', async () => {
const gruender = await spielerMitPaket('Anführer');
const zweiter = await spielerMitPaket('Mitstreiter');
const { character } = await characters.createCharacter(gruender, { name: 'Zweierbande' }, ctx);
const vorher = await credits.getBalances(zweiter);
const result = await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
assert.equal(result.memberCount, 2);
assert.equal(result.balances.CC, vorher.CC - 1, 'sein eigenes CC-Guthaben ist weg');
// Der Gründer zahlt nicht doppelt.
const gruenderStand = await credits.getBalances(gruender);
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
});
it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => {
const gruender = await spielerMitPaket('Chef');
const zweiter = await spielerMitPaket('Nachzügler');
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
const result = await characters.addMember(
character.id,
zweiter,
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
ctx,
);
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
assert.equal(result.memberCount, 2);
// Ein Redesign ersetzt einen CC, es kommt keiner dazu.
const aufgegeben = await db.prisma.character.findUniqueOrThrow({
where: { id: eigener.character.id },
});
assert.equal(aufgegeben.status, 'INACTIVE');
assert.equal(aufgegeben.inactiveReason, 'REWORKED');
const eigene = await characters.listCharacters(zweiter);
assert.deepEqual(
eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name),
['Redesign-Bande'],
'der alte CC ist weg, der Gruppen-CC an seiner Stelle',
);
const beitritt = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: zweiter },
});
assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar');
});
it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => {
const gruender = await spielerMitPaket('Chefin');
const zweiter = await spielerMitPaket('Ohne CC');
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx);
await assert.rejects(
() => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR',
'ohne Vorgänger gäbe es nichts zu ersetzen',
);
});
it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => {
const gruender = await spielerMitPaket('Anführer');
const zweiter = await spielerMitPaket('Rückkehrer');
const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx);
const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx);
await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx);
const result = await characters.addMember(
character.id,
zweiter,
{ useKind: 'REDESIGN_PAID', predecessorId: alt.character.id },
ctx,
);
assert.equal(result.memberCount, 2);
// Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben.
const unveraendert = await db.prisma.character.findUniqueOrThrow({
where: { id: alt.character.id },
});
assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY');
});
it('lässt niemanden einen fremden CC aufgeben', async () => {
const gruender = await spielerMitPaket('Wirt');
const fremder = await spielerMitPaket('Fremder');
const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx);
await assert.rejects(
() =>
characters.addMember(
character.id,
fremder,
{ useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER',
);
});
it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => {
const gruender = await spielerMitPaket('Gastgeber');
const zweiter = await spielerMitPaket('Gast');
const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx);
const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx);
await assert.rejects(
() =>
characters.addMember(
character.id,
zweiter,
{ useKind: 'CC', predecessorId: eigener.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED',
'ein CC-Paket kauft einen Platz, es ersetzt nichts',
);
});
it('nimmt denselben Spieler nicht zweimal auf', async () => {
const gruender = await spielerMitPaket('Doppelt', 2);
const { character } = await characters.createCharacter(gruender, { name: 'Einmalig' }, ctx);
await assert.rejects(
() => characters.addMember(character.id, gruender, { useKind: 'CC' }, ctx),
(error: Error & { code?: string }) => error.code === 'ALREADY_MEMBER',
);
});
it('trägt einen Spieler wieder aus, ohne Guthaben zurückzugeben', async () => {
const gruender = await spielerMitPaket('Bleibt');
const aussteiger = await spielerMitPaket('Geht');
const { character } = await characters.createCharacter(gruender, { name: 'Wechselbande' }, ctx);
await characters.addMember(character.id, aussteiger, { useKind: 'CC' }, ctx);
const vorAustritt = await credits.getBalances(aussteiger);
const result = await characters.removeMember(character.id, aussteiger, 'Gruppe verlassen', ctx);
assert.equal(result.memberCount, 1);
const nachAustritt = await credits.getBalances(aussteiger);
assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es');
});
it('zeigt den CC nach dem Austritt nicht mehr beim Aussteiger', async () => {
const gruender = await spielerMitPaket('Kern');
const kurz = await spielerMitPaket('Kurzzeitig');
const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx);
await characters.addMember(character.id, kurz, { useKind: 'CC' }, ctx);
const waehrend = await characters.listCharacters(kurz);
assert.ok(waehrend.some((c) => c.id === character.id), 'während der Mitgliedschaft sichtbar');
await characters.removeMember(character.id, kurz, undefined, ctx);
const danach = await characters.listCharacters(kurz);
assert.equal(danach.some((c) => c.id === character.id), false, 'danach nicht mehr');
// Beim Gründer bleibt er stehen.
const beimGruender = await characters.listCharacters(gruender);
assert.ok(beimGruender.some((c) => c.id === character.id));
});
it('verweigert den Austritt des letzten Nutzers', async () => {
const allein = await spielerMitPaket('Einzelgänger');
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
await assert.rejects(
() => characters.removeMember(character.id, allein, undefined, ctx),
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
);
});
it('nimmt die Gruppe ins Redesign mit', async () => {
const gruender = await spielerMitPaket('Alt-Chef');
const zweiter = await spielerMitPaket('Alt-Mitglied');
const { character } = await characters.createCharacter(gruender, { name: 'Vorher' }, ctx);
await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
const result = await characters.redesignCharacter(
character.id,
{ name: 'Nachher', useKind: 'REDESIGN_PAID' },
ctx,
);
const neue = await characters.listMembers(result.character.id);
assert.equal(neue.length, 2, 'beide sind weiterhin dabei');
assert.deepEqual(
neue.map((m) => m.playerId).sort(),
[gruender, zweiter].sort(),
);
});
it('lässt niemanden einem inaktiven CC beitreten', async () => {
const gruender = await spielerMitPaket('Inaktiv-Chef');
const spaet = await spielerMitPaket('Zu spät');
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
await assert.rejects(
() => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_ACTIVE',
);
});
});
describe('Tote und entzogene CCs', () => {
it('lässt einen toten CC nicht auf dem normalen Weg zurückkehren', async () => {
const playerId = await spielerMitPaket('Trauernder');
const { character } = await characters.createCharacter(playerId, { name: 'Gefallener' }, ctx);
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP erschossen');
await assert.rejects(
() => characters.reactivateFree(character.id, ctx),
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
);
await assert.rejects(
() => characters.reactivatePaid(character.id, 'CC', ctx),
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
);
});
it('verlangt für den Tod eine Begründung', async () => {
const playerId = await spielerMitPaket('Knapp');
const { character } = await characters.createCharacter(playerId, { name: 'Ohne Grund' }, ctx);
await assert.rejects(
() => characters.deactivateCharacter(character.id, 'DEAD', ctx),
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
);
});
it('holt einen toten CC auf Entscheidung zurück, mit Begründung', async () => {
const playerId = await spielerMitPaket('Auferstanden');
const { character } = await characters.createCharacter(playerId, { name: 'Zurück' }, ctx);
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP gestorben');
const vorher = await credits.getBalances(playerId);
const result = await characters.reviveDeadCharacter(
character.id,
{ reason: 'Rückholung nach Absprache mit der Leitung' },
ctx,
);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.inactiveReason, null);
assert.deepEqual(result.balances, vorher, 'ohne Guthaben-Art kostet es nichts');
});
it('kann die Rückkehr auch kostenpflichtig machen', async () => {
const playerId = await spielerMitPaket('Zahlt', 2);
const { character } = await characters.createCharacter(playerId, { name: 'Teuer' }, ctx);
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
const vorher = await credits.getBalances(playerId);
const result = await characters.reviveDeadCharacter(
character.id,
{ reason: 'Gegen ein Guthaben zurückgeholt', useKind: 'CC' },
ctx,
);
assert.equal(result.balances.CC, vorher.CC - 1);
});
it('verlangt auch bei der Rückkehr eine Begründung', async () => {
const playerId = await spielerMitPaket('Wortkarg');
const { character } = await characters.createCharacter(playerId, { name: 'Stumm' }, ctx);
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
await assert.rejects(
() => characters.reviveDeadCharacter(character.id, { reason: ' ' }, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
);
});
it('ein entzogener CC kommt nicht gratis zurück, aber gegen Guthaben', async () => {
const playerId = await spielerMitPaket('Entzogen', 2);
const { character } = await characters.createCharacter(playerId, { name: 'Weggenommen' }, ctx);
await characters.deactivateCharacter(character.id, 'NO_FREE_CLAIM', ctx, 'Regelverstoss');
await assert.rejects(
() => characters.reactivateFree(character.id, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
);
const result = await characters.reactivatePaid(character.id, 'CC', ctx);
assert.equal(result.character.status, 'ACTIVE');
});
});