Files
Inventory-Manager-CC/web/server/__tests__/server.test.js

309 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (1100000)', () => {
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 (11000)', () => {
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);
});
});