Initial commit: CraftDock as of 2026-11-01
The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
640e52c0c9
99 files changed
+12115
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createAuthService } from '../../src/services/authService.js';
|
||||
|
||||
describe('authService', () => {
|
||||
let svc;
|
||||
beforeAll(() => {
|
||||
const hash = bcrypt.hashSync('correct', 12);
|
||||
svc = createAuthService({ adminPasswordHash: hash });
|
||||
});
|
||||
|
||||
it('accepts the correct password', async () => {
|
||||
expect(await svc.verifyPassword('correct')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
expect(await svc.verifyPassword('wrong')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty input without throwing', async () => {
|
||||
expect(await svc.verifyPassword('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { createBedrockIdentityBridge } from '../../src/services/bedrockIdentityBridge.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('bedrockIdentityBridge', () => {
|
||||
let store;
|
||||
beforeEach(() => {
|
||||
store = null;
|
||||
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
|
||||
vi.mocked(fsp.readFile).mockImplementation(async () => {
|
||||
if (store == null) throw new Error('ENOENT');
|
||||
return store;
|
||||
});
|
||||
});
|
||||
|
||||
it('learn binds xuid to the LevelDB uuid when uniqueId matches', async () => {
|
||||
const fileAdapter = { findByUniqueId: vi.fn(async (u) => u === '-8589934591' ? { uuid: 'abc-uuid', data: {} } : null) };
|
||||
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||
|
||||
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', uniqueId: '-8589934591' });
|
||||
expect(binding.leveldbUuid).toBe('abc-uuid');
|
||||
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('abc-uuid');
|
||||
});
|
||||
|
||||
it('learn returns null and persists nothing when uniqueId matches no player', async () => {
|
||||
const fileAdapter = { findByUniqueId: vi.fn(async () => null) };
|
||||
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||
expect(await bridge.learn({ xuid: 'x', name: 'n', uniqueId: '-1' })).toBeNull();
|
||||
expect(store).toBeNull();
|
||||
});
|
||||
|
||||
it('learn binds xuid to fallbackUuid when provided even without uniqueId', async () => {
|
||||
const fileAdapter = { findByUniqueId: vi.fn() };
|
||||
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
|
||||
|
||||
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', fallbackUuid: 'fallback-uuid-123' });
|
||||
expect(binding.leveldbUuid).toBe('fallback-uuid-123');
|
||||
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('fallback-uuid-123');
|
||||
expect(fileAdapter.findByUniqueId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolveLeveldbUuid returns null for an unknown xuid', async () => {
|
||||
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter: {} });
|
||||
expect(await bridge.resolveLeveldbUuid('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDockerService, ContainerNotFoundError } from '../../src/services/dockerService.js';
|
||||
|
||||
function mockDocker({ inspectData, exists = true } = {}) {
|
||||
const container = {
|
||||
start: vi.fn().mockResolvedValue(),
|
||||
stop: vi.fn().mockResolvedValue(),
|
||||
restart: vi.fn().mockResolvedValue(),
|
||||
kill: vi.fn().mockResolvedValue(),
|
||||
inspect: vi.fn().mockResolvedValue(inspectData),
|
||||
};
|
||||
return {
|
||||
_container: container,
|
||||
listContainers: vi.fn().mockResolvedValue(
|
||||
exists ? [{ Names: ['/craftdock-mc-server'], Id: 'abc' }] : []
|
||||
),
|
||||
getContainer: vi.fn().mockReturnValue(container),
|
||||
};
|
||||
}
|
||||
|
||||
const config = { mcContainerName: 'craftdock-mc-server' };
|
||||
|
||||
describe('dockerService', () => {
|
||||
it('inspect returns state and parsed TYPE', async () => {
|
||||
const docker = mockDocker({
|
||||
inspectData: {
|
||||
State: { Status: 'running' },
|
||||
Config: { Env: ['EULA=TRUE', 'TYPE=PAPER'] },
|
||||
},
|
||||
});
|
||||
const svc = createDockerService(config, docker);
|
||||
const info = await svc.inspect();
|
||||
expect(info).toMatchObject({ found: true, state: 'running', type: 'PAPER' });
|
||||
});
|
||||
|
||||
it('inspect reports not_found when container is absent', async () => {
|
||||
const svc = createDockerService(config, mockDocker({ exists: false }));
|
||||
const info = await svc.inspect();
|
||||
expect(info).toEqual({ found: false, state: 'not_found', type: null });
|
||||
});
|
||||
|
||||
it('start delegates to the resolved container', async () => {
|
||||
const docker = mockDocker({ inspectData: { State: {}, Config: { Env: [] } } });
|
||||
const svc = createDockerService(config, docker);
|
||||
await svc.start();
|
||||
expect(docker._container.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('start throws ContainerNotFoundError when absent', async () => {
|
||||
const svc = createDockerService(config, mockDocker({ exists: false }));
|
||||
await expect(svc.start()).rejects.toBeInstanceOf(ContainerNotFoundError);
|
||||
});
|
||||
|
||||
it('getState works when destructured (no `this` binding)', async () => {
|
||||
const docker = mockDocker({
|
||||
inspectData: { State: { Status: 'running' }, Config: { Env: [] } },
|
||||
});
|
||||
const { getState } = createDockerService(config, docker);
|
||||
await expect(getState()).resolves.toBe('running');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { createDockerService } from '../../src/services/dockerService.js';
|
||||
|
||||
const config = { mcContainerName: 'craftdock-mc-server', mcServiceName: 'minecraft-server' };
|
||||
|
||||
// Simulates Coolify: the MC container has an auto-generated name (not
|
||||
// craftdock-mc-server) but carries the compose service/project labels. The panel
|
||||
// container (os.hostname()) shares the same project.
|
||||
function mockCoolifyDocker() {
|
||||
const selfId = os.hostname();
|
||||
const mcData = { State: { Status: 'running' }, Config: { Env: ['TYPE=BEDROCK'] } };
|
||||
const byId = {
|
||||
[selfId]: { inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'com.docker.compose.project': 'proj' } } }) },
|
||||
mcid: { inspect: vi.fn().mockResolvedValue(mcData), start: vi.fn().mockResolvedValue() },
|
||||
};
|
||||
return {
|
||||
byId,
|
||||
listContainers: vi.fn().mockResolvedValue([
|
||||
{
|
||||
Names: ['/minecraft-server-proj-abc123'],
|
||||
Id: 'mcid',
|
||||
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'proj' },
|
||||
},
|
||||
]),
|
||||
getContainer: vi.fn((id) => byId[id] || { inspect: vi.fn().mockRejectedValue(new Error('no such container')) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dockerService container resolution', () => {
|
||||
it('resolves the MC container by compose service label when the name does not match', async () => {
|
||||
const docker = mockCoolifyDocker();
|
||||
const svc = createDockerService(config, docker);
|
||||
const info = await svc.inspect();
|
||||
expect(info).toMatchObject({ found: true, state: 'running', type: 'BEDROCK' });
|
||||
});
|
||||
|
||||
it('scopes the label match to the panel own compose project', async () => {
|
||||
const docker = mockCoolifyDocker();
|
||||
// Same service label but a DIFFERENT project — must NOT match.
|
||||
docker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Names: ['/minecraft-server-other-xyz'],
|
||||
Id: 'mcid',
|
||||
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'other-project' },
|
||||
},
|
||||
]);
|
||||
const svc = createDockerService(config, docker);
|
||||
const info = await svc.inspect();
|
||||
expect(info).toEqual({ found: false, state: 'not_found', type: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDockerService } from '../../src/services/dockerService.js';
|
||||
|
||||
const statsSample = {
|
||||
cpu_stats: {
|
||||
cpu_usage: { total_usage: 2000000000 },
|
||||
system_cpu_usage: 20000000000,
|
||||
online_cpus: 2,
|
||||
},
|
||||
precpu_stats: {
|
||||
cpu_usage: { total_usage: 1000000000 },
|
||||
system_cpu_usage: 10000000000,
|
||||
},
|
||||
memory_stats: { usage: 536870912, limit: 1073741824 }, // 512MB / 1GB
|
||||
};
|
||||
|
||||
function mockDocker() {
|
||||
const container = {
|
||||
inspect: vi.fn().mockResolvedValue({ State: { Status: 'running' }, Config: { Env: [] } }),
|
||||
stats: vi.fn().mockResolvedValue(statsSample),
|
||||
};
|
||||
return {
|
||||
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
|
||||
getContainer: vi.fn().mockReturnValue(container),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dockerService.stats', () => {
|
||||
it('computes cpu and memory percentages', async () => {
|
||||
const svc = createDockerService({ mcContainerName: 'mc' }, mockDocker());
|
||||
const s = await svc.stats();
|
||||
// cpuDelta=1e9, sysDelta=1e10 -> 0.1 * 2 cpus * 100 = 20%
|
||||
expect(s.cpuPct).toBeCloseTo(20, 1);
|
||||
expect(s.memUsedMb).toBeCloseTo(512, 0);
|
||||
expect(s.memPct).toBeCloseTo(50, 1);
|
||||
});
|
||||
|
||||
it('returns zeros for a malformed/empty stats payload (e.g. stopped container)', async () => {
|
||||
const docker = mockDocker();
|
||||
docker.getContainer().stats.mockResolvedValue({
|
||||
cpu_stats: {},
|
||||
precpu_stats: {},
|
||||
memory_stats: {},
|
||||
});
|
||||
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||
const s = await svc.stats();
|
||||
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
|
||||
});
|
||||
|
||||
it('returns zeros for a completely empty stats payload', async () => {
|
||||
const docker = mockDocker();
|
||||
docker.getContainer().stats.mockResolvedValue({});
|
||||
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||
const s = await svc.stats();
|
||||
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// test/services/dockerServiceStreams.test.js
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDockerService } from '../../src/services/dockerService.js';
|
||||
|
||||
function mockDocker() {
|
||||
const container = {
|
||||
inspect: vi.fn().mockResolvedValue({ State: {}, Config: { Env: [] } }),
|
||||
logs: vi.fn().mockResolvedValue('LOGSTREAM'),
|
||||
attach: vi.fn().mockResolvedValue('ATTACHSTREAM'),
|
||||
};
|
||||
return {
|
||||
_container: container,
|
||||
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
|
||||
getContainer: vi.fn().mockReturnValue(container),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dockerService streams', () => {
|
||||
it('logStream requests a following log stream with tail', async () => {
|
||||
const docker = mockDocker();
|
||||
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||
const stream = await svc.logStream();
|
||||
expect(stream).toBe('LOGSTREAM');
|
||||
expect(docker._container.logs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ follow: true, stdout: true, stderr: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('attach requests a duplex stream with stdin', async () => {
|
||||
const docker = mockDocker();
|
||||
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
|
||||
const stream = await svc.attach();
|
||||
expect(stream).toBe('ATTACHSTREAM');
|
||||
expect(docker._container.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stream: true, stdin: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ensureFirstBootDefaults, MARKER_FILE } from '../../src/services/firstBootService.js';
|
||||
|
||||
let tmp, propertiesService, logger;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-fb-'));
|
||||
propertiesService = { update: vi.fn().mockResolvedValue({}) };
|
||||
logger = { info: vi.fn(), warn: vi.fn() };
|
||||
});
|
||||
afterEach(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||
|
||||
const run = (edition = 'bedrock') =>
|
||||
ensureFirstBootDefaults({ config: { mcDataPath: tmp, mcEdition: edition }, propertiesService, logger });
|
||||
|
||||
describe('ensureFirstBootDefaults', () => {
|
||||
it('first boot: forces the whitelist property to false and writes the marker', async () => {
|
||||
await run('bedrock');
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false' });
|
||||
const marker = JSON.parse(await fsp.readFile(path.join(tmp, MARKER_FILE), 'utf8'));
|
||||
expect(marker.initializedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('java edition writes white-list', async () => {
|
||||
await run('java');
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
|
||||
});
|
||||
|
||||
it('auto edition writes both keys (each server only reads its own)', async () => {
|
||||
await run('auto');
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false', 'white-list': 'false' });
|
||||
});
|
||||
|
||||
it('restart (marker present): does not touch properties', async () => {
|
||||
await fsp.writeFile(path.join(tmp, MARKER_FILE), JSON.stringify({ initializedAt: 'x' }));
|
||||
await run('bedrock');
|
||||
expect(propertiesService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never throws: a failing properties write is logged, and no marker is left so it retries next boot', async () => {
|
||||
propertiesService.update.mockRejectedValue(new Error('disk full'));
|
||||
await expect(run('bedrock')).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await expect(fsp.access(path.join(tmp, MARKER_FILE))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createPlayerDetailService } from '../../src/services/playerDetailService.js';
|
||||
|
||||
function setup({ edition, online, running = true, dir, bridge, readPlayer, listServerUuids, extraAdapter = {} }) {
|
||||
const adapter = {
|
||||
_edition: edition,
|
||||
listPlayers: vi.fn(async () => ({ players: online ? ['Lucas'] : [] })),
|
||||
queryUniqueId: vi.fn(async () => '-8589934591'),
|
||||
saveHold: vi.fn(async () => true),
|
||||
saveResume: vi.fn(async () => {}),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
...extraAdapter,
|
||||
};
|
||||
const deps = {
|
||||
config: { mcDataPath: '/data' },
|
||||
appState: { getAdapter: vi.fn(async () => adapter) },
|
||||
dockerService: { inspect: vi.fn(async () => ({ state: running ? 'running' : 'exited' })) },
|
||||
readDirectory: vi.fn(async () => dir),
|
||||
createFileAdapter: vi.fn(() => ({
|
||||
readPlayer: vi.fn(readPlayer),
|
||||
findByUniqueId: vi.fn(),
|
||||
listServerUuids: vi.fn(async () => listServerUuids ? listServerUuids() : []),
|
||||
})),
|
||||
createBridge: vi.fn(() => bridge),
|
||||
};
|
||||
return { adapter, service: createPlayerDetailService(deps), deps };
|
||||
}
|
||||
|
||||
describe('playerDetailService', () => {
|
||||
it('returns null for an unknown xuid', async () => {
|
||||
const { service } = setup({ edition: 'bedrock', online: false, dir: {}, bridge: {} });
|
||||
expect(await service.getDetail('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('bedrock online: learns the bridge and reads under a save snapshot', async () => {
|
||||
const bridge = {
|
||||
learn: vi.fn(async () => ({})),
|
||||
resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-1'),
|
||||
};
|
||||
const { adapter, service } = setup({
|
||||
edition: 'bedrock', online: true,
|
||||
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||
bridge,
|
||||
readPlayer: async (id) => id === 'uuid-1' ? { position: { x: 1, y: 2, z: 3 }, health: { current: 9, max: 20 } } : null,
|
||||
});
|
||||
const d = await service.getDetail('2535');
|
||||
expect(d.online).toBe(true);
|
||||
expect(d.position).toEqual({ x: 1, y: 2, z: 3 });
|
||||
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', uniqueId: '-8589934591' });
|
||||
expect(adapter.saveHold).toHaveBeenCalled();
|
||||
expect(adapter.saveResume).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bedrock offline and never bridged: auto-binds single LevelDB uuid fallback', async () => {
|
||||
const bridge = { learn: vi.fn(async () => ({})), resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-fallback-1') };
|
||||
const { service } = setup({
|
||||
edition: 'bedrock', online: false, running: false,
|
||||
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||
bridge,
|
||||
readPlayer: async (id) => id === 'uuid-fallback-1' ? { health: 20 } : null,
|
||||
listServerUuids: () => ['uuid-fallback-1'],
|
||||
});
|
||||
|
||||
const d = await service.getDetail('2535');
|
||||
expect(d.needsBridge).toBe(false);
|
||||
expect(d.health).toBe(20);
|
||||
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', fallbackUuid: 'uuid-fallback-1' });
|
||||
});
|
||||
|
||||
it('bedrock offline and never bridged with multiple uuids: returns needsBridge', async () => {
|
||||
const bridge = { learn: vi.fn(), resolveLeveldbUuid: vi.fn(async () => null) };
|
||||
const { service } = setup({
|
||||
edition: 'bedrock', online: false,
|
||||
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
|
||||
bridge,
|
||||
readPlayer: async () => null,
|
||||
listServerUuids: () => ['uuid-1', 'uuid-2'],
|
||||
});
|
||||
const d = await service.getDetail('2535');
|
||||
expect(d.needsBridge).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('java online: forces save then reads the player .dat', async () => {
|
||||
const { adapter, service } = setup({
|
||||
edition: 'java', online: true,
|
||||
dir: { 'uuid-xyz': { name: 'Lucas', xuid: 'uuid-xyz' } },
|
||||
bridge: {},
|
||||
readPlayer: async (id) => id === 'uuid-xyz' ? { position: { x: 0, y: 64, z: 0 }, food: 20 } : null,
|
||||
});
|
||||
const d = await service.getDetail('uuid-xyz');
|
||||
expect(adapter.forceSave).toHaveBeenCalled();
|
||||
expect(d.food).toBe(20);
|
||||
expect(d.needsBridge).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { updatePlayerDirectory, queryDirectory } from '../../src/services/playerDirectory.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
// A dockerService whose container returns the given log text, recording the opts
|
||||
// each `logs()` call was made with (to assert incremental scanning).
|
||||
function mockDocker(logText) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getContainer: async () => ({
|
||||
logs: async (opts) => { calls.push(opts); return Buffer.from(logText); },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('playerDirectory', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = null;
|
||||
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
|
||||
vi.mocked(fsp.rm).mockResolvedValue();
|
||||
// No existing directory / no legacy file by default.
|
||||
vi.mocked(fsp.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
it('records only players that actually spawned, keyed by XUID', async () => {
|
||||
const log = [
|
||||
'Player connected: Rejected, xuid: 111', // connect only → rejected by allowlist
|
||||
'Player Spawned: Lucasrguerra xuid: 222', // real access
|
||||
].join('\n');
|
||||
|
||||
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(log), edition: 'bedrock' });
|
||||
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({ xuid: '222', name: 'Lucasrguerra' }),
|
||||
]);
|
||||
// Rejected player (connected but never spawned) is absent.
|
||||
expect(list.find((e) => e.name === 'Rejected')).toBeUndefined();
|
||||
expect(JSON.parse(store)).toHaveProperty('222');
|
||||
});
|
||||
|
||||
it('parses gamertags containing spaces', async () => {
|
||||
const list = await updatePlayerDirectory({
|
||||
dataRoot: '/data', dockerService: mockDocker('Player Spawned: Lucas Guerra xuid: 333'), edition: 'bedrock',
|
||||
});
|
||||
expect(list[0]).toMatchObject({ xuid: '333', name: 'Lucas Guerra' });
|
||||
});
|
||||
|
||||
it('scans logs incrementally: bounded tail first, then since', async () => {
|
||||
// Fresh module so the module-level scan cursor starts at 0 for this test.
|
||||
vi.resetModules();
|
||||
const { updatePlayerDirectory: fresh } = await import('../../src/services/playerDirectory.js');
|
||||
|
||||
const docker = mockDocker('Player Spawned: steve xuid: 1');
|
||||
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
|
||||
expect(docker.calls[0].tail).toBe(2000);
|
||||
expect(docker.calls[0].since).toBeUndefined();
|
||||
|
||||
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
|
||||
expect(docker.calls[1].since).toBeGreaterThan(0);
|
||||
expect(docker.calls[1].tail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps previously-seen players even when no longer spawning (Scenario 2)', async () => {
|
||||
// Existing directory already has an old player; a new empty scan must not drop them.
|
||||
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({
|
||||
'999': { name: 'OldPlayer', xuid: '999', firstSeen: 'x', lastSeen: 'x' },
|
||||
}));
|
||||
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(''), edition: 'bedrock' });
|
||||
expect(list).toEqual([expect.objectContaining({ xuid: '999', name: 'OldPlayer' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryDirectory', () => {
|
||||
beforeEach(() => {
|
||||
// 30 players named Player00..Player29, keyed by xuid.
|
||||
const dir = {};
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const n = String(i).padStart(2, '0');
|
||||
dir[`x${n}`] = { xuid: `x${n}`, name: `Player${n}` };
|
||||
}
|
||||
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify(dir));
|
||||
});
|
||||
|
||||
it('returns the requested page with total', async () => {
|
||||
const r = await queryDirectory('/data', { page: 2, pageSize: 25 });
|
||||
expect(r.total).toBe(30);
|
||||
expect(r.page).toBe(2);
|
||||
expect(r.items).toHaveLength(5);
|
||||
expect(r.items[0].name).toBe('Player25');
|
||||
});
|
||||
|
||||
it('filters by name (case-insensitive) and paginates the filtered set', async () => {
|
||||
const r = await queryDirectory('/data', { q: 'player0', pageSize: 100 });
|
||||
expect(r.total).toBe(10); // Player00..Player09
|
||||
expect(r.items.every((e) => e.name.startsWith('Player0'))).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range page to the last page', async () => {
|
||||
const r = await queryDirectory('/data', { page: 99, pageSize: 25 });
|
||||
expect(r.page).toBe(2);
|
||||
expect(r.items).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createPropertiesService } from '../../src/services/propertiesService.js';
|
||||
|
||||
const SAMPLE = `#Minecraft server properties
|
||||
#Mon Jul 18
|
||||
difficulty=easy
|
||||
pvp=true
|
||||
max-players=20`;
|
||||
|
||||
function makeFs(content) {
|
||||
const store = { value: content };
|
||||
return {
|
||||
store,
|
||||
readFile: vi.fn().mockImplementation(async () => store.value),
|
||||
writeFile: vi.fn().mockImplementation(async (_p, data) => { store.value = data; }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('propertiesService', () => {
|
||||
it('parses key=value ignoring comments', () => {
|
||||
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
|
||||
const obj = svc.parse(SAMPLE);
|
||||
expect(obj).toEqual({ difficulty: 'easy', pvp: 'true', 'max-players': '20' });
|
||||
});
|
||||
|
||||
it('serialize preserves comments and updates values', () => {
|
||||
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
|
||||
const out = svc.serialize({ difficulty: 'hard', pvp: 'true', 'max-players': '20' }, SAMPLE);
|
||||
expect(out).toContain('#Minecraft server properties');
|
||||
expect(out).toContain('difficulty=hard');
|
||||
});
|
||||
|
||||
it('update merges patch and writes file', async () => {
|
||||
const fs = makeFs(SAMPLE);
|
||||
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
|
||||
const merged = await svc.update({ difficulty: 'hard' });
|
||||
expect(merged.difficulty).toBe('hard');
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
expect(fs.store.value).toContain('difficulty=hard');
|
||||
});
|
||||
|
||||
it('update appends new keys not present originally', async () => {
|
||||
const fs = makeFs(SAMPLE);
|
||||
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
|
||||
await svc.update({ motd: 'Hello' });
|
||||
expect(fs.store.value).toContain('motd=Hello');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createRconService, RconUnavailableError } from '../../src/services/rconService.js';
|
||||
|
||||
function fakeRconClass({ failConnect = false } = {}) {
|
||||
return class {
|
||||
static async connect() {
|
||||
if (failConnect) throw new Error('ECONNREFUSED');
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue('command output'),
|
||||
end: vi.fn().mockResolvedValue(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const config = { rconHost: 'h', rconPort: 25575, rconPassword: 'p' };
|
||||
|
||||
describe('rconService', () => {
|
||||
it('connects lazily and sends a command', async () => {
|
||||
const svc = createRconService(config, fakeRconClass());
|
||||
const out = await svc.send('list');
|
||||
expect(out).toBe('command output');
|
||||
});
|
||||
|
||||
it('throws RconUnavailableError when connect fails', async () => {
|
||||
const svc = createRconService(config, fakeRconClass({ failConnect: true }));
|
||||
await expect(svc.send('list')).rejects.toBeInstanceOf(RconUnavailableError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseBedrockSeed, createSeedService } from '../../src/services/seedService.js';
|
||||
|
||||
function levelDatWithSeed(seed) {
|
||||
const name = Buffer.from('RandomSeed', 'utf8');
|
||||
const value = Buffer.alloc(8);
|
||||
value.writeBigInt64LE(BigInt(seed));
|
||||
return Buffer.concat([Buffer.from([0x04, 0x0a, 0x00]), name, value, Buffer.from('tail')]);
|
||||
}
|
||||
|
||||
describe('parseBedrockSeed', () => {
|
||||
it('reads the RandomSeed int64 (little-endian) from a level.dat buffer', () => {
|
||||
expect(parseBedrockSeed(levelDatWithSeed('682981659723566343'))).toBe('682981659723566343');
|
||||
});
|
||||
|
||||
it('handles negative seeds', () => {
|
||||
expect(parseBedrockSeed(levelDatWithSeed('-4707462683490150816'))).toBe('-4707462683490150816');
|
||||
});
|
||||
|
||||
it('returns null when RandomSeed is absent', () => {
|
||||
expect(parseBedrockSeed(Buffer.from('no seed here'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedService.resolve', () => {
|
||||
const config = { mcDataPath: '/data' };
|
||||
|
||||
it('prefers a non-empty level-seed from server.properties', async () => {
|
||||
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '12345', 'level-name': 'Bedrock level' }) };
|
||||
const svc = createSeedService({ config, propertiesService });
|
||||
expect(await svc.resolve({}, 'bedrock')).toBe('12345');
|
||||
});
|
||||
|
||||
it('reads bedrock seed from level.dat when level-seed is empty', async () => {
|
||||
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
|
||||
const fs = { readFile: vi.fn().mockResolvedValue(levelDatWithSeed('682981659723566343')) };
|
||||
const svc = createSeedService({ config, propertiesService, fs });
|
||||
expect(await svc.resolve({}, 'bedrock')).toBe('682981659723566343');
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/data/worlds/Bedrock level/level.dat');
|
||||
});
|
||||
|
||||
it('uses the java adapter command when edition is java', async () => {
|
||||
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '' }) };
|
||||
const adapter = { getSeed: vi.fn().mockResolvedValue('99') };
|
||||
const svc = createSeedService({ config, propertiesService });
|
||||
expect(await svc.resolve(adapter, 'java')).toBe('99');
|
||||
expect(adapter.getSeed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null (not throw) when bedrock level.dat is unreadable', async () => {
|
||||
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
|
||||
const fs = { readFile: vi.fn().mockRejectedValue(new Error('ENOENT')) };
|
||||
const svc = createSeedService({ config, propertiesService, fs });
|
||||
expect(await svc.resolve({}, 'bedrock')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createStdinService } from '../../src/services/stdinService.js';
|
||||
|
||||
describe('stdinService', () => {
|
||||
it('writes command with newline and captures output window', async () => {
|
||||
const stream = new PassThrough();
|
||||
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||
const svc = createStdinService(dockerService, { windowMs: 20 });
|
||||
|
||||
const p = svc.send('list');
|
||||
// simulate server echoing output
|
||||
setTimeout(() => stream.write('There are 2 players online\n'), 5);
|
||||
const out = await p;
|
||||
|
||||
expect(out).toContain('2 players online');
|
||||
});
|
||||
|
||||
it('appends a newline to the written command', async () => {
|
||||
const stream = new PassThrough();
|
||||
const written = [];
|
||||
stream.write = (chunk) => { written.push(chunk.toString()); return true; };
|
||||
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||
const svc = createStdinService(dockerService, { windowMs: 5 });
|
||||
await svc.send('op steve');
|
||||
expect(written[0]).toBe('op steve\n');
|
||||
});
|
||||
|
||||
it('serializes concurrent sends (no interleaving)', async () => {
|
||||
const stream = new PassThrough();
|
||||
const order = [];
|
||||
// Track write order to prove serialization
|
||||
const originalWrite = stream.write.bind(stream);
|
||||
stream.write = (...args) => {
|
||||
order.push(args[0].toString().trim());
|
||||
return originalWrite(...args);
|
||||
};
|
||||
|
||||
|
||||
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
|
||||
const svc = createStdinService(dockerService, { windowMs: 40 });
|
||||
|
||||
// Fire 3 commands concurrently — they must NOT overlap
|
||||
const p1 = svc.send('cmd1');
|
||||
const p2 = svc.send('cmd2');
|
||||
const p3 = svc.send('cmd3');
|
||||
|
||||
// Simulate server responses aimed at the MIDDLE of each serialized capture
|
||||
// window ([0,40) / [40,80) / [80,120)ms) so timer jitter can't flake this.
|
||||
setTimeout(() => stream.push('reply1\n'), 15);
|
||||
setTimeout(() => stream.push('reply2\n'), 55);
|
||||
setTimeout(() => stream.push('reply3\n'), 95);
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||
|
||||
// Commands must have been written in order (serialized)
|
||||
expect(order).toEqual(['cmd1', 'cmd2', 'cmd3']);
|
||||
// Each response captured in its own window
|
||||
expect(r1).toContain('reply1');
|
||||
expect(r2).toContain('reply2');
|
||||
expect(r3).toContain('reply3');
|
||||
});
|
||||
|
||||
it('re-attaches after the stream ends (e.g. container stop/start on world import)', async () => {
|
||||
const stream1 = new PassThrough();
|
||||
const stream2 = new PassThrough();
|
||||
let n = 0;
|
||||
const dockerService = {
|
||||
attach: vi.fn().mockImplementation(async () => (++n === 1 ? stream1 : stream2)),
|
||||
};
|
||||
const svc = createStdinService(dockerService, { windowMs: 5 });
|
||||
|
||||
await svc.send('a');
|
||||
expect(dockerService.attach).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Container stopped: the attach stream ends. A stale, non-destroyed stream
|
||||
// must NOT be reused — the next command has to re-attach.
|
||||
stream1.emit('end');
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
setTimeout(() => stream2.push('ok\n'), 2);
|
||||
const out = await svc.send('b');
|
||||
expect(dockerService.attach).toHaveBeenCalledTimes(2);
|
||||
expect(out).toContain('ok');
|
||||
});
|
||||
|
||||
it('queue continues even if a command rejects (stream error)', async () => {
|
||||
let callCount = 0;
|
||||
const dockerService = {
|
||||
attach: vi.fn().mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First call: return a stream that immediately errors on write
|
||||
const bad = new PassThrough();
|
||||
bad.write = () => { throw new Error('broken pipe'); };
|
||||
return bad;
|
||||
}
|
||||
// Second call: return a healthy stream
|
||||
const good = new PassThrough();
|
||||
setTimeout(() => good.push('ok\n'), 3);
|
||||
return good;
|
||||
}),
|
||||
};
|
||||
const svc = createStdinService(dockerService, { windowMs: 10 });
|
||||
|
||||
// First send should reject
|
||||
await expect(svc.send('bad')).rejects.toThrow('broken pipe');
|
||||
|
||||
// The service must recover (re-attach) and the queue must not be stuck
|
||||
const result = await svc.send('good');
|
||||
expect(result).toContain('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import archiver from 'archiver';
|
||||
import unzipper from 'unzipper';
|
||||
import { createWorldService } from '../../src/services/worldService.js';
|
||||
|
||||
const extractZip = (zip, dest) =>
|
||||
new Promise((res, rej) =>
|
||||
createReadStream(zip).pipe(unzipper.Extract({ path: dest })).on('close', res).on('error', rej));
|
||||
|
||||
function makeService(mcDataPath) {
|
||||
return createWorldService({
|
||||
config: { mcDataPath, mcWorldName: 'worlds' },
|
||||
dockerService: { getState: async () => 'exited', stop: async () => {}, start: async () => {} },
|
||||
propertiesService: { read: async () => ({ 'level-name': 'Bedrock level' }) },
|
||||
archiver,
|
||||
extractZip,
|
||||
});
|
||||
}
|
||||
|
||||
async function mkWorld(dataPath, levelDatContent) {
|
||||
const world = path.join(dataPath, 'worlds', 'Bedrock level');
|
||||
await fsp.mkdir(path.join(world, 'db'), { recursive: true });
|
||||
await fsp.writeFile(path.join(world, 'level.dat'), levelDatContent);
|
||||
await fsp.writeFile(path.join(world, 'db', '000001.ldb'), 'blocks');
|
||||
}
|
||||
|
||||
describe('world export/import bundles player data (full backup)', () => {
|
||||
it('carries allowlist + history from one instance to another', async () => {
|
||||
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'craftdock-bk-'));
|
||||
const src = path.join(tmp, 'src');
|
||||
const dst = path.join(tmp, 'dst');
|
||||
|
||||
// Source: a world + player-data files at the data root.
|
||||
await mkWorld(src, 'SOURCE-WORLD');
|
||||
await fsp.writeFile(path.join(src, 'allowlist.json'),
|
||||
JSON.stringify([{ name: 'Lucasrguerra', ignoresPlayerLimit: false }]));
|
||||
await fsp.writeFile(path.join(src, 'permissions.json'), JSON.stringify([]));
|
||||
await fsp.writeFile(path.join(src, 'craftdock_players_history.json'), JSON.stringify(['Lucasrguerra']));
|
||||
|
||||
// Export.
|
||||
const zipPath = path.join(tmp, 'world.zip');
|
||||
const archive = await makeService(src).createDownloadStream();
|
||||
const out = createWriteStream(zipPath);
|
||||
archive.pipe(out);
|
||||
await new Promise((res, rej) => { out.on('close', res); archive.on('error', rej); });
|
||||
|
||||
// Destination: a different, older world and an empty allowlist.
|
||||
await mkWorld(dst, 'OLD-WORLD');
|
||||
await fsp.writeFile(path.join(dst, 'allowlist.json'), JSON.stringify([]));
|
||||
|
||||
// Import.
|
||||
await makeService(dst).importWorld(zipPath);
|
||||
|
||||
// World replaced.
|
||||
expect(await fsp.readFile(path.join(dst, 'worlds', 'Bedrock level', 'level.dat'), 'utf8'))
|
||||
.toBe('SOURCE-WORLD');
|
||||
// Allowlist migrated (Lucasrguerra now known on the destination).
|
||||
const allow = JSON.parse(await fsp.readFile(path.join(dst, 'allowlist.json'), 'utf8'));
|
||||
expect(allow.map((e) => e.name)).toContain('Lucasrguerra');
|
||||
// History migrated.
|
||||
const hist = JSON.parse(await fsp.readFile(path.join(dst, 'craftdock_players_history.json'), 'utf8'));
|
||||
expect(hist).toContain('Lucasrguerra');
|
||||
// permissions.json migrated too.
|
||||
await expect(fsp.access(path.join(dst, 'permissions.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { createWorldService } from '../../src/services/worldService.js';
|
||||
|
||||
describe('worldService.createDownloadStream', () => {
|
||||
it('queues the world directory and finalizes the archive', async () => {
|
||||
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
|
||||
const archiver = vi.fn().mockReturnValue(archive);
|
||||
const svc = createWorldService({
|
||||
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||
dockerService: {},
|
||||
// no player-data files present -> access rejects, nothing extra is bundled
|
||||
fs: { access: vi.fn().mockRejectedValue(new Error('ENOENT')) },
|
||||
archiver,
|
||||
});
|
||||
const out = await svc.createDownloadStream();
|
||||
expect(archiver).toHaveBeenCalledWith('zip', expect.any(Object));
|
||||
expect(archive.directory).toHaveBeenCalledWith(path.join('/data', 'world'), 'world');
|
||||
expect(archive.finalize).toHaveBeenCalled();
|
||||
expect(out).toBe(archive);
|
||||
});
|
||||
|
||||
it('bundles existing player-data files under craftdock/', async () => {
|
||||
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
|
||||
const archiver = vi.fn().mockReturnValue(archive);
|
||||
const svc = createWorldService({
|
||||
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||
dockerService: {},
|
||||
// only allowlist.json exists
|
||||
fs: { access: vi.fn().mockImplementation(async (p) => { if (!p.endsWith('allowlist.json')) throw new Error('ENOENT'); }) },
|
||||
archiver,
|
||||
});
|
||||
await svc.createDownloadStream();
|
||||
expect(archive.file).toHaveBeenCalledWith(path.join('/data', 'allowlist.json'), { name: 'craftdock/allowlist.json' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { createWorldService } from '../../src/services/worldService.js';
|
||||
|
||||
function deps() {
|
||||
return {
|
||||
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||
dockerService: {
|
||||
getState: vi.fn().mockResolvedValue('running'),
|
||||
stop: vi.fn().mockResolvedValue(),
|
||||
start: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
propertiesService: {
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
fs: { rm: vi.fn().mockResolvedValue() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('worldService.regen', () => {
|
||||
it('stops, removes world dir, restarts', async () => {
|
||||
const d = deps();
|
||||
const svc = createWorldService(d);
|
||||
const res = await svc.regen();
|
||||
expect(d.dockerService.stop).toHaveBeenCalled();
|
||||
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
|
||||
expect(d.dockerService.start).toHaveBeenCalled();
|
||||
expect(res).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not stop if already stopped', async () => {
|
||||
const d = deps();
|
||||
d.dockerService.getState.mockResolvedValue('exited');
|
||||
const svc = createWorldService(d);
|
||||
await svc.regen();
|
||||
expect(d.dockerService.stop).not.toHaveBeenCalled();
|
||||
expect(d.dockerService.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates level-seed in propertiesService if seed is provided', async () => {
|
||||
const d = deps();
|
||||
const svc = createWorldService(d);
|
||||
await svc.regen('12345');
|
||||
expect(d.propertiesService.update).toHaveBeenCalledWith({ 'level-seed': '12345' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { createWorldService } from '../../src/services/worldService.js';
|
||||
|
||||
function deps(extractOk = true) {
|
||||
return {
|
||||
config: { mcDataPath: '/data', mcWorldName: 'world' },
|
||||
dockerService: {
|
||||
getState: vi.fn().mockResolvedValue('running'),
|
||||
stop: vi.fn().mockResolvedValue(),
|
||||
start: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
fs: {
|
||||
rm: vi.fn().mockResolvedValue(),
|
||||
rename: vi.fn().mockResolvedValue(),
|
||||
mkdir: vi.fn().mockResolvedValue(),
|
||||
readdir: vi.fn().mockResolvedValue(['level.dat']),
|
||||
stat: vi.fn().mockResolvedValue({ isDirectory: () => false }),
|
||||
},
|
||||
// extractZip is injected for testability
|
||||
extractZip: vi.fn().mockImplementation(async () => {
|
||||
if (!extractOk) throw new Error('bad zip');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('worldService.importWorld', () => {
|
||||
it('extracts to temp, swaps, restarts on success', async () => {
|
||||
const d = deps(true);
|
||||
const svc = createWorldService(d);
|
||||
const res = await svc.importWorld('/tmp/upload.zip');
|
||||
expect(d.extractZip).toHaveBeenCalledWith('/tmp/upload.zip', path.join('/data', 'world.import'));
|
||||
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
|
||||
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import'), path.join('/data', 'world'));
|
||||
expect(d.dockerService.start).toHaveBeenCalled();
|
||||
expect(res).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('leaves live world intact and restarts server on bad zip', async () => {
|
||||
const d = deps(false);
|
||||
const svc = createWorldService(d);
|
||||
await expect(svc.importWorld('/tmp/upload.zip')).rejects.toThrow('bad zip');
|
||||
// live world never removed
|
||||
expect(d.fs.rm).not.toHaveBeenCalledWith(path.join('/data', 'world'), expect.anything());
|
||||
expect(d.fs.rename).not.toHaveBeenCalled();
|
||||
// temp cleaned + server restarted
|
||||
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import'), { recursive: true, force: true });
|
||||
expect(d.dockerService.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hoists nested directory for Java', async () => {
|
||||
const d = deps(true);
|
||||
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
|
||||
if (dir.endsWith('world.import')) {
|
||||
return ['my_world'];
|
||||
}
|
||||
if (dir.endsWith('my_world') || dir.endsWith('aligned_world')) {
|
||||
return ['level.dat', 'db'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
d.fs.stat = vi.fn().mockImplementation(async (filePath) => {
|
||||
return { isDirectory: () => filePath.endsWith('my_world') || filePath.endsWith('aligned_world') };
|
||||
});
|
||||
|
||||
const svc = createWorldService(d);
|
||||
await svc.importWorld('/tmp/upload.zip');
|
||||
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import/my_world'), path.join('/data', 'world.import/aligned_world'));
|
||||
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import/aligned_world'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('aligns directory to level-name for Bedrock flat zip', async () => {
|
||||
const d = deps(true);
|
||||
d.config.mcWorldName = 'worlds';
|
||||
d.propertiesService = {
|
||||
read: vi.fn().mockResolvedValue({ 'level-name': 'My Bedrock World' })
|
||||
};
|
||||
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
|
||||
if (dir.endsWith('worlds.import') || dir.endsWith('worlds.import.temp')) {
|
||||
return ['level.dat', 'db'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
d.fs.stat = vi.fn().mockResolvedValue({ isDirectory: () => false });
|
||||
|
||||
const svc = createWorldService(d);
|
||||
await svc.importWorld('/tmp/upload.zip');
|
||||
expect(d.fs.mkdir).toHaveBeenCalledWith(path.join('/data', 'worlds.import/My Bedrock World'), { recursive: true });
|
||||
});
|
||||
|
||||
// Bundled player-data (craftdock/) migration is covered end-to-end in
|
||||
// worldServiceBackup.test.js (real archiver + unzipper round-trip).
|
||||
});
|
||||
Reference in new issue
Block a user