Files
CC-Manager/server/test/backup.test.ts
T
Harlekin db37d3388a Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle
Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
2026-09-21 13:18:11 +02:00

231 lines
7.9 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Backup = typeof import('../src/services/backup.js');
type Players = typeof import('../src/services/players.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 backup: Backup;
let players: Players;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54339);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
backup = await import('../src/services/backup.js');
players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'umzug', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
/** Räumt den Bestand ab, als wäre es eine frische Instanz. */
async function leereInstanz() {
await db.prisma.characterLink.deleteMany();
await db.prisma.characterMember.deleteMany();
await db.prisma.creditLedger.deleteMany();
await db.prisma.character.deleteMany();
await db.prisma.playerAlias.deleteMany();
await db.prisma.player.deleteMany();
}
/** Ein Bestand mit allem, was der Export kennen muss. */
async function bestandAnlegen() {
const chef = await players.createPlayer(
{
displayName: 'Umzugs-Chef',
steamId: '76561198000030001',
forumName: 'UmzugsChef',
notes: 'Führt die Gruppe',
},
ctx,
);
const gast = await players.createPlayer(
{ displayName: 'Umzugs-Gast', steamId: '76561198000030002' },
ctx,
);
const problemfall = await players.createPlayer(
{ displayName: 'Umzugs-Problem', isProblem: true, problemNote: 'Kein Paket auffindbar' },
ctx,
);
await players.addAlias(chef.id, 'STEAM_ID', '76561198000030099', 'Erstes Konto', ctx);
await credits.bookCcPackage(chef.id, ctx);
await credits.bookCcPackage(gast.id, ctx);
await credits.bookRedesignPackage(chef.id, ctx);
const erster = await characters.createCharacter(
chef.id,
{ name: 'Erster Wurf', loreText: 'Ein Anfang' },
ctx,
);
const zweiter = await characters.redesignCharacter(
erster.character.id,
{ name: 'Zweiter Wurf', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.addMember(zweiter.character.id, gast.id, { useKind: 'CC' }, ctx);
await characters.addLink(
zweiter.character.id,
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=1' },
ctx,
);
return { chef, gast, problemfall, erster, zweiter };
}
describe('Umzug auf eine andere Instanz', () => {
let csv = '';
it('schreibt jede Satzart in die Tabelle', async () => {
await bestandAnlegen();
csv = await backup.exportAll();
for (const satzart of ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS']) {
assert.ok(csv.includes(`${satzart};`), `${satzart} fehlt im Export`);
}
assert.ok(csv.startsWith('satzart;'), 'die Kopfzeile nennt die Satzart zuerst');
assert.ok(csv.includes('Ersteller-Addon'), 'Verweise gehören dazu');
assert.ok(csv.includes('Kein Paket auffindbar'), 'Problemfälle auch');
});
it('stellt den Bestand auf einer leeren Instanz wieder her', async () => {
await leereInstanz();
assert.equal(await db.prisma.player.count(), 0);
const bericht = await backup.importAll(csv, ctx);
assert.deepEqual(bericht.fehler, [], 'keine Zeile darf hängen bleiben');
assert.equal(await db.prisma.player.count(), 3);
assert.equal(await db.prisma.character.count(), 2);
assert.equal(await db.prisma.characterLink.count(), 1);
const chef = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Chef' },
});
assert.equal(chef.steamId, '76561198000030001');
assert.equal(chef.forumName, 'UmzugsChef');
// Guthaben und Verbräuche stehen wieder wie zuvor.
const stand = await credits.getBalances(chef.id);
assert.equal(stand.CC, 0, 'ein Paket gekauft, einen CC daraus gebaut');
assert.equal(stand.REDESIGN_PAID, 1, 'zwei Redesigns erhalten, eines verbraucht');
});
it('hält die Redesign-Kette zusammen', async () => {
const chef = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Chef' },
});
const kette = await db.prisma.character.findMany({
where: { playerId: chef.id },
orderBy: { chainPosition: 'asc' },
});
assert.deepEqual(kette.map((c) => c.name), ['Erster Wurf', 'Zweiter Wurf']);
assert.equal(
kette[0]?.lineageId,
kette[1]?.lineageId,
'beide gehören zur selben Kette',
);
assert.equal(kette[0]?.status, 'INACTIVE');
assert.equal(kette[1]?.status, 'ACTIVE');
});
it('nimmt den Gruppen-CC samt seinem zweiten Nutzer mit', async () => {
const gast = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Gast' },
});
const seine = await characters.listCharacters(gast.id);
assert.deepEqual(
seine.map((c) => c.name),
['Zweiter Wurf'],
'er ist Mitglied, nicht Besitzer',
);
});
it('legt beim zweiten Einspielen nichts doppelt an', async () => {
const vorher = {
spieler: await db.prisma.player.count(),
ccs: await db.prisma.character.count(),
buchungen: await db.prisma.creditLedger.count(),
mitglieder: await db.prisma.characterMember.count(),
links: await db.prisma.characterLink.count(),
};
const bericht = await backup.importAll(csv, ctx);
assert.deepEqual(bericht.fehler, []);
assert.equal(
Object.values(bericht.angelegt).reduce((a, b) => a + b, 0),
0,
'alles war schon da',
);
assert.deepEqual(
{
spieler: await db.prisma.player.count(),
ccs: await db.prisma.character.count(),
buchungen: await db.prisma.creditLedger.count(),
mitglieder: await db.prisma.characterMember.count(),
links: await db.prisma.characterLink.count(),
},
vorher,
'ein zweiter Lauf darf den Bestand nicht verdoppeln',
);
});
it('ergänzt einen Spieler, der auf der Zielinstanz fehlt', async () => {
await db.prisma.player.deleteMany({ where: { displayName: 'Umzugs-Problem' } });
assert.equal(await db.prisma.player.count(), 2);
const bericht = await backup.importAll(csv, ctx);
assert.equal(bericht.angelegt.SPIELER, 1, 'nur der fehlende kommt dazu');
assert.equal(await db.prisma.player.count(), 3);
const wieder = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Problem' },
});
assert.equal(wieder.isProblem, true, 'auch die Markierung kommt mit');
});
it('meldet krumme Zeilen, statt den Lauf abzubrechen', async () => {
const kaputt = [
'satzart;spieler;steam_id;cc_name;bezeichnung;wert',
'VERWEIS;Gibt-Es-Nicht;;Kein CC;Irgendwas;https://example.org/',
'SPIELER;Kommt-Trotzdem-Durch;76561198000030777;;;',
].join('\n');
const bericht = await backup.importAll(kaputt, ctx);
assert.equal(bericht.fehler.length, 1);
assert.match(bericht.fehler[0]?.grund ?? '', /nicht gefunden/);
assert.equal(bericht.angelegt.SPIELER, 1, 'die gute Zeile läuft trotzdem durch');
});
it('weist eine Datei ohne Satzart ab', async () => {
await assert.rejects(
() => backup.importAll('name;wert\nirgendwas;1', ctx),
(error: Error & { code?: string }) => error.code === 'NO_SATZART',
);
});
});