d4b392a119
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.
181 lines
8.2 KiB
TypeScript
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([]);
|
|
});
|
|
}
|
|
});
|