Files
electric-cherry/tests/unit/autoscaling-lifecycle.test.ts
2026-10-15 20:40:03 -04:00

156 lines
6.8 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
import { gameEvent } from '../helpers/telemetry.js';
import { zombieMaps } from '../../src/lib/map-aliases.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
const server = 't6_zombie_town_1';
async function setup(mapSlug: string) {
const maps = zombieMaps('t6').filter((map) => mapSlug === '*' || map.slug === mapSlug);
const names = maps.map((map) => `t6_zombie_${map.slug}_1`);
const { host } = await prepareNativeHost(names);
const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const { autoscalingRemovalCapability } =
await import('../../src/lib/server/autoscaling/authorization.js');
db()
.prepare(
"UPDATE ec_hosts SET inventory=json_set(inventory,'$.capabilities',json(?)) WHERE id=?"
)
.run(JSON.stringify([autoscalingRemovalCapability]), host.id);
const at = new Date().toISOString();
for (const map of maps) {
const name = `t6_zombie_${map.slug}_1`;
db()
.prepare(
`INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_checked_at,last_players)
VALUES(?,'t6',?,?,'running',?,?,?,0)`
)
.run(name, map.canonical, map.slug, at, at, at);
db()
.prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)")
.run(name, `key-${name}`);
db().prepare('INSERT INTO server_key_leases(server_id,key_id) VALUES(?,?)').run(name, name);
}
// A free key ensures a lack of capacity cannot hide unwanted scale-up.
db()
.prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('spare','t6','spare-key')")
.run();
const policy = scaling.saveScaling({ game: 't6', mapSlug, maximum: 2, mode: 'create-destroy' });
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
return { db, scaling, policy, requestLifecycle };
}
it.each(['town', '*'])('keeps lineup-changing actions blocked for %s ownership', async (scope) => {
const { db, requestLifecycle } = await setup(scope);
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
for (const action of ['start', 'stop'] as const) {
expect(requestLifecycle(server, action)).toMatchObject({
ok: false,
status: 409,
error: expect.stringContaining('Autoscaling')
});
}
expect(destroyServer(server)).toMatchObject({
ok: false,
message: expect.stringContaining('Autoscaling')
});
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([]);
});
it.each([
['town', 'restart'],
['town', 'rebuild'],
['*', 'restart'],
['*', 'rebuild']
] as const)(
'allows %s-owned %s without scaling up during work or recovery',
async (scope, action) => {
vi.useFakeTimers({ toFake: ['Date'] });
const { db, scaling, policy, requestLifecycle } = await setup(scope);
const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js');
const result = requestLifecycle(server, action);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
expect(requestLifecycle(server, action)).toEqual(result);
for (const state of ['waiting', 'queued', 'running', 'awaiting-health', 'interrupted']) {
db()
.prepare('UPDATE server_deployments SET state=? WHERE id=?')
.run(state, result.deploymentId);
db()
.prepare(
"UPDATE servers SET container_state='stopped',last_players=NULL WHERE name=?"
)
.run(server);
const snapshot = scalingSnapshot(policy);
expect(snapshot.members.find((member) => member.name === server)).toMatchObject({
state: 'protected',
operationId: result.deploymentId
});
expect(snapshot.reservedNames).toContain(server);
expect(await scaling.runAutoscaling()).toBe(false);
}
// A terminal operation with unresolved recovery must still hold capacity.
db()
.prepare("UPDATE server_deployments SET state='failed' WHERE id=?")
.run(result.deploymentId);
const requestedAt = new Date().toISOString();
db()
.prepare('INSERT INTO restart_rollout(server,requested_at,operation_id) VALUES(?,?,?)')
.run(server, requestedAt, result.deploymentId);
expect(
scalingSnapshot(policy).members.find((member) => member.name === server)?.blocker
).toBe('Unresolved recovery');
expect(await scaling.runAutoscaling()).toBe(false);
expect(
db()
.prepare("SELECT id FROM server_deployments WHERE action IN ('create','destroy')")
.all()
).toEqual([]);
// Recovery status must be strictly newer than the restart request.
const at = new Date(Date.parse(requestedAt) + 1_000).toISOString();
vi.setSystemTime(new Date(at));
db()
.prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id=?")
.run(at, result.deploymentId);
db().prepare('UPDATE restart_rollout SET healthy_at=? WHERE server=?').run(at, server);
db()
.prepare(
"UPDATE servers SET container_state='running',last_players=0,container_checked_at=?,last_seen_at=?,last_checked_at=?"
)
.run(at, at, at);
expect(await scaling.runAutoscaling()).toBe(false);
expect(scaling.getScalingPolicy(policy.id)).toMatchObject({
mode: 'create-destroy',
pausedReason: null
});
// Recovery and occupancy alone cannot request a spare before round two.
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(server);
expect(await scaling.runAutoscaling()).toBe(false);
vi.setSystemTime(Date.now() + 1_000);
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
...gameEvent('heartbeat', 2, 1, server, 'zm_transit'),
receivedAt: new Date().toISOString()
});
expect(await scaling.runAutoscaling()).toBe(true);
expect(
db().prepare("SELECT server FROM server_deployments WHERE action='create'").all()
).toEqual([{ server: 't6_zombie_town_2' }]);
}
);