test(data): validate ng-eventually read filter on the real ORM set

Adds a @data scenario (workshop/read-filter) that enables the lib's read filter on the
real reactive ORM set (via a FilterProbe + setupReadFilter harness helper, granting each
participation to its own user) and asserts useShape returns only the target user's
participations. Validates the trickiest piece — filtering a live DeepSignalSet — against the
broker. @data 9/9. Doc: read filter marked implemented & validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-06-29 10:28:56 +02:00
parent e270cc6063
commit aec338441c
4 changed files with 119 additions and 3 deletions
@@ -0,0 +1,14 @@
# language: fr
@WORKSHOP @priority-1
Fonctionnalité: Filtre de lecture (ng-eventually)
En tant que développeur
Je veux valider, contre le vrai broker, que le filtre de lecture de la lib
ne renvoie que les données autorisées pour l'utilisateur courant, sur le vrai
set réactif de l'ORM.
@data
Scénario: Le filtre ne renvoie que les participations autorisées
Étant donné le wallet contient des participations de plusieurs utilisateurs
Quand j'active le filtre de lecture pour l'utilisateur courant
Alors je ne vois que les participations de l'utilisateur courant
Et le filtre a masqué au moins une participation d'un autre utilisateur
@@ -0,0 +1,62 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Validates ng-eventually's READ FILTER on the REAL ORM set, against the broker.
// The harness grants each participation to its own `user`; with the filter on
// for a chosen user, useShape must yield only that user's participations.
Given('le wallet contient des participations de plusieurs utilisateurs', async function (this: FestipodWorld) {
// Deterministic: create two participations for two synthetic users on a
// synthetic event (isolated from real-event counts; joinEvent is idempotent on
// event+user, so this doesn't accumulate across runs).
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
await td.joinEvent('urn:rf:event', 'urn:rf:alice');
await td.joinEvent('urn:rf:event', 'urn:rf:bob');
});
await this.appFrame!.waitForFunction(
() => {
const ps = [...(window as any).__testData.participations];
return ps.some((p: any) => p.user === 'urn:rf:alice') && ps.some((p: any) => p.user === 'urn:rf:bob');
},
null,
{ timeout: 15000 },
);
const data = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
const parts = [...td.participations];
const targetUser = 'urn:rf:alice';
return {
targetUser,
total: parts.length,
ownCount: parts.filter((p: any) => p.user === targetUser).length,
};
});
(this as any).rf = data;
expect(data.ownCount, 'exactly one participation for the target user').to.equal(1);
expect(data.total, 'other users have participations too').to.be.greaterThan(data.ownCount);
});
When('j\'active le filtre de lecture pour l\'utilisateur courant', async function (this: FestipodWorld) {
const { targetUser } = (this as any).rf;
await this.appFrame!.evaluate((u: string) => (window as any).__testData.setupReadFilter(u), targetUser);
await this.appFrame!.waitForFunction(
() => (window as any).__readFilter?.ready === true,
null,
{ timeout: 15000 },
);
});
Then('je ne vois que les participations de l\'utilisateur courant', async function (this: FestipodWorld) {
const r = await this.appFrame!.evaluate(() => (window as any).__readFilter);
const { targetUser, ownCount } = (this as any).rf;
expect(r.users.every((u: string) => u === targetUser), 'all filtered participations belong to the target user').to.be.true;
expect(r.count, 'filtered count equals the target user own participations').to.equal(ownCount);
});
Then('le filtre a masqué au moins une participation d\'un autre utilisateur', async function (this: FestipodWorld) {
const r = await this.appFrame!.evaluate(() => (window as any).__readFilter);
const { total } = (this as any).rf;
expect(total, 'the filter hid at least one other-user participation').to.be.greaterThan(r.count);
});
+40 -1
View File
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-eventually/client';
import { setGrantOf, setCurrentUser } from '@ng-eventually/client/polyfill';
import type { DeepSignalSet } from '@ng-eventually/client';
import {
FpEventShapeType,
@@ -67,6 +68,8 @@ function ConnectedHarness() {
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Read-filter validation: when set, <FilterProbe> mounts a filtered useShape.
const [filterUser, setFilterUser] = useState<string | null>(null);
useEffect(() => {
// Small delay for useShape to populate
@@ -146,6 +149,19 @@ function ConnectedHarness() {
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
},
/**
* Enable the lib's READ FILTER on the real ORM set: each participation is
* granted to its own `user`, and the current user is `user`. <FilterProbe>
* then exposes window.__readFilter with the filtered participations.
*/
setupReadFilter(user: string) {
setGrantOf((item: any) =>
item && item.user ? { read: [item.user], write: [item.user] } : undefined,
);
setCurrentUser(user);
setFilterUser(user);
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
@@ -157,7 +173,30 @@ function ConnectedHarness() {
return () => clearTimeout(timer);
}, [events, users, participations, ngCtx, appData]);
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{filterUser && privateNuri && <FilterProbe privateNuri={privateNuri} />}
</>
);
}
// ============================================================================
// FilterProbe — subscribes participations AFTER the read filter is enabled, so
// useShape returns a filtered view. Exposes window.__readFilter for the @data
// scenario validating the read filter on the real ORM set.
// ============================================================================
function FilterProbe({ privateNuri }: { privateNuri: string }) {
const set = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__readFilter = {
ready: true,
count: set.size,
users: [...set].map(p => p.user),
};
}, [set]);
return null;
}
// ============================================================================