226 lines
15 KiB
TypeScript
226 lines
15 KiB
TypeScript
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';
|
|
import type { CollectionPreview } from '../../src/lib/deployment-plan.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 (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
|
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 () => {}) }));
|
|
|
|
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([]);
|
|
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');
|
|
vi.stubEnv('SERVER_PORT_MIN', '28960');
|
|
vi.stubEnv('SERVER_PORT_MAX', '28980');
|
|
});
|
|
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
|
|
|
|
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('saves a mixed-game lineup and previews unique slots and global ports without reserving them', async () => {
|
|
const { blueprint, preview } = await previewLineup();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
|
|
expect(blueprint.totalServers).toBe(5);
|
|
expect(blueprint.entries.map(entry => entry.count)).toEqual([2, 1, 2]);
|
|
expect(preview.ready).toBe(true);
|
|
expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1', 't6_zombie_town_1', 't6_zombie_town_2']);
|
|
expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962, 28963, 28964]);
|
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
|
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
|
|
expect(JSON.stringify(preview)).not.toContain('private-');
|
|
expect(preview.entries[0].recipe.files[0]).not.toHaveProperty('content');
|
|
expect(() => previewCreation({ blueprintId: blueprint.id })).toThrow(/multiple servers/);
|
|
});
|
|
|
|
it('upgrades an existing one-server blueprint and preserves its saved bytes when counts change', async () => {
|
|
const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
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));
|
|
closeDb();
|
|
const old = listBlueprints()[0];
|
|
expect(old.totalServers).toBe(1);
|
|
expect(old.entries).toEqual([{ count: 1, recipe: old.recipe }]);
|
|
const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries });
|
|
expect(edited.totalServers).toBe(5);
|
|
expect(edited.entries[0].recipe.fingerprint).toBe(old.recipe.fingerprint);
|
|
expect(listBlueprints()).toHaveLength(1);
|
|
});
|
|
|
|
it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }],
|
|
[{ 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();
|
|
});
|
|
|
|
it('reports insufficient capacity without preventing blueprint creation', async () => {
|
|
vi.stubEnv('SERVER_PORT_MAX', '28961');
|
|
const { blueprint, preview } = await previewLineup();
|
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(blueprint.totalServers).toBe(5);
|
|
expect(preview.ready).toBe(false);
|
|
expect(preview.servers.filter(server => server.port === null)).toHaveLength(3);
|
|
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/not enough free game ports/);
|
|
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('atomic collection launches', () => {
|
|
it('queues all five servers, persists one group, and completes each independently', async () => {
|
|
const { preview } = await previewLineup();
|
|
const { queueCollection, getCollection, listCollections } = await import('../../src/lib/server/fleet/collections.js');
|
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
|
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
|
expect(komodoCreateDeployment).not.toHaveBeenCalled();
|
|
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
|
|
closeDb();
|
|
await processCreations();
|
|
const detail = getCollection(queued.collectionId)!;
|
|
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 5, failed: 0 });
|
|
expect(listCollections()).toHaveLength(1);
|
|
expect(komodoCreateDeployment).toHaveBeenCalledTimes(5);
|
|
expect(getDeployment(detail.servers[0].id)?.collection).toEqual({ id: queued.collectionId, name: 'Friday Zombies' });
|
|
expect(await readFile(join(fixture.t6, 't6_zombie_town_2', 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
|
|
expect(JSON.stringify(detail)).not.toContain('key-t5');
|
|
});
|
|
|
|
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(preview.servers[0].name!, 'original-key');
|
|
const keys = keysFor(preview);
|
|
delete keys[preview.servers[4].name!];
|
|
expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/No saved key/);
|
|
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!)).toBe('original-key');
|
|
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('rejects changed capacity before saving keys or partially queueing the lineup', async () => {
|
|
const { preview } = await previewLineup();
|
|
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
|
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
queueCreateServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', plutoKey: 'other-server-key' });
|
|
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/availability changed/);
|
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1);
|
|
expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1);
|
|
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
|
|
});
|
|
|
|
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!, `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.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_moon_2', 't6_zombie_town_3', 't6_zombie_town_4']);
|
|
expect(next.servers.map(server => server.port)).toEqual([28965, 28966, 28967, 28968, 28969]);
|
|
});
|
|
|
|
it('keeps the reviewed lineup and bytes after blueprint edits, deletion, and promotion', async () => {
|
|
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { script, version } = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('original') });
|
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
|
const { preview, blueprint } = await previewLineup();
|
|
const { updateBlueprint, deleteBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
|
const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
|
const newer = uploadNewVersion(script.id, Buffer.from('new version'));
|
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newer.id, script.id);
|
|
updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] });
|
|
deleteBlueprint(blueprint.id);
|
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
|
await processCreations();
|
|
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
|
|
for (const name of ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1']) {
|
|
expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('original');
|
|
}
|
|
});
|
|
|
|
it('keeps a provisioning failure separate while the other servers complete', async () => {
|
|
const { preview } = await previewLineup();
|
|
const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
|
vi.mocked(komodoCreateDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Host rejected this container' });
|
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
|
await processCreations();
|
|
const detail = getCollection(queued.collectionId)!;
|
|
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 4, failed: 1 });
|
|
expect(detail.servers[0]).toMatchObject({ state: 'failed', error: expect.stringContaining('Host rejected') });
|
|
expect(detail.servers.slice(1).every(server => server.state === 'done')).toBe(true);
|
|
});
|
|
|
|
it('validates collection API payloads and rejects an unused expired preview', async () => {
|
|
const { preview } = await previewLineup();
|
|
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([]);
|
|
});
|
|
});
|