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,184 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createBedrockAdapter } from '../../src/adapters/bedrockAdapter.js';
|
||||
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
|
||||
|
||||
function make(sendImpl) {
|
||||
const stdin = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||
return { stdin, adapter: createBedrockAdapter(stdin) };
|
||||
}
|
||||
|
||||
describe('BedrockAdapter', () => {
|
||||
it('whitelistAdd maps to "allowlist add"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistAdd('steve');
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist add "steve"');
|
||||
});
|
||||
|
||||
it('whitelistRemove maps to "allowlist remove"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistRemove('steve');
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist remove "steve"');
|
||||
});
|
||||
|
||||
it('ban throws NotSupportedError', async () => {
|
||||
const { adapter } = make();
|
||||
await expect(adapter.ban('x')).rejects.toBeInstanceOf(NotSupportedError);
|
||||
});
|
||||
|
||||
it('does not advertise ban capability', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('ban')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not expose getSeed (Bedrock has no seed command; read from level.dat)', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.getSeed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses querytarget response when it is clean JSON', async () => {
|
||||
const { adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return '[{"position":{"x":15.0,"y":70.0,"z":-30.0},"dimension":1}]';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toEqual({ x: 15, y: 70, z: -30, dimension: 'nether' });
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses querytarget response when it has a log/text prefix', async () => {
|
||||
const { adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return '[2026-07-18 23:08:38:396 INFO] Target data: [\n' +
|
||||
' {\n' +
|
||||
' "dimension" : 0,\n' +
|
||||
' "position" : {\n' +
|
||||
' "x" : 12.5,\n' +
|
||||
' "y" : 64.0,\n' +
|
||||
' "z" : -25.5\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toEqual({ x: 12.5, y: 64, z: -25.5, dimension: 'overworld' });
|
||||
});
|
||||
|
||||
it('getPlayerPosition returns null when querytarget fails, and does not fall back to tp', async () => {
|
||||
const { stdin, adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return 'Unknown command or failure';
|
||||
}
|
||||
return 'Teleported alex to 12.5, 64, -25.5';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toBeNull();
|
||||
expect(stdin.send).not.toHaveBeenCalledWith(expect.stringContaining('tp '));
|
||||
});
|
||||
|
||||
it('whitelistOn maps to "allowlist on"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistOn();
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist on');
|
||||
});
|
||||
|
||||
it('whitelistOff maps to "allowlist off"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistOff();
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist off');
|
||||
});
|
||||
|
||||
it('whitelistList parses "allowlist list" output into array', async () => {
|
||||
const { adapter } = make(async () => 'There are 2 allowlisted players: steve, alex');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual(['steve', 'alex']);
|
||||
});
|
||||
|
||||
it('whitelistList returns empty array when no players', async () => {
|
||||
const { adapter } = make(async () => 'There are 0 allowlisted players:');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
it('sendCommand sends command unmodified if no slash', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.sendCommand('gamerule showcoordinates true');
|
||||
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('sendCommand strips leading slash from command', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||
});
|
||||
|
||||
it('saveHold sends "save hold" then polls "save query" until ready', async () => {
|
||||
const { stdin, adapter } = make(async (cmd) =>
|
||||
cmd === 'save query' ? 'Data saved. Files are now ready to be copied.' : 'ok');
|
||||
const ok = await adapter.saveHold();
|
||||
expect(ok).toBe(true);
|
||||
expect(stdin.send).toHaveBeenCalledWith('save hold');
|
||||
expect(stdin.send).toHaveBeenCalledWith('save query');
|
||||
});
|
||||
|
||||
it('saveResume sends "save resume"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.saveResume();
|
||||
expect(stdin.send).toHaveBeenCalledWith('save resume');
|
||||
});
|
||||
|
||||
it('queryUniqueId extracts the uniqueId from querytarget output', async () => {
|
||||
const { adapter } = make(async (cmd) =>
|
||||
cmd.startsWith('querytarget')
|
||||
? '[{"dimension":0,"position":{"x":1,"y":2,"z":3},"uniqueId":"-8589934591"}]'
|
||||
: 'ok');
|
||||
expect(await adapter.queryUniqueId('alex')).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('queryUniqueId extracts decimal uniqueId from noisy log output', async () => {
|
||||
const { adapter } = make(async (cmd) =>
|
||||
cmd.startsWith('querytarget')
|
||||
? '[2026-07-22 22:43:05:123 INFO] Target data: [{"dimension":0,"position":{"x":100,"y":64,"z":-200},"uniqueId":2535407895138987}]'
|
||||
: 'ok');
|
||||
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('2535407895138987');
|
||||
});
|
||||
|
||||
it('queryUniqueId parses the real multi-line 1.26 output (UUID uniqueId, id present)', async () => {
|
||||
// Captured verbatim from a live bedrock 1.26.33.2 server
|
||||
const real = [
|
||||
'[2026-07-23 02:40:43:441 INFO] Target data: [',
|
||||
' {',
|
||||
' "dimension" : 0,',
|
||||
' "id" : -64424509439,',
|
||||
' "position" : {',
|
||||
' "x" : 79.52253723144531,',
|
||||
' "y" : 98.62001037597656,',
|
||||
' "z" : 14.37582015991211',
|
||||
' },',
|
||||
' "uniqueId" : "657cb1d4-99dd-3123-b27f-d0c27df79710",',
|
||||
' "yRot" : -82.85218048095703',
|
||||
' }',
|
||||
']',
|
||||
].join('\n');
|
||||
const { adapter } = make(async (cmd) => (cmd.startsWith('querytarget') ? real : 'ok'));
|
||||
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('657cb1d4-99dd-3123-b27f-d0c27df79710');
|
||||
expect(await adapter.getPlayerPosition('Lucasrguerra')).toEqual({
|
||||
x: 79.52253723144531, y: 98.62001037597656, z: 14.37582015991211, dimension: 'overworld',
|
||||
});
|
||||
});
|
||||
|
||||
it('queryUniqueId returns null when querytarget fails', async () => {
|
||||
const { adapter } = make(async () => 'Unknown command');
|
||||
expect(await adapter.queryUniqueId('alex')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import pkg from 'leveldb-zlib';
|
||||
import nbt from 'prismarine-nbt';
|
||||
import { createBedrockPlayerFile, parsePlayerBuffer } from '../../src/adapters/playerFile/bedrockPlayerFile.js';
|
||||
|
||||
const { LevelDB } = pkg;
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = path.join(here, '../fixtures/bedrock-player.nbt');
|
||||
const UUID = '1f633c09-f1f1-481c-b811-1cfa15b21973';
|
||||
// Real-world shape: querytarget's uniqueId is the player's MsaId, and the DB
|
||||
// holds a mapping record player_<MsaId> → { MsaId, SelfSignedId, ServerId }.
|
||||
const MSA_ID = '657cb1d4-99dd-3123-b27f-d0c27df79710';
|
||||
const SELF_SIGNED_ID = 'a257319c-50e9-3aad-aef3-8fc3e0d4e4c2';
|
||||
|
||||
function mappingRecord() {
|
||||
const str = (v) => ({ type: 'string', value: v });
|
||||
return nbt.writeUncompressed({
|
||||
type: 'compound',
|
||||
name: '',
|
||||
value: {
|
||||
MsaId: str(MSA_ID),
|
||||
SelfSignedId: str(SELF_SIGNED_ID),
|
||||
ServerId: str('player_server_' + UUID),
|
||||
},
|
||||
}, 'little');
|
||||
}
|
||||
|
||||
let tmp, config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const buf = fs.readFileSync(FIXTURE);
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-bpf-'));
|
||||
const dbPath = path.join(tmp, 'worlds', 'Bedrock level', 'db');
|
||||
await fsp.mkdir(dbPath, { recursive: true });
|
||||
// Build a real (Mojang-format) LevelDB holding the captured player NBT.
|
||||
const db = new LevelDB(dbPath, { createIfMissing: true });
|
||||
await db.open();
|
||||
await db.put(Buffer.from('player_server_' + UUID, 'latin1'), buf);
|
||||
await db.put(Buffer.from('player_' + MSA_ID, 'latin1'), mappingRecord());
|
||||
await db.put(Buffer.from('player_' + SELF_SIGNED_ID, 'latin1'), mappingRecord());
|
||||
await db.close();
|
||||
config = { mcDataPath: tmp, mcWorldName: 'worlds' };
|
||||
});
|
||||
|
||||
afterAll(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('bedrockPlayerFile', () => {
|
||||
it('parsePlayerBuffer decodes the captured real-world fixture', async () => {
|
||||
const d = await parsePlayerBuffer(fs.readFileSync(FIXTURE));
|
||||
expect(d.position).not.toBeNull();
|
||||
expect(d.health.max).toBe(20);
|
||||
expect(typeof d.food).toBe('number');
|
||||
expect(Array.isArray(d.inventory)).toBe(true);
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('readPlayer returns normalized data for an existing uuid', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
const d = await adapter.readPlayer(UUID);
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
expect(d.position).not.toBeNull();
|
||||
});
|
||||
|
||||
it('readPlayer returns null for a missing uuid', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
expect(await adapter.readPlayer('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
});
|
||||
|
||||
it('findByUniqueId locates the player by UniqueID (int64 or UUID string format)', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
const hit = await adapter.findByUniqueId('-8589934591');
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit.uuid).toBe(UUID);
|
||||
|
||||
// Modern Bedrock servers output uniqueId in UUID hex string format via querytarget:
|
||||
// e.g. "657cb1d4-99dd-3123-b27f-d0c27df79710" or corresponding int64 (-8589934591)
|
||||
const hit2 = await adapter.findByUniqueId('00000000-0000-0000-ffff-fffe00000001');
|
||||
expect(hit2).not.toBeNull();
|
||||
expect(hit2.uuid).toBe(UUID);
|
||||
});
|
||||
|
||||
it('findByUniqueId resolves via the player_<uuid> ServerId mapping record', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
// querytarget uniqueId (= MsaId) — the case seen against a live server
|
||||
const hit = await adapter.findByUniqueId(MSA_ID);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit.uuid).toBe(UUID);
|
||||
// SelfSignedId mapping record also resolves
|
||||
const hit2 = await adapter.findByUniqueId(SELF_SIGNED_ID);
|
||||
expect(hit2.uuid).toBe(UUID);
|
||||
});
|
||||
|
||||
it('listServerUuids enumerates player_server_ keys', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
expect(await adapter.listServerUuids()).toContain(UUID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createAdapter, isBedrock } from '../../src/adapters/index.js';
|
||||
|
||||
const deps = {
|
||||
rconService: { send: async () => 'r' },
|
||||
stdinService: { send: async () => 's' },
|
||||
};
|
||||
|
||||
describe('adapter factory', () => {
|
||||
it('isBedrock detects BEDROCK type case-insensitively', () => {
|
||||
expect(isBedrock('BEDROCK')).toBe(true);
|
||||
expect(isBedrock('bedrock')).toBe(true);
|
||||
expect(isBedrock('PAPER')).toBe(false);
|
||||
expect(isBedrock(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a java adapter for PAPER', () => {
|
||||
const a = createAdapter('PAPER', deps);
|
||||
expect(a.capabilities.has('ban')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a bedrock adapter for BEDROCK', () => {
|
||||
const a = createAdapter('BEDROCK', deps);
|
||||
expect(a.capabilities.has('ban')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createJavaAdapter } from '../../src/adapters/javaAdapter.js';
|
||||
|
||||
function make(sendImpl) {
|
||||
const rcon = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||
return { rcon, adapter: createJavaAdapter(rcon) };
|
||||
}
|
||||
|
||||
describe('JavaAdapter', () => {
|
||||
it('whitelistAdd issues "whitelist add"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistAdd('steve');
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist add steve');
|
||||
});
|
||||
|
||||
it('ban issues "ban" with reason', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.ban('griefer', 'no griefing');
|
||||
expect(rcon.send).toHaveBeenCalledWith('ban griefer no griefing');
|
||||
});
|
||||
|
||||
it('give issues "give" with item and count', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.give('steve', 'minecraft:diamond', 5);
|
||||
expect(rcon.send).toHaveBeenCalledWith('give steve minecraft:diamond 5');
|
||||
});
|
||||
|
||||
it('listPlayers parses rcon output', async () => {
|
||||
const { adapter } = make(async () => 'There are 1 of a max of 20 players online: steve');
|
||||
expect(await adapter.listPlayers()).toEqual({ online: 1, max: 20, players: ['steve'] });
|
||||
});
|
||||
|
||||
it('exposes JAVA capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('ban')).toBe(true);
|
||||
});
|
||||
|
||||
it('getSeed parses seed correctly', async () => {
|
||||
const { adapter } = make(async () => 'Seed: [123456789]');
|
||||
const seed = await adapter.getSeed();
|
||||
expect(seed).toBe('123456789');
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses coordinates and dimension correctly', async () => {
|
||||
let callCount = 0;
|
||||
const { rcon, adapter } = make(async (cmd) => {
|
||||
callCount++;
|
||||
if (cmd.startsWith('tp')) {
|
||||
return 'Teleported steve to 10.5, 64, -20.3';
|
||||
}
|
||||
if (cmd.startsWith('data get')) {
|
||||
return 'steve has the following entity data: "minecraft:overworld"';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('steve');
|
||||
expect(pos).toEqual({ x: 10.5, y: 64, z: -20.3, dimension: 'overworld' });
|
||||
});
|
||||
|
||||
it('whitelistOn maps to "whitelist on"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistOn();
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist on');
|
||||
});
|
||||
|
||||
it('whitelistOff maps to "whitelist off"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistOff();
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist off');
|
||||
});
|
||||
|
||||
it('whitelistList parses "whitelist list" output into array', async () => {
|
||||
const { adapter } = make(async () => 'There are 3 whitelisted players: steve, alex, notch');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual(['steve', 'alex', 'notch']);
|
||||
});
|
||||
|
||||
it('whitelistList returns empty array when no players', async () => {
|
||||
const { adapter } = make(async () => 'There are 0 whitelisted players:');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
it('sendCommand sends command unmodified if no slash', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.sendCommand('gamerule showcoordinates true');
|
||||
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('sendCommand strips leading slash from command', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||
});
|
||||
|
||||
it('forceSave issues "save-all flush"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.forceSave();
|
||||
expect(rcon.send).toHaveBeenCalledWith('save-all flush');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import nbt from 'prismarine-nbt';
|
||||
import { createJavaPlayerFile } from '../../src/adapters/playerFile/javaPlayerFile.js';
|
||||
|
||||
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||
let tmp, config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const doc = {
|
||||
type: 'compound', name: '', value: {
|
||||
Pos: { type: 'list', value: { type: 'double', value: [10.5, 64, -20.3] } },
|
||||
Dimension: { type: 'string', value: 'minecraft:the_end' },
|
||||
Health: { type: 'float', value: 18 },
|
||||
foodLevel: { type: 'int', value: 15 },
|
||||
Inventory: {
|
||||
type: 'list', value: {
|
||||
type: 'compound', value: [{
|
||||
Slot: { type: 'byte', value: 0 },
|
||||
id: { type: 'string', value: 'minecraft:dirt' },
|
||||
Count: { type: 'byte', value: 64 },
|
||||
}],
|
||||
},
|
||||
},
|
||||
playerGameType: { type: 'int', value: 0 },
|
||||
XpLevel: { type: 'int', value: 3 },
|
||||
},
|
||||
};
|
||||
const dat = zlib.gzipSync(nbt.writeUncompressed(doc, 'big'));
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-jpf-'));
|
||||
const pdir = path.join(tmp, 'world', 'playerdata');
|
||||
await fsp.mkdir(pdir, { recursive: true });
|
||||
await fsp.writeFile(path.join(pdir, `${UUID}.dat`), dat);
|
||||
config = { mcDataPath: tmp, mcWorldName: 'world' };
|
||||
});
|
||||
|
||||
afterAll(async () => { if (tmp) await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('javaPlayerFile', () => {
|
||||
it('reads and normalizes a playerdata .dat', async () => {
|
||||
const d = await createJavaPlayerFile(config).readPlayer(UUID);
|
||||
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||
expect(d.dimension).toBe('end');
|
||||
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||
expect(d.food).toBe(15);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.gamemode).toBe('survival');
|
||||
});
|
||||
|
||||
it('returns null when the player has no .dat', async () => {
|
||||
const d = await createJavaPlayerFile(config).readPlayer('00000000-0000-0000-0000-000000000000');
|
||||
expect(d).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeBedrock, normalizeJava, int64ToString } from '../../src/adapters/playerFile/nbtPlayer.js';
|
||||
|
||||
describe('int64ToString', () => {
|
||||
it('combines [high, low] int32 pair like prismarine-nbt longs', () => {
|
||||
expect(int64ToString([-2, 1])).toBe('-8589934591');
|
||||
expect(int64ToString([0, 5])).toBe('5');
|
||||
});
|
||||
it('passes through number/bigint', () => {
|
||||
expect(int64ToString(42)).toBe('42');
|
||||
expect(int64ToString(7n)).toBe('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBedrock', () => {
|
||||
it('extracts position, dimension, health, hunger, inventory, uniqueId', () => {
|
||||
const d = normalizeBedrock({
|
||||
Pos: [1.5, 2, 3], DimensionId: 1,
|
||||
Attributes: [
|
||||
{ Name: 'minecraft:health', Current: 9.5, Max: 20 },
|
||||
{ Name: 'minecraft:player.hunger', Current: 17 },
|
||||
],
|
||||
Inventory: [
|
||||
{ Slot: 0, Name: 'minecraft:iron_pickaxe', Count: 1 },
|
||||
{ Slot: 1, Name: '', Count: 0 },
|
||||
],
|
||||
PlayerGameMode: 1, PlayerLevel: 10, UniqueID: [-2, 1],
|
||||
});
|
||||
expect(d.position).toEqual({ x: 1.5, y: 2, z: 3 });
|
||||
expect(d.dimension).toBe('nether');
|
||||
expect(d.health).toEqual({ current: 9.5, max: 20 });
|
||||
expect(d.food).toBe(17);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:iron_pickaxe', count: 1 }]);
|
||||
expect(d.gamemode).toBe('creative');
|
||||
expect(d.xp).toEqual({ level: 10 });
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('extracts armor (head/chest/legs/feet) and offhand from their own NBT lists', () => {
|
||||
const d = normalizeBedrock({
|
||||
Armor: [
|
||||
{ Name: 'minecraft:iron_helmet', Count: 1 },
|
||||
{ Name: 'minecraft:iron_chestplate', Count: 1 },
|
||||
{ Name: '', Count: 0 },
|
||||
{ Name: 'minecraft:leather_boots', Count: 1 },
|
||||
{ Name: '', Count: 0 }, // 5th body slot on some versions — ignored
|
||||
],
|
||||
Offhand: [{ Name: 'minecraft:shield', Count: 1 }],
|
||||
});
|
||||
expect(d.armor).toEqual({
|
||||
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||
legs: null,
|
||||
feet: { name: 'minecraft:leather_boots', count: 1 },
|
||||
});
|
||||
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||
});
|
||||
|
||||
it('yields empty armor/offhand when the lists are absent or empty-named', () => {
|
||||
const d = normalizeBedrock({});
|
||||
expect(d.armor).toEqual({ head: null, chest: null, legs: null, feet: null });
|
||||
expect(d.offhand).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeJava', () => {
|
||||
it('extracts fields from a java playerdata shape', () => {
|
||||
const d = normalizeJava({
|
||||
Pos: [10.5, 64, -20.3], Dimension: 'minecraft:the_end',
|
||||
Health: 18.0, foodLevel: 15,
|
||||
Inventory: [{ Slot: 0, id: 'minecraft:dirt', Count: 64 }],
|
||||
playerGameType: 0, XpLevel: 3,
|
||||
});
|
||||
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||
expect(d.dimension).toBe('end');
|
||||
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||
expect(d.food).toBe(15);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.gamemode).toBe('survival');
|
||||
expect(d.xp).toEqual({ level: 3 });
|
||||
});
|
||||
|
||||
it('splits armor (slots 100-103) and offhand (-106) out of the java Inventory list', () => {
|
||||
const d = normalizeJava({
|
||||
Inventory: [
|
||||
{ Slot: 0, id: 'minecraft:dirt', Count: 64 },
|
||||
{ Slot: 100, id: 'minecraft:iron_boots', Count: 1 },
|
||||
{ Slot: 101, id: 'minecraft:iron_leggings', Count: 1 },
|
||||
{ Slot: 102, id: 'minecraft:iron_chestplate', Count: 1 },
|
||||
{ Slot: 103, id: 'minecraft:iron_helmet', Count: 1 },
|
||||
{ Slot: -106, id: 'minecraft:shield', Count: 1 },
|
||||
],
|
||||
});
|
||||
// armor/offhand are NOT part of the regular inventory grid
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.armor).toEqual({
|
||||
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||
legs: { name: 'minecraft:iron_leggings', count: 1 },
|
||||
feet: { name: 'minecraft:iron_boots', count: 1 },
|
||||
});
|
||||
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPlayerFileAdapter } from '../../src/adapters/playerFile/index.js';
|
||||
|
||||
const config = { mcDataPath: '/data', mcWorldName: 'worlds' };
|
||||
|
||||
describe('createPlayerFileAdapter', () => {
|
||||
it('returns the bedrock adapter (with findByUniqueId) for bedrock', () => {
|
||||
const a = createPlayerFileAdapter('bedrock', config);
|
||||
expect(typeof a.readPlayer).toBe('function');
|
||||
expect(typeof a.findByUniqueId).toBe('function');
|
||||
});
|
||||
|
||||
it('returns the java adapter for java', () => {
|
||||
const a = createPlayerFileAdapter('java', config);
|
||||
expect(typeof a.readPlayer).toBe('function');
|
||||
expect(a.findByUniqueId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePlayerList, parseWhitelistList, CAPABILITIES } from '../../src/adapters/serverAdapter.js';
|
||||
|
||||
describe('parsePlayerList', () => {
|
||||
it('parses Java list output', () => {
|
||||
const r = parsePlayerList(
|
||||
'There are 2 of a max of 20 players online: steve, alex'
|
||||
);
|
||||
expect(r).toEqual({ online: 2, max: 20, players: ['steve', 'alex'] });
|
||||
});
|
||||
|
||||
it('parses Bedrock list output', () => {
|
||||
const r = parsePlayerList('There are 1/10 players online:\nnotch');
|
||||
expect(r).toEqual({ online: 1, max: 10, players: ['notch'] });
|
||||
});
|
||||
|
||||
it('handles empty server', () => {
|
||||
const r = parsePlayerList('There are 0 of a max of 20 players online:');
|
||||
expect(r).toEqual({ online: 0, max: 20, players: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAPABILITIES', () => {
|
||||
it('java supports ban, bedrock does not', () => {
|
||||
expect(CAPABILITIES.JAVA.has('ban')).toBe(true);
|
||||
expect(CAPABILITIES.BEDROCK.has('ban')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWhitelistList', () => {
|
||||
it('parses Java style text whitelist list', () => {
|
||||
const res = parseWhitelistList('There are 2 whitelisted players: steve, alex');
|
||||
expect(res).toEqual(['steve', 'alex']);
|
||||
});
|
||||
|
||||
it('parses Bedrock style JSON allowlist list', () => {
|
||||
const res = parseWhitelistList(
|
||||
'41:55:938 INFO] ###* {"command":"allowlist", "result":[{"ignoresPlayerLimit":false,"name":"Lucasrguerra","xuid":"2535407895138987"}]} *###'
|
||||
);
|
||||
expect(res).toEqual(['Lucasrguerra']);
|
||||
});
|
||||
|
||||
it('returns empty for an empty Bedrock allowlist (result: null), not garbage', () => {
|
||||
// This is the exact line that produced ghost "names" from log fragments.
|
||||
const res = parseWhitelistList(
|
||||
'[2026-07-18 22:53:21:185 INFO] ###* {"command":"allowlist","result":null} *###'
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for a Bedrock allowlist with an empty array', () => {
|
||||
expect(parseWhitelistList('###* {"command":"allowlist","result":[]} *###')).toEqual([]);
|
||||
});
|
||||
|
||||
it('never scrapes names out of an unrelated log line with a colon', () => {
|
||||
expect(parseWhitelistList('[2026-07-18 22:53:23:695 ERROR] Syntax error: Unexpected token')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty (not [""]) for the Java empty-whitelist message', () => {
|
||||
expect(parseWhitelistList('There are no whitelisted players')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createAppState } from '../src/appState.js';
|
||||
|
||||
const deps = () => ({
|
||||
config: { mcEdition: 'auto' },
|
||||
dockerService: { inspect: vi.fn().mockResolvedValue({ type: 'PAPER' }) },
|
||||
rconService: { send: async () => 'r' },
|
||||
stdinService: { send: async () => 's' },
|
||||
});
|
||||
|
||||
describe('appState', () => {
|
||||
it('resolves a java adapter when TYPE is PAPER', async () => {
|
||||
const state = createAppState(deps());
|
||||
const a = await state.getAdapter();
|
||||
expect(a.capabilities.has('ban')).toBe(true);
|
||||
});
|
||||
|
||||
it('honors forced bedrock edition without inspecting', async () => {
|
||||
const d = deps();
|
||||
d.config.mcEdition = 'bedrock';
|
||||
const state = createAppState(d);
|
||||
const a = await state.getAdapter();
|
||||
expect(a.capabilities.has('ban')).toBe(false);
|
||||
expect(d.dockerService.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cache a fallback when TYPE is unknown, then re-resolves once known', async () => {
|
||||
const d = deps();
|
||||
// First inspect (container stopped) yields no type; second yields BEDROCK.
|
||||
d.dockerService.inspect = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: null })
|
||||
.mockResolvedValueOnce({ type: 'BEDROCK' });
|
||||
const state = createAppState(d);
|
||||
|
||||
const first = await state.getAdapter();
|
||||
expect(first.capabilities.has('ban')).toBe(true); // java fallback, not cached
|
||||
|
||||
const second = await state.getAdapter();
|
||||
expect(second.capabilities.has('ban')).toBe(false); // now correctly bedrock
|
||||
expect(d.dockerService.inspect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches once a definitive TYPE is resolved', async () => {
|
||||
const d = deps(); // inspect always returns PAPER
|
||||
const state = createAppState(d);
|
||||
await state.getAdapter();
|
||||
await state.getAdapter();
|
||||
expect(d.dockerService.inspect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
|
||||
const base = {
|
||||
ADMIN_PASSWORD_HASH: 'hash',
|
||||
SESSION_SECRET: 'secret',
|
||||
};
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('applies defaults for optional vars', () => {
|
||||
const c = loadConfig(base);
|
||||
expect(c.mcContainerName).toBe('craftdock-mc-server');
|
||||
expect(c.rconPort).toBe(25575);
|
||||
expect(c.port).toBe(3000);
|
||||
expect(c.mcEdition).toBe('auto');
|
||||
expect(c.maxUploadBytes).toBe(1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('throws when required vars are missing', () => {
|
||||
expect(() => loadConfig({})).toThrow(/ADMIN_PASSWORD_HASH/);
|
||||
});
|
||||
|
||||
it('does not require RCON_PASSWORD (Java-only), defaulting it to empty', () => {
|
||||
const c = loadConfig(base);
|
||||
expect(c.rconPassword).toBe('');
|
||||
});
|
||||
|
||||
it('accepts a base64-encoded hash when the raw hash is absent', () => {
|
||||
const realHash = '$2a$12$YjrtHh2BNcUT0P76L2ZUF.mCtxzUwN.gZ4siUqji2oxrDbIXRdvfe';
|
||||
const b64 = Buffer.from(realHash, 'utf8').toString('base64');
|
||||
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH_B64: b64 });
|
||||
expect(c.adminPasswordHash).toBe(realHash);
|
||||
});
|
||||
|
||||
it('prefers the raw hash over the base64 one when both are set', () => {
|
||||
const b64 = Buffer.from('other', 'utf8').toString('base64');
|
||||
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH: 'raw', ADMIN_PASSWORD_HASH_B64: b64 });
|
||||
expect(c.adminPasswordHash).toBe('raw');
|
||||
});
|
||||
|
||||
it('throws when neither raw nor base64 hash is provided', () => {
|
||||
expect(() => loadConfig({ SESSION_SECRET: 's' })).toThrow(/ADMIN_PASSWORD_HASH/);
|
||||
});
|
||||
|
||||
it('coerces numeric env vars', () => {
|
||||
const c = loadConfig({ ...base, RCON_PORT: '25580', PORT: '8080' });
|
||||
expect(c.rconPort).toBe(25580);
|
||||
expect(c.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('falls back to defaults for empty string or non-numeric numeric env vars', () => {
|
||||
const c = loadConfig({ ...base, RCON_PORT: '', PORT: 'abc' });
|
||||
expect(c.rconPort).toBe(25575);
|
||||
expect(c.port).toBe(3000);
|
||||
});
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLogger } from '../src/logger.js';
|
||||
|
||||
describe('logger', () => {
|
||||
it('formats text logs with timestamp, level, component and message', () => {
|
||||
let output = '';
|
||||
const stream = {
|
||||
write: (data) => { output += data; },
|
||||
};
|
||||
const log = createLogger('test-comp', { level: 'info', format: 'text', stream });
|
||||
log.info('hello world', { foo: 'bar' });
|
||||
|
||||
expect(output).toContain('[INFO ] [test-comp] hello world {"foo":"bar"}');
|
||||
});
|
||||
|
||||
it('formats JSON logs when format is json', () => {
|
||||
let output = '';
|
||||
const stream = {
|
||||
write: (data) => { output += data; },
|
||||
};
|
||||
const log = createLogger('json-comp', { level: 'info', format: 'json', stream });
|
||||
log.info('json message', { count: 42 });
|
||||
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.component).toBe('json-comp');
|
||||
expect(parsed.level).toBe('info');
|
||||
expect(parsed.msg).toBe('json message');
|
||||
expect(parsed.meta).toEqual({ count: 42 });
|
||||
});
|
||||
|
||||
it('filters out logs above threshold level', () => {
|
||||
let output = '';
|
||||
const stream = {
|
||||
write: (data) => { output += data; },
|
||||
};
|
||||
const log = createLogger('threshold-comp', { level: 'warn', format: 'text', stream });
|
||||
log.info('should be ignored');
|
||||
log.warn('should be logged');
|
||||
|
||||
expect(output).not.toContain('should be ignored');
|
||||
expect(output).toContain('should be logged');
|
||||
});
|
||||
|
||||
it('serializes Error objects correctly', () => {
|
||||
let output = '';
|
||||
const stream = {
|
||||
write: (data) => { output += data; },
|
||||
};
|
||||
const log = createLogger('error-comp', { level: 'error', format: 'json', stream });
|
||||
const err = new Error('something went wrong');
|
||||
log.error('failure', { err });
|
||||
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.meta.err.error).toBe('something went wrong');
|
||||
expect(parsed.meta.err.stack).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { requireAuth, socketAuth } from '../../src/middleware/auth.js';
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) { this.statusCode = c; return this; },
|
||||
json(b) { this.body = b; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('calls next when session is authed', () => {
|
||||
const next = vi.fn();
|
||||
requireAuth({ session: { authed: true } }, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns 401 when not authed', () => {
|
||||
const next = vi.fn();
|
||||
const res = mockRes();
|
||||
requireAuth({ session: {} }, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('socketAuth', () => {
|
||||
it('calls next with no error when session is authed', () => {
|
||||
const next = vi.fn();
|
||||
const socket = { request: {} };
|
||||
const fakeSessionMiddleware = (req, res, callback) => {
|
||||
req.session = { authed: true };
|
||||
callback();
|
||||
};
|
||||
|
||||
const middleware = socketAuth(fakeSessionMiddleware);
|
||||
middleware(socket, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('calls next with unauthorized error when not authed', () => {
|
||||
const next = vi.fn();
|
||||
const socket = { request: {} };
|
||||
const fakeSessionMiddleware = (req, res, callback) => {
|
||||
req.session = {};
|
||||
callback();
|
||||
};
|
||||
|
||||
const middleware = socketAuth(fakeSessionMiddleware);
|
||||
middleware(socket, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(next).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(next.mock.calls[0][0].message).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import request from 'supertest';
|
||||
import { createAuthRouter } from '../../src/routes/auth.js';
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({ secret: 't', resave: false, saveUninitialized: false }));
|
||||
const authService = { verifyPassword: async (p) => p === 'right' };
|
||||
app.use('/api/auth', createAuthRouter(authService));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('auth routes', () => {
|
||||
it('logs in with correct password and reports authed', async () => {
|
||||
const agent = request.agent(makeApp());
|
||||
const login = await agent.post('/api/auth/login').send({ password: 'right' });
|
||||
expect(login.status).toBe(200);
|
||||
const me = await agent.get('/api/auth/me');
|
||||
expect(me.body).toEqual({ authed: true });
|
||||
});
|
||||
|
||||
it('rejects wrong password', async () => {
|
||||
const res = await request(makeApp()).post('/api/auth/login').send({ password: 'no' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('logs out', async () => {
|
||||
const agent = request.agent(makeApp());
|
||||
await agent.post('/api/auth/login').send({ password: 'right' });
|
||||
await agent.post('/api/auth/logout');
|
||||
const me = await agent.get('/api/auth/me');
|
||||
expect(me.body).toEqual({ authed: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createPlayersRouter } from '../../src/routes/players.js';
|
||||
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
|
||||
|
||||
function makeApp(adapterOverrides = {}, { propertiesOverrides } = {}) {
|
||||
const adapter = {
|
||||
capabilities: new Set(['whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'ban', 'give']),
|
||||
_edition: 'java',
|
||||
listPlayers: vi.fn().mockResolvedValue({ online: 0, max: 20, players: [] }),
|
||||
whitelistAdd: vi.fn().mockResolvedValue('Added steve'),
|
||||
whitelistRemove: vi.fn().mockResolvedValue('Removed steve'),
|
||||
whitelistOn: vi.fn().mockResolvedValue('Whitelist enabled'),
|
||||
whitelistOff: vi.fn().mockResolvedValue('Whitelist disabled'),
|
||||
whitelistList: vi.fn().mockResolvedValue(['steve', 'alex']),
|
||||
ban: vi.fn().mockResolvedValue('Banned'),
|
||||
give: vi.fn().mockResolvedValue('Gave'),
|
||||
...adapterOverrides,
|
||||
};
|
||||
const propertiesService = {
|
||||
read: vi.fn().mockResolvedValue({ 'white-list': 'true' }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
...propertiesOverrides,
|
||||
};
|
||||
const appState = { getAdapter: vi.fn().mockResolvedValue(adapter) };
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/players', createPlayersRouter({ appState, propertiesService, config: { mcDataPath: '/data' } }));
|
||||
return { app, adapter, propertiesService };
|
||||
}
|
||||
|
||||
describe('players routes', () => {
|
||||
it('rejects a name-requiring action with an empty name (does not call the adapter)', async () => {
|
||||
const { app, adapter } = makeApp();
|
||||
const res = await request(app).post('/api/players/whitelistRemove').send({ name: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'name_required' });
|
||||
expect(adapter.whitelistRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows name-less actions (whitelistOn) without a name', async () => {
|
||||
const { app, adapter } = makeApp();
|
||||
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(adapter.whitelistOn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET / returns players and capability list (no embedded directory)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/players');
|
||||
expect(res.body.capabilities).toContain('ban');
|
||||
expect(res.body.players).toEqual({ online: 0, max: 20, players: [] });
|
||||
expect(res.body.players.directory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('GET /directory returns a paginated shape with online annotation', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/players/directory?page=1&pageSize=25');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ page: 1, pageSize: 25, total: expect.any(Number) });
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /detail requires an xuid', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/players/detail');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'xuid_required' });
|
||||
});
|
||||
|
||||
it('GET /detail returns 404 for an unknown player', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/players/detail?xuid=doesnotexist');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET / returns whitelistEnabled and whitelist', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/players');
|
||||
expect(res.body.whitelistEnabled).toBe(true);
|
||||
expect(res.body.whitelist).toEqual(['steve', 'alex']);
|
||||
});
|
||||
|
||||
it('GET / returns whitelistEnabled=false when property is false', async () => {
|
||||
const { app } = makeApp({}, {
|
||||
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'white-list': 'false' }) },
|
||||
});
|
||||
const res = await request(app).get('/api/players');
|
||||
expect(res.body.whitelistEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('GET / reads allow-list property for bedrock edition', async () => {
|
||||
const { app } = makeApp({ _edition: 'bedrock' }, {
|
||||
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'allow-list': 'true' }) },
|
||||
});
|
||||
const res = await request(app).get('/api/players');
|
||||
expect(res.body.whitelistEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /whitelistAdd delegates with name', async () => {
|
||||
const { app, adapter } = makeApp();
|
||||
const res = await request(app).post('/api/players/whitelistAdd').send({ name: 'steve' });
|
||||
expect(res.body).toEqual({ ok: true, output: 'Added steve' });
|
||||
expect(adapter.whitelistAdd).toHaveBeenCalledWith('steve');
|
||||
});
|
||||
|
||||
it('POST /whitelistOn calls adapter and persists to properties', async () => {
|
||||
const { app, adapter, propertiesService } = makeApp();
|
||||
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(adapter.whitelistOn).toHaveBeenCalled();
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'true' });
|
||||
});
|
||||
|
||||
it('POST /whitelistOff calls adapter and persists to properties', async () => {
|
||||
const { app, adapter, propertiesService } = makeApp();
|
||||
const res = await request(app).post('/api/players/whitelistOff').send({});
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(adapter.whitelistOff).toHaveBeenCalled();
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
|
||||
});
|
||||
|
||||
it('POST /whitelistOn for bedrock persists allow-list property', async () => {
|
||||
const { app, adapter, propertiesService } = makeApp({ _edition: 'bedrock' });
|
||||
const res = await request(app).post('/api/players/whitelistOn').send({});
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'true' });
|
||||
});
|
||||
|
||||
it('POST /give passes item and count', async () => {
|
||||
const { app, adapter } = makeApp();
|
||||
await request(app).post('/api/players/give').send({ name: 'steve', item: 'minecraft:dirt', count: 3 });
|
||||
expect(adapter.give).toHaveBeenCalledWith('steve', 'minecraft:dirt', 3);
|
||||
});
|
||||
|
||||
it('returns 409 on NotSupportedError', async () => {
|
||||
const { app } = makeApp({ ban: vi.fn().mockRejectedValue(new NotSupportedError('ban')) });
|
||||
const res = await request(app).post('/api/players/ban').send({ name: 'x' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: 'not_supported' });
|
||||
});
|
||||
|
||||
it('returns 400 for unknown action', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).post('/api/players/frobnicate').send({ name: 'x' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createPropertiesRouter } from '../../src/routes/properties.js';
|
||||
|
||||
function makeApp() {
|
||||
const propertiesService = {
|
||||
read: vi.fn().mockResolvedValue({ difficulty: 'easy', 'max-players': '20' }),
|
||||
update: vi.fn().mockImplementation(async (p) => ({ difficulty: 'easy', 'max-players': '20', ...p })),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
|
||||
return { app, propertiesService };
|
||||
}
|
||||
|
||||
describe('properties routes', () => {
|
||||
it('GET / returns properties', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/properties');
|
||||
expect(res.body.properties.difficulty).toBe('easy');
|
||||
});
|
||||
|
||||
it('GET / fills defaults for managed keys missing from the file (except motd)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/properties');
|
||||
const p = res.body.properties;
|
||||
// present in the file → kept as-is
|
||||
expect(p.difficulty).toBe('easy');
|
||||
expect(p['max-players']).toBe('20');
|
||||
// absent from the file → default applied
|
||||
expect(p.pvp).toBe('true');
|
||||
expect(p['keep-inventory']).toBe('false');
|
||||
expect(p.hardcore).toBe('false');
|
||||
expect(p['allow-flight']).toBe('false');
|
||||
expect(p['force-gamemode']).toBe('false');
|
||||
expect(p['allow-nether']).toBe('true');
|
||||
expect(p['enable-command-block']).toBe('false');
|
||||
expect(p['spawn-monsters']).toBe('true');
|
||||
expect(p['spawn-animals']).toBe('true');
|
||||
expect(p['spawn-npcs']).toBe('true');
|
||||
expect(p.gamemode).toBe('survival');
|
||||
expect(p['view-distance']).toBe('10');
|
||||
expect(p['simulation-distance']).toBe('10');
|
||||
expect(p['spawn-protection']).toBe('16');
|
||||
// motd deliberately has no default
|
||||
expect(p.motd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('GET / serves pure defaults when server.properties does not exist yet', async () => {
|
||||
const propertiesService = {
|
||||
read: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' })),
|
||||
};
|
||||
const app = express();
|
||||
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
|
||||
const res = await request(app).get('/api/properties');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.properties.pvp).toBe('true');
|
||||
expect(res.body.properties.gamemode).toBe('survival');
|
||||
});
|
||||
|
||||
it('PUT / accepts valid values', async () => {
|
||||
const { app, propertiesService } = makeApp();
|
||||
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'hard' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(propertiesService.update).toHaveBeenCalledWith({ difficulty: 'hard' });
|
||||
});
|
||||
|
||||
it('PUT / rejects invalid enum with 400', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'banana' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.field).toBe('difficulty');
|
||||
});
|
||||
|
||||
it('PUT / rejects non-numeric max-players', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).put('/api/properties').send({ properties: { 'max-players': 'lots' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.field).toBe('max-players');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createStatusRouter } from '../../src/routes/status.js';
|
||||
import { ContainerNotFoundError } from '../../src/services/dockerService.js';
|
||||
|
||||
function makeApp(overrides = {}) {
|
||||
const dockerService = {
|
||||
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
|
||||
stats: vi.fn().mockResolvedValue({ cpuPct: 10, memUsedMb: 512, memPct: 50 }),
|
||||
start: vi.fn().mockResolvedValue(),
|
||||
stop: vi.fn().mockResolvedValue(),
|
||||
restart: vi.fn().mockResolvedValue(),
|
||||
kill: vi.fn().mockResolvedValue(),
|
||||
...overrides.dockerService,
|
||||
};
|
||||
const adapter = { listPlayers: vi.fn().mockResolvedValue({ online: 1, max: 20, players: ['a'] }) };
|
||||
const appState = {
|
||||
getEdition: vi.fn().mockResolvedValue('java'),
|
||||
getAdapter: vi.fn().mockResolvedValue(adapter),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/status', createStatusRouter({ dockerService, appState }));
|
||||
return { app, dockerService };
|
||||
}
|
||||
|
||||
describe('status routes', () => {
|
||||
it('GET / returns aggregated status', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
state: 'running', edition: 'java', cpuPct: 10, memPct: 50,
|
||||
players: { online: 1, max: 20, players: ['a'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /start delegates to dockerService', async () => {
|
||||
const { app, dockerService } = makeApp();
|
||||
const res = await request(app).post('/api/status/start');
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(dockerService.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST /start returns 404 when container missing', async () => {
|
||||
const { app } = makeApp({
|
||||
dockerService: { start: vi.fn().mockRejectedValue(new ContainerNotFoundError('mc')) },
|
||||
});
|
||||
const res = await request(app).post('/api/status/start');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'not_found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createWorldRouter } from '../../src/routes/world.js';
|
||||
|
||||
function noopUpload() {
|
||||
// fake multer middleware that sets req.file
|
||||
return { single: () => (req, _res, next) => { req.file = { path: '/tmp/up.zip' }; next(); } };
|
||||
}
|
||||
|
||||
function makeApp(worldOverrides = {}) {
|
||||
const worldService = {
|
||||
regen: vi.fn().mockResolvedValue({ ok: true }),
|
||||
importWorld: vi.fn().mockResolvedValue({ ok: true }),
|
||||
createDownloadStream: vi.fn().mockImplementation(() => {
|
||||
const s = new PassThrough();
|
||||
process.nextTick(() => { s.end('zipbytes'); });
|
||||
return s;
|
||||
}),
|
||||
...worldOverrides,
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/world', createWorldRouter({ worldService, upload: noopUpload() }));
|
||||
return { app, worldService };
|
||||
}
|
||||
|
||||
describe('world routes', () => {
|
||||
it('POST /regen delegates with seed', async () => {
|
||||
const { app, worldService } = makeApp();
|
||||
const res = await request(app).post('/api/world/regen').send({ seed: '99988' });
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(worldService.regen).toHaveBeenCalledWith('99988');
|
||||
});
|
||||
|
||||
it('GET /download streams a zip', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await request(app).get('/api/world/download');
|
||||
expect(res.headers['content-type']).toContain('application/zip');
|
||||
expect(res.text).toContain('zipbytes');
|
||||
});
|
||||
|
||||
it('POST /upload imports the world', async () => {
|
||||
const { app, worldService } = makeApp();
|
||||
const res = await request(app).post('/api/world/upload');
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(worldService.importWorld).toHaveBeenCalledWith('/tmp/up.zip');
|
||||
});
|
||||
|
||||
it('POST /upload returns 400 on bad zip', async () => {
|
||||
const { app } = makeApp({ importWorld: vi.fn().mockRejectedValue(new Error('bad zip')) });
|
||||
const res = await request(app).post('/api/world/upload');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'invalid_world' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createApp } from '../src/server.js';
|
||||
|
||||
function fakeDeps() {
|
||||
const config = {
|
||||
adminPasswordHash: bcrypt.hashSync('pw', 10),
|
||||
sessionSecret: 'test-secret',
|
||||
nodeEnv: 'test',
|
||||
mcDataPath: '/data',
|
||||
};
|
||||
const dockerService = {
|
||||
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
|
||||
stats: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), kill: vi.fn(),
|
||||
};
|
||||
const appState = { getEdition: vi.fn().mockResolvedValue('java'), getAdapter: vi.fn() };
|
||||
const propertiesService = { read: vi.fn().mockResolvedValue({}), update: vi.fn() };
|
||||
const worldService = {};
|
||||
const authService = { verifyPassword: async (p) => p === 'pw' };
|
||||
const upload = { single: () => (req, _res, next) => next() };
|
||||
return { config, dockerService, appState, propertiesService, worldService, authService, upload };
|
||||
}
|
||||
|
||||
describe('server auth guard', () => {
|
||||
it('blocks /api/status when unauthenticated', async () => {
|
||||
const { app } = createApp(fakeDeps());
|
||||
const res = await request(app).get('/api/status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows /api/status after login', async () => {
|
||||
const { app } = createApp(fakeDeps());
|
||||
const agent = request.agent(app);
|
||||
await agent.post('/api/auth/login').send({ password: 'pw' });
|
||||
const res = await agent.get('/api/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.state).toBe('exited');
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { registerSockets } from '../../src/sockets/index.js';
|
||||
|
||||
describe('registerSockets', () => {
|
||||
it('creates /logs and /status namespaces with auth middleware', () => {
|
||||
const namespaces = {};
|
||||
const io = {
|
||||
of: vi.fn((name) => {
|
||||
const ns = { use: vi.fn(), on: vi.fn() };
|
||||
namespaces[name] = ns;
|
||||
return ns;
|
||||
}),
|
||||
};
|
||||
registerSockets(io, {
|
||||
dockerService: {}, appState: {}, sessionMiddleware: (req, res, next) => next(),
|
||||
});
|
||||
expect(io.of).toHaveBeenCalledWith('/logs');
|
||||
expect(io.of).toHaveBeenCalledWith('/status');
|
||||
expect(namespaces['/logs'].use).toHaveBeenCalled();
|
||||
expect(namespaces['/status'].use).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { registerLogsSocket } from '../../src/sockets/logsSocket.js';
|
||||
|
||||
function fakeNamespace() {
|
||||
const ns = new EventEmitter();
|
||||
ns.on = ns.on.bind(ns);
|
||||
return ns;
|
||||
}
|
||||
function fakeSocket() {
|
||||
const s = new EventEmitter();
|
||||
s.emit = s.emit.bind(s);
|
||||
s.emitted = [];
|
||||
const origEmit = s.emit;
|
||||
s.emit = (ev, payload) => { s.emitted.push([ev, payload]); return origEmit(ev, payload); };
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('logsSocket', () => {
|
||||
it('streams log chunks to the client', async () => {
|
||||
const logStream = new PassThrough();
|
||||
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
|
||||
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand: vi.fn() }) };
|
||||
const ns = fakeNamespace();
|
||||
registerLogsSocket(ns, { dockerService, appState });
|
||||
|
||||
const socket = fakeSocket();
|
||||
ns.emit('connection', socket);
|
||||
await new Promise((r) => setTimeout(r, 10)); // allow async logStream()
|
||||
|
||||
logStream.write('hello world');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const logs = socket.emitted.filter(([ev]) => ev === 'log').map(([, p]) => p);
|
||||
expect(logs.join('')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('routes command events to adapter.sendCommand', async () => {
|
||||
const logStream = new PassThrough();
|
||||
const sendCommand = vi.fn().mockResolvedValue('command result');
|
||||
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
|
||||
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand }) };
|
||||
const ns = fakeNamespace();
|
||||
registerLogsSocket(ns, { dockerService, appState });
|
||||
|
||||
const socket = fakeSocket();
|
||||
ns.emit('connection', socket);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
socket.emit('command', 'list');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(sendCommand).toHaveBeenCalledWith('list');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildStatusPayload } from '../../src/sockets/statusSocket.js';
|
||||
|
||||
describe('buildStatusPayload', () => {
|
||||
it('aggregates state, stats and players when running', async () => {
|
||||
const dockerService = {
|
||||
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
|
||||
stats: vi.fn().mockResolvedValue({ cpuPct: 5, memUsedMb: 256, memPct: 25 }),
|
||||
};
|
||||
const appState = {
|
||||
getAdapter: vi.fn().mockResolvedValue({
|
||||
listPlayers: vi.fn().mockResolvedValue({ online: 2, max: 20, players: ['a', 'b'] }),
|
||||
}),
|
||||
};
|
||||
const payload = await buildStatusPayload({ dockerService, appState });
|
||||
expect(payload).toMatchObject({
|
||||
state: 'running', cpuPct: 5, memPct: 25,
|
||||
players: { online: 2, max: 20, players: ['a', 'b'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeros and empty players when not running', async () => {
|
||||
const dockerService = {
|
||||
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
|
||||
stats: vi.fn(),
|
||||
};
|
||||
const appState = { getAdapter: vi.fn() };
|
||||
const payload = await buildStatusPayload({ dockerService, appState });
|
||||
expect(payload.state).toBe('exited');
|
||||
expect(payload.players).toEqual({ online: 0, max: 0, players: [] });
|
||||
expect(dockerService.stats).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user