import { expect, test } from "bun:test"; import { indexing } from "../src/indexing"; import { curate } from "../src/curator"; import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary"; import type { NextGraphPort, Nuri } from "../src/port"; import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill"; /** * The REAL `polyfill-adapter.ts`, running. * * ## What was wrong with the gate this replaces * * It mocked the polyfill to constants and drove exactly TWO of the adapter's seven * methods — `addLiteralProperty` and (through `createIndex`) `createPublicDocument` * and `openInbox`. Everything else was inert. Six methods could be gutted — write * nothing, return `[]` — with the suite still green, and the gate's own promise * ("every query it emits is read back") held only for the queries those two methods * emitted. A destructive statement planted in `readDeposits` was never recorded, * because `readDeposits` was never called. That matters most exactly there: * retiring an applied deposit — the README's own open question — lands in * `readDeposits`. * * ## What holds now * * The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather * than returning constants, so the tests below are ordinary behavioural tests that * happen to run through the real wiring. Gut any of the seven and something here * goes red, because each one is now load-bearing for an outcome that is asserted. * * Three properties are checked on top of behaviour, and each closes a hole the * review walked through: * * - **the fake EXECUTES the SPARQL** (`applyInsertData`) — an engine that can only * add, so a removal is unrunnable rather than "detected". This is what covers a * query site in a method nobody thought to inspect: whatever the query, it has to * go through the engine to have any effect at all, and a split literal is one * string by the time it gets there; * - **an unmodelled polyfill entry throws by name** — `docs.sparqlQuery` included, * so routing a statement through the read door is red, not silent; * - **the last test fails when a method is added without a driver** — it reads the * adapter's own keys, so there is no list to keep up to date. */ const world = installFakePolyfill(); const FIELD = "http://schema.org/datePublished"; /** Every adapter method actually CALLED by the tests below. Read by the last test. */ const driven = new Set(); function track(port: NextGraphPort): NextGraphPort { return new Proxy(port, { get(target, property, receiver) { const value: unknown = Reflect.get(target, property, receiver); if (typeof property !== "string" || typeof value !== "function") return value; return (...args: unknown[]) => { driven.add(property); return (value as (...a: unknown[]) => unknown).apply(target, args); }; }, }); } /** * Everything inside runs under `user`'s session. * * A polyfill session IS one identity, so this is not test sugar: it is the only * shape the real thing has. Each actor gets its OWN port and no actor is handed a * value another one computed, except the index NURI — which is exactly the value * the README says an application hardcodes in its own source, and the only thing * that legitimately travels. */ async function as(user: string, run: (port: NextGraphPort) => Promise): Promise { world.signIn(user); const { polyfillPort } = await import("../src/polyfill-adapter"); return run(track(polyfillPort({ sessionId: world.sessionId() }))); } /** A public document carrying one value for `field`, published by whoever is signed in. */ async function publish(port: NextGraphPort, field: string, value: string): Promise { const object = await port.createPublicDocument(); await port.addLiteralProperty(object, object, field, value); return object; } // --- the whole loop, through the real adapter ----------------------------- test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); const article = await as("bob", async (bob) => { const object = await publish(bob, FIELD, "2026-07-08"); await (await indexing(bob)).refer(index, object); return object; }); const report = await as("alice", (alice) => curate(alice, index)); expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]); const entries = await as("alice", async (alice) => (await indexing(alice)).read(index)); expect(entries).toEqual([{ object: article, value: "2026-07-08" }]); }); test("a deposit made while the owner is connected is curated as it lands, with nobody asking", async () => { const seen = await as("alice", async (alice) => { const api = await indexing(alice); const index = await api.createIndex(FIELD); // An owner may deposit into her own index: `refer` is open to anyone, and here it // keeps both sides on one session, which is all this fake models at a time. const object = await publish(alice, FIELD, "2026-09-09"); await api.refer(index, object); // NOTHING CALLS CURATION. The session is told a deposit landed on an inbox it // watches, and processes that inbox itself — through the real adapter, so the // address resolution and `inbox.watch` are the ones an application would get. await world.deliverNotifications(); return { object, subjects: await alice.readDocument(index) }; }); expect(seen.subjects.find((s) => s.subject === seen.object)?.props[ENTRY_VALUE]).toEqual([ "2026-09-09", ]); }); test("two indexes are two documents, each owned by whoever created it", async () => { const [first, second] = await as("alice", async (alice) => [ await (await indexing(alice)).createIndex(FIELD), await (await indexing(alice)).createIndex(FIELD), ]); expect(first).not.toBe(second); // A stranger holding the NURI still cannot write it — reaching is not owning. await expect( as("bob", (bob) => bob.addLiteralProperty(first, first, ENTRY_VALUE, "2026-01-01")), ).rejects.toThrow(/only a document's owner writes to it/); }); // --- the two answers a resolve may give, and why they must stay apart ----- test("a reference that could not be READ comes back unresolved", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); const article = await as("bob", async (bob) => { const object = await publish(bob, FIELD, "2026-07-08"); await (await indexing(bob)).refer(index, object); return object; }); // `readDoc` catches and yields `[]`, so a failed read arrives looking exactly // like an object that holds nothing. Telling them apart is not possible; filing // the failure as a FACT about the object is what must not happen. world.breakReadsOf(article, "broker unreachable"); try { const report = await as("alice", (alice) => curate(alice, index)); expect(report.outcomes).toEqual([ { result: "unresolved", object: article, reason: expect.stringContaining("absent") }, ]); } finally { world.healReadsOf(article); } }); test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); const unrelated = await as("bob", async (bob) => { const object = await publish(bob, "http://schema.org/name", "Anemone"); await (await indexing(bob)).refer(index, object); return object; }); // Paired with the test above ON PURPOSE. `resolveObject` is the only thing // keeping these two apart: replace `resolutionFromRead(subjects)` with // `{ state: "present", subjects }` and the unreadable object above is reported // here's answer instead — a broker failure filed as a property of the object. const report = await as("alice", (alice) => curate(alice, index)); expect(report.outcomes).toEqual([ { result: "skipped", object: unrelated, reason: "no-field" }, ]); }); test("an EMPTY read is unresolved, straight off the adapter method", async () => { // The same rule as the pair above, asserted directly rather than through a // curation report, so the wiring of `resolutionFromRead` is held by two // independent tests and not by one. const blank = await as("alice", (alice) => alice.createPublicDocument()); const resolution = await as("alice", (alice) => alice.resolveObject(blank)); expect(resolution.state).toBe("unresolved"); expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable"); }); test("a read that REJECTS outright is unresolved too, and names the failure", async () => { world.breakReadUnion("session lost"); try { const resolution = await as("alice", (alice) => alice.resolveObject("did:ng:o:whatever")); expect(resolution.state).toBe("unresolved"); expect(resolution.state === "unresolved" && resolution.reason).toContain("session lost"); } finally { world.healReadUnion(); } }); // --- the inbox, from both sides ------------------------------------------- test("a document whose owner never opened an inbox REFUSES the deposit", async () => { const orphan = await as("alice", (alice) => alice.createPublicDocument()); // A deposit that vanishes without an error is worse than a refusal. await expect(as("bob", (bob) => bob.depositTo(orphan, "did:ng:o:x"))).rejects.toThrow( /has no inbox/, ); }); test("anyone may deposit into an index, only its owner may read what was deposited", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); await as("bob", async (bob) => (await indexing(bob)).refer(index, "did:ng:o:some-object")); const own = await as("alice", (alice) => alice.readDeposits(index)); expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]); expect(own[0]?.from).toBe("bob"); await expect(as("bob", (bob) => bob.readDeposits(index))).rejects.toThrow( /may only READ your own/, ); }); // --- what the adapter actually wrote -------------------------------------- test("readDocument returns what was written, and refuses a document that is no index", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); const subjects = await as("alice", (alice) => alice.readDocument(index)); expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]); const ordinary = await as("alice", (alice) => alice.createPublicDocument()); await expect(as("alice", async (alice) => (await indexing(alice)).read(ordinary))).rejects.toThrow( /declares no index field/, ); }); test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => { const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD)); const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate"); const last = writes.at(-1); expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`); // No `GRAPH <…>` inside the statement — the anchor already scopes the write. expect(String(last?.args[1])).not.toContain("GRAPH"); expect(last?.args[2]).toBe(index); }); test("a hostile value lands as ONE inert literal, not as a second statement", async () => { const HOSTILE = '" } ; DROP GRAPH ; INSERT DATA { "c'; const doc = await as("alice", async (alice) => { const object = await alice.createPublicDocument(); await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE); return object; }); // Semantic, not a quote count: the value round-trips through a parser that would // have refused the query outright had the literal closed early — and had it // closed early and still parsed, the extra subject would show up here. expect(world.contentsOf(doc)).toEqual([ { subject: doc, predicate: ENTRY_VALUE, values: [HOSTILE] }, ]); }); // --- the two blanket checks ----------------------------------------------- test("nothing the adapter sent the polyfill, in any method, carries a destructive form", () => { // Over EVERY recorded call, not a chosen few, and after blanking SPARQL literals // so a keyword inside one reads as the inert text it is. If escaping ever breaks, // the injected statement lands outside a literal and survives the blanking — // which is the point of doing it this way rather than skipping queries wholesale. const strings = world.calls.flatMap((call) => call.args.flatMap((arg) => (typeof arg === "string" ? [arg] : [])), ); expect(world.calls.length).toBeGreaterThan(0); // not vacuously true expect(strings.length).toBeGreaterThan(0); for (const value of strings) { expect(blankLiterals(value)).not.toMatch(DESTRUCTIVE); } }); test("LAST — every method the adapter exposes was driven above", async () => { // Read off the adapter itself, so there is no list to remember to extend: add an // eighth method and this fails until something above actually calls it. That is // the whole answer to "the gate proves the methods it drives" — it now names them // from the code rather than from a test's memory. const { polyfillPort } = await import("../src/polyfill-adapter"); const exposed = Object.keys(polyfillPort({ sessionId: "session:alice" })); expect(exposed.length).toBeGreaterThan(0); expect(exposed.filter((method) => !driven.has(method)).sort()).toEqual([]); });