import { prepareNativeHost } from '../helpers/native-host.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { CollectionPreview } from '../../src/lib/deployment-plan.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => { // Version evidence must be observed strictly after the launch timestamp. await new Promise((resolve) => setTimeout(resolve, 2)); return { revision: 5354, error: null }; }), rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); let fixture: Fixture; const entries = [ { game: 't5', mapSlug: 'theater', count: 2 }, { game: 't5', mapSlug: 'moon', count: 1 }, { game: 't6', mapSlug: 'town', count: 2 } ]; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]); await prepareNativeHost(); vi.stubGlobal( 'fetch', vi.fn(async () => Response.json({ product: 'plutonium-core-prod', revision: 5354, files: [] }) ) ); vi.stubEnv('RCON_PASSWORD', 'private-rcon-password'); vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '1'); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); async function previewLineup() { const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection } = await import('../../src/lib/server/fleet/collections.js'); const blueprint = saveBlueprint({ name: 'Friday Zombies', entries }); return { blueprint, preview: previewCollection(blueprint.id) }; } function keysFor(preview: CollectionPreview) { return Object.fromEntries( preview.servers .filter((server) => server.name) .map((server) => [server.name!, 'key-' + server.name]) ); } describe('multi-server blueprints', () => { it('shows the saved startup limit in new previews and existing collection progress', async () => { const { blueprint, preview } = await previewLineup(); const { queueCollection, getCollection, previewCollection } = await import('../../src/lib/server/fleet/collections.js'); const result = queueCollection({ previewId: preview.id, keys: keysFor(preview) }); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 3 }); expect(previewCollection(blueprint.id).launchConcurrency).toBe(3); expect(getCollection(result.collectionId!)?.queue.concurrency).toBe(3); }); it('reads a legacy one-server blueprint as a map selection and saves edits without snapshots', async () => { const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const artifact = createScript({ releaseVersion: '1.0.0', name: 'Telemetry', targetRelPath: 'telemetry.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('legacy telemetry') }); db() .prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?') .run(artifact.version.id, artifact.script.id); const recipe = captureRecipe('t5', 'theater'); db() .prepare( 'INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)' ) .run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe)); const current = uploadNewVersion( artifact.script.id, Buffer.from('current telemetry'), undefined, '1.0.1' ); db() .prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?') .run(current.id, artifact.script.id); closeDb(); const old = listBlueprints()[0]; expect(old.totalServers).toBe(1); expect(old.entries).toEqual([ { count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' } ]); expect(old).not.toHaveProperty('recipe'); expect(previewCreation({ blueprintId: 'legacy' }).recipe.files).toContainEqual( expect.objectContaining({ path: 'telemetry.gsc', versionId: current.id }) ); const updatedEntries = [{ ...entries[0], count: 15 }, ...entries.slice(1)]; const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries: updatedEntries }); expect(edited.totalServers).toBe(18); expect(edited.entries[0]).toEqual({ ...old.entries[0], count: 15 }); const stored = db() .prepare('SELECT recipe FROM server_blueprints WHERE id = ?') .get('legacy') as { recipe: string }; expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: updatedEntries }); expect(listBlueprints()).toHaveLength(1); }); it.each( [ [], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 16 }], [{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }], [{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]] ].map((input) => ({ input })) )('rejects an invalid lineup: $input', async ({ input }) => { const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); expect(() => saveBlueprint({ name: 'Invalid', entries: input })).toThrow(); }); }); describe('atomic collection launches', () => { it('counts available keys once per game across all maps without reserving them in a preview', async () => { const { blueprint } = await previewLineup(); const { saveServerKey, reserveServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const { db } = await import('../../src/lib/server/db/index.js'); saveServerKey('t5', 'pool-one'); saveServerKey('t5', 'pool-two'); saveServerKey('t6', 'pool-three'); const preview = previewCollection(blueprint.id); expect(preview.servers.map((server) => server.hasSavedKey)).toEqual([ true, true, false, true, false ]); expect(db().prepare('SELECT * FROM server_key_leases').all()).toEqual([]); reserveServerKey('t5_zombie_pentagon_1', 't5'); expect(() => queueCollection({ previewId: preview.id })).toThrow( /No available T5 server keys/ ); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_key_leases').all()).toHaveLength(1); }); it('rolls back all earlier jobs and key changes if the final server has no valid key', async () => { const { preview } = await previewLineup(); const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { saveServerKey, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); saveServerKey('t5', 'original-key'); const keys = keysFor(preview); delete keys[preview.servers[4].name!]; expect(() => queueCollection({ previewId: preview.id, keys })).toThrow( /No available .* server keys/ ); for (const table of [ 'server_launch_collections', 'server_collection_members', 'server_creation_jobs', 'server_deployments', 'server_launch_previews', 'server_launch_recipes' ]) { expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]); } expect(getServerKey(preview.servers[0].name!)).toBeUndefined(); expect(db().prepare('SELECT pluto_key FROM server_keys').get()).toEqual({ pluto_key: 'original-key' }); expect(db().prepare('SELECT * FROM server_key_leases').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1); keys[preview.servers[4].name!] = 'invalid key'; expect(() => queueCollection({ previewId: preview.id, keys })).toThrow( /without whitespace/ ); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); expect(queueCollection({ previewId: preview.id, keys: keysFor(preview) }).ok).toBe(true); }); it('reuses saved keys and returns the same collection on resubmission after restart and expiry', async () => { const { preview, blueprint } = await previewLineup(); const { queueCollection, previewCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); const { saveServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); for (const server of preview.servers) saveServerKey(server.name!.slice(0, 2), `saved-${server.name}`); const ready = previewCollection(blueprint.id); expect(ready.servers.every((server) => server.hasSavedKey)).toBe(true); const queued = queueCollection({ previewId: ready.id }); db() .prepare( "UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?" ) .run(ready.id); closeDb(); expect(queueCollection({ previewId: ready.id })).toEqual(queued); expect(getCollection(queued.collectionId!)?.servers).toHaveLength(5); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(5); const next = previewCollection(blueprint.id); expect(next.servers).toEqual([]); expect(next.entries.map((entry) => entry.existing)).toEqual([2, 1, 2]); expect(queueCollection({ previewId: next.id })).toEqual({ ok: true, collectionId: null }); expect(db().prepare('SELECT * FROM server_launch_collections').all()).toHaveLength(1); }); it('validates collection API payloads and rejects an unused expired preview', async () => { const { preview, blueprint } = await previewLineup(); const { POST: singlePreview } = await import('../../src/routes/api/deployments/preview/+server.js'); const oldEndpoint = await singlePreview({ request: new Request('http://localhost/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId: blueprint.id }) }) }); expect(oldEndpoint.status).toBe(400); expect((await oldEndpoint.json()).error).toContain('target counts'); const { db } = await import('../../src/lib/server/db/index.js'); const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const { POST } = await import('../../src/routes/api/deployment-collections/+server.js'); for (const body of [ null, [], {}, { previewId: preview.id, keys: [] }, { previewId: preview.id, keys: { wrong: 'key' } } ]) { const response = await POST({ request: new Request('http://localhost/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }); expect(response.status).toBe(400); } db() .prepare( "UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?" ) .run(preview.id); expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow( /expired/ ); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); }); }); describe('matching a blueprint exactly', () => { async function blueprint(entries: { game: string; mapSlug: string; count: number }[]) { const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); return saveBlueprint({ name: 'Exact lineup', entries }); } it('cancels unstarted excess creations without booting them and retains their saved keys', async () => { const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const first = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'first' }); const second = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'second' }); if (!first.ok || !second.ok) throw new Error('Fixture queue failed'); const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]); const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); const queued = queueCollection({ previewId: previewCollection(plan.id, 'exact').id }); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare('SELECT state FROM server_deployments WHERE id = ?') .get(second.deploymentId) ).toEqual({ state: 'cancelled' }); expect( db() .prepare('SELECT state FROM server_deployments WHERE id = ?') .get(first.deploymentId) ).toEqual({ state: 'queued' }); expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ done: 1, total: 1, active: 0 }); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); expect(getServerKey(second.name)).toBeUndefined(); expect( db().prepare("SELECT pluto_key FROM server_keys WHERE pluto_key='second'").get() ).toEqual({ pluto_key: 'second' }); }); it('rolls back removals and creation cancellations when the game pool is exhausted', async () => { const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const old = queueCreateServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' }); if (!old.ok) throw new Error(old.message); const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 2 }]); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const preview = previewCollection(plan.id, 'exact'); expect(() => queueCollection({ previewId: preview.id })).toThrow( /No available .* server keys/ ); const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(old.deploymentId) ).toEqual({ state: 'queued' }); for (const table of [ 'server_collection_removals', 'server_launch_collections', 'server_creation_dependencies' ]) expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]); expect( db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all() ).toEqual([]); }); it('defaults old previews and collections to fill and validates the preview mode API', async () => { const { blueprint: plan } = await previewLineup(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); db().exec('ALTER TABLE server_collection_previews DROP COLUMN mode'); db().exec('ALTER TABLE server_collection_previews DROP COLUMN fleet_snapshot'); db().exec('ALTER TABLE server_launch_collections DROP COLUMN mode'); // Recreate the older schema, which predates the history preservation trigger. db().exec('DROP TRIGGER preserve_deleted_server_history'); db().exec('ALTER TABLE server_launch_collections DROP COLUMN retained_servers'); db().pragma('user_version = 0'); closeDb(); const { previewCollection } = await import('../../src/lib/server/fleet/collections.js'); expect(previewCollection(plan.id).mode).toBe('fill'); const { POST } = await import('../../src/routes/api/deployment-collections/preview/+server.js'); for (const mode of ['exact', 'fill', 'invalid', null, true]) { const response = await POST({ request: new Request('http://localhost/api/deployment-collections/preview', { method: 'POST', body: JSON.stringify({ blueprintId: plan.id, mode }) }) }); expect(response.status).toBe(mode === 'exact' || mode === 'fill' ? 200 : 400); if (response.ok) expect((await response.json()).mode).toBe(mode); } }); });