diff --git a/web/server/__tests__/db.test.js b/web/server/__tests__/db.test.js new file mode 100644 index 0000000..1d0a578 --- /dev/null +++ b/web/server/__tests__/db.test.js @@ -0,0 +1,250 @@ +/** + * Tests for the Inventory Manager database layer (db.js). + * + * Uses a temporary on-disk SQLite file per test to exercise real SQL + * without touching production data. The DB_PATH env var is set BEFORE + * the dynamic import so better-sqlite3 opens the right file. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// ── helpers ──────────────────────────────────────────────────────────── + +/** Create a fresh temp DB path and set the env var before importing db.js */ +async function createTestDb() { + const tmp = path.join(os.tmpdir(), `inv-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + process.env.DB_PATH = tmp; + + // Use a cache-buster query string so Node gives us a fresh module + const mod = await import(`../db.js?t=${Date.now()}-${Math.random()}`); + return { mod, tmp }; +} + +function cleanup(tmp) { + try { fs.unlinkSync(tmp); } catch { /* ignore */ } + try { fs.unlinkSync(tmp + '-wal'); } catch { /* ignore */ } + try { fs.unlinkSync(tmp + '-shm'); } catch { /* ignore */ } +} + +// ── tests ────────────────────────────────────────────────────────────── + +describe('db – item persistence', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('saveItems + loadItems round-trips item data', () => { + const items = [ + { name: 'minecraft:diamond', count: 64, displayName: 'Diamond' }, + { name: 'minecraft:iron_ingot', count: 128, displayName: 'Iron Ingot' }, + ]; + db.saveItems(items); + + const loaded = db.loadItems(); + expect(loaded).toHaveLength(2); + + // loadItems returns ORDER BY count DESC + expect(loaded[0].name).toBe('minecraft:iron_ingot'); + expect(loaded[0].count).toBe(128); + expect(loaded[1].name).toBe('minecraft:diamond'); + expect(loaded[1].count).toBe(64); + }); + + it('saveItems upserts — updates existing rows instead of duplicating', () => { + db.saveItems([{ name: 'minecraft:diamond', count: 10, displayName: 'Diamond' }]); + db.saveItems([{ name: 'minecraft:diamond', count: 99, displayName: 'Diamond' }]); + + const loaded = db.loadItems(); + expect(loaded).toHaveLength(1); + expect(loaded[0].count).toBe(99); + }); +}); + +describe('db – state key-value store', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('saveState + loadState round-trips JSON', () => { + db.saveState('smeltingPaused', true); + expect(db.loadState('smeltingPaused')).toBe(true); + + db.saveState('disabledRecipes', { 'minecraft:iron_ingot': true }); + expect(db.loadState('disabledRecipes')).toEqual({ 'minecraft:iron_ingot': true }); + }); + + it('loadState returns defaultValue when key missing', () => { + expect(db.loadState('nonexistent', 42)).toBe(42); + expect(db.loadState('nonexistent')).toBeNull(); + }); +}); + +describe('db – furnace persistence', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('saveFurnaces + loadFurnaces round-trips furnace status', () => { + const furnaces = { + 'minecraft:furnace_0': { + active: true, + type: 'minecraft:furnace', + input: { name: 'minecraft:raw_iron', count: 8 }, + fuel: { name: 'minecraft:coal', count: 3 }, + output: null, + }, + }; + + db.saveFurnaces(furnaces); + const loaded = db.loadFurnaces(); + + expect(loaded['minecraft:furnace_0']).toBeDefined(); + expect(loaded['minecraft:furnace_0'].active).toBe(true); + expect(loaded['minecraft:furnace_0'].input.name).toBe('minecraft:raw_iron'); + }); +}); + +describe('db – alert persistence', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('saveAlerts + loadAlerts round-trips triggered alerts', () => { + db.saveAlerts([ + { item: 'minecraft:diamond', triggered: true, current: 5, threshold: 10 }, + { item: 'minecraft:coal', triggered: false, current: 200, threshold: 50 }, + ]); + + const loaded = db.loadAlerts(); + // loadAlerts only returns triggered=1 + expect(loaded).toHaveLength(1); + expect(loaded[0].item).toBe('minecraft:diamond'); + expect(loaded[0].triggered).toBe(true); + expect(loaded[0].current).toBe(5); + expect(loaded[0].threshold).toBe(10); + }); +}); + +describe('db – item history', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('getHistory returns recorded snapshots ordered newest-first', () => { + // recordItemHistory is throttled (5-min internal cooldown), so we + // call it twice — first call records, second is throttled. + db.recordItemHistory([ + { name: 'minecraft:diamond', count: 64 }, + { name: 'minecraft:iron_ingot', count: 128 }, + ]); + + const history = db.getHistory('minecraft:diamond', 10); + expect(history.length).toBeGreaterThanOrEqual(1); + expect(history[0].count).toBe(64); + }); + + it('getHistorySummary groups by timestamp', () => { + db.recordItemHistory([ + { name: 'a', count: 10 }, + { name: 'b', count: 20 }, + ]); + + const summary = db.getHistorySummary(); + expect(summary.length).toBeGreaterThanOrEqual(1); + expect(summary[0].total).toBe(30); // 10 + 20 + }); +}); + +describe('db – full state round-trip', () => { + let db, tmp; + + beforeEach(async () => { + ({ mod: db, tmp } = await createTestDb()); + }); + + afterEach(() => { + try { db.closeDb(); } catch { /* already closed */ } + cleanup(tmp); + }); + + it('saveFullState + flushPendingSave + loadFullState restores everything', () => { + const state = { + inventoryState: { + itemList: [ + { name: 'minecraft:diamond', count: 64, displayName: 'Diamond' }, + ], + grandTotal: 64, + chestCount: 2, + totalSlots: 54, + usedSlots: 1, + freeSlots: 53, + usedRatio: 0.02, + dropperOk: true, + barrelOk: false, + furnaceCount: 1, + furnaceStatus: { + 'furnace_0': { active: false, type: 'minecraft:furnace', input: null, fuel: null, output: null }, + }, + droppers: ['dropper_0'], + }, + activityState: { lastScan: 12345 }, + alertsState: [{ item: 'minecraft:coal', triggered: true, current: 3, threshold: 10 }], + smeltingPaused: false, + disabledRecipes: { 'minecraft:charcoal': true }, + smeltableRecipes: { 'minecraft:raw_iron': 'minecraft:iron_ingot' }, + craftableRecipes: [{ output: 'minecraft:chest', count: 1, slots: { 1: 'minecraft:planks' } }], + craftTurtleOk: true, + }; + + db.saveFullState(state); + db.flushPendingSave(); // force the debounced write + + const restored = db.loadFullState(); + expect(restored.inventoryState.itemList).toHaveLength(1); + expect(restored.inventoryState.itemList[0].name).toBe('minecraft:diamond'); + expect(restored.inventoryState.grandTotal).toBe(64); + expect(restored.smeltingPaused).toBe(false); + expect(restored.disabledRecipes).toEqual({ 'minecraft:charcoal': true }); + expect(restored.craftTurtleOk).toBe(true); + expect(restored.alertsState).toHaveLength(1); + expect(restored.alertsState[0].item).toBe('minecraft:coal'); + }); +});