Files
CC-Manager/server/test/ketten.test.ts
T
Harlekin 6094e4eaef Zeitplan in der Oberflaeche einstellbar
Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
2026-09-21 13:25:56 +02:00

280 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';
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);
});
});