// bun test names/store.test.js import { test, expect, beforeEach, afterAll } from 'bun:test'; import { Database } from 'bun:sqlite'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { openStore } from './store.js'; const dir = mkdtempSync(join(tmpdir(), 'names-store-')); const db = join(dir, 'names.db'); const legacy = join(dir, 'names.json'); afterAll(() => rmSync(dir, { recursive: true, force: true })); const hex = (i) => i.toString(16).padStart(64, '0'); function fixture() { const names = {}; for (let i = 0; i < 2000; i++) names[`user${i}@coinos.io`] = { pubkey: hex(i), uri: `bitcoin:?ark=x${i}`, updated: i }; return { names, offers: { [hex(1)]: { key: 'user1@coinos.io', memo: 'payouts', created: 1 } }, invoices: {}, // an empty map must survive a reload pending: [{ name: 'user2@coinos.io', sat: 5, hash: hex(2), ts: 1 }], lastPayIndex: 817, serviceSk: hex(99), fwdArk: { vtxos: [{ id: 'a', amountSat: 1000, state: 'spendable' }], _rev: 1 }, }; } const fresh = () => { for (const f of [db, db + '-wal', db + '-shm']) rmSync(f, { force: true }); }; const plain = (o) => JSON.parse(JSON.stringify(o)); beforeEach(() => { fresh(); writeFileSync(legacy, JSON.stringify(fixture())); }); test('imports the legacy JSON once and leaves it in place', () => { let s = openStore(db, { legacyJson: legacy }); expect(plain(s.state)).toEqual(fixture()); s.close(); expect(existsSync(legacy)).toBe(true); writeFileSync(legacy, 'not json'); // a later start must not read it again s = openStore(db, { legacyJson: legacy }); expect(plain(s.state)).toEqual(fixture()); s.close(); }); test('a corrupt legacy file stops startup instead of importing nothing', () => { writeFileSync(legacy, '{"names":{"trunc'); expect(() => openStore(db, { legacyJson: legacy })).toThrow(); }); test('every kind of mutation survives a reopen', () => { const s = openStore(db, { legacyJson: legacy }); const st = s.state; st.names['new@coinos.io'] = { pubkey: hex(5000) }; delete st.names['user0@coinos.io']; st.names['user3@coinos.io'].uri = 'bitcoin:?changed'; st.pending.push({ name: 'user4@coinos.io', sat: 1, hash: hex(4), ts: 2 }); for (const p of st.pending) p.next = 0; // in-place edits of array items st.lastPayIndex++; st.hats = { [hex(7)]: { owned: ['beanie'] } }; delete st.offers; st.fwdArk._rev = 2; s.persist(); const want = plain(st); s.close(); const r = openStore(db); expect(plain(r.state)).toEqual(want); r.close(); }); test('one changed record rewrites one row; no change writes nothing', () => { const s = openStore(db, { legacyJson: legacy }); const raw = new Database(db, { readonly: true }); const before = raw.query('SELECT max(updated) m FROM kv').get().m; Bun.sleepSync(5); s.state.names['user9@coinos.io'].uri = 'bitcoin:?again'; s.persist(); s.persist(); expect(raw.query('SELECT count(*) c FROM kv WHERE updated > ?').get(before).c).toBe(1); raw.close(); s.close(); }); test('a snapshot taken while open equals the live state', () => { const s = openStore(db, { legacyJson: legacy }); s.state.names['snap@coinos.io'] = { pubkey: hex(6000) }; s.persist(); const snap = join(dir, 'snap.db'); rmSync(snap, { force: true }); s.snapshot(snap); const c = openStore(snap); expect(plain(c.state)).toEqual(plain(s.state)); c.close(); s.close(); }); test('kill -9 mid-persist leaves a consistent database', async () => { openStore(db, { legacyJson: legacy }).close(); const child = Bun.spawn(['bun', '-e', ` import { openStore } from ${JSON.stringify(join(import.meta.dir, 'store.js'))}; const s = openStore(${JSON.stringify(db)}); for (let i = 0; ; i++) { s.state.lastPayIndex = i; s.state.names['user1@coinos.io'].updated = i; s.state.pending.push({ i }); if (s.state.pending.length > 50) s.state.pending.shift(); s.persist(); }`]); await Bun.sleep(1500); child.kill(9); await child.exited; const raw = new Database(db); expect(Object.values(raw.query('PRAGMA integrity_check').get())[0]).toBe('ok'); raw.close(); const s = openStore(db); // both fields were written in the same transaction, so they always agree expect(s.state.lastPayIndex).toBeGreaterThan(0); expect(s.state.names['user1@coinos.io'].updated).toBe(s.state.lastPayIndex); s.close(); }, 10_000);