309 lines
10 KiB
JavaScript
309 lines
10 KiB
JavaScript
/**
|
||
* Tests for Inventory Manager server logic.
|
||
*
|
||
* server.js is a monolith that starts listening on import, so we can't
|
||
* import it directly in a test. Instead we extract and test the pure
|
||
* helper logic that lives inside it:
|
||
*
|
||
* • Rate limiter algorithm
|
||
* • Input validation rules (order, craft, reboot targets)
|
||
* • Lua -> Frontend state normalization (itemList, furnaces, alerts, recipes)
|
||
* • Idempotent command tracking
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
// ── Extracted: createRateLimiter ───────────────────────────────────────
|
||
|
||
function createRateLimiter(windowMs, max) {
|
||
const hits = new Map();
|
||
// Note: in tests we skip the setInterval cleanup
|
||
return (req, res, next) => {
|
||
const key = req.ip || 'unknown';
|
||
const now = Date.now();
|
||
const entry = hits.get(key);
|
||
if (!entry || now - entry.start > windowMs) {
|
||
hits.set(key, { start: now, count: 1 });
|
||
return next();
|
||
}
|
||
entry.count++;
|
||
if (entry.count > max) {
|
||
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
|
||
return res.status(429).json({ error: 'Too many requests — try again later' });
|
||
}
|
||
return next();
|
||
};
|
||
}
|
||
|
||
// ── Extracted: normalization helpers ───────────────────────────────────
|
||
|
||
function normalizeItemList(rawItems) {
|
||
return Array.isArray(rawItems)
|
||
? rawItems.map(item => ({
|
||
name: item.name || '',
|
||
count: item.total !== undefined ? item.total : (item.count || 0),
|
||
displayName: item.displayName || item.name || '',
|
||
}))
|
||
: [];
|
||
}
|
||
|
||
function normalizeFurnaces(rawFurnaces) {
|
||
let normalizedFurnaces = {};
|
||
if (Array.isArray(rawFurnaces)) {
|
||
rawFurnaces.forEach(f => {
|
||
if (f && f.name) {
|
||
normalizedFurnaces[f.name] = {
|
||
active: f.active || false,
|
||
type: f.type || 'minecraft:furnace',
|
||
input: f.input || null,
|
||
fuel: f.fuel || null,
|
||
output: f.output || null,
|
||
};
|
||
}
|
||
});
|
||
} else if (typeof rawFurnaces === 'object') {
|
||
normalizedFurnaces = rawFurnaces;
|
||
}
|
||
return normalizedFurnaces;
|
||
}
|
||
|
||
function normalizeAlerts(rawAlerts) {
|
||
return Array.isArray(rawAlerts)
|
||
? rawAlerts.map(a => ({
|
||
item: a.name || a.item || a.label || '',
|
||
triggered: true,
|
||
current: a.current || 0,
|
||
threshold: a.min || a.threshold || 0,
|
||
}))
|
||
: [];
|
||
}
|
||
|
||
function normalizeCraftable(rawCraftable) {
|
||
return Array.isArray(rawCraftable)
|
||
? rawCraftable.map(recipe => {
|
||
const slots = {};
|
||
if (recipe.grid && Array.isArray(recipe.grid)) {
|
||
recipe.grid.forEach((item, idx) => {
|
||
if (item) slots[idx + 1] = item;
|
||
});
|
||
} else if (recipe.slots) {
|
||
Object.assign(slots, recipe.slots);
|
||
}
|
||
return { output: recipe.output || '', count: recipe.count || 1, slots };
|
||
})
|
||
: [];
|
||
}
|
||
|
||
// ── Extracted: idempotent command tracking ─────────────────────────────
|
||
|
||
function createCommandTracker(ttl = 5 * 60 * 1000) {
|
||
const processedCommands = new Map();
|
||
return {
|
||
check(commandId) {
|
||
if (!commandId) return null;
|
||
return processedCommands.get(commandId)?.result || null;
|
||
},
|
||
record(commandId, result) {
|
||
if (!commandId) return;
|
||
processedCommands.set(commandId, { result, timestamp: Date.now() });
|
||
},
|
||
size() { return processedCommands.size; },
|
||
};
|
||
}
|
||
|
||
// ── Tests ──────────────────────────────────────────────────────────────
|
||
|
||
describe('createRateLimiter', () => {
|
||
it('allows requests under the limit', () => {
|
||
const limiter = createRateLimiter(60_000, 3);
|
||
const req = { ip: '127.0.0.1' };
|
||
const next = vi.fn();
|
||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||
|
||
limiter(req, res, next);
|
||
limiter(req, res, next);
|
||
limiter(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalledTimes(3);
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('blocks requests over the limit with 429', () => {
|
||
const limiter = createRateLimiter(60_000, 2);
|
||
const req = { ip: '10.0.0.1' };
|
||
const next = vi.fn();
|
||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||
|
||
limiter(req, res, next); // 1 — allowed
|
||
limiter(req, res, next); // 2 — allowed
|
||
limiter(req, res, next); // 3 — blocked
|
||
|
||
expect(next).toHaveBeenCalledTimes(2);
|
||
expect(res.status).toHaveBeenCalledWith(429);
|
||
expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(String));
|
||
});
|
||
|
||
it('tracks different IPs independently', () => {
|
||
const limiter = createRateLimiter(60_000, 1);
|
||
const next = vi.fn();
|
||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
|
||
|
||
limiter({ ip: 'a' }, res, next); // a:1 — allowed
|
||
limiter({ ip: 'b' }, res, next); // b:1 — allowed
|
||
limiter({ ip: 'a' }, res, next); // a:2 — blocked
|
||
|
||
expect(next).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe('normalizeItemList', () => {
|
||
it('converts Lua-style { name, total } to { name, count }', () => {
|
||
const raw = [
|
||
{ name: 'minecraft:diamond', total: 64 },
|
||
{ name: 'minecraft:coal', total: 200 },
|
||
];
|
||
const result = normalizeItemList(raw);
|
||
expect(result[0]).toEqual({ name: 'minecraft:diamond', count: 64, displayName: 'minecraft:diamond' });
|
||
expect(result[1].count).toBe(200);
|
||
});
|
||
|
||
it('falls back to count if total is absent', () => {
|
||
const raw = [{ name: 'minecraft:iron_ingot', count: 32, displayName: 'Iron Ingot' }];
|
||
const result = normalizeItemList(raw);
|
||
expect(result[0].count).toBe(32);
|
||
expect(result[0].displayName).toBe('Iron Ingot');
|
||
});
|
||
|
||
it('returns empty array for non-array input', () => {
|
||
expect(normalizeItemList(null)).toEqual([]);
|
||
expect(normalizeItemList('bad')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('normalizeFurnaces', () => {
|
||
it('converts Lua array-of-objects to keyed map', () => {
|
||
const raw = [
|
||
{ name: 'furnace_0', active: true, type: 'minecraft:blast_furnace', input: { name: 'iron' }, fuel: null, output: null },
|
||
];
|
||
const result = normalizeFurnaces(raw);
|
||
expect(result['furnace_0'].active).toBe(true);
|
||
expect(result['furnace_0'].type).toBe('minecraft:blast_furnace');
|
||
});
|
||
|
||
it('passes through pre-keyed objects as-is', () => {
|
||
const raw = { furnace_0: { active: false } };
|
||
const result = normalizeFurnaces(raw);
|
||
expect(result).toBe(raw); // same reference
|
||
});
|
||
|
||
it('skips entries without a name', () => {
|
||
const raw = [{ active: true }, { name: 'f1', active: false }];
|
||
const result = normalizeFurnaces(raw);
|
||
expect(Object.keys(result)).toEqual(['f1']);
|
||
});
|
||
});
|
||
|
||
describe('normalizeAlerts', () => {
|
||
it('normalizes Lua-style {label, current, min} fields', () => {
|
||
const raw = [{ label: 'Diamond', current: 2, min: 10 }];
|
||
const result = normalizeAlerts(raw);
|
||
expect(result[0]).toEqual({ item: 'Diamond', triggered: true, current: 2, threshold: 10 });
|
||
});
|
||
|
||
it('accepts name or item field', () => {
|
||
expect(normalizeAlerts([{ name: 'A' }])[0].item).toBe('A');
|
||
expect(normalizeAlerts([{ item: 'B' }])[0].item).toBe('B');
|
||
});
|
||
});
|
||
|
||
describe('normalizeCraftable', () => {
|
||
it('converts Lua grid (flat array of 9) to slots object', () => {
|
||
const raw = [{
|
||
output: 'minecraft:chest',
|
||
count: 1,
|
||
grid: [null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null],
|
||
}];
|
||
const result = normalizeCraftable(raw);
|
||
expect(result[0].output).toBe('minecraft:chest');
|
||
// grid indices are 0-based, slots keys are 1-based
|
||
expect(result[0].slots).toEqual({ 2: 'minecraft:planks', 4: 'minecraft:planks', 6: 'minecraft:planks', 8: 'minecraft:planks' });
|
||
});
|
||
|
||
it('passes through pre-slotted recipes', () => {
|
||
const raw = [{ output: 'x', count: 2, slots: { 1: 'a', 5: 'b' } }];
|
||
const result = normalizeCraftable(raw);
|
||
expect(result[0].slots).toEqual({ 1: 'a', 5: 'b' });
|
||
});
|
||
});
|
||
|
||
describe('idempotent command tracking', () => {
|
||
let tracker;
|
||
|
||
beforeEach(() => {
|
||
tracker = createCommandTracker();
|
||
});
|
||
|
||
it('returns null for unseen commandId', () => {
|
||
expect(tracker.check('cmd-1')).toBeNull();
|
||
});
|
||
|
||
it('returns cached result for repeated commandId', () => {
|
||
const result = { success: true, message: 'Order sent' };
|
||
tracker.record('cmd-1', result);
|
||
expect(tracker.check('cmd-1')).toEqual(result);
|
||
});
|
||
|
||
it('ignores null/undefined commandId', () => {
|
||
tracker.record(null, { x: 1 });
|
||
tracker.record(undefined, { x: 2 });
|
||
expect(tracker.size()).toBe(0);
|
||
});
|
||
|
||
it('does not cross-contaminate different command IDs', () => {
|
||
tracker.record('a', { val: 1 });
|
||
tracker.record('b', { val: 2 });
|
||
expect(tracker.check('a')).toEqual({ val: 1 });
|
||
expect(tracker.check('b')).toEqual({ val: 2 });
|
||
});
|
||
});
|
||
|
||
describe('input validation rules', () => {
|
||
const VALID_REBOOT_TARGETS = ['all', 'manager', 'client', 'turtle', 'bridge'];
|
||
|
||
it('accepts valid reboot targets', () => {
|
||
for (const t of VALID_REBOOT_TARGETS) {
|
||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('accepts numeric computer IDs', () => {
|
||
expect(/^\d+$/.test('42')).toBe(true);
|
||
expect(/^\d+$/.test('0')).toBe(true);
|
||
});
|
||
|
||
it('rejects invalid reboot targets', () => {
|
||
for (const t of ['admin', 'drop ; rm -rf /', '', 'ALL']) {
|
||
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('validates order amount range (1–100000)', () => {
|
||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 100000;
|
||
expect(valid(1)).toBe(true);
|
||
expect(valid(100000)).toBe(true);
|
||
expect(valid(0)).toBe(false);
|
||
expect(valid(-1)).toBe(false);
|
||
expect(valid(100001)).toBe(false);
|
||
expect(valid(NaN)).toBe(false);
|
||
expect(valid(Infinity)).toBe(false);
|
||
});
|
||
|
||
it('validates craft recipeIdx range (1–1000)', () => {
|
||
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 1000;
|
||
expect(valid(1)).toBe(true);
|
||
expect(valid(500)).toBe(true);
|
||
expect(valid(0)).toBe(false);
|
||
expect(valid(1001)).toBe(false);
|
||
});
|
||
});
|