import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { tmpdir } from 'os'; import { join } from 'path'; import { unlinkSync } from 'fs'; import { SqliteInboxStore } from '../src/sqlite-inbox-store.js'; function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); globalThis.crypto.getRandomValues(buf); return buf; } function tempDbPath(): string { return join( tmpdir(), `shade-inbox-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, ); } describe('SqliteInboxStore', () => { let dbPath: string; let store: SqliteInboxStore; beforeEach(() => { dbPath = tempDbPath(); store = new SqliteInboxStore(dbPath); }); afterEach(() => { store.close(); try { unlinkSync(dbPath); } catch {} try { unlinkSync(dbPath + '-wal'); } catch {} try { unlinkSync(dbPath + '-shm'); } catch {} }); test('owner save + get', async () => { const key = randBytes(32); await store.saveAddressOwner('bob', key); const got = await store.getAddressOwner('bob'); expect(got).not.toBeNull(); expect(got!).toEqual(key); }); test('putBlob is idempotent on (address, msgId)', async () => { const ct = randBytes(64); const a = await store.putBlob({ address: 'bob', msgId: 'a'.repeat(64), ciphertext: ct, expiresAt: Date.now() + 60_000, }); expect(a.created).toBe(true); const b = await store.putBlob({ address: 'bob', msgId: 'a'.repeat(64), ciphertext: ct, expiresAt: Date.now() + 60_000, }); expect(b.created).toBe(false); expect(b.receivedAt).toBe(a.receivedAt); }); test('fetchBlobs respects sinceCursor and expires_at', async () => { const now = Date.now(); await store.putBlob({ address: 'bob', msgId: '1'.repeat(64), ciphertext: randBytes(8), expiresAt: now + 60_000, }); await store.putBlob({ address: 'bob', msgId: '2'.repeat(64), ciphertext: randBytes(8), expiresAt: now - 1000, // already expired }); await store.putBlob({ address: 'bob', msgId: '3'.repeat(64), ciphertext: randBytes(8), expiresAt: now + 60_000, }); const all = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now, limit: 100, }); // Expired one filtered out. expect(all.length).toBe(2); expect(all.map((r) => r.msgId).sort()).toEqual(['1'.repeat(64), '3'.repeat(64)]); // Cursor advances strictly. const half = await store.fetchBlobs({ address: 'bob', sinceCursor: all[0]!.receivedAt, now, limit: 100, }); expect(half.length).toBe(1); expect(half[0]!.msgId).toBe(all[1]!.msgId); }); test('purgeExpired removes only expired rows', async () => { const now = Date.now(); await store.putBlob({ address: 'bob', msgId: '1'.repeat(64), ciphertext: randBytes(8), expiresAt: now - 1, }); await store.putBlob({ address: 'bob', msgId: '2'.repeat(64), ciphertext: randBytes(8), expiresAt: now + 60_000, }); const removed = await store.purgeExpired(now); expect(removed).toBe(1); const remaining = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now, limit: 10, }); expect(remaining.length).toBe(1); expect(remaining[0]!.msgId).toBe('2'.repeat(64)); }); test('persists across reopen', async () => { const key = randBytes(32); await store.saveAddressOwner('bob', key); const ct = randBytes(64); await store.putBlob({ address: 'bob', msgId: '5'.repeat(64), ciphertext: ct, expiresAt: Date.now() + 60_000, }); store.close(); store = new SqliteInboxStore(dbPath); const got = await store.getAddressOwner('bob'); expect(got).toEqual(key); const blobs = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); expect(blobs.length).toBe(1); expect(blobs[0]!.ciphertext).toEqual(ct); }); test('deleteAddress drops owner + blobs', async () => { const key = randBytes(32); await store.saveAddressOwner('bob', key); await store.putBlob({ address: 'bob', msgId: '7'.repeat(64), ciphertext: randBytes(8), expiresAt: Date.now() + 60_000, }); await store.deleteAddress('bob'); expect(await store.getAddressOwner('bob')).toBeNull(); const blobs = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); expect(blobs.length).toBe(0); }); test('senderFp round-trips through put + fetch (V4.8)', async () => { const ct = randBytes(40); const fp = '0123456789abcdef'; await store.putBlob({ address: 'bob', msgId: 'a'.repeat(64), ciphertext: ct, expiresAt: Date.now() + 60_000, senderFp: fp, }); const blobs = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); expect(blobs.length).toBe(1); expect(blobs[0]!.senderFp).toBe(fp); }); test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => { const ct = randBytes(40); await store.putBlob({ address: 'bob', msgId: 'b'.repeat(64), ciphertext: ct, expiresAt: Date.now() + 60_000, }); const blobs = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); expect(blobs.length).toBe(1); expect(blobs[0]!.senderFp).toBeUndefined(); }); test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => { // Reproduce a pre-4.8 schema in a fresh DB, then reopen via // SqliteInboxStore which should run the idempotent ALTER without // dropping the existing rows. store.close(); try { unlinkSync(dbPath); } catch {} try { unlinkSync(dbPath + '-wal'); } catch {} try { unlinkSync(dbPath + '-shm'); } catch {} const { Database } = await import('bun:sqlite'); const legacy = new Database(dbPath, { create: true }); legacy.exec(` CREATE TABLE inbox_blobs ( address TEXT NOT NULL, msg_id TEXT NOT NULL, ciphertext TEXT NOT NULL, received_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (address, msg_id) ); `); legacy.prepare( 'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)', ).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000); legacy.close(); store = new SqliteInboxStore(dbPath); const blobs = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); expect(blobs.length).toBe(1); expect(blobs[0]!.senderFp).toBeUndefined(); // New writes after migration carry senderFp. await store.putBlob({ address: 'bob', msgId: 'd'.repeat(64), ciphertext: randBytes(8), expiresAt: Date.now() + 60_000, senderFp: 'feedfacedeadbeef', }); const after = await store.fetchBlobs({ address: 'bob', sinceCursor: 0, now: Date.now(), limit: 10, }); const newer = after.find((b) => b.msgId === 'd'.repeat(64)); expect(newer?.senderFp).toBe('feedfacedeadbeef'); }); test('countBlobs ignores expired entries', async () => { const now = Date.now(); await store.putBlob({ address: 'bob', msgId: '1'.repeat(64), ciphertext: randBytes(8), expiresAt: now - 1, }); await store.putBlob({ address: 'bob', msgId: '2'.repeat(64), ciphertext: randBytes(8), expiresAt: now + 60_000, }); expect(await store.countBlobs('bob', now)).toBe(1); }); });