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.
281 lines
10 KiB
TypeScript
281 lines
10 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(54340);
|
|
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');
|
|
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);
|
|
});
|
|
});
|