Add unit tests for server logic including rate limiter, normalization helpers, and input validation

This commit is contained in:
MayaTheShy
2026-03-22 11:52:33 -04:00
parent a8513b339c
commit ba0cbfbbb6

View File

@@ -0,0 +1,308 @@
/**
* 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);
});
});