diff options
Diffstat (limited to 'names/store.test.js')
| -rw-r--r-- | names/store.test.js | 118 |
1 files changed, 118 insertions, 0 deletions
diff --git a/names/store.test.js b/names/store.test.js new file mode 100644 index 0000000..13a996e --- /dev/null +++ b/names/store.test.js @@ -0,0 +1,118 @@ +// 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: '[email protected]', memo: 'payouts', created: 1 } }, + invoices: {}, // an empty map must survive a reload + pending: [{ name: '[email protected]', 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['[email protected]'] = { pubkey: hex(5000) }; + delete st.names['[email protected]']; + st.names['[email protected]'].uri = 'bitcoin:?changed'; + st.pending.push({ name: '[email protected]', 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['[email protected]'].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['[email protected]'] = { 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['[email protected]'].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['[email protected]'].updated).toBe(s.state.lastPayIndex); + s.close(); +}, 10_000); |
