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>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createAdmin } from '../src/auth/admin.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { makeApp } from './helpers/app.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, ev } from './helpers/events.ts';
import { signedRequest } from './helpers/sign.ts';
let pool: Pool;
let app: FastifyInstance;
let token: string;
beforeEach(async () => {
await app?.close();
pool = await getTestPool();
await resetDb(pool);
({ app, token } = await makeApp(pool));
await createAdmin(pool, 'lucas', 'correct horse battery staple');
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
async function login(password = 'correct horse battery staple') {
const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'lucas', password } });
const cookie = res.cookies.find((c) => c.name === 'atm_admin');
return { res, cookie: cookie ? `atm_admin=${cookie.value}` : '', csrf: res.statusCode === 200 ? res.json().csrf : '' };
}
describe('admin', () => {
it('login with wrong password 401', async () => {
const { res } = await login('wrong');
expect(res.statusCode).toBe(401);
});
it('login sets an httpOnly strict cookie and the session endpoint works', async () => {
const { res, cookie, csrf } = await login();
expect(res.statusCode).toBe(200);
const raw = res.cookies.find((c) => c.name === 'atm_admin')!;
expect(raw.httpOnly).toBe(true);
expect(raw.sameSite).toBe('Strict');
const s = await app.inject({ method: 'GET', url: '/api/admin/session', headers: { cookie } });
expect(s.json()).toEqual({ username: 'lucas', csrf });
});
it('admin routes require a session', async () => {
expect((await app.inject({ method: 'GET', url: '/api/admin/status' })).statusCode).toBe(401);
});
it('mutating route without csrf 403', async () => {
const { cookie } = await login();
const res = await app.inject({
method: 'POST',
url: `/api/admin/players/${ALICE.uuid}/hidden`,
headers: { cookie },
payload: { hidden: true },
});
expect(res.statusCode).toBe(403);
});
it('hide player then profile 404, unhide restores it', async () => {
const { cookie, csrf } = await login();
const hide = (hidden: boolean) =>
app.inject({
method: 'POST',
url: `/api/admin/players/${ALICE.uuid}/hidden`,
headers: { cookie, 'x-csrf-token': csrf },
payload: { hidden },
});
expect((await hide(true)).statusCode).toBe(200);
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(404);
const list = await app.inject({ method: 'GET', url: '/api/admin/players', headers: { cookie } });
expect(list.json().players[0]).toMatchObject({ uuid: ALICE.uuid, hidden: true });
await hide(false);
expect((await app.inject({ method: 'GET', url: `/api/players/${ALICE.uuid}` })).statusCode).toBe(200);
});
it('rotate token invalidates old token for ingest', async () => {
const { cookie, csrf } = await login();
const rot = await app.inject({ method: 'POST', url: '/api/admin/ingest-token/rotate', headers: { cookie, 'x-csrf-token': csrf } });
expect(rot.statusCode).toBe(200);
const fresh = rot.json().token as string;
const send = (t: string) => {
const { headers, payload } = signedRequest({ protocol: 1, events: [ev('server_stop', {})] }, { token: t });
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
};
expect((await send(token)).statusCode).toBe(401);
expect((await send(fresh)).statusCode).toBe(200);
});
it('status lists errors and a sync request is queued', async () => {
const { cookie, csrf } = await login();
await pool.query(`INSERT INTO app_errors(source, message) VALUES ('sftp', 'connection refused')`);
const status = await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } });
expect(status.json().lastErrors[0]).toMatchObject({ source: 'sftp', message: 'connection refused' });
const sync = await app.inject({ method: 'POST', url: '/api/admin/sync', headers: { cookie, 'x-csrf-token': csrf } });
expect(sync.statusCode).toBe(202);
expect((await pool.query('SELECT count(*)::int AS n FROM sync_requests WHERE done_at IS NULL')).rows[0].n).toBe(1);
});
it('logout ends the session', async () => {
const { cookie, csrf } = await login();
await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie, 'x-csrf-token': csrf } });
expect((await app.inject({ method: 'GET', url: '/api/admin/status', headers: { cookie } })).statusCode).toBe(401);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { afterAll, describe, expect, it } from 'vitest';
import { loadConfig } from '../src/config.ts';
import { migrate } from '../src/db/migrate.ts';
import { closeTestPool, getTestPool } from './helpers/db.ts';
afterAll(closeTestPool);
describe('database', () => {
it('migrate is idempotent', async () => {
const pool = await getTestPool();
await migrate(pool);
await migrate(pool);
const { rows } = await pool.query(`SELECT to_regclass('public.events') AS t`);
expect(rows[0].t).toBe('events');
});
});
describe('config', () => {
it('loadConfig rejects missing DATABASE_URL', () => {
expect(() => loadConfig({ INGEST_HMAC_SECRET: 'x'.repeat(32) })).toThrow(/DATABASE_URL/);
});
it('loadConfig applies defaults', () => {
const c = loadConfig({ DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32) });
expect(c.PORT).toBe(3001);
expect(c.SFTP_INTERVAL_MIN).toBe(15);
expect(c.sftp).toBeNull();
});
});
@@ -0,0 +1,27 @@
{
"minecraft:recipes/misc/charcoal": {
"criteria": {
"has_log": "2026-10-09 18:10:02 +0200"
},
"done": true
},
"minecraft:story/root": {
"criteria": {
"crafting_table": "2026-10-09 18:11:40 +0200"
},
"done": true
},
"minecraft:story/enter_the_nether": {
"criteria": {
"entered_nether": "2026-10-12 12:42:23 +0200"
},
"done": true
},
"minecraft:adventure/adventuring_time": {
"criteria": {
"minecraft:plains": "2026-10-09 18:20:00 +0200"
},
"done": false
},
"DataVersion": 3955
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
{
default_hide_dependency_lines: false
filename: "getting_started"
group: ""
icon: "minecraft:oak_sapling"
id: "1A2B3C4D5E6F7081"
order_index: 0
quests: [
{
id: "3BC0A50886A3222B"
tasks: [{
id: "7F00000000000001"
item: "minecraft:oak_log"
type: "item"
}]
x: 0.0d
y: 0.0d
}
{
id: "33218EFDD498E9DB"
title: "Inline Title"
x: 1.5d
y: 0.0d
}
]
}
@@ -0,0 +1,4 @@
{
chapter.1A2B3C4D5E6F7081.title: "Getting Started"
chapter.1A2B3C4D5E6F7081.chapter_subtitle: ["A handwritten test chapter"]
}
@@ -0,0 +1,5 @@
{
quest.3BC0A50886A3222B.title: "Punch a Tree"
quest.3BC0A50886A3222B.quest_desc: ["Line one", "Line two"]
quest.33218EFDD498E9DB.title: "From Lang"
}
@@ -0,0 +1,16 @@
{
id: "5e0c1a2b-0000-4000-8000-00000000abcd"
type: "party"
ranks: {
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
c25d9ff0-dba3-48f0-b414-fad2b63473a9: "member"
0b7f3a6e-1111-4222-8333-444455556666: "invited"
7d1e2f3a-aaaa-4bbb-8ccc-dddddddddddd: "ally"
}
properties: {
"ftbteams:display_name": "Die Techniker"
"ftbteams:free_to_join": 0b
}
message_history: [ ]
extra: { }
}
@@ -0,0 +1,16 @@
{
id: "308a693c-2bd8-4d6b-8790-670aef5be601"
type: "player"
player_name: "CDOGamer"
ranks: {
308a693c-2bd8-4d6b-8790-670aef5be601: "owner"
}
properties: {
"ftbteams:display_name": "CDOGamer"
"ftbteams:description": ""
"ftbteams:color": "#FF6A59"
"ftbteams:free_to_join": 0b
}
message_history: [ ]
extra: { }
}
@@ -0,0 +1 @@
{"stats":{"minecraft:custom":{"minecraft:interact_with_crafting_table":375,"minecraft:open_barrel":22,"minecraft:leave_game":11,"minecraft:time_since_death":93480,"minecraft:play_time":1175736,"minecraft:deaths":8,"minecraft:jump":8719,"minecraft:interact_with_furnace":85,"minecraft:interact_with_cartography_table":1,"minecraft:sleep_in_bed":7,"minecraft:damage_dealt":18793,"waystones:waystone_activated":1,"minecraft:damage_dealt_absorbed":121,"minecraft:sneak_time":21567,"minecraft:total_world_time":1175736,"apotheosis:world_tiers_activated":1,"minecraft:time_since_rest":93509,"minecraft:undead_killed":57,"minecraft:swim_one_cm":203284,"minecraft:fly_one_cm":3233729,"minecraft:open_chest":1929,"minecraft:fall_one_cm":524999,"lootr:looted_stat":67,"minecraft:rite_of_silent_bond":2,"minecraft:damage_resisted":0,"minecraft:sprint_one_cm":1967504,"minecraft:drop":70,"minecraft:talked_to_villager":6,"minecraft:walk_on_water_one_cm":51311,"minecraft:crouch_one_cm":75087,"minecraft:mob_kills":100,"minecraft:damage_dealt_resisted":136,"minecraft:climb_one_cm":5945,"minecraft:walk_one_cm":1890607,"minecraft:walk_under_water_one_cm":26190,"minecraft:boat_one_cm":622649,"minecraft:damage_taken":7023},"minecraft:killed":{"minecraft:cave_spider":2,"minecraft:skeleton":12,"minecraft:zombie":32,"minecraft:enderman":2,"minecraft:spider":16,"minecraft:silverfish":5,"irons_spellbooks:summoned_skeleton":1,"artifacts:mimic":2,"minecraft:zombie_villager":5,"irons_spellbooks:necromancer":2,"minecraft:creeper":2,"irons_spellbooks:summoned_zombie":10,"auroral:auroral_nautilus":2,"minecraft:drowned":2},"minecraft:picked_up":{"silentgear:raw_crimson_iron":8,"herbsandharvest:cauliflower_seeds":16,"minecraft:diamond_sword":2,"herbsandharvest:pineapple":7,"minecraft:enchanted_book":1,"herbsandharvest:turnip_seeds":25,"oritech:raw_uranium":14,"mysticalagriculture:prosperity_seed_base":2,"minecraft:amethyst_block":1,"minecraft:string":11,"minecraft:cobbled_deepslate":1929,"mysticalagriculture:inferium_essence":3143,"minecraft:gold_nugget":31,"mekanism:basic_universal_cable":2,"minecraft:spruce_planks":95,"mekanism:basic_logistical_transporter":5,"rootsclassic:verdant_sprig":1,"mysticalagriculture:prosperity_shard":709,"silentgems:aquamarine":10,"mysticalagriculture:inferium_seeds":47,"minecraft:black_dye":3,"create:scoria":2,"farmersdelight:onion":1,"mysticalagradditions:supremium_apple":2,"minecraft:blue_dye":3,"alltheores:raw_iridium":3,"supplementaries:ash":63,"minecraft:iron_bars":2,"minecraft:flint":8,"mysticalagriculture:netherite_essence":1,"minecraft:map":1,"silentgems:opal":5,"artifacts:shock_pendant":1,"bhc:red_heart":14,"sushigocrafting:wasabi_seeds":3,"theurgy:sal_ammoniac_crystal":36,"herbsandharvest:pineapple_seeds":22,"sophisticatedstorage:chest":1,"forbidden_arcanus:rune":17,"mysticalagriculture:soulium_dust":7,"minecraft:glowstone_dust":16,"minecraft:moss_block":2,"alltheores:fluorite":20,"silentgear:fine_silk":1,"mekanism:metallurgic_infuser":2,"xycraft_world:xychorium_gem_red":29,"minecraft:trapped_chest":1,"xycraft_world:xychorium_gem_dark":6,"minecraft:barrel":3,"mekanism:basic_mechanical_pipe":3,"rootsclassic:blackcurrant":1,"minecolonies:mint":1,"herbsandharvest:green_bean":4,"minecraft:granite":27,"minecraft:bread":1,"alltheores:raw_platinum":12,"alltheores:osmium_ingot":38,"minecraft:crafting_table":1,"minecraft:clay_ball":80,"minecraft:leather_leggings":2,"sushigocrafting:soy_seeds":3,"powah:energizing_rod_starter":1,"alltheores:peridot":3,"minecraft:cartography_table":1,"minecraft:poppy":7,"minecraft:jungle_sapling":1,"minecraft:iron_nugget":11,"minecraft:spruce_slab":2,"minecraft:chest":4,"alltheores:raw_zinc":98,"minecraft:spruce_fence":6,"minecraft:white_dye":3,"minecraft:carrot":190,"minecraft:redstone":373,"mekanism:formulaic_assemblicator":1,"minecraft:hay_block":2,"mysticalagriculture:diamond_seeds":3,"alltheores:raw_osmium":178,"minecraft:white_wool":22,"powah:capacitor_basic_tiny":4,"mysticalagriculture:fertilized_essence":7,"silentgems:black_diamond":12,"minecraft:yellow_dye":3,"alltheores:cinnabar":2,"minecraft:crafter":3,"biomeswevegone:kovan_flower":35,"minecraft:ancient_debris":11,"silentgems:alexandrite":4,"tombstone:gift":4,"pamhc2crops:tealeafitem":1,"sophisticatedbackpacks:upgrade_base":3,"minecraft:stone_bricks":16,"herbsandharvest:raspberry_seeds":8,"minecraft:stone_pickaxe":2,"minecraft:gray_bed":1,"mysticalagriculture:wood_essence":9,"silentgems:turquoise":1,"forbidden_arcanus:darkstone":309,"pamhc2crops:zucchiniitem":1,"modern_industrialization:raw_tungsten":22,"minecraft:dark_oak_slab":1,"mysticalagriculture:iron_essence":8,"herbsandharvest:green_pepper_seeds":15,"minecraft:gravel":124,"powah:uraninite_raw":29,"minecraft:spruce_sapling":2,"minecraft:spruce_log":22,"silentgear:bort":11,"forbidden_arcanus:arcane_crystal":7,"immersiveengineering:seed":9,"refinedstorage:autocrafter":1,"minecraft:short_grass":2,"minecraft:redstone_ore":6,"alltheores:raw_silver":35,"mysticalagriculture:diamoLine truncated
@@ -0,0 +1 @@
{"stats":{"minecraft:killed":{"minecraft:spider":2,"minecraft:skeleton":3,"minecraft:chicken":3,"livingthings:shark":1,"irons_spellbooks:summoned_skeleton":1,"irons_spellbooks:necromancer":1,"minecraft:creeper":1,"minecraft:sheep":1,"minecraft:trader_llama":3,"minecraft:pillager":1,"minecraft:zombie":6,"irons_spellbooks:summoned_zombie":7,"livingthings:crab":4,"creeperoverhaul:spruce_creeper":1,"minecraft:enderman":5},"minecraft:custom":{"lootr:looted_stat":3,"minecraft:rite_of_silent_bond":2,"minecraft:interact_with_crafting_table":76,"minecraft:open_barrel":13,"minecraft:leave_game":7,"minecraft:time_since_death":671500,"minecraft:sprint_one_cm":1836705,"minecraft:drop":28,"minecraft:talked_to_villager":8,"minecraft:play_time":672734,"minecraft:deaths":1,"minecraft:jump":3882,"minecraft:walk_on_water_one_cm":9307,"minecraft:raider_killed":1,"minecraft:interact_with_furnace":322,"minecraft:sleep_in_bed":19,"minecraft:damage_dealt":8713,"minecraft:crouch_one_cm":60617,"minecraft:mob_kills":41,"minecraft:damage_dealt_resisted":0,"minecraft:climb_one_cm":19858,"minecraft:walk_one_cm":1367248,"minecraft:sneak_time":21896,"minecraft:walk_under_water_one_cm":13119,"minecraft:boat_one_cm":77411,"minecraft:total_world_time":672734,"minecraft:elyra_diary":1,"minecraft:time_since_rest":49269,"minecraft:damage_taken":2472,"minecraft:undead_killed":11,"minecraft:swim_one_cm":19389,"minecraft:fly_one_cm":661009,"minecraft:open_chest":1327,"minecraft:fall_one_cm":58825},"minecraft:mined":{"mekanismgenerators:wind_generator":4,"chipped:large_diamond_leaded_glass":13,"minecraft:white_bed":2,"minecraft:oak_sapling":7,"mekanism:energized_smelter":1,"herbsandharvest:cauliflower_crop":7,"minecraft:granite":44,"minecraft:grass_block":623,"ars_nouveau:sourceberry_bush":16,"mysticalagriculture:inferium_ore":8,"herbsandharvest:pineapple_crop":15,"trophymanager:trophy":16,"xycraft_world:xychorium_ore_stone_green":5,"herbsandharvest:fruit_leaves":1,"biomeswevegone:zelkova_leaves":2,"minecraft:iron_ore":23,"minecraft:fire":2,"herbsandharvest:broccoli_crop":3,"minecraft:deepslate_lapis_ore":2,"silentgear:deepslate_bort_ore":1,"minecraft:brown_bed":3,"minecraft:enchanting_table":1,"minecraft:dark_oak_stairs":2,"alltheores:fluorite_ore":11,"xycraft_world:xychorium_ore_stone_light":4,"xycraft_world:xychorium_ore_deepslate_green":4,"minecraft:deepslate_redstone_ore":3,"mekanism:enrichment_chamber":1,"minecraft:torch":58,"silentgems:garnet_ore":1,"minecraft:chest":25,"minecraft:spruce_wall_sign":1,"herbsandharvest:green_bean_crop":4,"minecraft:cobblestone":2,"productivebees:dark_oak_wood_nest":11,"pamhc2crops:shadedgarden":7,"minecraft:oak_planks":223,"minecraft:sugar_cane":1,"alltheores:deepslate_nickel_ore":8,"minecraft:wall_torch":25,"biomeswevegone:zelkova_log":1,"minecraft:deepslate_copper_ore":10,"herbsandharvest:zucchini_crop":6,"biomeswevegone:kovan_flower":1,"minecraft:dark_oak_slab":32,"minecraft:dark_oak_door":1,"minecraft:oak_log":207,"minecraft:dark_oak_sapling":4,"minecraft:sand":369,"minecraft:dirt":253,"powah:magmator_starter":1,"herbsandharvest:turnip_crop":14,"mekanism:basic_universal_cable":17,"minecraft:deepslate":168,"minecraft:oak_leaves":39,"mysticalagriculture:deepslate_inferium_ore":7,"minecraft:tuff":14,"ars_elemental:yellow_archwood_leaves":2,"trashcans:item_trash_can":1,"farmersdelight:onions":6,"minecraft:dark_oak_leaves":29,"minecraft:deepslate_diamond_ore":5,"sophisticatedstorage:iron_chest":12,"minecraft:dark_oak_log":684,"xycraft_world:xychorium_ore_stone_blue":4,"minecraft:ladder":10,"minecraft:spruce_fence":22,"alltheores:deepslate_silver_ore":1,"minecraft:spruce_leaves":118,"minecraft:moss_block":1,"xycraft_world:xychorium_ore_deepslate_light":1,"minecraft:large_fern":45,"herbsandharvest:green_pepper_crop":4,"minecraft:furnace":4,"minecraft:carrots":59,"minecraft:andesite":98,"minecraft:potatoes":1,"minecraft:deepslate_iron_ore":14,"alltheores:silver_ore":2,"sophisticatedstorage:iron_barrel":2,"minecraft:stone":1583,"ars_nouveau:purple_archwood_log":53,"deeperdarker:ancient_vase":1,"minecraft:crafting_table":9,"actuallyadditions:black_quartz_ore":4,"herbsandharvest:raspberry_crop":4,"minecraft:short_grass":7,"xycraft_world:xychorium_ore_stone_dark":7,"sophisticatedstorage:copper_chest":2,"minecraft:copper_ore":28,"minecraft:deepslate_coal_ore":13,"theurgy:sal_ammoniac_ore":8,"minecraft:lapis_ore":8,"mekanism:metallurgic_infuser":2,"ars_elemental:yellow_archwood_log":26,"actuallyadditions:flax":1,"minecraft:diorite":41,"supplementaries:urn":2,"ars_nouveau:purple_archwood_leaves":2,"modern_industrialization:antimony_ore":2,"minecraft:spruce_log":446,"minecraft:coal_ore":147,"minecraft:gravel":107,"minecraft:spruce_fence_gate":1,"minecraft:stone_bricks":68,"minecraft:gold_ore":8,"minecraft:fern":8,"herbsandharvest:lime_fruit_sapling":1,"minecraft:dark_oak_planks":1},"minecraft:picked_up":{"minecolonies:supplycampdeployer":1,"livingthings:crab":6,"herbsandharvest:cauliflower_seeds":30,"sophisticatedstorageLine truncated
+227
View File
@@ -0,0 +1,227 @@
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');
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { buildApp } from '../../src/app.ts';
import { loadConfig } from '../../src/config.ts';
import { createIngestToken } from '../../src/ingest/tokens.ts';
import { TEST_SECRET } from './sign.ts';
export function testConfig() {
return loadConfig({
NODE_ENV: 'test',
DATABASE_URL: process.env.TEST_DATABASE_URL,
INGEST_HMAC_SECRET: TEST_SECRET,
LOG_LEVEL: 'silent',
});
}
export async function makeApp(pool: Pool): Promise<{ app: FastifyInstance; token: string }> {
const app = await buildApp({ pool, config: testConfig() });
const token = await createIngestToken(pool);
return { app, token };
}
+26
View File
@@ -0,0 +1,26 @@
import type { Pool } from 'pg';
import { createPool } from '../../src/db/pool.ts';
import { migrate } from '../../src/db/migrate.ts';
let pool: Pool | undefined;
export async function getTestPool(): Promise<Pool> {
if (!pool) {
pool = createPool(process.env.TEST_DATABASE_URL!);
await migrate(pool);
}
return pool;
}
export async function resetDb(p: Pool): Promise<void> {
const { rows } = await p.query<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'schema_migrations'`,
);
if (rows.length === 0) return;
await p.query(`TRUNCATE ${rows.map((r) => `"${r.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
}
export async function closeTestPool(): Promise<void> {
await pool?.end();
pool = undefined;
}
+16
View File
@@ -0,0 +1,16 @@
import { randomUUID } from 'node:crypto';
import type { AtmEvent, EventOf, EventType } from '@atm/shared';
export const ALICE = { uuid: '308a693c-2bd8-4d6b-8790-670aef5be601', name: 'CDOGamer' };
export const BOB = { uuid: 'c25d9ff0-dba3-48f0-b414-fad2b63473a9', name: 'Bob' };
type Payload<T extends EventType> = Omit<EventOf<T>, 'id' | 'type' | 'ts'> & { ts?: number; id?: string };
/** Test factory: ev('player_join', { ...ALICE, ts }) */
export function ev<T extends EventType>(type: T, payload: Payload<T>): AtmEvent {
const { ts, id, ...rest } = payload as Payload<T>;
return { id: id ?? randomUUID(), type, ts: ts ?? Date.now(), ...rest } as unknown as AtmEvent;
}
export const T0 = Date.UTC(2026, 9, 12, 9, 0, 0);
export const MIN = 60_000;
+34
View File
@@ -0,0 +1,34 @@
import { createHash, createHmac } from 'node:crypto';
import { gzipSync } from 'node:zlib';
export const TEST_SECRET = 'test-hmac-secret-0123456789abcdef0123';
export function signature(secret: string, ts: number, body: Buffer): string {
const bodyHash = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
}
export interface SignOpts {
token: string;
secret?: string;
ts?: number;
gzip?: boolean;
serverId?: string;
}
/** Builds headers + payload exactly as the mod sends them: signature covers the transmitted (gzipped) bytes. */
export function signedRequest(body: unknown, opts: SignOpts): { headers: Record<string, string>; payload: Buffer } {
const raw = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
const gzip = opts.gzip ?? true;
const payload = gzip ? gzipSync(raw) : raw;
const ts = opts.ts ?? Date.now();
const headers: Record<string, string> = {
authorization: `Bearer ${opts.token}`,
'content-type': 'application/json',
'x-atm-server': opts.serverId ?? 'atm10',
'x-atm-timestamp': String(ts),
'x-atm-signature': signature(opts.secret ?? TEST_SECRET, ts, payload),
};
if (gzip) headers['content-encoding'] = 'gzip';
return { headers, payload };
}
+128
View File
@@ -0,0 +1,128 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { makeApp } from './helpers/app.ts';
import { ALICE, ev } from './helpers/events.ts';
import { signature, signedRequest, TEST_SECRET } from './helpers/sign.ts';
let pool: Pool;
let app: FastifyInstance;
let token: string;
beforeEach(async () => {
await app?.close();
pool = await getTestPool();
await resetDb(pool);
({ app, token } = await makeApp(pool));
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
function post(body: unknown, opts: Partial<Parameters<typeof signedRequest>[1]> = {}) {
const { headers, payload } = signedRequest(body, { token, ...opts });
return app.inject({ method: 'POST', url: '/api/ingest', headers, payload });
}
const batch = () => ({
protocol: 1,
events: [ev('player_join', { ...ALICE }), ev('player_leave', { ...ALICE })],
});
describe('POST /api/ingest', () => {
it('accepts valid batch', async () => {
const b = batch();
const res = await post(b);
expect(res.statusCode).toBe(200);
expect(res.json().accepted.sort()).toEqual(b.events.map((e) => e.id).sort());
const { rows } = await pool.query(`SELECT source FROM events`);
expect(rows).toHaveLength(2);
expect(rows.every((r) => r.source === 'push')).toBe(true);
});
it('same batch twice is idempotent', async () => {
const b = batch();
await post(b);
const res = await post(b);
expect(res.statusCode).toBe(200);
expect(res.json().accepted).toEqual([]);
expect(res.json().duplicate.sort()).toEqual(b.events.map((e) => e.id).sort());
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM events`);
expect(rows[0].n).toBe(2);
});
it('rejects wrong token', async () => {
const res = await post(batch(), { token: 'nope' });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'bad_token' });
});
it('rejects missing auth', async () => {
const res = await app.inject({ method: 'POST', url: '/api/ingest', payload: batch() });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'missing_auth' });
});
it('rejects bad signature', async () => {
const res = await post(batch(), { secret: 'another-secret-another-secret-123' });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'bad_signature' });
});
it('rejects timestamp 6 minutes old', async () => {
const res = await post(batch(), { ts: Date.now() - 6 * 60_000 });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: 'timestamp_out_of_range' });
});
it('rejects invalid event but accepts the rest', async () => {
const good = ev('player_join', { ...ALICE });
const bad = { id: '0199d6a0-0000-7000-8000-0000000000ff', type: 'player_join', ts: Date.now(), name: 'x' };
const res = await post({ protocol: 1, events: [good, bad] });
expect(res.statusCode).toBe(200);
expect(res.json().accepted).toEqual([good.id]);
expect(res.json().rejected).toHaveLength(1);
expect(res.json().rejected[0].id).toBe(bad.id);
expect(res.json().rejected[0].reason).toMatch(/uuid/);
});
it('rejects body over 2 MB unpacked', async () => {
const huge = JSON.stringify({ protocol: 1, events: [ev('server_stop', {})], pad: 'x'.repeat(2_200_000) });
const res = await post(huge);
expect(res.statusCode).toBe(413);
});
it('accepts uncompressed body too', async () => {
const res = await post(batch(), { gzip: false });
expect(res.statusCode).toBe(200);
});
});
describe('GET /api/ingest/ping', () => {
it('answers with server time for a valid signature', async () => {
const ts = Date.now();
const res = await app.inject({
method: 'GET',
url: '/api/ingest/ping',
headers: {
authorization: `Bearer ${token}`,
'x-atm-timestamp': String(ts),
'x-atm-signature': signature(TEST_SECRET, ts, Buffer.alloc(0)),
},
});
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
expect(typeof res.json().serverTime).toBe('number');
});
});
describe('signature vector (shared with the mod)', () => {
it('matches the constant used in SignerTest.java', () => {
expect(signature('test-secret', 1760000000000, Buffer.from('hello'))).toBe(
'c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043',
);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { getLeaderboard, getPlayerRanks } from '../src/stats/leaderboards.ts';
import { BOARDS } from '../src/stats/metrics.ts';
import { takeSnapshots } from '../src/stats/snapshots.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, BOB, ev, T0 } from './helpers/events.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
afterAll(closeTestPool);
const DAY = 86_400_000;
async function stats(who: { uuid: string; name: string }, stats: Record<string, Record<string, number>>, ts = T0) {
await processEvents(pool, [ev('stats_snapshot', { uuid: who.uuid, name: who.name, full: false, ts, stats })], 'push', 'atm10');
}
describe('boards', () => {
it('defines the 14 boards from the spec', () => {
expect(BOARDS.map((b) => b.id)).toEqual([
'playtime', 'deaths', 'mobkills', 'mined', 'distance', 'flight', 'diamonds',
'tech', 'magic', 'farming', 'quests', 'advancements', 'loot', 'worldtier',
]);
});
it('playtime converts ticks to hours', async () => {
await stats(ALICE, { 'minecraft:custom': { 'minecraft:play_time': 1175736 } });
const [top] = await getLeaderboard(pool, 'playtime', 'all');
expect(top).toMatchObject({ rank: 1, uuid: ALICE.uuid, name: ALICE.name });
expect(top!.value).toBeCloseTo(16.33, 2);
});
it('distance sums all *_one_cm keys except falling, in km', async () => {
await stats(ALICE, {
'minecraft:custom': {
'minecraft:walk_one_cm': 150_000,
'minecraft:fly_one_cm': 50_000,
'minecraft:fall_one_cm': 999_999,
'minecraft:jump': 5,
},
});
const [top] = await getLeaderboard(pool, 'distance', 'all');
expect(top!.value).toBe(2);
});
it('tech sums crafted+used of tech namespaces only', async () => {
await stats(ALICE, {
'minecraft:crafted': { 'mekanism:steel_casing': 3, 'minecraft:stick': 100, 'ars_nouveau:source_gem': 9 },
'minecraft:used': { 'powah:energy_cell_basic': 2 },
'minecraft:mined': { 'mekanism:osmium_ore': 50 },
});
const [top] = await getLeaderboard(pool, 'tech', 'all');
expect(top!.value).toBe(5);
});
it('hidden player excluded', async () => {
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3 } });
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
const rows = await getLeaderboard(pool, 'deaths', 'all');
expect(rows.map((r) => r.uuid)).toEqual([ALICE.uuid]);
});
it('ties share rank, ordered by name; zero values omitted', async () => {
const carl = { uuid: randomUUID(), name: 'Carl' };
await stats(BOB, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
await stats(ALICE, { 'minecraft:custom': { 'minecraft:mob_kills': 7 } });
await stats(carl, { 'minecraft:custom': { 'minecraft:mob_kills': 0 } });
const rows = await getLeaderboard(pool, 'mobkills', 'all');
expect(rows.map((r) => [r.rank, r.name])).toEqual([
[1, 'Bob'],
[1, 'CDOGamer'],
]);
});
it('7d period subtracts the daily snapshot at the period start', async () => {
const now = new Date(T0 + 10 * DAY);
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 4 } }, T0);
await takeSnapshots(pool, 'day', new Date(T0 + 2 * DAY));
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 10 } }, T0 + 9 * DAY);
const [top] = await getLeaderboard(pool, 'deaths', '7d', 10, now);
expect(top!.value).toBe(6);
});
it('quests uses team progress and counts only known quests when defs exist', async () => {
await stats(ALICE, { 'minecraft:custom': { 'minecraft:jump': 1 } });
await pool.query(
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES ($1, 'AAAAAAAAAAAAAAAA', now()), ($1, 'BBBBBBBBBBBBBBBB', now())`,
[ALICE.uuid],
);
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(2);
await pool.query(`INSERT INTO quest_defs(quest_id, chapter_id) VALUES ('AAAAAAAAAAAAAAAA', 'C')`);
expect((await getLeaderboard(pool, 'quests', 'all'))[0]!.value).toBe(1);
});
it('player ranks cover every board the player appears on', async () => {
await stats(ALICE, { 'minecraft:custom': { 'minecraft:deaths': 3, 'minecraft:play_time': 72000 } });
await stats(BOB, { 'minecraft:custom': { 'minecraft:deaths': 9 } });
const ranks = await getPlayerRanks(pool, ALICE.uuid);
expect(ranks.deaths).toEqual({ rank: 2, value: 3 });
expect(ranks.playtime).toEqual({ rank: 1, value: 1 });
expect(ranks.magic).toBeUndefined();
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { processEvents } from '../src/pipeline/process.ts';
import { makeApp } from './helpers/app.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, BOB, ev } from './helpers/events.ts';
let pool: Pool;
let app: FastifyInstance;
let base: string;
beforeEach(async () => {
await app?.close();
pool = await getTestPool();
await resetDb(pool);
({ app } = await makeApp(pool));
await app.listen({ port: 0, host: '127.0.0.1' });
const addr = app.server.address();
base = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`;
await app.liveHub.ready();
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
interface Sse {
id: number;
event: string;
data: Record<string, unknown>;
}
/** Opens /api/live and collects parsed SSE messages until `until` returns true. */
async function collect(until: (msgs: Sse[]) => boolean, headers: Record<string, string> = {}, trigger?: () => Promise<void>) {
const ctrl = new AbortController();
const res = await fetch(`${base}/api/live`, { headers, signal: ctrl.signal });
expect(res.headers.get('content-type')).toContain('text/event-stream');
const reader = res.body!.getReader();
const msgs: Sse[] = [];
let buf = '';
const timeout = setTimeout(() => ctrl.abort(), 8000);
let triggered = false;
try {
for (;;) {
if (!triggered && trigger) {
triggered = true;
// wait for the stream to be registered before triggering
const first = await reader.read();
buf += new TextDecoder().decode(first.value);
await trigger();
}
let idx: number;
while ((idx = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const fields = Object.fromEntries(
block
.split('\n')
.filter((l) => !l.startsWith(':'))
.map((l) => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).trim()]),
);
if (fields.event) msgs.push({ id: Number(fields.id), event: fields.event, data: JSON.parse(fields.data ?? '{}') });
}
if (until(msgs)) return msgs;
const { value, done } = await reader.read();
if (done) return msgs;
buf += new TextDecoder().decode(value);
}
} finally {
clearTimeout(timeout);
ctrl.abort();
}
}
const death = (who: typeof ALICE) =>
ev('player_death', { ...who, dimension: 'minecraft:overworld', cause: 'minecraft:fall', killerType: null, killerName: null });
describe('GET /api/live', () => {
it('delivers death event after ingest', async () => {
const msgs = await collect(
(m) => m.some((x) => x.event === 'death'),
{},
async () => {
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
},
);
const d = msgs.find((m) => m.event === 'death')!;
expect(d.data).toMatchObject({ uuid: ALICE.uuid, name: ALICE.name, cause: 'minecraft:fall' });
});
it('resumes from Last-Event-ID', async () => {
await processEvents(pool, [ev('player_join', { ...ALICE })], 'push', 'atm10');
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
await new Promise((r) => setTimeout(r, 300));
const all = app.liveHub.since(0);
expect(all.map((e) => e.kind)).toEqual(['online', 'death']);
const msgs = await collect((m) => m.length >= 1, { 'last-event-id': String(all[0]!.id) });
expect(msgs.map((m) => m.event)).toEqual(['death']);
});
it('hidden player not broadcast', async () => {
await processEvents(pool, [ev('player_join', { ...BOB })], 'push', 'atm10');
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
const msgs = await collect(
(m) => m.some((x) => x.event === 'death'),
{ 'last-event-id': String(app.liveHub.lastId()) },
async () => {
await processEvents(pool, [death(BOB)], 'push', 'atm10');
await processEvents(pool, [death(ALICE)], 'push', 'atm10');
},
);
const deaths = msgs.filter((m) => m.event === 'death');
expect(deaths.map((d) => d.data.uuid)).toEqual([ALICE.uuid]);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseLogChunk, parseLogTimestamp } from '../src/parsers/log.ts';
const log = readFileSync(join(import.meta.dirname, 'fixtures', 'latest.anon.log'));
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
const TZ = 'Europe/Berlin';
describe('parseLogTimestamp', () => {
it('interprets log time in the server time zone', () => {
// 12 Oct 2026 is CEST (UTC+2)
expect(parseLogTimestamp('12Oct2026 09:03:03.006', TZ)).toBe(Date.parse('2026-10-12T07:03:03.006Z'));
// 1 Dec 2026 is CET (UTC+1)
expect(parseLogTimestamp('01Dec2026 00:00:00.000', TZ)).toBe(Date.parse('2026-11-30T23:00:00.000Z'));
});
});
describe('parseLogChunk', () => {
it('finds join of CDOGamer with uuid 308a…', () => {
const { events } = parseLogChunk(log, 0, new Map(), TZ);
const join = events.find((e) => e.type === 'player_join');
expect(join).toMatchObject({ type: 'player_join', uuid: ALICE, name: 'CDOGamer' });
expect(join!.ts).toBe(parseLogTimestamp('12Oct2026 09:03:03.006', TZ));
expect(events.filter((e) => e.type === 'player_leave').length).toBeGreaterThan(0);
});
it('finds advancement Infusing Efficiency and goals', () => {
const { events } = parseLogChunk(log, 0, new Map(), TZ);
const titles = events.flatMap((e) => (e.type === 'advancement' ? [e.title] : []));
expect(titles).toContain('Infusing Efficiency');
expect(titles).toContain('The Factory Must Grow!');
const adv = events.find((e) => e.type === 'advancement')!;
expect(adv).toMatchObject({ uuid: ALICE, advancementId: 'log:Infusing Efficiency' });
});
it('resolves names known from earlier chunks', () => {
const line = Buffer.from('[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\r\n');
const known = new Map([['Bob', 'c25d9ff0-dba3-48f0-b414-fad2b63473a9']]);
expect(parseLogChunk(line, 0, known, TZ).events[0]).toMatchObject({ type: 'player_leave', name: 'Bob' });
expect(parseLogChunk(line, 0, new Map(), TZ).events).toEqual([]);
});
it('ignores chat lines that imitate system messages', () => {
const line = Buffer.from(
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <CDOGamer> Bob joined the game\n',
);
expect(parseLogChunk(line, 0, new Map([['Bob', ALICE]]), TZ).events).toEqual([]);
});
it('ignores incomplete last line', () => {
const text = Buffer.from(
'[12Oct2026 10:00:00.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob left the game\n' +
'[12Oct2026 10:00:01.000] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Bob joined the ga',
);
const r = parseLogChunk(text, 100, new Map([['Bob', ALICE]]), TZ);
expect(r.events).toHaveLength(1);
expect(r.consumed).toBe(text.indexOf('\n') + 1);
});
it('same chunk twice yields same ids; ids depend on absolute offset', () => {
const a = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
const b = parseLogChunk(log, 0, new Map(), TZ).events.map((e) => e.id);
const c = parseLogChunk(log, 10, new Map(), TZ).events.map((e) => e.id);
expect(a).toEqual(b);
expect(a[0]).not.toBe(c[0]);
});
});
+172
View File
@@ -0,0 +1,172 @@
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
afterAll(closeTestPool);
const push = (...events: Parameters<typeof processEvents>[1]) => processEvents(pool, events, 'push', 'atm10');
const ms = (d: Date) => d.getTime();
describe('sessions', () => {
it('join then leave creates closed session with duration', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_leave', { ...ALICE, ts: T0 + 30 * MIN }));
const { rows } = await pool.query('SELECT started_at, ended_at, end_reason FROM sessions');
expect(rows).toHaveLength(1);
expect(ms(rows[0].ended_at) - ms(rows[0].started_at)).toBe(30 * MIN);
expect(rows[0].end_reason).toBe('leave');
});
it('closes dangling sessions on server_start', async () => {
await push(
ev('player_join', { ...ALICE, ts: T0 }),
ev('health', {
ts: T0 + 10 * MIN,
windowStart: T0 + 9 * MIN,
windowEnd: T0 + 10 * MIN,
msptAvg: 30,
msptP95: 40,
msptMax: 50,
tps: 20,
players: 1,
chunks: 100,
heapUsedMb: 1000,
heapMaxMb: 4000,
}),
);
await push(
ev('server_start', { ts: T0 + 60 * MIN, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21', mods: [] }),
);
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
expect(ms(rows[0].ended_at)).toBe(T0 + 10 * MIN);
expect(rows[0].end_reason).toBe('crash');
const info = await pool.query('SELECT mc_version FROM server_info');
expect(info.rows[0].mc_version).toBe('1.21.1');
});
it('server_stop closes open sessions', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('server_stop', { ts: T0 + 5 * MIN }));
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
expect(ms(rows[0].ended_at)).toBe(T0 + 5 * MIN);
expect(rows[0].end_reason).toBe('stop');
});
it('second join closes the previous open session as superseded', async () => {
await push(ev('player_join', { ...ALICE, ts: T0 }), ev('player_join', { ...ALICE, ts: T0 + 5 * MIN }));
const { rows } = await pool.query('SELECT end_reason FROM sessions ORDER BY started_at');
expect(rows.map((r) => r.end_reason)).toEqual(['superseded', null]);
});
});
describe('players', () => {
it('rename updates name and history', async () => {
await push(ev('player_join', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + MIN }));
const p = await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid]);
expect(p.rows[0].name).toBe('NewName');
const h = await pool.query('SELECT name FROM name_history WHERE uuid = $1 ORDER BY seen_at', [ALICE.uuid]);
expect(h.rows.map((r) => r.name)).toEqual(['OldName', 'NewName']);
});
});
describe('stats', () => {
const snap = (ts: number, value: number, source: 'push' | 'sftp' = 'push') =>
processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: ALICE.name, full: false, ts, stats: { 'minecraft:custom': { 'minecraft:deaths': value } } })],
source,
'atm10',
);
const deaths = async () =>
(await pool.query(`SELECT value FROM stat_current WHERE key = 'minecraft:deaths'`)).rows[0]?.value;
it('newer observation wins even if smaller', async () => {
await snap(T0 + 2 * MIN, 5);
await snap(T0 + 1 * MIN, 9, 'sftp');
expect(await deaths()).toBe(5);
await snap(T0 + 3 * MIN, 3);
expect(await deaths()).toBe(3);
});
it('full snapshot does not zero missing keys', async () => {
await processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 7 } } })],
'push',
'atm10',
);
await processEvents(
pool,
[ev('stats_snapshot', { uuid: ALICE.uuid, name: null, full: true, ts: T0 + MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 1 } } })],
'sftp',
'atm10',
);
const { rows } = await pool.query(`SELECT key, value FROM stat_current ORDER BY key`);
expect(rows).toEqual([
{ key: 'minecraft:deaths', value: 1 },
{ key: 'minecraft:jump', value: 7 },
]);
});
it('creates unknown player without name and fills the name later', async () => {
await processEvents(
pool,
[ev('stats_snapshot', { uuid: BOB.uuid, name: null, full: true, ts: T0, stats: { 'minecraft:custom': { 'minecraft:jump': 1 } } })],
'sftp',
'atm10',
);
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBeNull();
await push(ev('player_join', { ...BOB, ts: T0 + MIN }));
expect((await pool.query('SELECT name FROM players')).rows[0].name).toBe('Bob');
});
});
describe('advancements, combat, dimensions, health', () => {
it('advancement keeps earliest timestamp', async () => {
await push(ev('advancement', { ...ALICE, ts: T0 + MIN, advancementId: 'a:b', title: null }));
await push(ev('advancement', { ...ALICE, ts: T0, advancementId: 'a:b', title: 'Title' }));
const { rows } = await pool.query('SELECT achieved_at, title FROM advancements');
expect(ms(rows[0].achieved_at)).toBe(T0);
expect(rows[0].title).toBe('Title');
});
it('death and boss kill are stored', async () => {
await push(
ev('player_death', { ...ALICE, ts: T0, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
ev('entity_kill', { ...ALICE, ts: T0, dimension: 'minecraft:the_end', victimType: 'minecraft:ender_dragon', isBoss: true, weapon: null }),
);
expect((await pool.query('SELECT cause FROM deaths')).rows[0].cause).toBe('minecraft:lava');
expect((await pool.query('SELECT is_boss FROM kills')).rows[0].is_boss).toBe(true);
});
it('dimension visits keep first visit', async () => {
await push(ev('dimension_change', { ...ALICE, ts: T0, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
await push(ev('dimension_change', { ...ALICE, ts: T0 + MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }));
const { rows } = await pool.query('SELECT dimension, first_at FROM dimension_visits ORDER BY dimension');
expect(rows.map((r) => r.dimension)).toEqual(['minecraft:overworld', 'minecraft:the_nether']);
expect(ms(rows[1].first_at)).toBe(T0);
});
it('health stored per minute bucket', async () => {
const h = { msptAvg: 30, msptP95: 40, msptMax: 50, tps: 20, players: 1, chunks: 100, heapUsedMb: 1000, heapMaxMb: 4000 };
await push(ev('health', { ...h, ts: T0 + MIN, windowStart: T0 + 5_000, windowEnd: T0 + MIN }));
const { rows } = await pool.query('SELECT bucket, tps FROM server_health');
expect(ms(rows[0].bucket)).toBe(T0);
expect(rows[0].tps).toBe(20);
});
});
describe('review fixes', () => {
it('a late event with an older name does not revert a rename', async () => {
await push(ev('player_join', { uuid: ALICE.uuid, name: 'NewName', ts: T0 + 10 * MIN }));
await push(ev('player_leave', { uuid: ALICE.uuid, name: 'OldName', ts: T0 }));
expect((await pool.query('SELECT name FROM players WHERE uuid = $1', [ALICE.uuid])).rows[0].name).toBe('NewName');
});
});
+182
View File
@@ -0,0 +1,182 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { processEvents } from '../src/pipeline/process.ts';
import { makeApp } from './helpers/app.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, BOB, ev, MIN, T0 } from './helpers/events.ts';
let pool: Pool;
let app: FastifyInstance;
const DAY = 86_400_000;
beforeEach(async () => {
await app?.close();
pool = await getTestPool();
await resetDb(pool);
({ app } = await makeApp(pool));
const now = Date.now();
await processEvents(
pool,
[
ev('player_join', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY }),
ev('player_leave', { uuid: ALICE.uuid, name: 'OldAlice', ts: now - 3 * DAY + 30 * MIN }),
ev('player_join', { ...ALICE, ts: now - 10 * MIN }),
ev('dimension_change', { ...ALICE, ts: now - 5 * MIN, from: 'minecraft:overworld', to: 'minecraft:the_nether' }),
ev('player_join', { ...BOB, ts: now - 2 * MIN }),
ev('stats_snapshot', {
...ALICE,
full: false,
ts: now - MIN,
stats: {
'minecraft:custom': { 'minecraft:play_time': 144000, 'minecraft:deaths': 2, 'minecraft:mob_kills': 5, 'minecraft:walk_one_cm': 1000 },
'minecraft:mined': { 'mekanism:osmium_ore': 30, 'mekanism:steel_casing': 1, 'minecraft:stone': 99 },
'minecraft:killed': { 'minecraft:zombie': 4 },
},
}),
ev('stats_snapshot', { ...BOB, full: false, ts: now - MIN, stats: { 'minecraft:custom': { 'minecraft:deaths': 7 } } }),
ev('player_death', { ...ALICE, ts: now - 4 * MIN, dimension: 'minecraft:the_nether', cause: 'minecraft:lava', killerType: null, killerName: null }),
ev('advancement', { ...ALICE, ts: now - 3 * MIN, advancementId: 'minecraft:story/enter_the_nether', title: 'We Need to Go Deeper' }),
],
'push',
'atm10',
);
});
afterAll(async () => {
await app?.close();
await closeTestPool();
});
const get = async (url: string) => {
const res = await app.inject({ method: 'GET', url });
return { status: res.statusCode, body: res.statusCode < 500 ? res.json() : res.body, headers: res.headers };
};
describe('public api', () => {
it('overview counts', async () => {
const { status, body, headers } = await get('/api/overview');
expect(status).toBe(200);
expect(body).toMatchObject({ online: 2, totalPlayers: 2, totalPlayHours: 2, totalDeaths: 9, totalMobKills: 5, advancements: 1 });
expect(headers['cache-control']).toBe('public, max-age=15');
});
it('online lists open sessions only, with current dimension', async () => {
const { body } = await get('/api/online');
expect(body.players.map((p: { name: string }) => p.name).sort()).toEqual(['Bob', 'CDOGamer']);
expect(body.players.find((p: { name: string }) => p.name === 'CDOGamer').dimension).toBe('minecraft:the_nether');
});
it('profile by uuid, current name and old name', async () => {
for (const key of [ALICE.uuid, 'cdogamer', 'OldAlice']) {
const { status, body } = await get(`/api/players/${key}`);
expect(status).toBe(200);
expect(body.uuid).toBe(ALICE.uuid);
}
const { body } = await get(`/api/players/${ALICE.uuid}`);
expect(body).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2, sessions: 2 });
expect(body.movement).toEqual({ 'minecraft:walk_one_cm': 1000 });
expect(body.general['minecraft:deaths']).toBe(2);
expect(body.deathsByCause).toEqual([{ cause: 'minecraft:lava', count: 1 }]);
expect(body.killsByType).toEqual([{ entity: 'minecraft:zombie', count: 4 }]);
expect(body.advancements[0].title).toBe('We Need to Go Deeper');
expect(body.ranks.deaths).toEqual({ rank: 2, value: 2 });
expect(body.activityHeatmap).toHaveLength(7);
expect(body.activityHeatmap[0]).toHaveLength(24);
expect(body.activityHeatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBeGreaterThanOrEqual(39);
});
it('unknown player is 404', async () => {
expect((await get('/api/players/Nobody')).status).toBe(404);
});
it('hidden player is 404 and absent from leaderboards, online and overview', async () => {
await pool.query('UPDATE players SET hidden = true WHERE uuid = $1', [BOB.uuid]);
expect((await get(`/api/players/${BOB.uuid}`)).status).toBe(404);
expect((await get('/api/online')).body.players.map((p: { uuid: string }) => p.uuid)).toEqual([ALICE.uuid]);
const lb = (await get('/api/leaderboards/deaths')).body;
expect(lb.entries.map((e: { uuid: string }) => e.uuid)).toEqual([ALICE.uuid]);
expect((await get('/api/overview')).body).toMatchObject({ online: 1, totalPlayers: 1, totalDeaths: 2 });
expect((await get('/api/players?search=bo')).body.players).toEqual([]);
});
it('leaderboards list has every board with top entries', async () => {
const { body } = await get('/api/leaderboards');
expect(body.boards).toHaveLength(14);
expect(body.boards.find((b: { id: string }) => b.id === 'deaths').top[0].name).toBe('Bob');
expect((await get('/api/leaderboards/nope')).status).toBe(404);
expect((await get('/api/leaderboards/deaths?period=1y')).status).toBe(400);
});
it('player search and paging', async () => {
const { body } = await get('/api/players?search=cdo');
expect(body.total).toBe(1);
expect(body.players[0]).toMatchObject({ name: 'CDOGamer', online: true, playHours: 2 });
});
it('items grouped by namespace', async () => {
const { body } = await get(`/api/players/${ALICE.uuid}/items?category=mined`);
expect(body.mods.map((m: { namespace: string; total: number }) => [m.namespace, m.total])).toEqual([
['minecraft', 99],
['mekanism', 31],
]);
expect((await get(`/api/players/${ALICE.uuid}/items?category=bogus`)).status).toBe(400);
});
it('history returns daily points and the current value', async () => {
await pool.query(
`INSERT INTO stat_snapshots(uuid, metric, bucket, granularity, value) VALUES ($1, 'deaths', date_trunc('day', now() - interval '2 days'), 'day', 1)`,
[ALICE.uuid],
);
const { body } = await get(`/api/players/${ALICE.uuid}/history?metric=deaths&range=30d`);
expect(body.unit).toBe('count');
expect(body.points.map((p: { v: number }) => p.v)).toEqual([1, 2]);
});
it('server and mods expose versions from server_start', async () => {
await processEvents(
pool,
[ev('server_start', { ts: T0, mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.5', mods: [{ id: 'mekanism', version: '10.7.19', name: 'Mekanism' }] })],
'push',
'atm10',
);
const mods = (await get('/api/mods')).body;
expect(mods.server).toMatchObject({ mcVersion: '1.21.1', neoforgeVersion: '21.1.219', modCount: 1 });
expect(mods.mods[0]).toMatchObject({ id: 'mekanism', activity: 31, category: 'tech' });
expect((await get('/api/server?range=24h')).body.info.javaVersion).toBe('21.0.5');
});
it('quests and sources respond', async () => {
expect((await get('/api/quests')).body).toMatchObject({ defsKnown: false, chapters: [] });
const src = (await get('/api/sources')).body;
expect(src.push.reachable).toBe(true);
expect(src.sftp.enabled).toBe(false);
expect(src.limitations.length).toBeGreaterThan(3);
});
it('avatar rejects invalid uuid with 400', async () => {
expect((await get('/api/avatar/not-a-uuid')).status).toBe(400);
});
});
describe('quest titles in the profile', () => {
it('lists recently completed quests with titles, newest first, only known quests', async () => {
await pool.query(
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title) VALUES
('AAAAAAAAAAAAAAAA', 'C1', 'Getting Started', 'Punch a Tree'),
('BBBBBBBBBBBBBBBB', 'C1', 'Getting Started', NULL),
('CCCCCCCCCCCCCCCC', 'C1', 'Getting Started', 'Craft a Table')`,
);
await pool.query(
`INSERT INTO quest_progress(team_id, quest_id, completed_at) VALUES
($1, 'AAAAAAAAAAAAAAAA', now() - interval '2 hours'),
($1, 'BBBBBBBBBBBBBBBB', now() - interval '1 hour'),
($1, 'CCCCCCCCCCCCCCCC', now() - interval '30 minutes'),
($1, 'DDDDDDDDDDDDDDDD', now())`,
[ALICE.uuid],
);
const { body } = await get(`/api/players/${ALICE.uuid}`);
expect(body.quests.completed).toBe(3);
expect(body.quests.recent.map((q: { title: string }) => q.title)).toEqual(['Craft a Table', 'Punch a Tree']);
expect(body.quests.recent[0]).toMatchObject({ chapterTitle: 'Getting Started' });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { runJob } from '../src/worker/scheduler.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
});
afterAll(closeTestPool);
describe('runJob', () => {
it('never runs the same job twice concurrently', async () => {
let running = 0;
let maxRunning = 0;
const job = async () => {
running++;
maxRunning = Math.max(maxRunning, running);
await new Promise((r) => setTimeout(r, 200));
running--;
};
const results = await Promise.all([runJob(pool, 'x', 900_001, job), runJob(pool, 'x', 900_001, job)]);
expect(maxRunning).toBe(1);
expect(results.sort()).toEqual(['locked', 'ok']);
});
it('records failures in app_errors', async () => {
const r = await runJob(pool, 'sftp', 900_002, async () => {
throw new Error('connection refused');
});
expect(r).toBe('error');
const { rows } = await pool.query('SELECT source, message FROM app_errors');
expect(rows).toEqual([{ source: 'sftp', message: 'connection refused' }]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { applyRetention, takeSnapshots } from '../src/stats/snapshots.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, ev, T0 } from './helpers/events.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
await processEvents(
pool,
[ev('stats_snapshot', { ...ALICE, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:play_time': 72000 } } })],
'push',
'atm10',
);
});
afterAll(closeTestPool);
const DAY = 86_400_000;
const count = async (granularity: string) =>
(await pool.query(`SELECT count(*)::int AS n FROM stat_snapshots WHERE granularity = $1`, [granularity])).rows[0].n;
describe('snapshots', () => {
it('takeSnapshots idempotent', async () => {
await takeSnapshots(pool, 'hour', new Date(T0 + 1000));
const first = await count('hour');
await takeSnapshots(pool, 'hour', new Date(T0 + 2000));
expect(await count('hour')).toBe(first);
const { rows } = await pool.query(`SELECT value FROM stat_snapshots WHERE metric = 'playtime'`);
expect(rows[0].value).toBe(72000);
});
it('retention deletes hourly older than 30 days but keeps daily', async () => {
await takeSnapshots(pool, 'hour', new Date(T0));
await takeSnapshots(pool, 'day', new Date(T0));
await applyRetention(pool, new Date(T0 + 31 * DAY));
expect(await count('hour')).toBe(0);
expect(await count('day')).toBeGreaterThan(0);
});
it('retention folds health minutes older than 14 days into hourly aggregates', async () => {
for (const m of [0, 1, 2]) {
await pool.query(
`INSERT INTO server_health VALUES (to_timestamp($1 / 1000.0), $2, 50, 100, 20, 2, 500, 1000, 4000)`,
[T0 + m * 60_000, 10 * (m + 1)],
);
}
await applyRetention(pool, new Date(T0 + 15 * DAY));
expect((await pool.query('SELECT count(*)::int AS n FROM server_health')).rows[0].n).toBe(0);
const { rows } = await pool.query('SELECT mspt_avg, samples FROM server_health_hourly');
expect(rows).toEqual([{ mspt_avg: 20, samples: 3 }]);
});
});
describe('events retention', () => {
it('drops bulky projected events after 30 days but keeps player events', async () => {
const old = T0 - 40 * DAY;
await processEvents(
pool,
[
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: old, windowEnd: old, ts: old }),
ev('player_join', { ...ALICE, ts: old }),
],
'push',
'atm10',
);
await pool.query(`UPDATE events SET received_at = to_timestamp($1 / 1000.0)`, [old]);
await applyRetention(pool, new Date(T0));
const { rows } = await pool.query(`SELECT type FROM events ORDER BY type`);
expect(rows.map((r) => r.type)).toEqual(['player_join']);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { parseSnbt, SnbtError } from '../src/parsers/snbt.ts';
describe('parseSnbt', () => {
it('parses newline-separated compound', () => {
expect(parseSnbt('{\n\tversion: 1\n\tlock: false\n\tname: "CDOGamer#308a693c"\n}')).toEqual({
version: 1,
lock: false,
name: 'CDOGamer#308a693c',
});
});
it('parses comma-separated compound', () => {
expect(parseSnbt('{a: 1, b: "x", c: {d: 2b}}')).toEqual({ a: 1, b: 'x', c: { d: 2 } });
});
it('parses suffixes 1L 2b 1.5f 0.0d -3s', () => {
expect(parseSnbt('[1L, 2b, 1.5f, 0.0d, -3s, 1791559740996L]')).toEqual([1, 2, 1.5, 0, -3, 1791559740996]);
});
it('parses keys that start with digits and quoted keys with colons', () => {
expect(
parseSnbt('{ 0000000000000001: 5L\n 308a693c2bd84d6b: { x: 1 }\n "a:B": 2L\n quest.ABC.title: "t" }'),
).toEqual({ '0000000000000001': 5, '308a693c2bd84d6b': { x: 1 }, 'a:B': 2, 'quest.ABC.title': 't' });
});
it('parses typed arrays and empty containers', () => {
expect(parseSnbt('{a: [I; 1, 2], b: [L; 3L], c: [B; 1b], d: [], e: { }}')).toEqual({
a: [1, 2],
b: [3],
c: [1],
d: [],
e: {},
});
});
it('parses string escapes and single quotes', () => {
expect(parseSnbt(`["a\\"b", 'c\\'d', "\\\\"]`)).toEqual(['a"b', "c'd", '\\']);
});
it('keeps unquoted non-numeric words as strings', () => {
expect(parseSnbt('{type: item, id: 3BC0A50886A3222B}')).toEqual({ type: 'item', id: '3BC0A50886A3222B' });
});
it('reports line on error', () => {
try {
parseSnbt('{\n a: 1\n b: {\n}');
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(SnbtError);
expect((e as SnbtError).line).toBe(4);
}
});
});
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { advancementsFileToEvents, statsFileToEvent } from '../src/parsers/vanilla.ts';
const fx = (p: string) => readFileSync(join(import.meta.dirname, 'fixtures', p), 'utf8');
const ALICE = '308a693c-2bd8-4d6b-8790-670aef5be601';
describe('statsFileToEvent', () => {
it('stats file maps play_time', () => {
const e = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1760300000000);
expect(e.type).toBe('stats_snapshot');
if (e.type !== 'stats_snapshot') return;
expect(e.full).toBe(true);
expect(e.ts).toBe(1760300000000);
expect(e.name).toBeNull();
expect(e.stats['minecraft:custom']?.['minecraft:play_time']).toBe(1175736);
expect(e.stats['minecraft:mined']?.['mekanism:enrichment_chamber']).toBe(2);
});
it('id stable for same content', () => {
const a = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
const b = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 1);
const c = statsFileToEvent(ALICE, fx(`stats/${ALICE}.json`), 2);
expect(a.id).toBe(b.id);
expect(a.id).not.toBe(c.id);
});
});
describe('advancementsFileToEvents', () => {
it('emits done, non-recipe advancements with earliest criterion time', () => {
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
const ids = events.map((e) => (e.type === 'advancement' ? e.advancementId : ''));
expect(ids.sort()).toEqual(['minecraft:story/enter_the_nether', 'minecraft:story/root']);
const nether = events.find((e) => e.type === 'advancement' && e.advancementId === 'minecraft:story/enter_the_nether')!;
expect(nether.ts).toBe(Date.parse('2026-10-12T12:42:23+02:00'));
});
});
describe('advancement names', () => {
it('file-derived advancements carry no name so they never overwrite the player name', () => {
const events = advancementsFileToEvents(ALICE, fx(`advancements/${ALICE}.json`));
expect(events.every((e) => e.type === 'advancement' && e.name === null)).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import { appendFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { LocalFileSource } from '../src/worker/fileSource.ts';
import { closeStaleSessions, syncOnce } from '../src/worker/sync.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, ev, MIN } from './helpers/events.ts';
const FX = join(import.meta.dirname, 'fixtures');
const OPTS = { timeZone: 'Europe/Berlin', serverId: 'atm10', worldDir: 'world' };
let pool: Pool;
let root: string;
function makeServerDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'atm-sftp-'));
mkdirSync(join(dir, 'world', 'stats'), { recursive: true });
mkdirSync(join(dir, 'world', 'advancements'), { recursive: true });
mkdirSync(join(dir, 'world', 'ftbquests'), { recursive: true });
mkdirSync(join(dir, 'config', 'ftbquests', 'quests', 'chapters'), { recursive: true });
mkdirSync(join(dir, 'logs'), { recursive: true });
cpSync(join(FX, 'stats'), join(dir, 'world', 'stats'), { recursive: true });
cpSync(join(FX, 'advancements'), join(dir, 'world', 'advancements'), { recursive: true });
cpSync(join(FX, 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'), join(dir, 'world', 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'));
cpSync(join(FX, 'ftbquests', 'config', 'chapters', 'getting_started.snbt'), join(dir, 'config', 'ftbquests', 'quests', 'chapters', 'getting_started.snbt'));
cpSync(join(FX, 'latest.anon.log'), join(dir, 'logs', 'latest.log'));
return dir;
}
const LINE = (t: string, msg: string) => `[12Oct2026 ${t}] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: ${msg}\r\n`;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
root = makeServerDir();
});
afterAll(closeTestPool);
const count = async (sql: string) => (await pool.query(sql)).rows[0].n as number;
describe('syncOnce', () => {
it('first sync imports stats, advancements, quests, quest config and log', async () => {
const r = await syncOnce(pool, new LocalFileSource(root), OPTS);
expect(r.files).toBe(6);
expect(await count(`SELECT count(*)::int AS n FROM stat_current WHERE key = 'minecraft:play_time'`)).toBe(2);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id = 'minecraft:story/root'`)).toBe(1);
expect(await count(`SELECT count(*)::int AS n FROM quest_progress`)).toBeGreaterThan(0);
expect(await count(`SELECT count(*)::int AS n FROM quest_defs`)).toBe(2);
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBeGreaterThan(0);
expect((await pool.query(`SELECT name FROM players WHERE uuid = $1`, [ALICE.uuid])).rows[0].name).toBe('CDOGamer');
expect(await count(`SELECT count(*)::int AS n FROM events WHERE source = 'sftp'`)).toBeGreaterThan(0);
});
it('second sync without changes reads nothing', async () => {
const src = new LocalFileSource(root);
await syncOnce(pool, src, OPTS);
const spy = vi.spyOn(src, 'read');
const r = await syncOnce(pool, src, OPTS);
expect(r.files).toBe(0);
// only the 256-byte head of latest.log is read to detect rotation
expect(spy.mock.calls.every(([path, start, length]) => path === 'logs/latest.log' && start === 0 && length === 256)).toBe(true);
});
it('appended log lines are imported incrementally', async () => {
// log advancements only count for players without a synced advancements file
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
const src = new LocalFileSource(root);
await syncOnce(pool, src, OPTS);
const before = await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`);
appendFileSync(join(root, 'logs', 'latest.log'), LINE('23:50:00.000', 'CDOGamer has made the advancement [Brand New Title]'));
const r = await syncOnce(pool, src, OPTS);
expect(r.files).toBe(1);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE advancement_id LIKE 'log:%'`)).toBe(before + 1);
});
it('rotated log resets offset', async () => {
rmSync(join(root, 'world', 'advancements', `${ALICE.uuid}.json`));
const src = new LocalFileSource(root);
await syncOnce(pool, src, OPTS);
writeFileSync(
join(root, 'logs', 'latest.log'),
LINE('23:58:00.000', 'UUID of player CDOGamer is 308a693c-2bd8-4d6b-8790-670aef5be601').replace('MinecraftServer', 'ServerLoginPacketListenerImpl') +
LINE('23:58:01.000', 'CDOGamer has made the advancement [After Rotation]'),
);
await syncOnce(pool, src, OPTS);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'After Rotation'`)).toBe(1);
const { rows } = await pool.query(`SELECT "offset", size FROM sync_state WHERE path = 'logs/latest.log'`);
expect(rows[0].offset).toBe(rows[0].size);
});
it('log joins skipped while push is fresh', async () => {
await processEvents(pool, [ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: Date.now() - MIN, windowEnd: Date.now() })], 'push', 'atm10');
await syncOnce(pool, new LocalFileSource(root), OPTS);
expect(await count(`SELECT count(*)::int AS n FROM sessions`)).toBe(0);
});
it('log advancement skipped when the player already has it with that title', async () => {
await processEvents(pool, [ev('advancement', { ...ALICE, advancementId: 'mysticalagriculture:infusion', title: 'Infusing Efficiency' })], 'push', 'atm10');
await syncOnce(pool, new LocalFileSource(root), OPTS);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE title = 'Infusing Efficiency'`)).toBe(1);
});
it('changed stats file is re-imported with its new mtime', async () => {
const src = new LocalFileSource(root);
await syncOnce(pool, src, OPTS);
const f = join(root, 'world', 'stats', '308a693c-2bd8-4d6b-8790-670aef5be601.json');
writeFileSync(f, JSON.stringify({ stats: { 'minecraft:custom': { 'minecraft:play_time': 2_000_000 } }, DataVersion: 3955 }));
const future = new Date(Date.now() + 60_000);
utimesSync(f, future, future);
await syncOnce(pool, src, OPTS);
const { rows } = await pool.query(`SELECT value FROM stat_current WHERE uuid = $1 AND key = 'minecraft:play_time'`, [ALICE.uuid]);
expect(rows[0].value).toBe(2_000_000);
});
});
describe('closeStaleSessions', () => {
it('closes open sessions when the mod stopped reporting health', async () => {
const now = Date.now();
await processEvents(
pool,
[
ev('player_join', { ...ALICE, ts: now - 60 * MIN }),
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: now - 31 * MIN, windowEnd: now - 30 * MIN, ts: now - 30 * MIN }),
],
'push',
'atm10',
);
expect(await closeStaleSessions(pool, new Date(now))).toBe(1);
const { rows } = await pool.query('SELECT ended_at, end_reason FROM sessions');
expect(rows[0].end_reason).toBe('stale');
expect(rows[0].ended_at.getTime()).toBe(now - 30 * MIN);
});
it('keeps sessions while health is fresh or when push was never used', async () => {
const now = Date.now();
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 60 * MIN })], 'sftp', 'atm10');
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
});
});
describe('review fixes: log fallback', () => {
const health = (end: number) =>
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 1, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: end - MIN, windowEnd: end, ts: end });
it('a log session that started after the mod went silent stays open', async () => {
const now = Date.now();
await processEvents(pool, [health(now - 60 * MIN)], 'push', 'atm10');
await processEvents(pool, [ev('player_join', { ...ALICE, ts: now - 20 * MIN })], 'sftp', 'atm10');
expect(await closeStaleSessions(pool, new Date(now))).toBe(0);
expect(await count(`SELECT count(*)::int AS n FROM sessions WHERE ended_at IS NULL`)).toBe(1);
});
it('late push join/leave inside an existing log session do not create a second session', async () => {
const t = Date.now() - 120 * MIN;
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN })], 'sftp', 'atm10');
await processEvents(pool, [ev('player_join', { ...ALICE, ts: t + 1000 }), ev('player_leave', { ...ALICE, ts: t + 60 * MIN + 1000 })], 'push', 'atm10');
const { rows } = await pool.query(`SELECT started_at, ended_at FROM sessions`);
expect(rows).toHaveLength(1);
expect(rows[0].ended_at.getTime() - rows[0].started_at.getTime()).toBeGreaterThanOrEqual(60 * MIN);
});
it('pull-only: advancements from file and log are counted once', async () => {
await syncOnce(pool, new LocalFileSource(root), OPTS);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}' AND advancement_id LIKE 'log:%'`)).toBe(0);
expect(await count(`SELECT count(*)::int AS n FROM advancements WHERE uuid = '${ALICE.uuid}'`)).toBe(2);
});
});
describe('quest config and teams via SFTP', () => {
it('reads chapters, English split lang files and team files; skips other languages', async () => {
const lang = join(root, 'config', 'ftbquests', 'quests', 'lang');
mkdirSync(join(lang, 'en_us', 'chapters'), { recursive: true });
mkdirSync(join(lang, 'de_de', 'chapters'), { recursive: true });
cpSync(join(FX, 'ftbquests', 'config', 'lang', 'en_us'), join(lang, 'en_us'), { recursive: true });
writeFileSync(join(lang, 'de_de', 'chapters', 'getting_started.snbt_merged'), '{ quest.3BC0A50886A3222B.title: "Baum schlagen" }');
mkdirSync(join(root, 'world', 'ftbteams'), { recursive: true });
cpSync(join(FX, 'ftbteams'), join(root, 'world', 'ftbteams'), { recursive: true });
const src = new LocalFileSource(root);
const spy = vi.spyOn(src, 'read');
await syncOnce(pool, src, OPTS);
expect(spy.mock.calls.some(([p]) => String(p).includes('de_de'))).toBe(false);
const { rows } = await pool.query(`SELECT title, chapter_title FROM quest_defs WHERE quest_id = '3BC0A50886A3222B'`);
expect(rows).toEqual([{ title: 'Punch a Tree', chapter_title: 'Getting Started' }]);
expect(await count(`SELECT count(*)::int AS n FROM team_members`)).toBe(2);
});
});