Files
minecraft-atm10-stats/backend/test/public-api.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

183 lines
8.5 KiB
TypeScript

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