Add comprehensive tests for the database layer in db.test.js

This commit is contained in:
MayaTheShy
2026-03-22 11:51:47 -04:00
parent 21f12501ca
commit a8513b339c

View File

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