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