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