Files
ng-eventually/packages/polyfill/test/listing-surfaces-failure.test.ts
T
Sylvain Duchesne d4b392a119 chore: les dix injections de useShape passent par le faux partage
Dix sites, pas les deux que le suivi annoncait : `(() => {}) as unknown as
UseShapeLike` survivait la ou le type etait encore `any` et ou le cast ne
disait donc rien. Ils prennent le faux partage, sans cast.
2026-09-24 19:00:25 +02:00

181 lines
8.2 KiB
TypeScript

/**
* `listMyEntityDocs` answers a VERIFIED listing, or it surfaces — never a half-truth.
*
* The call stands on two reads of the same store document, one behind the other: the Main
* branch says WHICH documents are in there, the Store branch hands back the KEY of each
* (the emulated `AddRepo { read_cap }`). Both have to answer for the array it returns to
* mean what a caller reads into it — *these are your documents, here they are*.
*
* Neither failure is visible from the outside, and that is the whole reason this file
* exists. A listing whose keys never arrived is the SAME `Nuri[]` as one whose keys did:
* the caller learns nothing at the call, and finds out at the next read, which comes back
* empty with the cause long gone. An empty listing over a store that did not answer is
* worse still — it says "you created nothing", and an application renders that.
*
* That is the shape this library shipped twice already: `resolveAccount` answering "no such
* account" for a read that FAILED (`8c8ade7`), and `connectedUser` resolving over a restore
* that never ran (`f6d1734`, which reached a consuming application). The listing was the
* last member of the family, tolerated on the argument that this caller already holds the
* array by the time the key read fails — which is exactly what makes returning it a lie
* rather than a shortcut.
*
* ── The fault is the broker's, and it is one a real one produces ──────────
* The two reads are two round-trips. Between them the connection can die — that is all
* that is simulated here: the broker answers normally, then stops answering, and every
* later query rejects the way the wasm binding rejects a `RepoNotFound`/transport error.
* Nothing reaches into the library to make one of its functions throw artificially, and
* nothing plants a state the wallet could not be in: the documents below were CREATED
* through the surface, over the same wallet, in the session before.
*/
import { test, expect, describe, mock, beforeEach } from "bun:test";
import { makeUseShapeHook } from "./use-shape-fake";
import { configure, storeRegistry, docs } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read — "which documents are in this store". */
const LISTING_READ = `<${SHIM}:contains>`;
/** The Store-branch read — "and what is the key of each". */
const KEY_READ = `<${SHIM}:readCap>`;
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
*
* A dropped connection is not selective, so neither is this: the switch decides *when* the
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
*/
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
const wallet = makeWallet(quads);
let lost = false;
const ng = {
doc_create: wallet.doc_create,
sparql_update: wallet.sparql_update,
sparql_query: mock(async (...a: unknown[]) => {
if (lost || lostAt(a[1] as string)) {
lost = true;
throw new Error("BrokerError: connection lost");
}
return wallet.sparql_query(...a);
}),
};
configure({ ng: ng as unknown as NgLike, useShape: makeUseShapeHook().hook });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
}
/**
* The session before: alice signs in and creates one document, over a broker that works.
*
* Returns the reference she keeps, and the quads the wallet now holds — the durable half
* that outlives the page.
*/
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
note,
);
return { quads, note };
}
/**
* The page comes back over the same wallet and alice signs in — and only THEN does the link
* die, on `read`. Signing in succeeds, so the failure lands inside the one call under test
* rather than upstream of it, which is what makes the assertion about `listMyEntityDocs`.
*/
async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Promise<void> {
let armed = false;
forgetEverything();
bootPage(quads, (query) => armed && query.includes(read));
await signIn("alice");
armed = true;
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
beforeEach(() => {
forgetEverything();
});
describe("listMyEntityDocs answers a verified listing or surfaces", () => {
// The NORMAL case first, and it is not a formality: a call that threw unconditionally
// would pass both failure branches below. This is what the two of them are a departure
// from — the listing answers, and every document it names opens.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a broker that answers gives a listing whose documents open`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
expect(await listMyEntityDocs("alice", scope)).toContain(note);
// Read the way an application reads one document now that `readUnion` is gone: the
// published anchored query, naming the document it was just handed.
expect(await titlesIn(note)).toEqual(["the note"]);
});
}
// The tolerance this file was written to remove: the listing is IN HAND when the key read
// fails, so returning it costs the library nothing and tells the caller something false.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the keys not answering surfaces, instead of a keyless listing`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
// And the point of the refusal, stated as the caller sees it: nothing came back that
// could be mistaken for "here are your documents".
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([note]);
});
}
// The other half of the same promise, and the more dangerous value of the two: an empty
// array reads as "this account created nothing", which an application renders as an
// empty screen rather than as a breakdown.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the listing itself not answering surfaces, instead of "you own nothing"`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([]);
});
}
});