import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync } from 'node:fs'; import { readFile, rm } 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(), komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })), komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); let fixture: Fixture; const serverName = 't5_zombie_cosmodrome_1'; const input = { gameMode: 'zombie' as const, mapSlug: 'cosmodrome', plutoKey: 'test-key' }; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]); vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test'); vi.stubEnv('KOMODO_API_KEY', 'test'); vi.stubEnv('KOMODO_API_SECRET', 'test'); vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host'); vi.stubEnv('RCON_PASSWORD', 'test-rcon'); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); async function promotedConfig() { const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); for (const artifact of [ { name: 'baseline', kind: 'baseline' as const, scopeType: 'game' as const, scopeValue: 't5', content: Buffer.from('set sv_voice "1"\nset sv_maprotation "map zombie_theater"\n') }, { name: 'map settings', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_cosmodrome', content: Buffer.from('set sv_maprotation "map zombie_moon"\nset g_inactivity "0"\n') }, { name: 'other map', kind: 'config' as const, scopeType: 'map' as const, scopeValue: 'zombie_theater', content: Buffer.from('set sv_voice "0"\n') } ]) { const { script } = createScript(artifact); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?') .run(getLatestVersion(script.id)!.id, script.id); } } describe('server creation map selection', () => { it('installs only promoted scope-matching scripts and plugins before first boot', async () => { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { destOfKind } = await import('../../src/lib/server/config.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); const artifacts = [ { kind: 'script' as const, target: 'zm_spawn_fix.gsc', scopeType: 'game' as const, scopeValue: 't5', promoted: true, expected: true }, { kind: 'plugin' as const, target: 'plugin.dll', scopeType: 'map' as const, scopeValue: 'zombie_cosmodrome', promoted: true, expected: true }, { kind: 'script' as const, target: 'other.gsc', scopeType: 'map' as const, scopeValue: 'zombie_theater', promoted: true, expected: false }, { kind: 'script' as const, target: 'draft.gsc', scopeType: 'game' as const, scopeValue: 't5', promoted: false, expected: false } ].map((a) => { const { script, version } = createScript({ name: a.target, kind: a.kind, targetRelPath: a.target, scopeType: a.scopeType, scopeValue: a.scopeValue, content: Buffer.from(a.target) }); if (a.promoted) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); return { ...a, version }; }); vi.mocked(komodoDeploy).mockImplementationOnce(async () => { for (const a of artifacts) { const path = join(destOfKind(serverName, a.kind), a.target); expect(existsSync(path)).toBe(a.expected); if (a.expected) { expect(await readFile(path)).toEqual(a.version.content); expect(getServerScript(serverName, a.target)).toMatchObject({ script_version_id: a.version.id, source: 'create' }); } } return { ok: true }; }); expect(await createServer(input)).toMatchObject({ ok: true }); expect(await scanServerDrift(serverName)).toEqual([]); }); it('keeps Plutainer defaults without a baseline and overrides the map before rotation', async () => { const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const result = await createServer(input); expect(result).toMatchObject({ ok: true, name: serverName }); expect(existsSync(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'))).toBe(false); const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0]; const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value])); expect(env.PLUTAINER_MAP_ROTATE).toBe('false'); expect(env.PLUTAINER_EXTRA_ARGS).toBe(`+exec electric_cherry_map.cfg +set tws_server_id ${serverName} +map_rotate`); expect(await readFile(join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg'), 'utf8')) .toContain('set sv_maprotation "map zombie_cosmodrome"'); expect(env.PLUTAINER_CONFIG_FILE).toBe('dedicated_sp.cfg'); expect(env.PLUTAINER_SKIP_SEED).toBeUndefined(); expect(komodoDeploy).toHaveBeenCalledWith(serverName); expect(getServer(serverName)?.map).toBe('zombie_cosmodrome'); const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js'); expect(await scanServerDrift(serverName)).toEqual([]); const mapPath = join(fixture.t5, serverName, 'configs', 'electric_cherry_map.cfg'); await rm(mapPath); expect((await runDriftDeployment({ servers: [serverName] })).totalRestored).toBe(1); expect(await readFile(mapPath, 'utf8')).toContain('set sv_maprotation "map zombie_cosmodrome"'); expect(await scanServerDrift(serverName)).toEqual([]); }); it('writes promoted settings and selected map before first boot and tracks the file', async () => { await promotedConfig(); const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); vi.mocked(komodoDeploy).mockImplementationOnce(async () => { const config = await readFile(join(fixture.t5, serverName, 'configs', 'dedicated_sp.cfg'), 'utf8'); expect(config).toContain('set sv_maprotation "map zombie_cosmodrome"'); expect(config).toContain('set sv_voice "1"'); expect(config).toContain('set g_inactivity "0"'); expect(config).not.toContain('map zombie_moon'); expect(getServerScript(serverName, 'dedicated_sp.cfg')?.source).toBe('create'); return { ok: true }; }); expect(await createServer(input)).toMatchObject({ ok: true }); expect(await scanServerDrift(serverName)).toEqual([]); }); it('removes the generated config and inventory if deployment fails', async () => { await promotedConfig(); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { script, version } = createScript({ name: 'spawn fix', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'zm_spawn_fix.gsc', content: Buffer.from('main(){}') }); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); vi.mocked(komodoDeploy).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'test failure' }); expect(await createServer(input)).toMatchObject({ ok: false, kind: 'komodo' }); expect(getServer(serverName)).toBeUndefined(); expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined(); expect(getServerScript(serverName, 'zm_spawn_fix.gsc')).toBeUndefined(); expect(existsSync(join(fixture.t5, serverName))).toBe(false); }); it('rejects unknown maps before creating a deployment', async () => { const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); expect(await createServer({ ...input, mapSlug: 'unknown' })).toMatchObject({ ok: false, kind: 'unknown-map' }); expect(komodoCreateDeployment).not.toHaveBeenCalled(); }); }); describe('persistent server keys', () => { it('saves a key, retains it through destruction, and reuses it for the exact lobby', async () => { const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); const { getServerKey, listServerKeys } = await import('../../src/lib/server/fleet/server-keys.js'); const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never); const name = 't5_zombie_cosmodrome_2'; expect(await createServer(input)).toMatchObject({ ok: true, name: serverName }); expect(await createServer(input)).toMatchObject({ ok: true, name }); expect(getServerKey(name)).toBe('test-key'); expect(await destroyServer(name)).toMatchObject({ ok: true }); expect(listServerKeys()).toContainEqual(expect.objectContaining({ server_id: name, deployed: 0 })); expect(JSON.stringify(listServerKeys())).not.toContain('test-key'); expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: name })).toMatchObject({ ok: true, name }); const spec = vi.mocked(komodoCreateDeployment).mock.calls.at(-1)![0]; expect(spec.environment).toContainEqual({ variable: 'PLUTO_SERVER_KEY', value: 'test-key' }); expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome' })).toMatchObject({ ok: false, kind: 'bad-request' }); }); it('updates and removes keys independently of deployments and survives reopening the database', async () => { const { saveServerKey, getServerKey, removeServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { closeDb } = await import('../../src/lib/server/db/index.js'); saveServerKey(serverName, ' first-key '); closeDb(); expect(getServerKey(serverName)).toBe('first-key'); saveServerKey(serverName, 'replacement'); expect(getServerKey(serverName)).toBe('replacement'); expect(removeServerKey(serverName)).toBe(true); expect(getServerKey(serverName)).toBeUndefined(); expect(() => saveServerKey('t5_zombie_unknown_1', 'key')).toThrow(); expect(() => saveServerKey(serverName, ' ')).toThrow(); expect(() => saveServerKey(serverName, 'two words')).toThrow(); }); it('fills the lowest gap and rejects stale previews before saving a key or deploying', async () => { const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false } as never); expect(await createServer(input)).toMatchObject({ ok: true, name: serverName }); expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' }); await destroyServer(serverName); expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' })) .toMatchObject({ ok: false, kind: 'no-slot' }); expect(getServerKey(serverName)).toBe('test-key'); expect(komodoCreateDeployment).toHaveBeenCalledTimes(2); expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: serverName })) .toMatchObject({ ok: true, name: serverName }); }); });