import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { parseMetricsQuery } from '../../src/lib/metrics.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); it('runs reports off the request thread and invalidates cached history after writes', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { metricsReport } = await import('../../src/lib/server/reports/index.js'); const now = Date.UTC(2026, 9, 4, 12); vi.spyOn(Date, 'now').mockReturnValue(now); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; recordMetrics([sample], now - 30_000); const query = parseMetricsQuery(new URLSearchParams('days=1')); const first = metricsReport(query); const winner = await Promise.race([ first.then(() => 'report'), new Promise((resolve) => setTimeout(() => resolve('timer'), 0)) ]); expect(winner).toBe('timer'); expect(await first).toEqual(readMetrics(query, now)); expect(await metricsReport(query)).toEqual(await first); db().prepare('UPDATE metric_samples SET players=3').run(); const changed = await metricsReport(query); expect(changed).toEqual(readMetrics(query, now)); expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3); }); it('refreshes cached match metrics after active matches and recorded participants change without new samples', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { metricsReport } = await import('../../src/lib/server/reports/index.js'); const now = Date.UTC(2026, 9, 4, 12); vi.spyOn(Date, 'now').mockReturnValue(now); const at = new Date(now - 30_000).toISOString(); const query = parseMetricsQuery(new URLSearchParams('days=1')); const report = async () => { const result = await metricsReport(query); expect(result).toEqual(readMetrics(query, now)); return result; }; expect((await report()).matches.started).toBe(0); const match = db().prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint) VALUES (?, 'removed-server', 't5', 'zombie_moon', 't5:moon', 'Moon', ?, ?, 'active', 'game_start', 'test')`); match.run(1, at, at); expect((await report()).matches).toEqual({ started: 1, withRecordedPlayers: 0, averageGroupSize: null, solo: 0, soloShare: null }); const player = db().prepare(`INSERT INTO leaderboard_match_players (match_id, guid, first_seen_at, last_seen_at) VALUES (1, ?, ?, ?)`); player.run('one', at, at); expect((await report()).matches).toMatchObject({ averageGroupSize: 1, soloShare: 100 }); player.run('two', at, at); expect((await report()).matches).toMatchObject({ averageGroupSize: 2, soloShare: 0 }); match.run(2, at, at); expect((await report()).matches).toMatchObject({ started: 2, withRecordedPlayers: 1 }); db().prepare("UPDATE leaderboard_match_players SET match_id=2 WHERE guid='two'").run(); expect((await report()).matches).toMatchObject({ withRecordedPlayers: 2, averageGroupSize: 1, solo: 2, soloShare: 100 }); db().prepare("DELETE FROM leaderboard_match_players WHERE guid='one'").run(); expect((await report()).matches).toMatchObject({ withRecordedPlayers: 1, solo: 1 }); db().prepare('DELETE FROM leaderboard_matches WHERE id=2').run(); expect((await report()).matches).toMatchObject({ started: 1, withRecordedPlayers: 0, averageGroupSize: null }); db().prepare("UPDATE leaderboard_matches SET map_name='Town Survival', game='t6'").run(); expect((await report()).maps.map((map) => [map.game, map.map])).toEqual([ ['t6', 'Town Survival'] ]); db() .prepare('UPDATE leaderboard_matches SET started_at=?') .run(new Date(now - 2 * 86400_000).toISOString()); const outsideWindow = await report(); expect(outsideWindow.matches.started).toBe(0); expect(outsideWindow.maps).toEqual([]); expect(outsideWindow.scopeOptions.maps).toEqual([{ game: 't6', map: 'Town Survival' }]); db().prepare('DELETE FROM leaderboard_matches').run(); expect((await report()).scopeOptions).toEqual({ games: [], maps: [] }); }); it('refreshes policy signals after policy edits and global pauses without new samples', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { metricsReport } = await import('../../src/lib/server/reports/index.js'); const now = Date.UTC(2026, 9, 4, 12); vi.spyOn(Date, 'now').mockReturnValue(now); recordMetrics( [{ server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }], now - 60_000 ); const query = parseMetricsQuery(new URLSearchParams('days=1')); expect((await metricsReport(query)).maps[0].policy).toBeNull(); const settings = { game: 't5', mapSlug: '*', mode: 'create-destroy', minimum: 1, maximum: 1, spares: 1, emptyMinutes: 15 }; db() .prepare( `INSERT INTO autoscaling_policies (id,game,map_slug,settings,mode,owns_map,updated_at) VALUES(?,?,?,?,?,1,?)` ) .run( 'moon-policy', 't5', '*', JSON.stringify(settings), settings.mode, new Date(now).toISOString() ); const atMaximum = (await metricsReport(query)).maps[0]; expect(atMaximum.policy?.maximum).toBe(1); expect(atMaximum.noEmptyAtMaximum).toBeCloseTo(1 / 60); db() .prepare('UPDATE autoscaling_policies SET settings=? WHERE id=?') .run(JSON.stringify({ ...settings, maximum: 3 }), 'moon-policy'); const belowMaximum = (await metricsReport(query)).maps[0]; expect(belowMaximum.policy?.maximum).toBe(3); expect(belowMaximum.noEmptyAtMaximum).toBe(0); expect(belowMaximum.noEmptyBelowMaximum).toBeCloseTo(1 / 60); db() .prepare( "INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'" ) .run(); const globallyPaused = await metricsReport(query); expect(globallyPaused.autoscalingPaused).toBe(true); expect(globallyPaused.maps[0]).toEqual(belowMaximum); db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run(); const resumed = await metricsReport(query); expect(resumed.autoscalingPaused).toBe(false); expect(resumed.maps).toEqual(globallyPaused.maps); db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run(); expect((await metricsReport(query)).maps[0].policy).toBeNull(); }); it('refreshes autoscaling outcomes after completion, source changes and retention without new samples', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { metricsReport } = await import('../../src/lib/server/reports/index.js'); const now = Date.UTC(2026, 9, 10, 12); vi.spyOn(Date, 'now').mockReturnValue(now); const query = parseMetricsQuery(new URLSearchParams('days=7')); const report = () => metricsReport(query); expect((await report()).autoscaling.added).toBe(0); db() .prepare( `INSERT INTO server_deployments (id,server,action,state,stage,started_at,triggered_by) VALUES('operation','t5_zombie_moon_1','create','awaiting-health','Waiting',?,'test')` ) .run(new Date(now - 72_000).toISOString()); db().exec(`INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('operation','autoscaling','deleted-policy','t5_zombie_moon_1')`); expect((await report()).autoscaling.added).toBe(0); db() .prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id='operation'") .run(new Date(now).toISOString()); expect((await report()).autoscaling).toMatchObject({ added: 1, readinessSamples: 1, readinessMeanSeconds: 72, readinessMaxSeconds: 72 }); db().exec("UPDATE provisioning_sources SET kind='maintenance'"); expect((await report()).autoscaling.added).toBe(0); db().exec("UPDATE provisioning_sources SET kind='autoscaling'"); expect((await report()).autoscaling.added).toBe(1); db().exec("UPDATE server_deployments SET state='failed'"); expect((await report()).autoscaling).toMatchObject({ added: 0, failed: 1, readinessSamples: 0 }); db().exec('DELETE FROM server_deployments'); expect((await report()).autoscaling.failed).toBe(0); expect((await report()).scopeOptions.maps).toEqual([]); });