import { getCaps } from "../src/shared-wallet/bootstrap"; import { test, expect, mock, afterAll } from "bun:test"; import { readUnion } from "../src/surface/read-model"; import type { Nuri } from "../src/model/types"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetCaps } from "../src/shared-wallet/bootstrap"; // The cap registry is process-wide, so each inject() starts from an empty one: // once ANY cap exists the possession gate is in force for every reader, and a // suite that never declares caps must not inherit another suite's. afterAll(() => { resetCaps(); setCurrentUser(null); }); // A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o // WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is // NO anchorless union scan: each doc is read independently by its own anchor. Each // entity subject IRI IS its own document NURI (writeEntity convention), so the // fixture keys triples by the doc NURI and returns them for the matching anchor. function fakeNgWith(triplesByDoc: Record>) { return { doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => { // Every read is ANCHORED to one doc NURI — never anchorless. if (anchor === undefined) { throw new Error("read-model must NEVER run an anchorless (union) query"); } const doc = anchor as string; const triples = triplesByDoc[doc]; if (!triples) return { results: { bindings: [] } }; const bindings = triples.map(([p, o]) => ({ s: { value: doc }, p: { value: p }, o: { value: o }, })); return { results: { bindings } }; }), }; } function inject(triplesByDoc: Record>) { const ng = fakeNgWith(triplesByDoc); resetCaps(); setCurrentUser(null); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), normalizeId: (u: string) => u, }); return ng; } const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const FP = "http://festipod.org/"; test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => { const ng = inject({ "did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]], "did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]], }); const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]); // One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set). expect(ng.sparql_query).toHaveBeenCalledTimes(2); const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined); expect(anchored.length).toBe(2); // The anchors are exactly the requested doc NURIs. expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual( new Set(["did:ng:o:a", "did:ng:o:b"]), ); expect(subjects.length).toBe(2); const a = subjects.find((s) => s.subject === "did:ng:o:a")!; expect(a.props[`${FP}title`]).toEqual(["A"]); expect(a.graph).toBe("did:ng:o:a"); }); test("readUnion groups predicates per subject", async () => { inject({ "did:ng:o:p": [ [TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"], [`${FP}user`, "urn:festipod:user:x"], ], }); const s = (await readUnion(["did:ng:o:p"]))[0]!; expect(s.subject).toBe("did:ng:o:p"); expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]); expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]); }); test("readUnion returns [] for an empty doc set (no query)", async () => { const ng = inject({}); const subjects = await readUnion([]); expect(subjects).toEqual([]); expect(ng.sparql_query).toHaveBeenCalledTimes(0); }); test("a doc that fails to read is skipped, not aborting the batch", async () => { const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] }); const orig = ng.sparql_query; // Make the anchored read throw for the bad doc only. ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => { if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound"); return orig(sid, query, base, anchor); }) as any; configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), normalizeId: (u: string) => u, }); const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]); // The bad doc failed its read but the good one still lists. expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]); }); // The possession gate, at the read-model's own level: once ANY cap exists, a doc // whose cap is not in what the current holder holds is dropped — however well its // NURI resolves. Before the first cap the gate is inert (no regression). test("readUnion drops a doc whose cap the holder does not hold", async () => { inject({ "did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]], "did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]], }); const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"]; // Inert: no cap issued yet → everything flows through. expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both); // One cap issued → possession is now the rule for every document. setCurrentUser("alice"); getCaps().mint("did:ng:o:mine"); expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]); // …and for every holder: bob holds nothing, so bob reads nothing. setCurrentUser("bob"); expect(await readUnion(both)).toEqual([]); }); test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => { // Two different things that must not be conflated, and conflating them broke a whole // reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller // assembling a list from optional values should not have to compact it — while a // non-reference is a caller mistake worth a loud error. Validating before filtering // turned the first into the second. inject({}); await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]); await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i); }); // ── several subjects inside one document ─────────────────────────────────── // // The fixture above pins every row's subject to the doc NURI, which is what an // application writing one entity per document produces — the recommended placement, // and the only case it can exercise. A document may nevertheless carry SEVERAL // subjects, and upstream that is the provided case, not an accident: the ORM carries // `@id` and `@graph` as two distinct read-only properties and fabricates an `@id` // when the writer leaves it empty, precisely so objects sharing a `@graph` stay // distinguishable. This fixture lets the subject vary so that case can be tested. function injectTriples(triplesByDoc: Record>) { const ng = { doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => { if (anchor === undefined) { throw new Error("read-model must NEVER run an anchorless (union) query"); } const triples = triplesByDoc[anchor as string]; if (!triples) return { results: { bindings: [] } }; return { results: { bindings: triples.map(([s, p, o]) => ({ s: { value: s }, p: { value: p }, o: { value: o }, })), }, }; }), }; resetCaps(); setCurrentUser(null); configure({ ng, useShape: () => undefined }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), normalizeId: (u: string) => u, }); return ng; } test("two subjects in ONE document come back as two entries, unmixed", async () => { // The document holds two entities under two IRIs of the consumer's choosing. injectTriples({ "did:ng:o:d": [ ["urn:app:alice", TYPE, `${FP}Person`], ["urn:app:alice", `${FP}title`, "A"], ["urn:app:bob", TYPE, `${FP}Person`], ["urn:app:bob", `${FP}title`, "B"], ], }); const subjects = await readUnion(["did:ng:o:d"]); // Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`, // whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag, // and both re-labelled with the document's own NURI. expect(subjects.length).toBe(2); expect(subjects.map((s) => s.subject).sort()).toEqual(["urn:app:alice", "urn:app:bob"]); const alice = subjects.find((s) => s.subject === "urn:app:alice")!; const bob = subjects.find((s) => s.subject === "urn:app:bob")!; expect(alice.props[`${FP}title`]).toEqual(["A"]); expect(bob.props[`${FP}title`]).toEqual(["B"]); // Both were read from the same document, and `graph` is the reference the caller // passed — that is what identifies the document and what goes back into the SDK. expect(alice.graph).toBe("did:ng:o:d"); expect(bob.graph).toBe("did:ng:o:d"); }); test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => { injectTriples({ "did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]], }); const [only] = await readUnion(["did:ng:o:d"]); // The old fold reported `did:ng:o:d` here, a subject the document never carried. expect(only!.subject).toBe("urn:app:thing"); expect(only!.graph).toBe("did:ng:o:d"); }); test("the SAME subject IRI in two documents stays two entries, told apart by graph", async () => { injectTriples({ "did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]], "did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]], }); const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]); expect(subjects.length).toBe(2); expect(subjects.map((s) => s.graph).sort()).toEqual(["did:ng:o:a", "did:ng:o:b"]); // Never merged across documents: an object is identified by its subject WITHIN a graph. expect(subjects.find((s) => s.graph === "did:ng:o:a")!.props[`${FP}title`]).toEqual(["in-a"]); expect(subjects.find((s) => s.graph === "did:ng:o:b")!.props[`${FP}title`]).toEqual(["in-b"]); }); test("machinery is dropped per subject, and the real subjects beside it survive", async () => { injectTriples({ "did:ng:o:d": [ ["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"], ["urn:app:entity", `${FP}title`, "kept"], ], }); const subjects = await readUnion(["did:ng:o:d"]); expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]); expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]); }); test("a document holding ONLY machinery yields no entry at all", async () => { injectTriples({ "did:ng:o:d": [ ["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"], ], }); expect(await readUnion(["did:ng:o:d"])).toEqual([]); });