import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoCreateDeployment: vi.fn(async () => ({ ok: true })), komodoDeploy: vi.fn(async () => ({ ok: true })), komodoDestroyDeployment: vi.fn(async () => ({ ok: true })), komodoDeleteDeployment: vi.fn(async () => ({ ok: true })), komodoInspectHealthy: vi.fn(async () => ({ ok: true, state: 'running' })), komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })), komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true })) })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); let fixture: Fixture; const selection = { game: 't5' as const, mapSlug: 'theater' }; const input = { ...selection, gameMode: 'zombie' as const, plutoKey: 'private-pluto-key' }; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]); vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test'); vi.stubEnv('KOMODO_API_KEY', 'private-api-key'); vi.stubEnv('KOMODO_API_SECRET', 'private-api-secret'); vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host'); vi.stubEnv('RCON_PASSWORD', 'private-rcon-password'); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); async function seedArtifacts() { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const baseline = createScript({ name: 'Classic settings', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') }); const script = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('main(){ old_perks(); }') }); for (const item of [baseline, script]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id); return { baseline, script }; } describe('launch recipes and previews', () => { it('previews without reserving capacity, saving keys, exposing bytes, or calling Komodo', async () => { await seedArtifacts(); const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); const first = previewCreation(selection); const second = previewCreation(selection); expect(first).toMatchObject({ name: 't5_zombie_theater_1', port: 28960, hasSavedKey: false, ready: true }); expect(second.name).toBe(first.name); expect(second.port).toBe(first.port); expect(first.recipe.files).toHaveLength(3); expect(first.recipe.files[0]).not.toHaveProperty('content'); expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); expect(komodoCreateDeployment).not.toHaveBeenCalled(); expect(JSON.stringify(first)).not.toContain('private-'); }); it('uses promotions made after review and queueing, including plugins and composed configuration', async () => { const { baseline, script } = await seedArtifacts(); const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const plugin = createScript({ name: 'Game utilities', kind: 'plugin', scopeType: 'game', scopeValue: 't5', targetRelPath: 'utilities.dll', content: Buffer.from('old plugin') }); const overlay = createScript({ name: 'Kino settings', kind: 'config', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('set g_speed "180"\n') }); for (const item of [plugin, overlay]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id); const preview = previewCreation(selection); const newScript = uploadNewVersion(script.script.id, Buffer.from('main(){ new_perks(); }')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newScript.id, script.script.id); const newOverlay = uploadNewVersion(overlay.script.id, Buffer.from('set g_speed "200"\n')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newOverlay.id, overlay.script.id); uploadNewVersion(script.script.id, Buffer.from('main(){ unpromoted_draft(); }')); const queued = queueCreateServer({ ...input, previewId: preview.id }); if (!queued.ok) throw new Error(queued.message); expect(recipeForDeployment(queued.deploymentId)?.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: newScript.id })); const newBaseline = uploadNewVersion(baseline.script.id, Buffer.from('set sv_voice "0"\n')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newBaseline.id, baseline.script.id); const newPlugin = uploadNewVersion(plugin.script.id, Buffer.from('new plugin')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newPlugin.id, plugin.script.id); uploadNewVersion(plugin.script.id, Buffer.from('unpromoted plugin')); closeDb(); await processCreations(); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ new_perks(); }'); const deployedConfig = await readFile(join(fixture.t5, queued.name, 'configs/dedicated_sp.cfg'), 'utf8'); expect(deployedConfig).toContain('set sv_voice "0"'); expect(deployedConfig).toContain('set g_speed "200"'); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/plugins/utilities.dll'), 'utf8')).toBe('new plugin'); expect(getDeployment(queued.deploymentId)?.recipe?.files).toContainEqual(expect.objectContaining({ path: 'utilities.dll', versionId: newPlugin.id })); expect(getServerScript(queued.name, 'perks.gsc')).toMatchObject({ script_version_id: newScript.id, sha256: newScript.sha256 }); expect(getServerScript(queued.name, 'utilities.dll')).toMatchObject({ script_version_id: newPlugin.id, sha256: newPlugin.sha256 }); const recordedConfig = recipeForDeployment(queued.deploymentId)?.recipe.files.find(file => file.path === 'dedicated_sp.cfg'); expect(Buffer.from(recordedConfig!.content, 'base64').toString()).toBe(deployedConfig); expect(getDeployment(queued.deploymentId)?.recipe?.fingerprint).not.toBe(preview.recipe.fingerprint); expect(getDeployment(queued.deploymentId)?.deployment).toMatchObject({ state: 'done' }); expect(JSON.stringify(getDeployment(queued.deploymentId))).not.toContain('private-'); }); it('uses the latest promotion at provisioning for API clients without a preview', async () => { const { script } = await seedArtifacts(); const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const queued = queueCreateServer(input); if (!queued.ok) throw new Error(queued.message); const changed = uploadNewVersion(script.script.id, Buffer.from('changed')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(changed.id, script.script.id); await processCreations(); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('changed'); }); it('returns the same operation for repeated submission, even after expiry and restart', async () => { const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const preview = previewCreation(selection); const queued = queueCreateServer({ ...input, previewId: preview.id }); expect(queued.ok).toBe(true); db().prepare("UPDATE server_launch_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id); closeDb(); expect(queueCreateServer({ ...input, previewId: preview.id, plutoKey: 'replacement-key' })).toEqual(queued); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1); expect(getServerKey(preview.name!)).toBe('private-pluto-key'); }); it('rejects stale slots and ports before saving a key or queueing a second server', async () => { const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const preview = previewCreation(selection); expect(queueCreateServer({ ...input, mapSlug: 'moon' }).ok).toBe(true); expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'no-slot' }); expect(getServerKey(preview.name!)).toBeUndefined(); const fresh = previewCreation(selection); expect(fresh.port).toBe(28961); expect(queueCreateServer({ ...input, previewId: fresh.id }).ok).toBe(true); expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'no-slot' }); expect(getServerKey('t5_zombie_theater_2')).toBeUndefined(); }); it('does not provision an already accepted preview again through internal callers', async () => { const { previewCreation, createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const preview = previewCreation(selection); const first = await createServer({ ...input, previewId: preview.id }); expect(first.ok).toBe(true); expect(await createServer({ ...input, previewId: preview.id })).toEqual(first); expect(komodoCreateDeployment).toHaveBeenCalledTimes(1); expect(komodoDeploy).toHaveBeenCalledTimes(1); }); it('carries preview identity through the public creation API and rejects malformed requests', async () => { const { POST: preview } = await import('../../src/routes/api/deployments/preview/+server.js'); const { POST: launch } = await import('../../src/routes/api/servers/+server.js'); const request = (body: unknown) => ({ request: new Request('http://localhost/api', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }); for (const body of [null, [], { game: 't7', mapSlug: 'theater' }]) expect((await preview(request(body))).status).toBe(400); const response = await preview(request(selection)); const plan = await response.json(); expect(response.status).toBe(200); expect((await launch(request({ ...input, previewId: '' }))).status).toBe(400); const first = await launch(request({ ...input, previewId: plan.id, expectedName: plan.name })); expect(first.status).toBe(202); const retry = await launch(request({ ...input, previewId: plan.id, expectedName: plan.name })); expect(await retry.json()).toEqual(await first.json()); }); it('rejects expired, missing, and mismatched previews', async () => { const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const { db } = await import('../../src/lib/server/db/index.js'); const preview = previewCreation(selection); expect(queueCreateServer({ ...input, mapSlug: 'moon', previewId: preview.id })).toMatchObject({ ok: false, kind: 'bad-request' }); db().prepare("UPDATE server_launch_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id); expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'bad-request' }); expect(queueCreateServer({ ...input, previewId: 'missing' })).toMatchObject({ ok: false, kind: 'bad-request' }); expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]); }); it('reports configuration blockers while still allowing recipes to be saved', async () => { vi.stubEnv('KOMODO_TARGET_SERVER', ''); vi.stubEnv('RCON_PASSWORD', ''); const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const preview = previewCreation(selection); expect(preview.ready).toBe(false); expect(preview.checks.filter(c => c.state === 'blocked')).toHaveLength(2); expect(saveBlueprint({ name: 'For later', previewId: preview.id }).name).toBe('For later'); expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'unconfigured' }); }); it('resolves current file eligibility after a blueprint review and queued launch', async () => { const { script } = await seedArtifacts(); const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { saveBlueprint, deleteBlueprint, listBlueprints, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { createScript, setScope } = await import('../../src/lib/server/fleet/scripts.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const removed = createScript({ name: 'Removed script', targetRelPath: 'removed.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('removed') }); const unpromoted = createScript({ name: 'Unpromoted script', targetRelPath: 'unpromoted.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('unpromoted') }); for (const item of [removed, unpromoted]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id); const blueprint = saveBlueprint({ name: 'Classic night', description: 'Kino lineup', previewId: previewCreation(selection).id }); const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get(blueprint.id) as { recipe: string }; expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: [{ count: 1, ...selection }] }); closeDb(); expect(listBlueprints()[0]).toEqual(blueprint); const preview = previewCreation({ blueprintId: blueprint.id }); deleteBlueprint(blueprint.id); const queued = queueCreateServer({ ...input, previewId: preview.id }); if (!queued.ok) throw new Error(queued.message); db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(removed.script.id); db().prepare('DELETE FROM scripts WHERE id = ?').run(removed.script.id); db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(unpromoted.script.id); setScope({ scriptId: script.script.id, scopeType: 'map', scopeValue: 'zombie_moon' }); const added = createScript({ name: 'New telemetry', targetRelPath: 'telemetry.gsc', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('newly promoted telemetry') }); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(added.version.id, added.script.id); createScript({ name: 'Draft telemetry', targetRelPath: 'draft.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('never promoted') }); await processCreations(); for (const file of ['perks.gsc', 'removed.gsc', 'unpromoted.gsc', 'draft.gsc']) { await expect(readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp', file))).rejects.toMatchObject({ code: 'ENOENT' }); } expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/telemetry.gsc'), 'utf8')).toBe('newly promoted telemetry'); expect(recipeForDeployment(queued.deploymentId)?.blueprintName).toBe('Classic night'); expect(recipeForDeployment(queued.deploymentId)?.recipe.files.filter(file => file.kind === 'script')).toEqual([ expect.objectContaining({ path: 'telemetry.gsc', versionId: added.version.id }) ]); }); it('launches again with current promotions while preserving the completed deployment record', async () => { const { script } = await seedArtifacts(); const { previewCreation, createServer, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { saveBlueprint, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const original = await createServer(input); if (!original.ok) throw new Error(original.message); const historicalRecipe = recipeForDeployment(original.deploymentId); const promoted = uploadNewVersion(script.script.id, Buffer.from('current telemetry')); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(promoted.id, script.script.id); const replay = previewCreation({ deploymentId: original.deploymentId }); expect(replay).toMatchObject({ name: 't5_zombie_theater_2', port: 28961, hasSavedKey: false }); expect(replay.recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: promoted.id })); const blueprint = saveBlueprint({ name: 'Restored', deploymentId: original.deploymentId }); expect(blueprint.entries).toEqual([{ count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' }]); const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get(blueprint.id) as { recipe: string }; expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: [{ count: 1, ...selection }] }); expect(previewCreation({ blueprintId: blueprint.id }).recipe.files).toContainEqual(expect.objectContaining({ path: 'perks.gsc', versionId: promoted.id })); const queued = queueCreateServer({ ...input, previewId: replay.id }); if (!queued.ok) throw new Error(queued.message); await processCreations(); expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('current telemetry'); expect(recipeForDeployment(original.deploymentId)).toEqual(historicalRecipe); expect(await readFile(join(fixture.t5, original.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }'); }); it('preserves the exact T6 map variant in a blueprint', async () => { const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const preview = previewCreation({ game: 't6', mapSlug: 'town' }); const blueprint = saveBlueprint({ name: 'Town survival', previewId: preview.id }); const saved = previewCreation({ blueprintId: blueprint.id }); expect(saved.recipe).toMatchObject({ game: 't6', mapSlug: 'town', expectedMap: 'zm_transit', configFile: 'dedicated_zm.cfg' }); const queued = queueCreateServer({ ...input, game: 't6', mapSlug: 'town', previewId: saved.id }); if (!queued.ok) throw new Error(queued.message); await processCreations(); expect(await readFile(join(fixture.t6, queued.name, 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit'); }); it('validates blueprint names, sources, and case-insensitive duplicates', async () => { const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const previewId = previewCreation(selection).id; expect(() => saveBlueprint({ name: '', previewId })).toThrow(/name/); expect(() => saveBlueprint({ name: 'Valid', previewId, deploymentId: 'also' })).toThrow(/exactly one/); expect(() => saveBlueprint({ name: 'Valid', previewId: 'missing' })).toThrow(/unavailable/); saveBlueprint({ name: 'Classic Night', previewId }); expect(() => saveBlueprint({ name: ' classic night ', previewId })).toThrow(/already exists/); }); }); describe('resuming verification', () => { it('resumes an interrupted creation without creating or restarting its container again', async () => { const { createServer, processCreations } = await import('../../src/lib/server/fleet/create.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { interruptDeployments, getDeployment, resumeVerification } = await import('../../src/lib/server/fleet/deployments.js'); const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const created = await createServer(input); if (!created.ok) throw new Error(created.message); db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(created.deploymentId); interruptDeployments(); expect(getDeployment(created.deploymentId)?.canResumeVerification).toBe(true); expect(resumeVerification(created.deploymentId)).toBe(true); expect(resumeVerification(created.deploymentId)).toBe(false); await processCreations(); expect(getDeployment(created.deploymentId)?.deployment).toMatchObject({ state: 'done', error: null }); expect(komodoCreateDeployment).toHaveBeenCalledTimes(1); expect(komodoDeploy).toHaveBeenCalledTimes(1); }); it('refuses to observe a replacement server or interfere with a later destruction', async () => { const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { finishDeployment, resumeVerification, getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); const created = await createServer(input); if (!created.ok) throw new Error(created.message); finishDeployment(created.deploymentId, { ok: false, message: 'Interrupted' }); db().prepare('UPDATE servers SET game_port = game_port + 1 WHERE name = ?').run(created.name); expect(resumeVerification(created.deploymentId)).toBe(false); db().prepare('UPDATE servers SET game_port = ? WHERE name = ?').run(created.port, created.name); expect(getDeployment(created.deploymentId)?.canResumeVerification).toBe(true); destroyServer(created.name); expect(resumeVerification(created.deploymentId)).toBe(false); }); });