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({ 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); }); });