Files
minecraft-atm10-stats/backend/test/ftbquests.test.ts
T
lucashahmannandClaude Opus 5.5 e66f89f49e feat: ATM10-Statistikportal
Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:33:25 +02:00

228 lines
10 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam, stripFormatting } from '../src/parsers/ftbquests.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
const dir = join(import.meta.dirname, 'fixtures', 'ftbquests');
const read = (p: string) => readFileSync(join(dir, p), 'utf8');
const ALICE_FILE = '308a693c-2bd8-4d6b-8790-670aef5be601.snbt';
describe('parseQuestProgress', () => {
it('parses real progress file 308a…', () => {
const p = parseQuestProgress(read(ALICE_FILE));
expect(p.teamId).toBe('308a693c-2bd8-4d6b-8790-670aef5be601');
expect(p.name).toBe('CDOGamer#308a693c');
expect(p.completed.length).toBeGreaterThan(0);
for (const q of p.completed) {
expect(q.questId).toMatch(/^[0-9A-F]{16}$/);
expect(q.at).toBeGreaterThan(1.7e12);
}
expect(p.completed.find((q) => q.questId === '3BC0A50886A3222B')?.at).toBe(1791559740996);
expect(p.started).toBeGreaterThan(0);
});
it('parses the second real file', () => {
expect(parseQuestProgress(read('c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt')).teamId).toBe(
'c25d9ff0-dba3-48f0-b414-fad2b63473a9',
);
});
});
const CHAPTER = { path: 'config/ftbquests/quests/chapters/getting_started.snbt', content: read('config/chapters/getting_started.snbt') };
const LANG_CHAPTERS = { path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged', content: read('config/lang/en_us/chapter.snbt_merged') };
const LANG_QUESTS = {
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
content: read('config/lang/en_us/chapters/getting_started.snbt_merged'),
};
describe('parseQuestDefs', () => {
it('reads chapters with titles from the split en_us lang files (ftbquestslangsplitter layout)', () => {
const defs = parseQuestDefs([CHAPTER, LANG_CHAPTERS, LANG_QUESTS]);
expect(defs).toEqual([
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '3BC0A50886A3222B', title: 'Punch a Tree' },
{ chapterId: '1A2B3C4D5E6F7081', chapterTitle: 'Getting Started', questId: '33218EFDD498E9DB', title: 'From Lang' },
]);
});
it('also reads a single en_us.snbt and ignores other languages', () => {
const defs = parseQuestDefs([
CHAPTER,
{ path: 'config/ftbquests/quests/lang/en_us.snbt', content: '{ quest.3BC0A50886A3222B.title: "Single File" }' },
{ path: 'config/ftbquests/quests/lang/de_de/chapters/getting_started.snbt_merged', content: '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }' },
]);
expect(defs[0]!.title).toBe('Single File');
});
it('falls back to inline title and filename when no lang file exists', () => {
const defs = parseQuestDefs([CHAPTER]);
expect(defs.map((d) => [d.chapterTitle, d.title])).toEqual([
['getting_started', null],
['getting_started', 'Inline Title'],
]);
});
});
describe('isQuestConfigFile', () => {
it('keeps chapters and English texts, skips other languages, reward tables and the big combined file', () => {
const wanted = [
'config/ftbquests/quests/chapters/aether.snbt',
'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
'config/ftbquests/quests/lang/en_us/chapters/aether.snbt_merged',
];
const skipped = [
'config/ftbquests/quests/lang/de_de/chapters/aether.snbt_merged',
'config/ftbquests/quests/lang/en_us.snbt',
'config/ftbquests/quests/reward_tables/loot.snbt',
'config/ftbquests/quests/data.snbt',
];
expect(wanted.filter(isQuestConfigFile)).toEqual(wanted);
expect(skipped.filter(isQuestConfigFile)).toEqual([]);
});
});
describe('parseTeam', () => {
const team = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', p), 'utf8');
it('reads a player team (real structure)', () => {
expect(parseTeam(team('player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt'))).toEqual({
teamId: '308a693c-2bd8-4d6b-8790-670aef5be601',
type: 'player',
name: 'CDOGamer',
members: ['308a693c-2bd8-4d6b-8790-670aef5be601'],
});
});
it('reads a party team; invited players and allies are not members', () => {
const t = parseTeam(team('party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt'));
expect(t.type).toBe('party');
expect(t.name).toBe('Die Techniker');
expect(t.members.sort()).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']);
});
});
describe('file_sync projection', () => {
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
afterAll(closeTestPool);
const fileEvent = (path: string, content: string) => ({
id: randomUUID(),
type: 'file_sync' as const,
ts: Date.now(),
path,
sha256: createHash('sha256').update(content).digest('hex'),
content,
});
it('stores quest progress for the team', async () => {
await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, read(ALICE_FILE))], 'push', 'atm10');
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM quest_progress WHERE team_id = $1`, [
'308a693c-2bd8-4d6b-8790-670aef5be601',
]);
expect(rows[0].n).toBe(parseQuestProgress(read(ALICE_FILE)).completed.length);
});
it('rebuilds quest_defs from all cached config files, also across batches', async () => {
await processEvents(pool, [fileEvent(CHAPTER.path, CHAPTER.content)], 'push', 'atm10');
await processEvents(pool, [fileEvent(LANG_CHAPTERS.path, LANG_CHAPTERS.content), fileEvent(LANG_QUESTS.path, LANG_QUESTS.content)], 'push', 'atm10');
const { rows } = await pool.query(`SELECT quest_id, title, chapter_title FROM quest_defs ORDER BY quest_id`);
expect(rows).toEqual([
{ quest_id: '33218EFDD498E9DB', title: 'From Lang', chapter_title: 'Getting Started' },
{ quest_id: '3BC0A50886A3222B', title: 'Punch a Tree', chapter_title: 'Getting Started' },
]);
});
it('party membership decides whose quest progress a player gets', async () => {
const party = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'party', '5e0c1a2b-0000-4000-8000-00000000abcd.snbt'), 'utf8');
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', party)], 'push', 'atm10');
const { rows } = await pool.query(`SELECT t.name, t.type, array_agg(m.uuid::text ORDER BY m.uuid) AS members
FROM teams t JOIN team_members m USING (team_id) GROUP BY t.name, t.type`);
expect(rows).toEqual([
{ name: 'Die Techniker', type: 'party', members: ['308a693c-2bd8-4d6b-8790-670aef5be601', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9'] },
]);
// leaving the party: a newer file without Bob
const smaller = party.replace(/\t\tc25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"\n/, '');
await processEvents(pool, [fileEvent('world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt', smaller)], 'push', 'atm10');
const after = await pool.query(`SELECT uuid::text FROM team_members`);
expect(after.rows.map((r) => r.uuid)).toEqual(['308a693c-2bd8-4d6b-8790-670aef5be601']);
});
it('player team files set the team name but no party membership', async () => {
const player = readFileSync(join(import.meta.dirname, 'fixtures', 'ftbteams', 'player', `${ALICE_FILE}`), 'utf8');
await processEvents(pool, [fileEvent(`world/ftbteams/player/${ALICE_FILE}`, player)], 'push', 'atm10');
expect((await pool.query(`SELECT name, type FROM teams`)).rows).toEqual([{ name: 'CDOGamer', type: 'player' }]);
expect((await pool.query(`SELECT count(*)::int AS n FROM team_members`)).rows[0].n).toBe(0);
});
it('a broken file is cached but does not fail the batch', async () => {
const res = await processEvents(pool, [fileEvent(`world/ftbquests/${ALICE_FILE}`, '{ broken')], 'push', 'atm10');
expect(res.accepted).toHaveLength(1);
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM app_errors`);
expect(rows[0].n).toBe(1);
});
});
describe('quest reset', () => {
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
it('quests missing from a newer progress file are no longer completed', async () => {
const file = (completed: string) =>
`{\n\tversion: 1\n\tuuid: "308a693c2bd84d6b8790670aef5be601"\n\tname: "T"\n\tcompleted: {\n${completed}\n\t}\n}\n`;
const send = (content: string) =>
processEvents(
pool,
[{ id: randomUUID(), type: 'file_sync', ts: Date.now(), path: `world/ftbquests/${ALICE_FILE}`, sha256: createHash('sha256').update(content).digest('hex'), content }],
'push',
'atm10',
);
await send(file('\t\tAAAAAAAAAAAAAAAA: 1L\n\t\tBBBBBBBBBBBBBBBB: 2L'));
await send(file('\t\tBBBBBBBBBBBBBBBB: 2L'));
const { rows } = await pool.query(`SELECT quest_id FROM quest_progress`);
expect(rows.map((r) => r.quest_id)).toEqual(['BBBBBBBBBBBBBBBB']);
});
});
describe('formatting codes', () => {
it('strips Minecraft colour and style codes from chapter and quest titles', () => {
const defs = parseQuestDefs([
CHAPTER,
{
path: 'config/ftbquests/quests/lang/en_us/chapters/getting_started.snbt_merged',
content: '{ chapter.1A2B3C4D5E6F7081.title: "&aChapter 4&r: &dCreative" \n quest.3BC0A50886A3222B.title: "&6&lATM Star§r" }',
},
]);
expect(defs[0]).toMatchObject({ chapterTitle: 'Chapter 4: Creative', title: 'ATM Star' });
});
});
describe('escaped ampersand', () => {
it('keeps a literal "&" that FTB escapes as backslash-ampersand', () => {
// real lang file: "Forbidden \\& Arcanus" in SNBT, i.e. the parsed string contains a backslash before "&"
expect(stripFormatting('Forbidden \\& Arcanus')).toBe('Forbidden & Arcanus');
expect(stripFormatting('&6Gold \\&b &lBold')).toBe('Gold &b Bold');
});
it('decodes the escape through the real SNBT path', () => {
const defs = parseQuestDefs([
CHAPTER,
{
path: 'config/ftbquests/quests/lang/en_us/chapter.snbt_merged',
content: '{ chapter.1A2B3C4D5E6F7081.title: "Forbidden \\\\& Arcanus" }',
},
]);
expect(defs[0]!.chapterTitle).toBe('Forbidden & Arcanus');
});
});