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; const now = Date.UTC(2026, 9, 10, 12); const day = 86400_000; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); async function operation( id: string, options: { server?: string; action?: string; state?: string; start?: number | string; finish?: number | null; source?: string; } = {} ) { const { db } = await import('../../src/lib/server/db/index.js'); const handle = db(); const server = options.server ?? 't5_zombie_moon_1'; const finish = options.finish === undefined ? now - 1000 : options.finish; const start = options.start ?? (finish ?? now) - 72_000; handle .prepare( `INSERT INTO server_deployments (id,server,server_deleted_at,action,state,stage,started_at,finished_at,triggered_by) VALUES(?,?,? ,?,?,'test',?,?,'test')` ) .run( id, server, new Date(now).toISOString(), options.action ?? 'create', options.state ?? 'done', typeof start === 'string' ? start : new Date(start).toISOString(), finish === null ? null : new Date(finish).toISOString() ); handle .prepare( `INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES(?,?, 'deleted-policy',?)` ) .run(id, options.source ?? 'autoscaling', server); return handle; } it.each([1, 7, 30] as const)( 'counts outcomes by finish time in the inclusive %i-day window', async (days) => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const since = now - days * day; await operation('before', { finish: since - 1 }); await operation('at-start', { finish: since, start: since - 60_000 }); await operation('at-end', { finish: now, start: now - 120_000, state: 'warning' }); await operation('after', { finish: now + 1 }); await operation('removed', { action: 'destroy' }); await operation('removed-warning', { action: 'destroy', state: 'warning' }); await operation('failed-create', { state: 'failed' }); await operation('failed-removal', { action: 'destroy', state: 'failed' }); await operation('cancelled', { state: 'cancelled' }); await operation('pending', { state: 'awaiting-health', finish: null }); await operation('interrupted', { state: 'interrupted' }); await operation('manual', { source: 'maintenance' }); await operation('rebuild', { action: 'rebuild' }); const result = readMetrics(days, now); expect(result.autoscaling).toEqual({ added: 2, removed: 2, failed: 2, cancelled: 1, readinessSamples: 2, readinessMeanSeconds: 90, readinessMaxSeconds: 120 }); expect(result.summary.observed).toBe(0); expect(result.maps).toEqual([]); expect(result.scopeOptions.maps).toEqual([{ game: 't5', map: 'Moon' }]); } ); it('weights readiness by completed creates and counts each operation once after policy/server deletion', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const handle = await operation('one', { start: now - 61_000 }); await operation('two', { start: now - 121_000 }); await operation('three', { server: 't6_zombie_town_1', start: now - 181_000 }); // Several source rows and another consumer do not multiply the result. handle .prepare( `INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('one','autoscaling','second-source','t5_zombie_moon_1'), ('one','maintenance','maintenance-source','t5_zombie_moon_1')` ) .run(); expect(handle.prepare('SELECT * FROM autoscaling_policies').all()).toEqual([]); expect(handle.prepare('SELECT * FROM autoscaling_decisions').all()).toEqual([]); expect(handle.prepare('SELECT * FROM servers').all()).toEqual([]); expect(readMetrics(7, now).autoscaling).toEqual({ added: 3, removed: 0, failed: 0, cancelled: 0, readinessSamples: 3, readinessMeanSeconds: 120, readinessMaxSeconds: 180 }); }); it('filters operation identity by game and map variant independently of the policy scope', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); await operation('moon'); await operation('town', { server: 't6_zombie_town_1', action: 'destroy' }); await operation('farm', { server: 't6_zombie_farm_1', state: 'failed' }); await operation('transit', { server: 't6_zombie_transit_1', state: 'cancelled' }); await operation('older', { server: 't5_zombie_theater_1', finish: now - 8 * day }); await operation('future', { server: 't6_zombie_tomb_1', finish: now + 1 }); const report = (game: string, map = '') => readMetrics(parseMetricsQuery(new URLSearchParams({ days: '7', game, map })), now); expect(report('t6').autoscaling).toMatchObject({ added: 0, removed: 1, failed: 1, cancelled: 1 }); expect(report('t5', 'Moon').autoscaling.added).toBe(1); expect(report('t5', 'zombie_moon').autoscaling.added).toBe(1); for (const label of ['Town Survival', 'town']) { expect(report('t6', label).autoscaling).toMatchObject({ added: 0, removed: 1, failed: 0, cancelled: 0 }); } for (const [game, map] of [ ['t6', 'zm_transit'], ['t5', 'Town Survival'], ['t6', 'Unknown'] ]) expect(report(game, map).autoscaling).toMatchObject({ added: 0, removed: 0, failed: 0, cancelled: 0 }); const options = report('t6').scopeOptions; expect(options.maps).toContainEqual({ game: 't5', map: 'Kino Der Toten' }); expect(options.maps).not.toContainEqual({ game: 't6', map: 'Origins' }); expect(report('t5', 'Kino Der Toten').autoscaling.added).toBe(0); expect(readMetrics(30, now).autoscaling.added).toBe(2); }); it('keeps absent readiness null and excludes invalid durations without losing completed counts', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); expect(readMetrics(7, now).autoscaling).toEqual({ added: 0, removed: 0, failed: 0, cancelled: 0, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null }); await operation('negative', { start: now }); await operation('invalid-start', { start: 'not-recorded' }); await operation('missing-finish', { finish: null }); expect(readMetrics(7, now).autoscaling).toMatchObject({ added: 2, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null }); await operation('zero', { start: now, finish: now }); expect(readMetrics(7, now).autoscaling).toMatchObject({ added: 3, readinessSamples: 1, readinessMeanSeconds: 0, readinessMaxSeconds: 0 }); });