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.
280 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|