Files
ng-eventually/packages/polyfill/test/open-repo.test.ts
T
Sylvain Duchesne 8c890411bb fix!: la reprise cesse de dependre d-un front, et une ouverture abandonnee se termine
Quatrieme tour. Ce n'est pas un drapeau de plus : c'est la forme qui change.

Les tours 2 et 3 construisaient tous deux la reprise ainsi - noter qu'on a
refuse, puis reagir a un evenement ulterieur - et c'est la note qui etait
fautive les deux fois : une fois conditionnee a quelque chose qui ne pouvait pas
devenir vrai, une fois ecrite apres la fenetre ou arrive l'evenement decisif. Un
front manque n'est pas rattrapable par construction. Donc :

- LA DECISION EST UN NIVEAU. Les documents nommes sont reinterroges a chaque
  publication, jamais reportes depuis la barriere. Une capacite qui arrive change
  `isError` en document par le canal qui existait deja, et rien n'etant memorise,
  il n'y a aucun evenement a manquer. C'est aussi ce qui rend sans objet la
  question du porteur : une requete ouverte pendant qu'un autre etait connecte ne
  peut pas revenir a cet abonne en succes auquel il n'a pas droit.
- UNE TENTATIVE QUI A COURU CONTRE UN CHANGEMENT S'EN APERCOIT au lieu
  d'attendre. `open()` echantillonne `CapRegistry.version()` avant la barriere et
  compare apres son echec ; un ecart signifie qu'elle a decide sur un terrain
  perime, et elle retente d'elle-meme. Cela termine : une tentative ne se repete
  que parce que le trousseau a bouge, les mouvements sont finis, et ceux qu'une
  tentative provoque elle-meme sont les capacites qu'elle depose - au plus une par
  document nomme.

La comparaison de porteur disparait : `holderKey()` vaut "" tant que personne
n'est connecte, donc un refus pris avant que l'identite se fixe - la ou les
composants se montent - ne correspondait plus jamais ensuite.

Un instantane d'echec inchange n'est plus republie : la surface documente sa
stabilite, et huit arrivees sans rapport produisaient huit objets porteurs de huit
`Error` distinctes pour un etat identique.

Enfin, un defaut a moi, trouve en mesurant : le changement rendait la suite
intermittente. `resetOpenedRepos()` abandonnait les ouvertures en vol sans les
terminer, donc un appelant attendait les 8s du repli sur un abonnement demonte,
puis repartait avec son etat de synchro efface - et « unknown » est la lecture
LAXE, celle qui accepte qu'une lecture a zero ligne soit une absence averee. Une
absence-qui-cache-un-echec latente, de la famille meme de cette tache. Une
reinitialisation termine desormais ce qu'elle abandonne, en « timed-out ».

Deux fixtures qui se couraient apres sont corrigees, raison ecrite a cote.
2026-09-21 17:10:20 +02:00

221 lines
9.3 KiB
TypeScript

