feat!: le paquet crée un index et lui ajoute une référence, rien de plus
This commit is contained in:
+62
-138
@@ -1,7 +1,6 @@
|
||||
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 { indexingOn } from "../src/indexing";
|
||||
import { INDEX_FIELD } from "../src/vocabulary";
|
||||
import type { NextGraphPort, Nuri } from "../src/port";
|
||||
import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill";
|
||||
|
||||
@@ -10,22 +9,18 @@ import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill
|
||||
*
|
||||
* ## 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`.
|
||||
* It mocked the polyfill to constants and drove only the methods `create` happens to
|
||||
* use. Everything else was inert: a method 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 methods emitted. A
|
||||
* destructive statement planted in a method nobody drove was never recorded.
|
||||
*
|
||||
* ## 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.
|
||||
* happen to run through the real wiring. Gut any of the four and something here goes
|
||||
* red, because each one is 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:
|
||||
@@ -83,122 +78,39 @@ async function publish(port: NextGraphPort, field: string, value: string): Promi
|
||||
return object;
|
||||
}
|
||||
|
||||
// --- the whole loop, through the real adapter -----------------------------
|
||||
// --- both acts, 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));
|
||||
test("the real adapter carries both acts: create, publish, add", async () => {
|
||||
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
||||
|
||||
const article = await as("bob", async (bob) => {
|
||||
const object = await publish(bob, FIELD, "2026-07-08");
|
||||
await (await indexing(bob)).refer(index, object);
|
||||
await indexingOn(bob).add(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",
|
||||
// The index declares its field, and Bob's bare reference is waiting in its inbox.
|
||||
// That is the whole of what these two acts do; making an entry of that reference
|
||||
// is the business of the layer below, and nothing here can do it or ask for it.
|
||||
expect(world.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
||||
]);
|
||||
expect(world.depositsIn(index)).toEqual([{ from: "bob", payload: article, ts: 1 }]);
|
||||
});
|
||||
|
||||
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),
|
||||
await indexingOn(alice).create(FIELD),
|
||||
await indexingOn(alice).create(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")),
|
||||
as("bob", (bob) => bob.addLiteralProperty(first, first, INDEX_FIELD, "urn:forged")),
|
||||
).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 () => {
|
||||
@@ -209,34 +121,33 @@ test("a document whose owner never opened an inbox REFUSES the deposit", async (
|
||||
);
|
||||
});
|
||||
|
||||
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"));
|
||||
test("anyone may hand a reference to an index they do not own", async () => {
|
||||
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
||||
await as("bob", async (bob) => indexingOn(bob).add(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/,
|
||||
);
|
||||
// Bob needed no permission and got no write, and the deposit carries who made it.
|
||||
const waiting = world.depositsIn(index);
|
||||
expect(waiting?.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
|
||||
expect(waiting?.[0]?.from).toBe("bob");
|
||||
expect(world.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
||||
]);
|
||||
});
|
||||
|
||||
// --- 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] } }]);
|
||||
|
||||
test("creating an index writes its field declaration, and nothing else", async () => {
|
||||
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
||||
expect(world.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
||||
]);
|
||||
// …and an ordinary public document is left exactly as it was made.
|
||||
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/,
|
||||
);
|
||||
expect(world.contentsOf(ordinary)).toEqual([]);
|
||||
});
|
||||
|
||||
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 index = await as("alice", async (alice) => indexingOn(alice).create(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}" }`);
|
||||
@@ -247,17 +158,15 @@ test("the write is the polyfill's canonical anchored form: the document named on
|
||||
|
||||
test("a hostile value lands as ONE inert literal, not as a second statement", async () => {
|
||||
const HOSTILE = '" } ; DROP GRAPH <did:ng:o:doc-1> ; INSERT DATA { <a> <b> "c';
|
||||
const doc = await as("alice", async (alice) => {
|
||||
const object = await alice.createPublicDocument();
|
||||
await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE);
|
||||
return object;
|
||||
});
|
||||
// A field is caller-supplied and goes straight into the statement, so it is the
|
||||
// value this package really does have to survive.
|
||||
const index = await as("alice", async (alice) => indexingOn(alice).create(HOSTILE));
|
||||
|
||||
// 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] },
|
||||
expect(world.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [HOSTILE] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -278,9 +187,24 @@ test("nothing the adapter sent the polyfill, in any method, carries a destructiv
|
||||
}
|
||||
});
|
||||
|
||||
test("the port the entry point builds has NO operation that reads or removes", async () => {
|
||||
// Building it must not touch the broker: `indexing(sessionId)` builds one for every
|
||||
// handle, and only the calls on it talk to anything. This check lived on the
|
||||
// published surface while `polyfillPort` was published; the port is internal now,
|
||||
// and the properties it guards are not.
|
||||
const { polyfillPort } = await import("../src/polyfill-adapter");
|
||||
const port = polyfillPort({ sessionId: "session:alice" });
|
||||
expect(typeof port.createPublicDocument).toBe("function");
|
||||
expect(typeof port.addLiteralProperty).toBe("function");
|
||||
const forbidden = Object.keys(port).filter((name) =>
|
||||
/delete|remove|clear|drop|read|list|watch/i.test(name),
|
||||
);
|
||||
expect(forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
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
|
||||
// Read off the adapter itself, so there is no list to remember to extend: add a
|
||||
// fifth 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");
|
||||
|
||||
Reference in New Issue
Block a user