import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { prepareNativeHost } from '../helpers/native-host.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { await fixture.cleanup(); }); const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'dry-run' }; async function setup() { const { db } = await import('../../src/lib/server/db/index.js'); const scaling = await import('../../src/lib/server/autoscaling/index.js'); const { saveServerKey, reserveServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); return { db, scaling, saveServerKey, reserveServerKey }; } it.each(['off', 'dry-run', 'create-only', 'create-destroy'])('saves a %s policy without preallocating keys for its maximum', async mode => { if (mode.startsWith('create')) await prepareNativeHost(); const { db, scaling } = await setup(); const input = { ...settings, mode, maximum: 20 }; const preview = scaling.previewScaling(input); expect(preview.blockers).toEqual([]); expect(preview.decision.action).toBe('wait'); if (mode !== 'off') expect(preview.decision.reason).toContain('No Plutonium keys'); const { POST } = await import('../../src/routes/api/autoscaling/+server.js'); const response = await POST({ request: new Request('http://localhost/api/autoscaling', { method: 'POST', body: JSON.stringify({ action: 'save', settings: input, token: preview.token }) }) }); expect(response.status).toBe(200); expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_policies').get()).toEqual({ n: 1 }); expect(await scaling.runAutoscaling()).toBe(false); }); it('uses any free key for the selected game independently of its label or map', async () => { const { scaling, saveServerKey } = await setup(); saveServerKey('t5', 'other-game-key', 'Town Lobby 1'); saveServerKey('t6', 'game-key', 'Farm Lobby 8'); const preview = scaling.previewScaling(settings); expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 }); expect(preview.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_1' }); expect(scaling.saveScaling(settings, undefined, undefined, preview.token).maximum).toBe(2); }); it('uses one shared pool for every map without requiring a key for each maximum', async () => { const { scaling, saveServerKey } = await setup(); saveServerKey('t6', 'game-key'); const input = { ...settings, mapSlug: '*', maximum: 20 }; const preview = scaling.previewScaling(input); expect(preview.blockers).toEqual([]); expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 }); expect(scaling.saveScaling(input, undefined, undefined, preview.token).mapSlug).toBe('*'); }); it('rechecks game pool deletion and reservations after preview', async () => { const { db, scaling, saveServerKey, reserveServerKey } = await setup(); const keyId = saveServerKey('t6', 'game-key'); const preview = scaling.previewScaling(settings); db().prepare('DELETE FROM server_keys WHERE id=?').run(keyId); expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('preview changed'); saveServerKey('t6', 'game-key'); const available = scaling.previewScaling(settings); reserveServerKey('t6_zombie_farm_1', 't6'); expect(() => scaling.saveScaling(settings, undefined, undefined, available.token)).toThrow('preview changed'); const held = scaling.previewScaling(settings); expect(held.decision.reason).toContain('keys are in use'); expect(held.snapshot.members).toEqual([]); expect(scaling.saveScaling(settings, undefined, undefined, held.token).maximum).toBe(2); }); it('shares capacity between live map policies and admits the job holding the last key', async () => { await prepareNativeHost(); const { db, scaling, saveServerKey } = await setup(); saveServerKey('t6', 'game-key'); saveServerKey('t5', 'other-game-key'); for (const mapSlug of ['town', 'farm']) { const input = { ...settings, mapSlug, mode: 'create-only' }; scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token); } expect(await scaling.runAutoscaling()).toBe(true); const jobs = db().prepare('SELECT deployment_id,server FROM server_creation_jobs').all() as { deployment_id: string; server: string }[]; expect(jobs).toHaveLength(1); expect(jobs[0].server).toBe('t6_zombie_farm_1'); const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js'); expect(scalingAdmission(jobs[0].deployment_id)).toBeNull(); const town = scaling.scalingReport().policies.find(p => p.policy.mapSlug === 'town')!; expect(town.snapshot.keyPool).toEqual({ total: 1, available: 0 }); expect(town.decision.reason).toContain('keys are in use'); expect(town.policy.pausedReason).toBeNull(); expect(await scaling.runAutoscaling()).toBe(false); expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 }); }); it('reserves a lobby identity for an unsettled failed launch with a retained key', async () => { const { scaling, saveServerKey, reserveServerKey } = await setup(); saveServerKey('t6', 'held-key'); reserveServerKey('t6_zombie_town_1', 't6'); saveServerKey('t6', 'free-key'); const preview = scaling.previewScaling(settings); expect(preview.snapshot.reservedNames).toEqual(['t6_zombie_town_1']); expect(preview.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_2' }); });