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(54340); 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: 'ketten', passwordHash: 'x', role: 'ADMIN' }, }); ctx = { actorId: user.id, actorName: user.username }; }); after(async () => { await db.prisma.$disconnect(); await database.stop(); }); async function spielerMitPaketen(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; } /** Die Kette eines Spielers, wie sie angezeigt würde. */ async function kette(playerId: number, lineageId: string) { const glieder = await db.prisma.character.findMany({ where: { playerId, lineageId }, orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], select: { id: true, name: true, predecessorId: true, chainPosition: true }, }); return glieder; } /** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */ function verkettungStimmt(glieder: { id: number; predecessorId: number | null }[]): void { let erwartet: number | null = null; for (const glied of glieder) { assert.equal( glied.predecessorId, erwartet, 'die Verkettung muss der Reihenfolge folgen, sonst laufen beide Sichten auseinander', ); erwartet = glied.id; } } describe('Lücken in einer Redesign-Kette schliessen', () => { it('legt Redesigns hintereinander ab', async () => { const spieler = await spielerMitPaketen('Kettenbauer', 1); const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx); await credits.bookRedesignPackage(spieler, ctx); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Redesign 2', useKind: 'REDESIGN_PAID' }, ctx, ); const glieder = await kette(spieler, zwei.character.lineageId); assert.deepEqual(glieder.map((g) => g.name), ['CC 1', 'Redesign 2']); verkettungStimmt(glieder); }); it('schiebt ein später gefundenes Redesign mitten hinein', async () => { const spieler = await spielerMitPaketen('Nachtraeger', 2); const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx); await credits.bookRedesignPackage(spieler, ctx); const drei = await characters.redesignCharacter( eins.character.id, { name: 'Redesign 3', useKind: 'REDESIGN_PAID' }, ctx, ); // Redesign 2 taucht erst jetzt auf - als eigener CC, noch ohne Kette. const zwei = await characters.createCharacter(spieler, { name: 'Redesign 2' }, ctx); assert.notEqual(zwei.character.lineageId, drei.character.lineageId); await characters.moveInChain( zwei.character.id, { lineageId: drei.character.lineageId, afterId: eins.character.id }, ctx, ); const glieder = await kette(spieler, drei.character.lineageId); assert.deepEqual( glieder.map((g) => g.name), ['CC 1', 'Redesign 2', 'Redesign 3'], 'die Lücke ist geschlossen, ohne die übrigen anzufassen', ); verkettungStimmt(glieder); }); it('setzt einen Eintrag auch an den Anfang', async () => { const spieler = await spielerMitPaketen('Vorspann', 2); const b = await characters.createCharacter(spieler, { name: 'B' }, ctx); const a = await characters.createCharacter(spieler, { name: 'A' }, ctx); await characters.moveInChain( a.character.id, { lineageId: b.character.lineageId, afterId: null }, ctx, ); const glieder = await kette(spieler, b.character.lineageId); assert.deepEqual(glieder.map((g) => g.name), ['A', 'B']); verkettungStimmt(glieder); assert.equal(glieder[0]?.predecessorId, null, 'der Erste hat keinen Vorgänger'); }); it('holt einen Eintrag aus einer Kette in eine andere', async () => { const spieler = await spielerMitPaketen('Umsortierer', 3); const ersteKette = await characters.createCharacter(spieler, { name: 'Strang A' }, ctx); const zweiteKette = await characters.createCharacter(spieler, { name: 'Strang B' }, ctx); const wanderer = await characters.createCharacter(spieler, { name: 'Wanderer' }, ctx); await characters.moveInChain( wanderer.character.id, { lineageId: ersteKette.character.lineageId, afterId: ersteKette.character.id }, ctx, ); assert.equal((await kette(spieler, ersteKette.character.lineageId)).length, 2); // Doch die andere Kette. await characters.moveInChain( wanderer.character.id, { lineageId: zweiteKette.character.lineageId, afterId: zweiteKette.character.id }, ctx, ); const erste = await kette(spieler, ersteKette.character.lineageId); const zweite = await kette(spieler, zweiteKette.character.lineageId); assert.deepEqual(erste.map((g) => g.name), ['Strang A'], 'die alte Kette schliesst sich'); assert.deepEqual(zweite.map((g) => g.name), ['Strang B', 'Wanderer']); verkettungStimmt(erste); verkettungStimmt(zweite); }); it('löst einen Eintrag wieder heraus', async () => { const spieler = await spielerMitPaketen('Loeser', 2); const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx); await credits.bookRedesignPackage(spieler, ctx); const zwei = await characters.redesignCharacter( eins.character.id, { name: 'Geht wieder', useKind: 'REDESIGN_PAID' }, ctx, ); const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx); assert.notEqual(lineageId, eins.character.lineageId); const alte = await kette(spieler, eins.character.lineageId); assert.deepEqual(alte.map((g) => g.name), ['Bleibt']); const allein = await db.prisma.character.findUniqueOrThrow({ where: { id: zwei.character.id }, }); assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger'); }); it('lässt keinen CC in die Kette eines anderen Spielers', async () => { const einer = await spielerMitPaketen('Einer', 1); const anderer = await spielerMitPaketen('Anderer', 1); const seiner = await characters.createCharacter(einer, { name: 'Seiner' }, ctx); const fremder = await characters.createCharacter(anderer, { name: 'Fremder' }, ctx); await assert.rejects( () => characters.moveInChain( fremder.character.id, { lineageId: seiner.character.lineageId, afterId: seiner.character.id }, ctx, ), (error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN', ); }); it('lässt einen CC nicht hinter sich selbst stellen', async () => { const spieler = await spielerMitPaketen('Schlange', 1); const cc = await characters.createCharacter(spieler, { name: 'Beisst sich' }, ctx); await assert.rejects( () => characters.moveInChain( cc.character.id, { lineageId: cc.character.lineageId, afterId: cc.character.id }, ctx, ), (error: Error & { code?: string }) => error.code === 'SELF_REFERENCE', ); }); it('rührt den Status nicht an', async () => { const spieler = await spielerMitPaketen('Unberuehrt', 2); const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx); const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx); await characters.moveInChain( aktiv.character.id, { lineageId: ziel.character.lineageId, afterId: ziel.character.id }, ctx, ); const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: aktiv.character.id }, }); assert.equal(danach.status, 'ACTIVE', 'verschieben ordnet nur ein, es entscheidet nichts'); }); }); describe('Verweise an einem CC', () => { it('hängt einen Verweis an und gibt ihn mit aus', async () => { const spieler = await spielerMitPaketen('Verweiser', 1); const cc = await characters.createCharacter(spieler, { name: 'Mit Addon' }, ctx); await characters.addLink( cc.character.id, { label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=42' }, ctx, ); const liste = await characters.listCharacters(spieler); const gefunden = liste.find((c) => c.id === cc.character.id); assert.deepEqual( gefunden?.links.map((l) => l.label), ['Ersteller-Addon'], ); }); it('weist eine Adresse ab, die keine ist', async () => { const spieler = await spielerMitPaketen('Vorsichtig', 1); const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx); for (const url of ['javascript:alert(1)', 'nur-text', 'file:///etc/passwd']) { await assert.rejects( () => characters.addLink(cc.character.id, { label: 'Test', url }, ctx), (error: Error & { code?: string }) => error.code === 'INVALID_URL', `"${url}" hätte abgewiesen werden müssen`, ); } }); it('lässt sich ändern und entfernen', async () => { const spieler = await spielerMitPaketen('Pfleger', 1); const cc = await characters.createCharacter(spieler, { name: 'Gepflegt' }, ctx); const link = await characters.addLink( cc.character.id, { label: 'Alt', url: 'https://example.org/alt' }, ctx, ); const geaendert = await characters.updateLink( link.id, { label: 'Neu', url: 'https://example.org/neu' }, ctx, ); assert.equal(geaendert.label, 'Neu'); assert.equal(geaendert.url, 'https://example.org/neu'); await characters.removeLink(link.id, ctx); assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0); }); });