Files
electric-cherry/tests/unit/report-workers.test.ts
2026-10-10 18:47:56 -04:00

229 lines
11 KiB
TypeScript

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<string>((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 player trends when participation crosses a period and first-seen identity changes', 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, 30);
vi.spyOn(Date, 'now').mockReturnValue(now);
const query = parseMetricsQuery(new URLSearchParams('days=1'));
db().exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T11:30:00.000Z','2026-10-10T12:30:00.000Z','heartbeat','test');
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');
INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t5','player','Player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');`);
const first = await metricsReport(query);
expect(first.players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
expect(first.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
db().exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T12:15:00.000Z'");
expect((await metricsReport(query)).points.at(-1)).toMatchObject({
uniquePlayers: 1,
newPlayers: 0
});
db().exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t6','player','Player','2026-10-01T11:30:00.000Z','2026-10-01T11:30:00.000Z')`);
expect((await metricsReport(query)).players).toMatchObject({ uniquePlayers: 1, newPlayers: 0 });
});
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([]);
});