import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; import { defaultFleetSettings as defaults, additionalConcurrencyKeys, fleetSettingLabels, settingDisplayValue, settingHelp, settingMinimum, settingsGroups, settingUnit, settingZeroMode, validateFleetSettings, type FleetSettings } from '../../src/lib/fleet-settings.js'; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); for (const key of [ 'DEPLOY_KEEP_DAYS', 'DEPLOYMENT_RESTART_CONCURRENCY', 'DEPLOYMENT_CREATE_CONCURRENCY' ]) vi.stubEnv(key, undefined); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); describe('fleet settings API and persistence', () => { it('returns defaults and saves all settings across a module reload and database reopen', async () => { const { GET, PUT } = await import('../../src/routes/api/settings/+server.js'); expect(await GET().json()).toEqual(defaults); const saved = { ...defaults, deployKeepDays: 30, observationKeepDays: 5, restartConcurrency: 3, createConcurrency: 2, destroyConcurrency: 4 }; const response = await PUT({ request: new Request('http://localhost/api/settings', { method: 'PUT', body: JSON.stringify(saved) }) }); expect(response.status).toBe(200); expect(await response.json()).toEqual(saved); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); vi.resetModules(); // Once saved, even invalid legacy environment values are irrelevant. vi.stubEnv('DEPLOY_KEEP_DAYS', '-1'); vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', 'bad'); vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '9'); const reopened = await import('../../src/routes/api/settings/+server.js'); expect(await reopened.GET().json()).toEqual(saved); }); it('imports legacy values into an existing database once and preserves unrelated settings', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); db().prepare("DELETE FROM settings WHERE key = 'fleet_settings'").run(); db().prepare("INSERT INTO settings (key, value) VALUES ('unrelated', 'true')").run(); closeDb(); vi.stubEnv('DEPLOY_KEEP_DAYS', '14'); vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '4'); vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '2'); const { fleetSettings } = await import('../../src/lib/server/settings.js'); expect(fleetSettings()).toEqual({ ...defaults, deployKeepDays: 14, observationKeepDays: 1, restartConcurrency: 4, createConcurrency: 2, destroyConcurrency: 1 }); closeDb(); vi.stubEnv('DEPLOY_KEEP_DAYS', '7'); expect(fleetSettings().deployKeepDays).toBe(14); expect(db().prepare("SELECT value FROM settings WHERE key = 'unrelated'").get()).toEqual({ value: 'true' }); }); it('upgrades saved settings with a default destruction limit while retaining existing values', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const previous = { deployKeepDays: 21, restartConcurrency: 3, createConcurrency: 2 }; db() .prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'") .run(JSON.stringify(previous)); closeDb(); vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad'); vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '9'); vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '8'); const { GET } = await import('../../src/routes/api/settings/+server.js'); const upgraded = { ...defaults, ...previous, destroyConcurrency: 1, observationKeepDays: 1 }; expect(await GET().json()).toEqual(upgraded); expect( JSON.parse( ( db() .prepare("SELECT value FROM settings WHERE key = 'fleet_settings'") .get() as { value: string } ).value ) ).toEqual(upgraded); closeDb(); expect(await GET().json()).toEqual(upgraded); }); it('adds routine observation retention to modern saved settings without changing any existing values', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const previous = { deployKeepDays: 45, restartConcurrency: 3, createConcurrency: 2, destroyConcurrency: 4 }; db() .prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'") .run(JSON.stringify(previous)); db().prepare("INSERT INTO settings(key,value) VALUES('unrelated','keep')").run(); closeDb(); vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad'); const { GET } = await import('../../src/routes/api/settings/+server.js'); const upgraded = { ...defaults, ...previous, observationKeepDays: 1 }; expect(await GET().json()).toEqual(upgraded); expect( JSON.parse( ( db() .prepare("SELECT value FROM settings WHERE key = 'fleet_settings'") .get() as { value: string } ).value ) ).toEqual(upgraded); expect(db().prepare("SELECT value FROM settings WHERE key='unrelated'").get()).toEqual({ value: 'keep' }); closeDb(); expect(await GET().json()).toEqual(upgraded); }); it.each([0, 36500])( 'persists routine observation retention of %s days across database reopen', async (observationKeepDays) => { const { GET, PUT } = await import('../../src/routes/api/settings/+server.js'); const saved = { ...defaults, observationKeepDays }; const response = await PUT({ request: new Request('http://localhost/api/settings', { method: 'PUT', body: JSON.stringify(saved) }) }); expect(response.status).toBe(200); expect(await response.json()).toEqual(saved); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); expect(await GET().json()).toEqual(saved); } ); it.each([ 'null', '[]', '{}', '{', ...['restartConcurrency', 'createConcurrency', 'destroyConcurrency'].flatMap((key) => [0, -1, 1.5, '2', true, null, 9007199254740992].map((value) => JSON.stringify({ ...defaults, [key]: value }) ) ), ...[ 'deployKeepDays', 'observationKeepDays', 'failureKeepDays', 'matchKeepDays', 'gameFileGraceDays' ].flatMap((key) => [-1, 1.5, '2', true, null, 36501, 9007199254740992].map((value) => JSON.stringify({ ...defaults, [key]: value }) ) ), JSON.stringify({ deployKeepDays: 7, restartConcurrency: 1, createConcurrency: 1 }), JSON.stringify({ deployKeepDays: 7, restartConcurrency: 1, createConcurrency: 1, destroyConcurrency: 1 }), JSON.stringify({ ...defaults, deployKeepDays: 36501 }), JSON.stringify({ ...defaults, unknown: 1 }) ])('rejects invalid input without partially saving: %s', async (body) => { const { GET, PUT } = await import('../../src/routes/api/settings/+server.js'); const response = await PUT({ request: new Request('http://localhost/api/settings', { method: 'PUT', body }) }); expect(response.status).toBe(400); expect(await response.json()).toHaveProperty('error'); expect(await GET().json()).toEqual(defaults); }); }); it('applies saved retention to cleanup while protecting unfinished campaigns and provisioning history', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); const { sweep } = await import('../../src/lib/server/pipeline/sweep.js'); const old = new Date(Date.now() - 10 * 86400000).toISOString(); for (const id of ['completed', 'pending', 'failed', 'held']) { db() .prepare("INSERT INTO deployments (id, action, started_at) VALUES (?, 'test', ?)") .run(id, old); db() .prepare( 'INSERT INTO deployment_servers (deployment_id, server, restart_state, files_changed) VALUES (?, ?, ?, 0)' ) .run(id, 'server-' + id, id === 'completed' ? 'done' : id); } db() .prepare( "INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES ('history', 'removed', 'destroy', 'done', 'Complete', ?, 'ui')" ) .run(old); saveFleetSettings({ ...fleetSettings(), deployKeepDays: 30 }); await sweep(); expect(db().prepare('SELECT id FROM deployments').all()).toHaveLength(4); saveFleetSettings({ ...fleetSettings(), deployKeepDays: 7 }); await sweep(); const { pruneHistory } = await import('../../src/lib/server/retention.js'); pruneHistory(); expect(db().prepare('SELECT id FROM deployments ORDER BY id').all()).toEqual([ { id: 'failed' }, { id: 'held' }, { id: 'pending' } ]); expect( db() .prepare("SELECT server FROM deployment_servers WHERE deployment_id = 'completed'") .all() ).toEqual([]); expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([{ id: 'history' }]); }); it('saves a section without overwriting changes in the other section', async () => { const { PATCH, GET } = await import('../../src/routes/api/settings/+server.js'); const { concurrencyKeys } = await import('../../src/lib/fleet-settings.js'); const { saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...defaults, deployKeepDays: 42 }); const response = await PATCH({ request: new Request('http://localhost/api/settings', { method: 'PATCH', body: JSON.stringify({ section: 'Concurrency', values: Object.fromEntries( concurrencyKeys.map((key) => [ key, key === 'rebuildConcurrency' ? 2 : defaults[key] ]) ) }) }) }); expect(response.status).toBe(200); expect(await GET().json()).toMatchObject({ deployKeepDays: 42, rebuildConcurrency: 2 }); }); describe('settings presentation semantics', () => { it('places every saved setting in exactly one searchable group', () => { const grouped = settingsGroups.flatMap((group) => group.keys); expect(grouped.length).toBe(Object.keys(defaults).length); expect(new Set(grouped).size).toBe(grouped.length); expect([...grouped].sort()).toEqual(Object.keys(defaults).sort()); expect(settingsGroups.map((group) => group.title)).toEqual([ 'Concurrency', 'Operation history', 'Matches & players', 'Telemetry & metrics', 'Files & versions', 'Accounts & audit' ]); }); it('distinguishes an unlimited cap from permanent retention, age expiry, and minimum counts', () => { for (const key of [ ...additionalConcurrencyKeys, 'telemetryMaxPerServer', 'telemetryMaxTotal' ] as (keyof FleetSettings)[]) { expect(settingZeroMode(key)).toBe('No limit'); } for (const key of Object.keys(defaults) as (keyof FleetSettings)[]) { if (key.endsWith('Days') && !['versionKeepDays', 'gameFileGraceDays'].includes(key)) expect(settingZeroMode(key)).toBe('Forever'); if (settingMinimum(key) === 1) expect(settingZeroMode(key)).toBeNull(); } expect(settingZeroMode('versionKeepDays')).toBe('No age expiry'); expect(settingZeroMode('gameFileGraceDays')).toBe('No delay'); expect(settingZeroMode('versionKeepCount')).toBe('No minimum'); expect(settingZeroMode('historyKeepCount')).toBe('No minimum'); expect(settingHelp.versionKeepDays).toContain('version count policy may still remove'); expect(settingHelp.versionKeepCount).toContain( 'a positive count removes older unused versions' ); }); it('formats numeric values and defaults without changing their stored meaning', () => { const original = structuredClone(defaults); for (const key of Object.keys(defaults) as (keyof FleetSettings)[]) { const zero = settingZeroMode(key); if (zero) { expect(settingDisplayValue(key, 0)).toBe(zero); expect(validateFleetSettings({ ...defaults, [key]: 0 })[key]).toBe(0); } expect(settingDisplayValue(key, 1)).toBe(`1 ${settingUnit(key).slice(0, -1)}`); expect(settingDisplayValue(key, 14)).toBe(`14 ${settingUnit(key)}`); expect(validateFleetSettings({ ...defaults, [key]: 14 })[key]).toBe(14); } expect(settingDisplayValue('telemetryMaxTotal', 10_000)).toBe('10,000 records'); expect(settingDisplayValue('preparationConcurrency', 2)).toBe('2 hosts'); expect(settingDisplayValue('versionKeepCount', 1)).toBe('1 version'); expect(settingDisplayValue('matchKeepDays', NaN)).toBe('—'); expect(defaults).toEqual(original); expect(fleetSettingLabels.destroyConcurrency).toBe('Concurrent server removals'); expect(fleetSettingLabels.destroyConcurrencyPerHost).toBe( 'Concurrent server removals per host' ); }); });