/**
* open-repo.test.ts — behavioral tests for ensureRepoOpen (src/open-repo.ts).
*
* Core invariant: on a fresh session over a persistent wallet, a scope-index
* or entity repo is NOT yet in `self.repos`, so an anchored sparql_query returns
* 0 rows. `ensureRepoOpen(nuri)` calls `doc_subscribe(nuri, …)` FIRST (which
* pushes the repo into the session), then the anchored read returns data.
*
* Fake design:
* - sparql_query returns EMPTY for a nuri UNTIL doc_subscribe has been called
* for that nuri (tracked in a Set).
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via `readDocumentSubjects` (from `surface/read-document`)
* because that is the production caller — it opens the document before its anchored
* read, and `emulated-verifier/index-deposit.ts` depends on that ordering to tell an
* absence from an unsynced repo. It was `readUnion` until `1.0.0-dev.8`; the integration
* path is the same one, minus the batch.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, getSyncState, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readDocumentSubjects } from "../src/surface/read-document";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
// The reach guard and the cap registry are process-wide: once ANY cap exists the
// boundary applies to every reader. A suite that declares none must start from an
// empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetOpenedRepos();
resetRegistryCache();
resetCaps();
setCurrentUser(null);
});
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
// ---------------------------------------------------------------------------
// Fake ng builder: tracks which nuris have been doc_subscribe-d.
// sparql_query returns rows only AFTER the corresponding nuri is subscribed.
// ---------------------------------------------------------------------------
function makeFakeNgWithSubscribe(
triplesByDoc: Record<string, Array<[string, string]>>,
options: { pushState?: boolean } = {},
) {
// A broker that accepts the subscription and never pushes is the state the bounded
// fallback exists for, and the only way to hold an open genuinely IN FLIGHT.
const pushState = options.pushState !== false;
const subscribed = new Set<string>();
const subscribeCallOrder: string[] = [];
// doc_subscribe: record the call, fire callback immediately (initial push), return unsub
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subscribed.add(nuri);
subscribeCallOrder.push(nuri);
// Simulate initial State push (synchronously deferred so the subscription
// setup promise path in ensureRepoOpen can resolve it).
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {}; // unsubscribe fn
});
const sparql_query = mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
const doc = anchor as string | undefined;
if (!doc) return { results: { bindings: [] } };
// Only return data if the repo has been subscribed (i.e. opened)
if (!subscribed.has(doc)) return { results: { bindings: [] } };
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 } };
});
const doc_create = mock(async () => "did:ng:o:new");
const sparql_update = mock(async () => undefined);
return { doc_subscribe, sparql_query, doc_create, sparql_update, subscribed, subscribeCallOrder };
}
function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ensureRepoOpen", () => {
it("calls doc_subscribe BEFORE the anchored read returns data", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Directly call ensureRepoOpen then verify read sees data
await ensureRepoOpen("did:ng:o:a");
// doc_subscribe was called for the nuri
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readDocumentSubjects("did:ng:o:a");
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
it("WITHOUT doc_subscribe, sparql_query returns 0 rows (verifies fake mechanics)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass `readDocumentSubjects`, which opens the repo internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
});
it("idempotence: a 2nd ensureRepoOpen for the same nuri does NOT re-subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:b": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureRepoOpen("did:ng:o:b");
await ensureRepoOpen("did:ng:o:b"); // second call
// doc_subscribe must have been called exactly ONCE
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("no-op when the fake ng has no doc_subscribe (unit fake path)", async () => {
// Fake ng WITHOUT doc_subscribe
const noSubscribeNg = {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: noSubscribeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
// Must not throw; nuri is added to opened Set (guard skips subscribe)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
// Calling again should also be a no-op (idempotent, already in opened)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
});
// An open ENDS, whatever happens to the registry underneath it.
//
// A reset - which is what a session change does, `syncSession` - tears down the
// subscription an in-flight open is waiting on. Until 2026-09-21 it then left that open
// hanging: its `State` could no longer arrive, so the caller waited out the whole
// 8-second fallback and only then proceeded, with its sync state CLEARED. Cleared reads
// as `"unknown"`, and `unknown` is the LAX answer - `emulated-verifier/index-deposit.ts`
// accepts it as "a zero-row read means the document is empty" - so a read taken right
// after a session change could have an absence read into it. Both halves are the point:
// it must END, and it must end as `"timed-out"`.
it("a reset ENDS the opens it abandons, and marks them timed-out rather than unknown", async () => {
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
inject(ng);
const inFlight = ensureRepoOpen("did:ng:o:abandoned");
await new Promise((r) => setTimeout(r, 5)); // let the subscribe land
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resetOpenedRepos();
// Ends at once. Without the fix it resolves only when the 8s fallback fires, which is
// longer than any test's patience and longer than any page's.
await expect(
Promise.race([
inFlight.then(() => "ended"),
new Promise((r) => setTimeout(() => r("still waiting"), 200)),
]),
).resolves.toBe("ended");
// ...and NOT as a barrier that was reached.
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readDocumentSubjects("did:ng:o:p");
// doc_subscribe was called as part of the `ensureRepoOpen` inside the read
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
});
});