import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { post, read, materialize, watch } from "../src/inbox"; import type { Deposit } from "../src/inbox"; import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig, setCurrentUser, } from "../src/polyfill"; import type { RegistrySession } from "../src/store-registry"; // This suite injects a fake `ng` via configure() and reuses the storeRegistry's // injected session provider (inbox docs live in the shared wallet). Restore the // un-configured state at the end so docs.test.ts's guard still sees null config. afterAll(() => { resetConfig(); resetStoreRegistry(); setCurrentUser(null); }); // NOTE ORDER: the "not configured → throw" case runs first — it exercises the // registry-deps guard before any configureStoreRegistry() call. test("throws a clear error when configureStoreRegistry() was not called", async () => { resetStoreRegistry(); await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow( /configureStoreRegistry\(\) must be called before use/, ); await expect(read("did:ng:o:inbox")).rejects.toThrow( /configureStoreRegistry\(\) must be called before use/, ); }); // --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read // SELECT over an in-memory quad store. interface Quad { g: string; s: string; p: string; o: string } const INBOX = "urn:ng-eventually:inbox"; /** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */ function unescapeLiteral(s: string): string { let out = ""; for (let i = 0; i < s.length; i++) { if (s[i] === "\\" && i + 1 < s.length) { const next = s[++i]; out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next; } else { out += s[i]; } } return out; } function makeFakeNg() { const quads: Quad[] = []; const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new"); // Parses one deposit: ` a ; "..." ; "..." [; "..."] .` const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); if (!gm) return undefined; const g = gm[1]!; const body = gm[2]!; const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; const after = body.slice(body.indexOf(sm[0]) + sm[0].length); // predicate/object pairs: `a ` or `

"literal"`. const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; let m: RegExpExecArray | null; while ((m = pairRe.exec(after)) !== null) { const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish // Un-escape the SPARQL literal so payload JSON round-trips. Single pass // over `\x` sequences (reverses the lib's escapeLiteral without the // double-processing that chained .replace() would cause). const rawLit = m[2]; const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? ""); quads.push({ g, s, p, o }); } return undefined; }); const sparql_query = mock(async (...a: unknown[]) => { const anchor = a[3] as string | undefined; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; if (q.p === `${INBOX}:Deposit`) { // rdf:type marker — ensure the subject exists. if (!bySubject.has(q.s)) bySubject.set(q.s, {}); continue; } const rec = bySubject.get(q.s) ?? {}; if (q.p === `${INBOX}:payload`) rec.payload = q.o; if (q.p === `${INBOX}:ts`) rec.ts = q.o; if (q.p === `${INBOX}:from`) rec.from = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.payload !== undefined && r.ts !== undefined) .map((r) => { const row: Record = { payload: { value: r.payload! }, ts: { value: r.ts! }, }; if (r.from !== undefined) row.from = { value: r.from }; return row; }); return { results: { bindings } }; }); return { doc_create, sparql_update, sparql_query, _quads: quads }; } const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; const TARGET = "did:ng:o:host-inbox"; function inject() { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION }); setCurrentUser(null); return ng; } let fake: ReturnType; beforeEach(() => { fake = inject(); }); test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => { setCurrentUser("alice"); // `from` is bound to the current identity await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 }); expect(fake.sparql_update).toHaveBeenCalledTimes(1); const call = fake.sparql_update.mock.calls[0]!; expect(call[0]).toBe("sid-1"); // sessionId from the injected session expect(call[2]).toBe(TARGET); // anchored to the target inbox expect(call[1] as string).toContain(`GRAPH <${TARGET}>`); }); test("post → read round-trips payload, from and ts", async () => { setCurrentUser("alice"); // `from` is bound to the current identity await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 }); const deposits = await read(TARGET); expect(deposits).toHaveLength(1); expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 }); }); // (c) `from` is BOUND to the current identity — a spoof (naming another // principal) is REJECTED; identifying as self or anonymous (null) is allowed. test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => { setCurrentUser("alice"); // SPOOF: alice tries to deposit AS bob → rejected. await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow( /spoof|current identity/i, ); // Identifying as self → allowed. await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 }); // Explicit anonymous → allowed. await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 }); const froms = (await read(TARGET)).map((d) => d.from); expect(froms).toEqual(["alice", null]); }); test("from is optional — omitting it defaults to the current user", async () => { setCurrentUser("bob"); await post(TARGET, { payload: { hi: 1 }, ts: 200 }); const deposits = await read(TARGET); expect(deposits[0]!.from).toBe("bob"); }); test("from: null makes an anonymous deposit even when a current user is set", async () => { setCurrentUser("bob"); await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 }); const deposits = await read(TARGET); expect(deposits[0]!.from).toBeNull(); }); test("read returns deposits sorted by ts ascending and materialize is an alias", async () => { await post(TARGET, { from: null, payload: "second", ts: 300 }); await post(TARGET, { from: null, payload: "first", ts: 100 }); await post(TARGET, { from: null, payload: "third", ts: 500 }); const deposits = await materialize(TARGET); expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]); }); test("read is scoped to one inbox — deposits in another inbox are not returned", async () => { await post(TARGET, { from: null, payload: "mine", ts: 1 }); await post("did:ng:o:other-inbox", { from: null, payload: "theirs", ts: 2 }); const deposits = await read(TARGET); expect(deposits.map((d) => d.payload)).toEqual(["mine"]); }); test("payload with quotes/newlines/backslashes survives the round-trip", async () => { const payload = { text: 'a "quoted"\nline\\path\ttab' }; await post(TARGET, { from: null, payload, ts: 1 }); const deposits = await read(TARGET); expect(deposits[0]!.payload).toEqual(payload); }); test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => { const seen: Deposit[][] = []; const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 }); // Give the immediate tick a chance to run (empty inbox → still fires once). await new Promise((r) => setTimeout(r, 20)); expect(seen.length).toBeGreaterThanOrEqual(1); expect(seen[seen.length - 1]).toEqual([]); await post(TARGET, { from: null, payload: "x", ts: 1 }); await new Promise((r) => setTimeout(r, 20)); const last = seen[seen.length - 1]!; expect(last.map((d) => d.payload)).toEqual(["x"]); stop(); const countAfterStop = seen.length; await post(TARGET, { from: null, payload: "y", ts: 2 }); await new Promise((r) => setTimeout(r, 20)); expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe });