Files
electric-cherry/tests/unit/fleet-settings.test.ts
josh 8d1bb69591
CI / checks (push) Successful in 5m6s
Use ordered SQLite migrations (Refs #75)
2026-10-10 00:01:18 -04:00

341 lines
14 KiB
TypeScript

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();
db().pragma('user_version = 0');
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));
db().pragma('user_version = 0');
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();
db().pragma('user_version = 0');
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'
);
});
});