Files
electric-cherry/tests/unit/beta-selection.test.ts
2026-10-12 18:46:57 -04:00

266 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn() }));
let fixture: Fixture;
const t5 = ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_cosmodrome_1'];
const t6 = ['t6_zombie_town_1', 't6_zombie_town_2'];
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([...t5, ...t6]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
const now = new Date().toISOString();
db()
.prepare(
`UPDATE servers SET container_state='running', container_checked_at=?,
last_checked_at=?, last_seen_at=?, last_players=0`
)
.run(now, now, now);
});
afterEach(async () => {
await fixture.cleanup();
});
async function file(scopeType: 'game' | 'map' = 'game', scopeValue = 't5') {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
return createScript({
releaseVersion: '1.0.0',
name: `Test ${scopeValue}`,
targetRelPath: `${scopeValue}.gsc`,
scopeType,
scopeValue,
content: Buffer.from('beta bytes')
});
}
describe('automatic beta server selection', () => {
it('selects the requested count independently per game, least occupied first with deterministic ties', async () => {
const files = [await file(), await file('game', 't6')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=4 WHERE name=?').run(t5[0]);
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t6[0]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(
selectBetaServers(
files.map((f) => f.script),
{ t5: 2, t6: 1 }
)
).toEqual([t5[2], t5[1], t6[1]]);
});
it('accounts for occupied telemetry and fresh positive RCON evidence', async () => {
const { script } = await file();
const { db } = await import('../../src/lib/server/db/index.js');
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
const put = db().prepare(
'INSERT INTO server_telemetry(server,round,players,received_at,payload) VALUES(?,0,?,?,?)'
);
const report = (server: string, players: number) => ({
event: 'game_start',
server,
map: 'zombie_theater',
receivedAt: new Date().toISOString(),
players: Array.from({ length: players }, (_, i) => ({ name: `P${i}`, guid: String(i) }))
});
put.run(t5[2], 3, new Date().toISOString(), JSON.stringify(report(t5[2], 3)));
put.run(t5[0], 0, new Date().toISOString(), JSON.stringify(report(t5[0], 0)));
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t5[0]);
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[1]]);
});
it.each(['stale', 'stopped', 'destroyed', 'provisioning', 'beta', 'campaign'])(
'excludes %s servers',
async (reason) => {
const { script, version } = await file();
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(),
server = t5[2];
if (reason === 'stale')
handle
.prepare("UPDATE servers SET last_seen_at='2000-01-01T00:00:00Z' WHERE name=?")
.run(server);
if (reason === 'stopped')
handle
.prepare("UPDATE servers SET desired_state='stopped' WHERE name=?")
.run(server);
if (reason === 'destroyed')
handle
.prepare("UPDATE servers SET destroyed_at='2020-01-01T00:00:00Z' WHERE name=?")
.run(server);
if (reason === 'provisioning')
handle
.prepare(
`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by)
VALUES('busy',?,'destroy','queued','Queued',?,'test')`
)
.run(server, new Date().toISOString());
if (reason === 'beta') {
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
await openBeta({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
servers: [server],
waitForHealth: false,
waitForMatch: false
});
}
if (reason === 'campaign') {
handle
.prepare(
"INSERT INTO deployments(id,action,triggered_by,started_at) VALUES('campaign','promote','test',?)"
)
.run(new Date().toISOString());
handle
.prepare(
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')"
)
.run(server);
}
const { selectBetaServers } =
await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
}
);
it.each(['disabled', 'draining', 'disconnected', 'deleted'])(
'excludes instances on a %s host despite fresh game status',
async (state) => {
const { script } = await file();
const { host } = await prepareNativeHost([t5[2]]);
const { db } = await import('../../src/lib/server/db/index.js');
if (state === 'disconnected')
db().prepare('UPDATE ec_hosts SET last_heartbeat=NULL WHERE id=?').run(host.id);
else if (state === 'deleted')
db()
.prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?')
.run(new Date().toISOString(), host.id);
else db().prepare('UPDATE ec_hosts SET mode=? WHERE id=?').run(state, host.id);
const { selectBetaServers } =
await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
}
);
it('covers every file scope instead of filling all slots from the emptiest map', async () => {
const files = [await file('map', 'zombie_theater'), await file('map', 'zombie_cosmodrome')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=3 WHERE name=?').run(t5[2]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(
selectBetaServers(
files.map((f) => f.script),
{ t5: 2 }
)
).toEqual([t5[0], t5[2]]);
expect(() =>
selectBetaServers(
files.map((f) => f.script),
{ t5: 1 }
)
).toThrow('every selected file');
});
it('reserves game slots when a map can be tested in either game', async () => {
const files = [await file('map', 'shared'), await file('map', 'only_t5')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE servers SET map='shared'").run();
db().prepare("UPDATE servers SET map='only_t5', last_players=4 WHERE name=?").run(t5[2]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(
selectBetaServers(
files.map((f) => f.script),
{ t5: 1, t6: 1 }
)
).toEqual([t6[0], t5[2]]);
});
it('fails insufficient capacity or untested files before creating a beta', async () => {
const files = [await file(), await file('game', 't6')];
const { POST } = await import('../../src/routes/api/betas/+server.js');
const scripts = files.map((f) => ({
scriptId: f.script.id,
scriptVersionId: f.version.id
}));
for (const instancesPerGame of [{ t5: 4, t6: 1 }, { t5: 1 }]) {
const response = await POST({
request: new Request('http://test/api/betas', {
method: 'POST',
body: JSON.stringify({ scripts, instancesPerGame })
})
});
expect(response.status).toBe(409);
}
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT * FROM betas').all()).toEqual([]);
});
it('accepts automatic API setup and requires every selected instance to pass even with a quorum preset', async () => {
const files = [await file(), await file('game', 't6')];
const { POST } = await import('../../src/routes/api/betas/+server.js');
const { defaultPlan } = await import('../../src/lib/beta-plan.js');
const response = await POST({
request: new Request('http://test/api/betas', {
method: 'POST',
body: JSON.stringify({
scripts: files.map((f) => ({
scriptId: f.script.id,
scriptVersionId: f.version.id
})),
instancesPerGame: { t5: 2, t6: 1 },
testPlan: { ...defaultPlan(), scope: 'quorum', quorum: 1 }
})
})
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body.result.servers).toEqual([t5[2], t5[0], t6[0]]);
const { storedPlan } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(JSON.parse(storedPlan(body.result.betaId)!.plan).scope).toBe('all');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT * FROM beta_server_scripts').all()).toHaveLength(3);
});
it.each([
{},
{ t5: 0 },
{ t5: -1 },
{ t5: 1.5 },
{ t5: '2' },
{ t5: 1001 },
{ t7: 1 },
[],
null
])('rejects malformed counts %j', async (instancesPerGame) => {
const { script, version } = await file();
const { POST } = await import('../../src/routes/api/betas/+server.js');
const response = await POST({
request: new Request('http://test/api/betas', {
method: 'POST',
body: JSON.stringify({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
instancesPerGame
})
})
});
expect(response.status).toBe(400);
});
it('rejects ambiguous simultaneous manual and automatic targeting', async () => {
const { script, version } = await file();
const { POST } = await import('../../src/routes/api/betas/+server.js');
const response = await POST({
request: new Request('http://test/api/betas', {
method: 'POST',
body: JSON.stringify({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
instancesPerGame: { t5: 1 },
servers: [t5[0]]
})
})
});
expect(response.status).toBe(400);
});
});