190 lines
7.4 KiB
TypeScript
190 lines
7.4 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;
|
|
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
|
|
});
|
|
});
|