/** * 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); }); });