534d8f91b7
(p) `graphs: ""` est l'autre orthographe du dataset entier, jamais un nom de document : le filtre reste seul a decider. C'est une garde, pas un test discriminant - `isNuri` ecarte deja la chaine vide -, et elle epingle le comportement plutot que le chemin. (q) se desabonner pendant que la barriere est en train d'etre franchie ne doit laisser aucune requete ouverte. La barriere rend l'ouverture asynchrone, donc il existe desormais une fenetre ou l'observable a des abonnes, pas de requete, et une ouverture en chemin ; verifie en retirant le controle de generation, ou la requete est creee apres le dernier auditeur et plus rien ne la ferme.
680 lines
26 KiB
TypeScript
680 lines
26 KiB
TypeScript
/**
|
|
* use-shape-query.test.ts - behavioural tests for `useShapeQuery`
|
|
* (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by
|
|
* `orm-fake.ts` and a stateful fake `ng` for everything else the page does.
|
|
*
|
|
* -- The montage, and why it takes TWO fakes -------------------------------
|
|
* The read goes through the SDK now, so the thing that answers it is the injected
|
|
* shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way
|
|
* `schema_shape_to_sparql` does). Everything AROUND the read is still this package's:
|
|
* signing in, creating a document, filing its capability. That is the fake `ng` - and
|
|
* keeping it is what makes the documents here real ones, obtained from
|
|
* `createEntityDoc` rather than named by the test.
|
|
*
|
|
* These prove the distinctions the surface exists for:
|
|
* (a) isPending until the initial payload arrives, isSuccess once it has;
|
|
* (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction);
|
|
* (c) an engine patch updates data (reactivity, no polling);
|
|
* (d) a query that never delivers stays isPending - it never becomes "nothing";
|
|
* plus the three readings of the SDK `Scope`, which this module no longer interprets
|
|
* and therefore has to hand over VERBATIM, and the capability filter over what comes
|
|
* back.
|
|
*/
|
|
|
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
|
import { useShapeQuery } from "../src/surface/use-shape-query";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
|
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
|
import { makeOrm, type FakeOrm } from "./orm-fake";
|
|
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
|
import type { NgLike, UseShapeLike } from "../src/model/types";
|
|
|
|
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
|
const EVERYTHING = { graphs: "did:ng:i" };
|
|
const FP = "http://festipod.org/";
|
|
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** Reverse of 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;
|
|
}
|
|
|
|
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
|
|
|
/**
|
|
* The stateful fake `ng` - the shim account, the per-scope index and the document
|
|
* creation the page does around the read. It is NOT what answers the read any more.
|
|
*/
|
|
function makeFake() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
const subs: SubRec[] = [];
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
let g: string;
|
|
let body: string;
|
|
if (gm) {
|
|
g = gm[1]!;
|
|
body = gm[2]!;
|
|
} else {
|
|
if (!anchor) return undefined;
|
|
g = anchor;
|
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
}
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
for (const sub of subs) {
|
|
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
// Pointer SELECT (store-root -> doc-shim).
|
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
const onlySubject = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const bindings = [...bySubject.values()]
|
|
.filter((r) => r.id)
|
|
.map((r) => ({
|
|
id: { value: r.id! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
}));
|
|
return { results: { bindings } };
|
|
}
|
|
for (const marker of ["inboxCap", "readCap", "contains"] as const) {
|
|
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
|
|
const key = marker === "contains" ? "e" : "c";
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`)
|
|
.map((q) => ({ [key]: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
}
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor)
|
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
});
|
|
|
|
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
|
subs.push({ nuri, cb });
|
|
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
|
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
|
return () => {};
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
|
|
}
|
|
|
|
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
|
|
configure({
|
|
ng: ng as unknown as NgLike,
|
|
useShape: (() => {}) as unknown as UseShapeLike,
|
|
ormSubscription: orm.query,
|
|
});
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
}
|
|
|
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
|
|
/**
|
|
* A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title.
|
|
*
|
|
* Both predicates are `minCardinality: 1`, because that is what
|
|
* `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of
|
|
* them would match every subject in the dataset. `rdf-orm build` generates exactly this
|
|
* object from a `.shex` file, and the generator is not what is under test here.
|
|
*/
|
|
const EventShape = {
|
|
shape: `${FP}EventShape`,
|
|
schema: {
|
|
[`${FP}EventShape`]: {
|
|
iri: `${FP}EventShape`,
|
|
predicates: [
|
|
{
|
|
iri: TYPE,
|
|
readablePredicate: "@type",
|
|
dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
{
|
|
iri: `${FP}title`,
|
|
readablePredicate: "title",
|
|
dataTypes: [{ valType: "string" }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies ShapeType<BaseType>;
|
|
|
|
/** The titles a snapshot carries, sorted - what a view would render. */
|
|
function titles(data: BaseType[]): string[] {
|
|
return data.map((d) => String(d.title)).sort();
|
|
}
|
|
|
|
/** Seed one shape-matching object into a document's graph. */
|
|
function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void {
|
|
orm.seed(doc, subject, TYPE, `${FP}Event`);
|
|
orm.seed(doc, subject, `${FP}title`, title);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetRegistryCache();
|
|
});
|
|
afterEach(() => {
|
|
setCurrentUser(null);
|
|
});
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
// The cap registry is process-wide: leaving caps behind would put the possession
|
|
// gate in force for a suite that never declares any.
|
|
resetCaps();
|
|
});
|
|
|
|
describe("useShapeQuery", () => {
|
|
it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm({ autoDeliver: false });
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
|
|
// Before the engine answers: pending, no data.
|
|
await tick();
|
|
expect(obs.getSnapshot().isPending).toBe(true);
|
|
expect(obs.getSnapshot().isSuccess).toBe(false);
|
|
expect(obs.getSnapshot().data).toEqual([]);
|
|
|
|
orm.deliver();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Alpha"]);
|
|
expect(notes).toBeGreaterThan(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("bob");
|
|
// bob has no document of this shape anywhere - the scope is genuinely empty.
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
|
|
unsub();
|
|
});
|
|
|
|
it("(c) an engine patch updates data (reactivity, no polling)", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("carol");
|
|
const doc = await createEntityDoc("carol", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "One");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
|
|
|
|
// A second object lands in a second document - the engine pushes it into the live
|
|
// subscription, and the snapshot follows. No document subscription of ours, no timer.
|
|
const doc2 = await createEntityDoc("carol", "protected");
|
|
seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => {
|
|
// The state the real ORM produces when the engine refuses a start: `orm_start_graph`
|
|
// is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness
|
|
// promise is built with `resolve` alone, so nothing ever settles it. The surface must
|
|
// keep saying "still asking" - the one thing it must never do is publish the
|
|
// empty-and-answered snapshot, which is what an application renders as "you have
|
|
// created nothing".
|
|
const ng = makeFake();
|
|
const orm = makeOrm({ autoDeliver: false });
|
|
inject(ng, orm);
|
|
setCurrentUser("dave");
|
|
const doc = await createEntityDoc("dave", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(e) opening the shape query threw -> isError, and never an empty answer", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("erin");
|
|
orm.refuse("the shape could not be subscribed");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/);
|
|
unsub();
|
|
});
|
|
|
|
it("(f) `configure` without a shape query says so, rather than answering empty", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
// ...and then a bootstrap that forgot the injection. The read has nothing to go
|
|
// through, and an empty set would be the one lie this surface exists to prevent.
|
|
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(String((snap.error as Error).message)).toMatch(/ormSubscription/);
|
|
unsub();
|
|
});
|
|
|
|
// -- the scope axis: the three readings upstream gives it ------------------
|
|
//
|
|
// These are not variants of one case. `graphs` names ONE document's graph per entry;
|
|
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
|
|
// `undefined` returns no objects at all. This module no longer interprets any of it -
|
|
// the scope is handed to the SDK verbatim - so each case pins BOTH that the object
|
|
// arrived unchanged and what the engine then answers.
|
|
|
|
it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("frank");
|
|
const kept = await createEntityDoc("frank", "protected");
|
|
seedEvent(orm, kept, `${kept}:q:one`, "Named");
|
|
const other = await createEntityDoc("frank", "protected");
|
|
seedEvent(orm, other, `${other}:q:one`, "Unnamed");
|
|
|
|
// The whole dataset sees both - that is the reading the named list departs from.
|
|
const all = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsubAll = all.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
|
|
unsubAll();
|
|
|
|
const scope = { graphs: [kept] };
|
|
const obs = useShapeQuery(EventShape, scope);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(obs.getSnapshot().isSuccess).toBe(true);
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Named"]);
|
|
// The object the caller wrote is the object the SDK received: no translation, no
|
|
// copy, nothing added. That is what removing the pipeline bought.
|
|
expect(orm.asked.at(-1)!.scope).toBe(scope);
|
|
unsub();
|
|
});
|
|
|
|
it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("gina");
|
|
const doc = await createEntityDoc("gina", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Unreachable");
|
|
|
|
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
|
|
// It is an ANSWER, not a failure - and not the pending state either, which is what an
|
|
// application would otherwise be left staring at.
|
|
// `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it
|
|
// `undefined`" half of that sentence is no longer reachable by omitting the key. `[]`
|
|
// is the other half, and it is the one that still spells "empty".
|
|
const obs = useShapeQuery(EventShape, { graphs: [] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("hank");
|
|
const doc = await createEntityDoc("hank", "protected");
|
|
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and
|
|
// `graphs` cannot - both carry the same `@graph`.
|
|
seedEvent(orm, doc, `${FP}one`, "First");
|
|
seedEvent(orm, doc, `${FP}two`, "Second");
|
|
|
|
const both = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsubBoth = both.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
|
|
unsubBoth();
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Second"]);
|
|
unsub();
|
|
});
|
|
|
|
// -- the capability filter, which this module still owns -------------------
|
|
|
|
it("(j) an object whose document the holder has no capability for does not come back", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("iris");
|
|
// Hers: created through the surface, so its capability is filed as a by-product.
|
|
const mine = await createEntityDoc("iris", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
// Somebody else's, sitting in the same local dataset - which is exactly what
|
|
// `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader
|
|
// may open. The capability filter is the only thing between it and the caller.
|
|
seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]);
|
|
unsub();
|
|
});
|
|
|
|
// -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` ---
|
|
//
|
|
// `graphs` does not only narrow a search: each entry NAMES a document, and this surface
|
|
// used to name one, hand it to the engine, and empty the result with the capability
|
|
// filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists
|
|
// to reserve for "this scope holds nothing". The four cases below are the distinct ones:
|
|
// the document that cannot be reached, the one that can and is empty, the list where
|
|
// only one member fails, and the emulation not yet in force.
|
|
|
|
it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("kate");
|
|
// Creating one document is what puts the emulation in force - the cap registry is
|
|
// inert until a first cap exists, and (o) below is the case where it still is.
|
|
await createEntityDoc("kate", "protected");
|
|
const theirs = "did:ng:o:someone-elses";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(String((snap.error as Error).message)).toContain("does not hold this document");
|
|
// RULE 2, and it is the half a filter could never give: the question was not put at
|
|
// all. Kate's session never asked the engine about a document she cannot open.
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("liam");
|
|
// His own, so the barrier passes; nothing of the shape is ever seeded into it.
|
|
const mine = await createEntityDoc("liam", "protected");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// THE line that must not move. An empty document is a document that answered, and
|
|
// the repair above would only have relocated the lie if this had become an error.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(n) one unreachable document in a named list refuses the whole scope", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("maya");
|
|
const mine = await createEntityDoc("maya", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
const theirs = "did:ng:o:not-mayas";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// Not a short list. Answering "Mine" alone would say the scope holds one object when
|
|
// the truth is that half of it was never asked - the tolerance `readUnion` was
|
|
// removed for in `1.0.0-dev.8`, arriving by another door.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("nora");
|
|
// No document created here, so no cap was ever minted: the emulation is not in force,
|
|
// which is the state every early path runs in. A barrier that fired here would break
|
|
// all of them.
|
|
const early = "did:ng:o:early-doc";
|
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Early"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(p) the empty-string `graphs` is the whole dataset too, and is not guarded either", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("omar");
|
|
const mine = await createEntityDoc("omar", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
// Somebody else's, in the same local dataset: the filter is what keeps it out, and
|
|
// must stay the one that decides here - `""` names no document to refuse.
|
|
seedEvent(orm, "did:ng:o:not-omars", "did:ng:o:not-omars:q:one", "Theirs");
|
|
|
|
// `normalizeScope` maps `""` onto `did:ng:i` (`@ng-org/orm`'s `types.ts`), so the two
|
|
// spellings are one marker. A barrier that treated `""` as a document name would
|
|
// refuse the whole dataset for a reference nobody wrote.
|
|
const obs = useShapeQuery(EventShape, { graphs: "" });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Mine"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(q) unsubscribing while the barrier is still crossing leaves no query open", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("pia");
|
|
const mine = await createEntityDoc("pia", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
|
|
// The barrier is asynchronous now, so there is a window in which the observable has
|
|
// subscribers, no query, and an open on its way. A teardown landing in that window
|
|
// must not be overtaken by the open it superseded - otherwise the shape query is
|
|
// created after the last listener left and nothing is ever going to close it.
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
unsub(); // synchronously, while `ensureRepoOpen` is still awaiting the first `State`
|
|
await tick();
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(orm.openCount()).toBe(0);
|
|
});
|
|
|
|
// -- teardown ------------------------------------------------------------
|
|
|
|
it("(k) the last unsubscribe closes the shape query", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("jane");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const first = obs.subscribe(() => {});
|
|
const second = obs.subscribe(() => {});
|
|
await tick();
|
|
expect(orm.openCount()).toBe(1);
|
|
|
|
first();
|
|
expect(orm.openCount()).toBe(1); // still a subscriber
|
|
second();
|
|
expect(orm.openCount()).toBe(0); // the query is closed, not leaked
|
|
});
|
|
});
|