ebf866b1f2
Un polyfill ne doit rien faire de plus que ce qui est prévu. `isNuri` / `hasReadCap` et les utilitaires SPARQL `escapeLiteral` / `escapeIri` / `assertNuri` n'ont de pendant à aucun niveau et n'en auront pas : le binding prend `nuri: String`, le moteur est fortement typé en Rust et n'a besoin d'aucun prédicat, l'ORM n'expose rien de tel. Le contrat les justifiait parce qu'ils « restent utiles à n'importe quelle app » — c'est exactement le raisonnement à refuser : utile n'est pas prévu, et chacun serait un appel à réécrire le jour du SDK. Le besoin d'un guard venait de notre propre signature : les entrées publiques exigeaient `Nuri`, donc un consommateur devait narrower ce qu'il lisait d'une URL ou du stockage. Elles prennent désormais `NuriLike` — n'importe quelle chaîne — et valident à l'intérieur (`toNuri`). Ce que la bibliothèque REND reste typé `Nuri` : l'app en profite gratuitement, et un type plus large ne cassera rien quand le SDK rendra des chaînes. Les guards et les utilitaires restent, internes, là où la validation se fait. Un défaut introduit puis corrigé en chemin, qui valait le test qu'il a produit : `readUnion` a toujours toléré les trous dans sa liste — un index de scope peut porter une entrée blanche, et un appelant qui assemble depuis des valeurs optionnelles n'a pas à compacter. Valider AVANT de filtrer a transformé cette tolérance en exception. Vide est une absence, pas une référence malformée ; les deux sont désormais distingués par un test. 170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la bibliothèque, l'exemple et le harnais.
152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
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,configureStoreRegistry,resetCaps,setCurrentUser} from "../src/polyfill";
|
|
|
|
// 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<string, Array<[string, string]>>) {
|
|
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<string, Array<[string, string]>>) {
|
|
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);
|
|
});
|