171 lines
6.8 KiB
TypeScript
171 lines
6.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
|
import { ConfigEditorService } from '../src/modules/config/config-editor-service.js';
|
|
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
|
|
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
|
|
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
|
|
import type { ServerRecord } from '../src/modules/servers/server-service.js';
|
|
import { createLogger } from '../src/lib/logger.js';
|
|
|
|
const server: ServerRecord = {
|
|
id: 'srv-1',
|
|
slug: 'training-server',
|
|
name: 'SCAR Operations',
|
|
providerType: 'pterodactyl',
|
|
pterodactylServerId: 'abc123',
|
|
status: 'online',
|
|
maxPlayers: 16,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
describe('PerformanceSettingsService', () => {
|
|
let provider: MockGameServerProvider;
|
|
let gateway: ConfigFileGateway;
|
|
let editor: ConfigEditorService;
|
|
let service: PerformanceSettingsService;
|
|
|
|
beforeEach(() => {
|
|
provider = new MockGameServerProvider();
|
|
gateway = new ConfigFileGateway(provider, '/config.json');
|
|
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
|
|
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
|
|
service = new PerformanceSettingsService(gateway, editor, createLogger('silent'));
|
|
});
|
|
|
|
it('reads current values, reporting absent keys as null', async () => {
|
|
const { settings } = await service.get(server);
|
|
// Present in the mock config.json:
|
|
expect(settings.maxPlayers).toBe(16);
|
|
expect(settings.serverMaxViewDistance).toBe(2500);
|
|
expect(settings.disableAI).toBe(false);
|
|
expect(settings.aiLimit).toBe(40);
|
|
expect(settings.disableThirdPerson).toBe(false);
|
|
// Absent keys:
|
|
expect(settings.playerSaveTime).toBeNull();
|
|
expect(settings.fastValidation).toBeNull();
|
|
});
|
|
|
|
it('sets changed values and removes nulled keys, preserving everything else', async () => {
|
|
const result = await service.update(server, {
|
|
maxPlayers: 32,
|
|
playerSaveTime: 180, // new key
|
|
disableAI: null,
|
|
aiLimit: null, // remove key -> game default
|
|
});
|
|
|
|
expect(result.changedFields.sort()).toEqual([
|
|
'aiLimit',
|
|
'disableAI',
|
|
'maxPlayers',
|
|
'playerSaveTime',
|
|
]);
|
|
expect(result.requiresRestart).toBe(true);
|
|
|
|
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
|
expect(written.game.maxPlayers).toBe(32);
|
|
expect(written.operating.playerSaveTime).toBe(180);
|
|
expect('aiLimit' in written.operating).toBe(false);
|
|
// Untouched fields preserved:
|
|
expect(written.bindPort).toBe(2001);
|
|
expect(written.game.scenarioId).toContain('Missions');
|
|
expect(written.game.mods).toHaveLength(1);
|
|
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
|
|
});
|
|
|
|
/**
|
|
* The old form posted every field on every save, so a form loaded before
|
|
* somebody else's change silently reverted it. Only the submitted keys may
|
|
* ever be written.
|
|
*/
|
|
it('leaves fields the caller did not submit alone', async () => {
|
|
await service.update(server, { maxPlayers: 48 });
|
|
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
|
expect(written.game.maxPlayers).toBe(48);
|
|
expect(written.game.gameProperties.serverMaxViewDistance).toBe(2500);
|
|
expect(written.operating.aiLimit).toBe(40);
|
|
});
|
|
|
|
it('does not write the file at all when nothing changed', async () => {
|
|
const { settings } = await service.get(server);
|
|
const result = await service.update(server, {
|
|
maxPlayers: settings.maxPlayers,
|
|
aiLimit: settings.aiLimit,
|
|
});
|
|
expect(result.changedFields).toEqual([]);
|
|
expect(provider.writtenFiles.has('/config.json')).toBe(false);
|
|
});
|
|
|
|
it('rejects a save based on a revision that has since moved', async () => {
|
|
const stale = (await service.get(server)).revision;
|
|
await service.update(server, { maxPlayers: 24 });
|
|
await expect(
|
|
service.update(server, { maxPlayers: 48 }, { expectedRevision: stale }),
|
|
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
});
|
|
});
|
|
|
|
describe('ConfigEditorService', () => {
|
|
let provider: MockGameServerProvider;
|
|
let editor: ConfigEditorService;
|
|
|
|
beforeEach(() => {
|
|
provider = new MockGameServerProvider();
|
|
const gateway = new ConfigFileGateway(provider, '/config.json');
|
|
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
|
|
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
|
|
});
|
|
|
|
it('exposes every key in the file, not just the ones the panel knows', async () => {
|
|
const tree = await editor.getTree(server);
|
|
const paths = tree.entries.map((entry) => entry.path);
|
|
expect(paths).toContain('bindAddress');
|
|
expect(paths).toContain('game.crossPlatform');
|
|
expect(paths).toContain('game.gameProperties.networkViewDistance');
|
|
expect(tree.revision).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
/**
|
|
* Reforger eggs often re-template config.json from startup variables at
|
|
* boot, which is why edits could appear to save and then vanish.
|
|
*/
|
|
it('flags config keys that a startup variable also controls', async () => {
|
|
const tree = await editor.getTree(server);
|
|
const mirror = tree.mirrors.find((entry) => entry.envVariable === 'MAX_PLAYERS');
|
|
expect(mirror).toBeDefined();
|
|
expect(mirror!.configPath).toBe('game.maxPlayers');
|
|
});
|
|
|
|
it('patches an arbitrary path and reports what changed', async () => {
|
|
const result = await editor.patch(server, [
|
|
{ path: 'operating.slotReservationTimeout', value: 90 },
|
|
]);
|
|
expect(result.changedPaths).toEqual(['operating.slotReservationTimeout']);
|
|
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
|
expect(written.operating.slotReservationTimeout).toBe(90);
|
|
});
|
|
|
|
it('mirrors a changed value into its startup variable when asked', async () => {
|
|
const result = await editor.patch(server, [{ path: 'game.maxPlayers', value: 40 }], {
|
|
writeStartupVars: true,
|
|
});
|
|
expect(result.startupVarsWritten).toContain('MAX_PLAYERS');
|
|
const variables = await provider.listStartupVariables();
|
|
expect(variables.find((v) => v.envVariable === 'MAX_PLAYERS')?.serverValue).toBe('40');
|
|
});
|
|
|
|
it('round-trips the raw editor and rejects invalid JSON', async () => {
|
|
const raw = await editor.getRaw(server);
|
|
expect(JSON.parse(raw.content).game.name).toBe('Mock Reforger Server');
|
|
await expect(editor.putRaw(server, '{ nope')).rejects.toMatchObject({
|
|
code: 'VALIDATION_ERROR',
|
|
});
|
|
});
|
|
|
|
it('refuses a raw write that would drop the game section', async () => {
|
|
await expect(editor.putRaw(server, '{"bindPort":2001}')).rejects.toMatchObject({
|
|
code: 'VALIDATION_ERROR',
|
|
});
|
|
});
|
|
});
|