overhaul
This commit is contained in:
66 files changed
+9227
-3679
No files matched your search
@@ -16,7 +16,9 @@ import type { ResourceHistoryService } from '../src/modules/servers/resource-his
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
|
||||
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
|
||||
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
|
||||
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
|
||||
import type { MissionsService } from '../src/modules/reforger-logs/missions-catalog.js';
|
||||
import { ServerMetricsService } from '../src/modules/servers/metrics-service.js';
|
||||
|
||||
const OWNER_ID = '111111111111111111';
|
||||
|
||||
@@ -115,24 +117,42 @@ function buildApp() {
|
||||
sessions,
|
||||
servers,
|
||||
provider,
|
||||
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
|
||||
workshop: {
|
||||
warm: () => undefined,
|
||||
peekMod: () => undefined,
|
||||
tryGetMod: async () => null,
|
||||
} as unknown as WorkshopCache,
|
||||
metrics: new ServerMetricsService(provider, null),
|
||||
consoleHub: null,
|
||||
scheduler,
|
||||
resolveLogPath: async () => '/profile/logs/console.log',
|
||||
configSync: null,
|
||||
configEditor: null,
|
||||
mods: {
|
||||
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
|
||||
getMods: async () => ({
|
||||
mods: [],
|
||||
revision: 'a1b2c3d4',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}),
|
||||
setMods: async () => ({
|
||||
mods: [],
|
||||
revision: 'a1b2c3d4',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
requiresRestart: true as const,
|
||||
}),
|
||||
} as unknown as ServerModsService,
|
||||
performance: {
|
||||
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
|
||||
get: async () => ({
|
||||
settings: {},
|
||||
revision: 'a1b2c3d4',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}),
|
||||
update: async (_server: unknown, settings: unknown) => ({
|
||||
settings,
|
||||
revision: 'a1b2c3d4',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
changedFields: [],
|
||||
requiresRestart: true as const,
|
||||
@@ -141,7 +161,9 @@ function buildApp() {
|
||||
resourceHistory: {
|
||||
history: () => ({ samples: [], intervalSeconds: 15 }),
|
||||
} as unknown as ResourceHistoryService,
|
||||
missions: null,
|
||||
missions: {
|
||||
list: async () => ({ groups: [], incompleteModIds: [], fetchedAt: null }),
|
||||
} as unknown as MissionsService,
|
||||
});
|
||||
return { app, provider, activity };
|
||||
}
|
||||
@@ -335,7 +357,7 @@ describe('schedule management by role', () => {
|
||||
});
|
||||
|
||||
describe('performance config by role', () => {
|
||||
const validBody = {
|
||||
const validSettings = {
|
||||
maxPlayers: 32,
|
||||
serverMaxViewDistance: null,
|
||||
networkViewDistance: null,
|
||||
@@ -349,6 +371,9 @@ describe('performance config by role', () => {
|
||||
slotReservationTimeout: null,
|
||||
lobbyPlayerSynchronise: null,
|
||||
};
|
||||
// The endpoint takes a settings envelope so callers can also pass the
|
||||
// revision their edits were based on.
|
||||
const validBody = { settings: validSettings };
|
||||
|
||||
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
|
||||
const { app } = buildApp();
|
||||
@@ -373,7 +398,7 @@ describe('performance config by role', () => {
|
||||
const response = await request(app)
|
||||
.put('/api/servers/training-server/config/performance')
|
||||
.set(asUser('owner-token'))
|
||||
.send({ ...validBody, serverMaxViewDistance: 99999 });
|
||||
.send({ settings: { ...validSettings, serverMaxViewDistance: 99999 } });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toContain('serverMaxViewDistance');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { WorkshopModDetail } from '@reforger-panel/shared';
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
|
||||
import { ServerModsService } from '../src/modules/config/mods-service.js';
|
||||
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
|
||||
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
|
||||
import type { ServerRecord } from '../src/modules/servers/server-service.js';
|
||||
import { createLogger } from '../src/lib/logger.js';
|
||||
import { ApiError } from '../src/lib/errors.js';
|
||||
@@ -19,38 +20,81 @@ const server: ServerRecord = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const MOCK_MOD = '591AF5BDA9F7CE8B';
|
||||
const ADMIN_TOOLS = '5AAF0CCE3F001FB5';
|
||||
const DEPENDENCY = 'BBBB000000000001';
|
||||
|
||||
function workshopDetail(overrides: Partial<WorkshopModDetail> & { id: string }) {
|
||||
return {
|
||||
name: `Mod ${overrides.id}`,
|
||||
author: 'Author',
|
||||
summary: null,
|
||||
imageUrl: null,
|
||||
workshopUrl: null,
|
||||
version: '1.0.2',
|
||||
gameVersion: null,
|
||||
sizeBytes: 1024,
|
||||
sizeText: '1 KiB',
|
||||
rating: null,
|
||||
ratingCount: null,
|
||||
subscriberCount: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
tags: [],
|
||||
obsolete: false,
|
||||
description: null,
|
||||
license: null,
|
||||
downloadCount: null,
|
||||
previewImages: [],
|
||||
screenshots: [],
|
||||
versionCount: 1,
|
||||
dependencyCount: 0,
|
||||
scenarioCount: 0,
|
||||
dependencySizeBytes: null,
|
||||
totalSizeBytes: null,
|
||||
dependencies: [],
|
||||
scenarios: [],
|
||||
...overrides,
|
||||
} as WorkshopModDetail;
|
||||
}
|
||||
|
||||
function fakeWorkshop(catalog: Record<string, WorkshopModDetail> = {}) {
|
||||
const resolved = new Map(Object.entries(catalog));
|
||||
return {
|
||||
warm: vi.fn(),
|
||||
peekMod: (id: string) => resolved.get(id.toUpperCase()) ?? null,
|
||||
tryGetMod: async (id: string) => resolved.get(id.toUpperCase()) ?? null,
|
||||
getMod: async (id: string) => {
|
||||
const mod = resolved.get(id.toUpperCase());
|
||||
if (!mod) throw ApiError.notFound('Workshop mod not found.');
|
||||
return mod;
|
||||
},
|
||||
} as unknown as WorkshopCache;
|
||||
}
|
||||
|
||||
describe('ServerModsService', () => {
|
||||
let provider: MockGameServerProvider;
|
||||
let service: ServerModsService;
|
||||
let configSyncCalled: number;
|
||||
let gateway: ConfigFileGateway;
|
||||
|
||||
function build(catalog: Record<string, WorkshopModDetail> = {}) {
|
||||
return new ServerModsService(gateway, fakeWorkshop(catalog), createLogger('silent'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MockGameServerProvider();
|
||||
configSyncCalled = 0;
|
||||
const configSync = {
|
||||
sync: async () => {
|
||||
configSyncCalled += 1;
|
||||
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
|
||||
},
|
||||
} as unknown as ConfigSyncService;
|
||||
service = new ServerModsService(
|
||||
new ConfigFileGateway(provider, '/config.json'),
|
||||
configSync,
|
||||
createLogger('silent'),
|
||||
);
|
||||
gateway = new ConfigFileGateway(provider, '/config.json');
|
||||
});
|
||||
|
||||
it('reads the current mods from config.json', async () => {
|
||||
const result = await service.getMods(server);
|
||||
expect(result.mods).toEqual([
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
]);
|
||||
it('reads the current mods from config.json with a revision to write back against', async () => {
|
||||
const result = await build().getMods(server);
|
||||
expect(result.mods).toEqual([{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }]);
|
||||
expect(result.revision).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('writes the new mod list while preserving every other config field', async () => {
|
||||
const result = await service.setMods(server, [
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
|
||||
const result = await build().setMods(server, [
|
||||
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
|
||||
]);
|
||||
|
||||
expect(result.added).toBe(1);
|
||||
@@ -58,14 +102,11 @@ describe('ServerModsService', () => {
|
||||
expect(result.requiresRestart).toBe(true);
|
||||
expect(result.mods).toHaveLength(2);
|
||||
|
||||
const written = provider.writtenFiles.get('/config.json')!;
|
||||
const parsed = JSON.parse(written);
|
||||
// game.mods replaced…
|
||||
const parsed = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
||||
expect(parsed.game.mods).toEqual([
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
|
||||
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
|
||||
]);
|
||||
// …everything else untouched.
|
||||
expect(parsed.bindPort).toBe(2001);
|
||||
expect(parsed.game.name).toBe('Mock Reforger Server');
|
||||
expect(parsed.game.maxPlayers).toBe(16);
|
||||
@@ -73,37 +114,178 @@ describe('ServerModsService', () => {
|
||||
expect(parsed.operating.aiLimit).toBe(40);
|
||||
});
|
||||
|
||||
it('writes a rollback backup of the previous file before modifying it', async () => {
|
||||
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
|
||||
await service.setMods(server, []);
|
||||
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
|
||||
// Removal reflected in the live file.
|
||||
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
||||
expect(after.game.mods).toEqual([]);
|
||||
it('counts a version change as changed rather than add plus remove', async () => {
|
||||
const result = await build().setMods(server, [
|
||||
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '2.0.0' },
|
||||
]);
|
||||
expect(result).toMatchObject({ added: 0, removed: 0, changed: 1 });
|
||||
});
|
||||
|
||||
it('imports a config revision after a successful write', async () => {
|
||||
await service.setMods(server, []);
|
||||
expect(configSyncCalled).toBe(1);
|
||||
it('writes a rollback backup of the previous file before modifying it', async () => {
|
||||
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
|
||||
await build().setMods(server, []);
|
||||
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
|
||||
expect(JSON.parse(provider.writtenFiles.get('/config.json')!).game.mods).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
|
||||
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
|
||||
const result = await build().setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
|
||||
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
|
||||
});
|
||||
|
||||
it('rejects a write based on a stale revision instead of clobbering it', async () => {
|
||||
const service = build();
|
||||
const stale = (await service.getMods(server)).revision;
|
||||
// Somebody else edits the file in between.
|
||||
await service.setMods(server, [{ modId: ADMIN_TOOLS }]);
|
||||
await expect(service.setMods(server, [], stale)).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
});
|
||||
|
||||
it('fails the write when read-back verification does not match', async () => {
|
||||
// Simulate a server that ignores writes to config.json.
|
||||
const originalWrite = provider.writeTextFile.bind(provider);
|
||||
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
|
||||
if (path === '/config.json') return; // swallow the write
|
||||
await originalWrite(sid, path, content);
|
||||
});
|
||||
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
|
||||
await expect(build().setMods(server, [])).rejects.toThrow(/verification failed/);
|
||||
});
|
||||
|
||||
it('refuses to modify a config without a game section', async () => {
|
||||
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
|
||||
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
|
||||
await expect(build().setMods(server, [])).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
describe('overview', () => {
|
||||
it('joins installed mods with workshop metadata and flags updates', async () => {
|
||||
const service = build({
|
||||
[MOCK_MOD]: workshopDetail({ id: MOCK_MOD, name: 'Mock Sample Mod', version: '1.4.0' }),
|
||||
});
|
||||
const overview = await service.getOverview(server);
|
||||
|
||||
expect(overview.mods).toHaveLength(1);
|
||||
const entry = overview.mods[0]!;
|
||||
expect(entry.pinnedVersion).toBe('1.0.2');
|
||||
expect(entry.workshop?.latestVersion).toBe('1.4.0');
|
||||
expect(entry.updateAvailable).toBe(true);
|
||||
expect(overview.updatesAvailable).toBe(1);
|
||||
expect(overview.warming).toBe(false);
|
||||
});
|
||||
|
||||
it('lists missing dependencies and removal blockers', async () => {
|
||||
await build().setMods(server, [{ modId: MOCK_MOD }, { modId: ADMIN_TOOLS }]);
|
||||
const service = build({
|
||||
[MOCK_MOD]: workshopDetail({
|
||||
id: MOCK_MOD,
|
||||
dependencyCount: 1,
|
||||
dependencies: [
|
||||
{
|
||||
id: DEPENDENCY,
|
||||
name: 'Required Pack',
|
||||
version: null,
|
||||
sizeBytes: 10,
|
||||
published: true,
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[ADMIN_TOOLS]: workshopDetail({
|
||||
id: ADMIN_TOOLS,
|
||||
dependencyCount: 1,
|
||||
dependencies: [
|
||||
{
|
||||
id: MOCK_MOD,
|
||||
name: 'Mock Sample Mod',
|
||||
version: null,
|
||||
sizeBytes: 10,
|
||||
published: true,
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const overview = await service.getOverview(server);
|
||||
const sample = overview.mods.find((mod) => mod.modId === MOCK_MOD)!;
|
||||
expect(sample.missingDependencies.map((dep) => dep.id)).toEqual([DEPENDENCY]);
|
||||
// Admin Tools depends on the sample mod, so removing it is unsafe.
|
||||
expect(sample.requiredBy).toEqual([ADMIN_TOOLS]);
|
||||
});
|
||||
|
||||
it('reports mods the workshop could not resolve', async () => {
|
||||
const overview = await build().getOverview(server);
|
||||
expect(overview.unresolvedIds).toEqual([MOCK_MOD]);
|
||||
expect(overview.mods[0]!.workshop).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('pulls in transitive dependencies with their sizes', async () => {
|
||||
const service = build({
|
||||
[MOCK_MOD]: workshopDetail({
|
||||
id: MOCK_MOD,
|
||||
sizeBytes: 100,
|
||||
dependencyCount: 1,
|
||||
dependencies: [
|
||||
{
|
||||
id: DEPENDENCY,
|
||||
name: 'Required Pack',
|
||||
version: null,
|
||||
sizeBytes: 900,
|
||||
published: true,
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[DEPENDENCY]: workshopDetail({ id: DEPENDENCY, name: 'Required Pack', sizeBytes: 900 }),
|
||||
});
|
||||
|
||||
const result = await service.resolve([{ modId: MOCK_MOD, version: '1.0.2' }]);
|
||||
expect(result.mods.map((mod) => mod.modId).sort()).toEqual([DEPENDENCY, MOCK_MOD].sort());
|
||||
expect(result.addedDependencies).toHaveLength(1);
|
||||
expect(result.addedDependencies[0]).toMatchObject({
|
||||
modId: DEPENDENCY,
|
||||
viaDependency: true,
|
||||
requiredBy: [MOCK_MOD],
|
||||
});
|
||||
expect(result.totalSizeBytes).toBe(1000);
|
||||
});
|
||||
|
||||
it('does not loop forever on a circular dependency graph', async () => {
|
||||
const service = build({
|
||||
[MOCK_MOD]: workshopDetail({
|
||||
id: MOCK_MOD,
|
||||
dependencies: [
|
||||
{
|
||||
id: DEPENDENCY,
|
||||
name: 'B',
|
||||
version: null,
|
||||
sizeBytes: 1,
|
||||
published: true,
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[DEPENDENCY]: workshopDetail({
|
||||
id: DEPENDENCY,
|
||||
dependencies: [
|
||||
{
|
||||
id: MOCK_MOD,
|
||||
name: 'A',
|
||||
version: null,
|
||||
sizeBytes: 1,
|
||||
published: true,
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const result = await service.resolve([{ modId: MOCK_MOD }]);
|
||||
expect(result.mods).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('names ids the workshop does not know instead of dropping them', async () => {
|
||||
const result = await build().resolve([{ modId: 'CCCC000000000009' }]);
|
||||
expect(result.unresolvedIds).toEqual(['CCCC000000000009']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -20,16 +21,16 @@ const server: ServerRecord = {
|
||||
|
||||
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;
|
||||
service = new PerformanceSettingsService(
|
||||
new ConfigFileGateway(provider, '/config.json'),
|
||||
configSync,
|
||||
createLogger('silent'),
|
||||
);
|
||||
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 () => {
|
||||
@@ -46,16 +47,19 @@ describe('PerformanceSettingsService', () => {
|
||||
});
|
||||
|
||||
it('sets changed values and removes nulled keys, preserving everything else', async () => {
|
||||
const { settings } = await service.get(server);
|
||||
const result = await service.update(server, {
|
||||
...settings,
|
||||
maxPlayers: 32,
|
||||
playerSaveTime: 180, // new key
|
||||
disableAI: null,
|
||||
aiLimit: null, // remove key → game default
|
||||
aiLimit: null, // remove key -> game default
|
||||
});
|
||||
|
||||
expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']);
|
||||
expect(result.changedFields.sort()).toEqual([
|
||||
'aiLimit',
|
||||
'disableAI',
|
||||
'maxPlayers',
|
||||
'playerSaveTime',
|
||||
]);
|
||||
expect(result.requiresRestart).toBe(true);
|
||||
|
||||
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
||||
@@ -66,14 +70,101 @@ describe('PerformanceSettingsService', () => {
|
||||
expect(written.bindPort).toBe(2001);
|
||||
expect(written.game.scenarioId).toContain('Missions');
|
||||
expect(written.game.mods).toHaveLength(1);
|
||||
// Backup written:
|
||||
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, settings);
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user