Fix NextGraph write persistence: use private_store_id as useShape scope
Writes (doc_create, orm_frontend_update) failed with RepoNotFound because useShape with did:ng:i scope doesn't open individual repos in the verifier's cache. Switched to did🆖${session.private_store_id} as both scope and @graph, matching the expense-tracker-rdf pattern. This opens the private store repo via orm_start_graph, making it available for subsequent writes. Also adds wallet login step to ensureAuth so the verifier bootstraps repos from the remote broker into localStorage on first run. Key changes: - useShapeWithDefaults accepts storeNuri param (private store NURI) - FestipodDataContext.useNgData() passes private store scope - ensureGraphNuri() simplified: reuse existing @graph or private_store_id - ngBootstrap uses ensureGraphNuri + flushAndWait between ORM adds - harness-ng.tsx uses private store scope for test bridge shapes - hooks.ts: wallet creation logs in to bootstrap verifier repos - E2e steps for data loading and persistence verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Clicking "Charger données de test" loaded data in-memory (via ORM signals) but produced `RepoNotFound` errors from `doc_create` and `orm_frontend_update`. Data disappeared after page reload because SPARQL writes never reached the broker. The NextGraph verifier's `self.repos` HashMap didn't contain the private store repo, so `resolve_target()` failed.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` for @graph
|
||||
Use "entire user site" scope for reads, create a new document for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- `did:ng:i` is well-documented as a valid subscription scope
|
||||
- `doc_create` returns a real document NURI
|
||||
|
||||
**Arguments against:**
|
||||
- `did:ng:i` uses a special code path (`NuriTargetV0::UserSite`) that doesn't open individual repos
|
||||
- `doc_create` calls `resolve_target(NuriTargetV0::PrivateStore)` which needs the repo in `self.repos` — fails if repo wasn't opened
|
||||
- Requires complex retry logic / timing workarounds
|
||||
|
||||
### Option B: `private_store_id` as both scope AND @graph
|
||||
Mirror the expense-tracker-rdf example: `useShape(type, `did:ng:${session.private_store_id}`)` and `@graph: `did:ng:${session.private_store_id}``.
|
||||
|
||||
**Arguments for:**
|
||||
- Proven pattern: expense-tracker-rdf uses exactly this and works
|
||||
- `orm_start_graph` with private store NURI opens the repo in the verifier's `self.repos` HashMap
|
||||
- Subsequent writes via `orm_frontend_update` find the repo because it's now in the cache
|
||||
- Simple, no retry logic needed
|
||||
|
||||
**Arguments against:**
|
||||
- Slightly less flexible than `did:ng:i` (scoped to one store)
|
||||
- Requires passing session to `useShapeWithDefaults`
|
||||
|
||||
### Option C: `did:ng:i` scope + reuse existing entity @graph
|
||||
Subscribe with `did:ng:i`, then reuse `@graph` from any existing entity for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- Works for returning users who already have data
|
||||
|
||||
**Arguments against:**
|
||||
- Fails for empty wallets (no existing entities to reuse)
|
||||
- Still needs `doc_create` fallback which hits the same `RepoNotFound` issue
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B**: Use `did:ng:${session.private_store_id}` as both `useShape` scope and `@graph` for writes. This matches the official expense-tracker-rdf example exactly.
|
||||
|
||||
The `useShapeWithDefaults` hook accepts a `storeNuri` parameter. `FestipodDataContext.useNgData()` gets the session from `useNextGraph()` and passes `did:ng:${session.private_store_id}`.
|
||||
|
||||
`ensureGraphNuri()` simplified: checks existing entities first (optimization), then falls back to `did:ng:${session.private_store_id}`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Writes work immediately after connection (no retries needed)
|
||||
- Data persists across page reloads
|
||||
- Pattern matches official NextGraph examples
|
||||
- All 7 e2e scenarios pass including data persistence
|
||||
|
||||
**Negative:**
|
||||
- `useShapeWithDefaults` signature changed (added `storeNuri` parameter)
|
||||
|
||||
**Risks:**
|
||||
- If NextGraph changes the private store behavior, this would break
|
||||
@@ -31,14 +31,18 @@ Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
|
||||
Fully automated — no manual interaction required. CI-ready.
|
||||
|
||||
### First Run (wallet creation)
|
||||
### First Run (wallet creation + bootstrap)
|
||||
1. `BeforeAll` detects no `.wallet-ready` marker in `.playwright-profile/`
|
||||
2. Launches headless Chromium with persistent profile
|
||||
3. Navigates to `https://nextgraph.eu/` → clicks "Create Wallet"
|
||||
4. Redirected to `account.nextgraph.eu` → clicks "I accept" (ToS)
|
||||
5. Redirected back → fills username/password form → submits
|
||||
6. Wallet created in localStorage → marker written
|
||||
7. This is also a real test of the app's auth/login feature
|
||||
6. Wallet created in localStorage
|
||||
7. **Logs in to the wallet** — this triggers the verifier bootstrap from the remote broker, populating localStorage with repo data
|
||||
8. Waits 10s for bootstrap to complete, then closes context
|
||||
9. Marker written
|
||||
|
||||
Step 7 is critical: the NextGraph verifier starts with an empty `repos` HashMap. On first login, `verifier.sync()` bootstraps from the remote broker, downloading repo data (including store repos). This data is saved to localStorage via `session_save`. Without this initial login, subsequent sessions would have empty repos and all writes would fail with `RepoNotFound`.
|
||||
|
||||
### Subsequent Runs (automated login)
|
||||
1. Marker found → skip wallet creation
|
||||
@@ -73,7 +77,7 @@ Required because broker at `nextgraph.eu` (public) loads harness from `http://12
|
||||
- Shut down in `AfterAll`
|
||||
|
||||
### ORM Subscriptions
|
||||
Harness creates subscriptions for all three shapes with scope `did:ng:i` (private store):
|
||||
Harness creates subscriptions for all three shapes with scope `did:ng:${session.private_store_id}` (opens the store repo for reads AND writes):
|
||||
- `FpEventShapeType` → events
|
||||
- `FpUserProfileShapeType` → users
|
||||
- `FpParticipationShapeType` → participations
|
||||
|
||||
@@ -35,6 +35,25 @@ ORM bindings in `src/shared/shapes/orm/`:
|
||||
|
||||
Regenerate with `bun run build:orm`.
|
||||
|
||||
## NextGraph Read/Write Pattern
|
||||
|
||||
The app follows the same pattern as the official expense-tracker-rdf example:
|
||||
|
||||
- **Scope**: `useShape(shapeType, `did:ng:${session.private_store_id}`)` — opens the private store repo in the verifier
|
||||
- **@graph**: `did:ng:${session.private_store_id}` — writes target the same NURI
|
||||
|
||||
This is critical: `orm_start_graph` with the private store NURI explicitly opens the repo in the verifier's `self.repos` HashMap. Without this, `orm_frontend_update` fails with `RepoNotFound`.
|
||||
|
||||
**Do NOT use `did:ng:i` as scope** — it subscribes to the entire user site via a special code path that doesn't open individual repos, breaking all writes.
|
||||
|
||||
### Key files
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — Accepts `storeNuri` param, passes to `useShape`
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` returns `@graph` for entity creation
|
||||
- `src/shared/utils/ngBootstrap.ts` — Seeds test data using `ensureGraphNuri()` for `@graph`
|
||||
|
||||
See [decision record](.project/decisions/2026-03-17-1600-private-store-nuri-scope.md) for why.
|
||||
|
||||
## Context Providers
|
||||
|
||||
### NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
|
||||
@@ -76,3 +76,19 @@ Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
Quand l'utilisateur navigue vers l'écran "home" sans historique
|
||||
Et l'utilisateur clique sur le bouton "Galerie"
|
||||
Alors l'application affiche la galerie
|
||||
|
||||
@e2e
|
||||
Scénario: Le bouton "Charger données de test" est visible quand connecté
|
||||
Alors la galerie affiche le bouton "Charger données de test"
|
||||
|
||||
@e2e
|
||||
Scénario: Charger les données de test remplit le portefeuille
|
||||
Quand l'utilisateur clique sur le bouton "Charger données de test"
|
||||
Et l'utilisateur attend la fin du chargement
|
||||
Et l'utilisateur navigue vers l'écran "home"
|
||||
Alors l'écran d'accueil affiche des événements
|
||||
|
||||
@e2e
|
||||
Scénario: Les données chargées persistent après reconnexion
|
||||
Quand l'utilisateur navigue vers l'écran "home"
|
||||
Alors l'écran d'accueil affiche des événements
|
||||
|
||||
@@ -94,6 +94,65 @@ When('l\'utilisateur clique sur le bouton {string}', async function (this: Festi
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
Then('la galerie affiche le bouton {string}', async function (this: FestipodWorld, buttonText: string) {
|
||||
// The app starts at the Gallery in e2e mode — verify button is visible
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
(text: string) => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => b.textContent?.includes(text));
|
||||
},
|
||||
buttonText,
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
buttons: Array.from(document.querySelectorAll('button')).map(b => b.textContent?.trim()),
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
expect.fail(
|
||||
`Button "${buttonText}" not found. Buttons: ${JSON.stringify(debug.buttons)}. Content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
When('l\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {
|
||||
// Wait for the "Chargement..." button to disappear (bootstrap finished)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return !buttons.some(b => b.textContent?.includes('Chargement...'));
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Extra wait for ORM to flush all microtasks and broker to process
|
||||
await this.appFrame!.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
Then('l\'écran d\'accueil affiche des événements', async function (this: FestipodWorld) {
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return false;
|
||||
const text = root.textContent || '';
|
||||
// Home screen shows "Mes événements à venir" with event cards containing "inscrits" badges
|
||||
return text.includes('Mes événements à venir') && text.includes('inscrits');
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
hash: window.location.hash,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected home screen with events (containing "inscrits") but got hash="${debug.hash}", ` +
|
||||
`content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('l\'application affiche la galerie', async function (this: FestipodWorld) {
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Button, Title, Text, Card, NavBar, Badge } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import type { ScreenProps } from '../../../screens';
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
NavBar,
|
||||
Badge,
|
||||
} from "../../../shared/components/sketchy";
|
||||
import { useFestipodData } from "../../../shared/context/FestipodDataContext";
|
||||
import type { ScreenProps } from "../../../screens";
|
||||
|
||||
function EventCard({ title, date, location, distance, attendees, onClick }: { title: string; date: string; location: string; distance: number; attendees: number; onClick: () => void }) {
|
||||
function EventCard({
|
||||
title,
|
||||
date,
|
||||
location,
|
||||
distance,
|
||||
attendees,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
date: string;
|
||||
location: string;
|
||||
distance: number;
|
||||
attendees: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card onClick={onClick} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{title}</Text>
|
||||
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 14 }}>{date}</Text>
|
||||
<Text style={{ margin: '2px 0 0 0', fontSize: 14 }}>
|
||||
<Text
|
||||
className="user-content"
|
||||
style={{ margin: 0, fontWeight: "bold" }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
className="user-content"
|
||||
style={{ margin: "4px 0 0 0", fontSize: 14 }}
|
||||
>
|
||||
{date}
|
||||
</Text>
|
||||
<Text style={{ margin: "2px 0 0 0", fontSize: 14 }}>
|
||||
📍 <span className="user-content">{location}</span>
|
||||
{distance != null && <span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>}
|
||||
{distance != null && (
|
||||
<span style={{ color: "var(--sketch-gray)" }}>
|
||||
{" "}
|
||||
· {distance} km
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge>{attendees} inscrits</Badge>
|
||||
@@ -22,53 +64,86 @@ function EventCard({ title, date, location, distance, attendees, onClick }: { ti
|
||||
}
|
||||
|
||||
export function HomeScreen({ navigate }: ScreenProps) {
|
||||
const { events, getUserEvents, currentUserId, setSelectedEventId } = useFestipodData();
|
||||
const { getUserEvents, currentUserId, setSelectedEventId } =
|
||||
useFestipodData();
|
||||
|
||||
const myEvents = getUserEvents(currentUserId);
|
||||
// Show user's events first, fall back to all events if none
|
||||
const displayEvents = myEvents.length > 0 ? myEvents : events;
|
||||
|
||||
const handleEventClick = (eventId: string) => {
|
||||
setSelectedEventId(eventId);
|
||||
navigate('event-detail');
|
||||
navigate("event-detail");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px', borderBottom: '2px solid var(--sketch-black)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderBottom: "2px solid var(--sketch-black)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
<span onClick={() => navigate('profile')} style={{ cursor: 'pointer', fontSize: 24 }}>☺</span>
|
||||
<span
|
||||
onClick={() => navigate("profile")}
|
||||
style={{ cursor: "pointer", fontSize: 24 }}
|
||||
>
|
||||
☺
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
<div style={{ flex: 1, padding: 16, overflow: "auto" }}>
|
||||
{/* Helper text */}
|
||||
<div style={{
|
||||
background: 'var(--sketch-light-gray)',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Text style={{ margin: 0, fontSize: 13, color: 'var(--sketch-gray)', lineHeight: 1.5 }}>
|
||||
Voici les événements auxquels vous participez. Retrouvez les infos pratiques
|
||||
et les autres participants.
|
||||
<div
|
||||
style={{
|
||||
background: "var(--sketch-light-gray)",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
color: "var(--sketch-gray)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Voici les événements auxquels vous participez. Retrouvez les infos
|
||||
pratiques et les autres participants.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Text style={{ margin: 0, fontWeight: 'bold' }}>Mes événements à venir</Text>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontWeight: "bold" }}>
|
||||
Mes événements à venir
|
||||
</Text>
|
||||
<Text
|
||||
style={{ margin: 0, fontSize: 14, cursor: 'pointer' }}
|
||||
onClick={() => navigate('events')}
|
||||
style={{ margin: 0, fontSize: 14, cursor: "pointer" }}
|
||||
onClick={() => navigate("events")}
|
||||
>
|
||||
Voir tout →
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{displayEvents.slice(0, 3).map((event) => (
|
||||
{myEvents.slice(0, 3).map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
title={event.title}
|
||||
@@ -81,7 +156,11 @@ export function HomeScreen({ navigate }: ScreenProps) {
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button variant="primary" onClick={() => navigate('create-event')} style={{ width: '100%' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => navigate("create-event")}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
+ Relayer un événement
|
||||
</Button>
|
||||
</div>
|
||||
@@ -90,10 +169,14 @@ export function HomeScreen({ navigate }: ScreenProps) {
|
||||
{/* Bottom Nav */}
|
||||
<NavBar
|
||||
items={[
|
||||
{ icon: '⌂', label: 'Accueil', active: true },
|
||||
{ icon: '◎', label: 'Découvrir', onClick: () => navigate('events') },
|
||||
{ icon: '+', label: 'Relayer', onClick: () => navigate('create-event') },
|
||||
{ icon: '☺', label: 'Profil', onClick: () => navigate('profile') },
|
||||
{ icon: "⌂", label: "Accueil", active: true },
|
||||
{ icon: "◎", label: "Découvrir", onClick: () => navigate("events") },
|
||||
{
|
||||
icon: "+",
|
||||
label: "Relayer",
|
||||
onClick: () => navigate("create-event"),
|
||||
},
|
||||
{ icon: "☺", label: "Profil", onClick: () => navigate("profile") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
seedFriendships,
|
||||
} from '../data/seedData';
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { ensureGraphNuri } from '../utils/ngGraph';
|
||||
|
||||
// ============================================================================
|
||||
// Context interface
|
||||
@@ -226,14 +226,19 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
// ============================================================================
|
||||
|
||||
function useNgData(): FestipodDataContextValue {
|
||||
const { session } = useNextGraph();
|
||||
// Use private store NURI as scope (same as expense-tracker-rdf).
|
||||
// This opens the store repo in the verifier, enabling both reads and writes.
|
||||
const privateNuri = session && `did:ng:${session.private_store_id}`;
|
||||
|
||||
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
|
||||
const emptyEvents: FpEventData[] = [];
|
||||
const emptyUsers: FpUserData[] = [];
|
||||
const emptyParticipations: FpParticipationData[] = [];
|
||||
|
||||
const eventsShape = useShapeWithDefaults(FpEventShapeType, emptyEvents, mapEvent, true);
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, emptyParticipations, mapParticipation, true);
|
||||
const eventsShape = useShapeWithDefaults(FpEventShapeType, privateNuri, emptyEvents, mapEvent, true);
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, privateNuri, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, privateNuri, emptyParticipations, mapParticipation, true);
|
||||
|
||||
const events = eventsShape.items;
|
||||
const users = usersShape.items;
|
||||
@@ -269,8 +274,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
|
||||
console.log('[FestipodData] createEvent (NG):', event.title);
|
||||
(async () => {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
const graph = await ensureGraphNuri(eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any);
|
||||
eventsShape.ngSet.add({
|
||||
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
|
||||
title: event.title, description: event.description, date: event.date,
|
||||
@@ -288,7 +292,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
}
|
||||
})();
|
||||
return { ...event, id: `ng-pending-${Date.now()}` };
|
||||
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
|
||||
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, currentUserId]);
|
||||
|
||||
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (NG):', id, updates);
|
||||
@@ -312,8 +316,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
const graph = await ensureGraphNuri(eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any);
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: eventId, user: uid, isConfirmed: true,
|
||||
@@ -324,7 +327,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
}
|
||||
console.log('[FestipodData] joinEvent done');
|
||||
})();
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, usersShape.ngSet, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
|
||||
+103
-1
@@ -757,10 +757,112 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
"text": "les utilisateurs ont des identifiants NextGraph"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "L'écran de connexion redirige vers l'accueil si déjà connecté",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur navigue vers l'écran \"login\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'application affiche l'écran \"home\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "La navigation interne met à jour l'URL",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur navigue vers l'écran \"events\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'URL contient \"demo/events\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "L'application ne redirige pas vers le broker quand elle est dans l'iframe",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'application est toujours dans l'iframe"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Le bouton Galerie ramène à la galerie depuis le mode démo",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur navigue vers l'écran \"home\" sans historique"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur clique sur le bouton \"Galerie\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'application affiche la galerie"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Le bouton \"Charger données de test\" est visible quand connecté",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "la galerie affiche le bouton \"Charger données de test\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Charger les données de test remplit le portefeuille",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur clique sur le bouton \"Charger données de test\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur attend la fin du chargement"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur navigue vers l'écran \"home\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'écran d'accueil affiche des événements"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Les données chargées persistent après reconnexion",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur navigue vers l'écran \"home\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'écran d'accueil affiche des événements"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"filePath": "src/modules/auth/features/connexion-nextgraph.feature",
|
||||
"rawContent": "# language: fr\n@AUTH @priority-1\nFonctionnalité: Connexion NextGraph et chargement des données\n En tant qu'utilisateur\n Je peux me connecter à mon portefeuille NextGraph\n Et charger les données de test dans mon portefeuille\n Afin d'utiliser l'application avec mes propres données\n\n # --- UI layer: écran de connexion ---\n\n @ui\n Scénario: L'écran de connexion affiche le bouton NextGraph\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran contient un bouton \"Se connecter avec NextGraph\"\n\n @ui\n Scénario: L'écran de connexion redirige automatiquement quand connecté\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère la redirection automatique après connexion\n\n @ui\n Scénario: L'état initial est \"en cours\" quand une connexion est en attente\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère l'état de connexion en cours\n\n @ui\n Scénario: Aucune donnée de démonstration n'est visible pendant la connexion\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran n'importe pas de données de démonstration\n\n # --- Data layer: comportement du portefeuille ---\n\n @data\n Scénario: Un portefeuille connecté est vide par défaut\n Alors le portefeuille est connecté\n Et le portefeuille ne contient aucun événement de démonstration\n\n @data\n Scénario: Charger les données de test dans le portefeuille\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors le portefeuille contient des événements\n Et le portefeuille contient des utilisateurs\n\n @data\n Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données\n Étant donné que le portefeuille contient déjà des événements\n Quand je charge les données de test\n Alors le nombre d'événements n'a pas changé\n\n @data\n Scénario: Les données du portefeuille sont distinctes des données par défaut\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors les événements ont des identifiants NextGraph\n Et les utilisateurs ont des identifiants NextGraph\n",
|
||||
"rawContent": "# language: fr\n@AUTH @priority-1\nFonctionnalité: Connexion NextGraph et chargement des données\n En tant qu'utilisateur\n Je peux me connecter à mon portefeuille NextGraph\n Et charger les données de test dans mon portefeuille\n Afin d'utiliser l'application avec mes propres données\n\n # --- UI layer: écran de connexion ---\n\n @ui\n Scénario: L'écran de connexion affiche le bouton NextGraph\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran contient un bouton \"Se connecter avec NextGraph\"\n\n @ui\n Scénario: L'écran de connexion redirige automatiquement quand connecté\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère la redirection automatique après connexion\n\n @ui\n Scénario: L'état initial est \"en cours\" quand une connexion est en attente\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran gère l'état de connexion en cours\n\n @ui\n Scénario: Aucune donnée de démonstration n'est visible pendant la connexion\n Étant donné je suis sur la page \"connexion\"\n Alors l'écran n'importe pas de données de démonstration\n\n # --- Data layer: comportement du portefeuille ---\n\n @data\n Scénario: Un portefeuille connecté est vide par défaut\n Alors le portefeuille est connecté\n Et le portefeuille ne contient aucun événement de démonstration\n\n @data\n Scénario: Charger les données de test dans le portefeuille\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors le portefeuille contient des événements\n Et le portefeuille contient des utilisateurs\n\n @data\n Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données\n Étant donné que le portefeuille contient déjà des événements\n Quand je charge les données de test\n Alors le nombre d'événements n'a pas changé\n\n @data\n Scénario: Les données du portefeuille sont distinctes des données par défaut\n Étant donné que le portefeuille est vide\n Quand je charge les données de test\n Alors les événements ont des identifiants NextGraph\n Et les utilisateurs ont des identifiants NextGraph\n\n # --- E2E layer: comportement réel dans le navigateur ---\n\n @e2e\n Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté\n Quand l'utilisateur navigue vers l'écran \"login\"\n Alors l'application affiche l'écran \"home\"\n\n @e2e\n Scénario: La navigation interne met à jour l'URL\n Quand l'utilisateur navigue vers l'écran \"events\"\n Alors l'URL contient \"demo/events\"\n\n @e2e\n Scénario: L'application ne redirige pas vers le broker quand elle est dans l'iframe\n Alors l'application est toujours dans l'iframe\n\n @e2e\n Scénario: Le bouton Galerie ramène à la galerie depuis le mode démo\n Quand l'utilisateur navigue vers l'écran \"home\" sans historique\n Et l'utilisateur clique sur le bouton \"Galerie\"\n Alors l'application affiche la galerie\n\n @e2e\n Scénario: Le bouton \"Charger données de test\" est visible quand connecté\n Alors la galerie affiche le bouton \"Charger données de test\"\n\n @e2e\n Scénario: Charger les données de test remplit le portefeuille\n Quand l'utilisateur clique sur le bouton \"Charger données de test\"\n Et l'utilisateur attend la fin du chargement\n Et l'utilisateur navigue vers l'écran \"home\"\n Alors l'écran d'accueil affiche des événements\n\n @e2e\n Scénario: Les données chargées persistent après reconnexion\n Quand l'utilisateur navigue vers l'écran \"home\"\n Alors l'écran d'accueil affiche des événements\n",
|
||||
"screenIds": [
|
||||
"login"
|
||||
]
|
||||
|
||||
@@ -80,6 +80,76 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"sourceCode": "Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {\n const ids = await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n return [...td.users].map((u: any) => u['@id']);\n });",
|
||||
"lineNumber": 142
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur navigue vers l'écran {string}",
|
||||
"keyword": "When",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur navigue vers l\\'écran {string}', async function (this: FestipodWorld, screenId: string) {\n await this.appFrame!.evaluate((id: string) => {\n window.location.hash = `#/demo/${id}`;\n }, screenId);\n // Wait for React to process the navigation\n await this.appFrame!.waitForTimeout(1500);\n});",
|
||||
"lineNumber": 19
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur navigue vers l'écran {string} sans historique",
|
||||
"keyword": "When",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur navigue vers l\\'écran {string} sans historique', async function (this: FestipodWorld, screenId: string) {\n // Use replaceState to navigate without creating a back-history entry\n // (simulates the app being loaded directly at a DemoMode URL in the broker iframe)\n await this.appFrame!.evaluate((id: string) => {\n window.history.replaceState(null, '', `#/demo/${id}`);\n window.dispatchEvent(new HashChangeEvent('hashchange'));\n }, screenId);\n await this.appFrame!.waitForTimeout(1500);\n});",
|
||||
"lineNumber": 27
|
||||
},
|
||||
{
|
||||
"pattern": "l'application est toujours dans l'iframe",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('l\\'application est toujours dans l\\'iframe', async function (this: FestipodWorld) {\n // Verify the app didn't redirect away (initNgWeb would redirect if not in iframe)\n const url = await this.appFrame!.evaluate(() => window.location.href);\n expect(url, 'App should still be on localhost, not redirected to broker').to.include('127.0.0.1');\n\n // Verify the app rendered (not a blank page or error)\n const hasContent = await this.appFrame!.evaluate(() => {\n const root = document.getElementById('root');\n return root && root.innerHTML.length > 100;\n });",
|
||||
"lineNumber": 37
|
||||
},
|
||||
{
|
||||
"pattern": "l'application affiche l'écran {string}",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('l\\'application affiche l\\'écran {string}', async function (this: FestipodWorld, expectedScreenId: string) {\n const marker = SCREEN_MARKERS[expectedScreenId];\n\n // Wait for the expected screen to appear (handles async redirects like login→home)\n const appeared = await this.appFrame!.waitForFunction(\n ([id, markerText]: [string, string | undefined]) => {\n const hash = window.location.hash;\n if (!hash.includes(`demo/${id}`)) return false;\n\n const root = document.getElementById('root');\n if (!root || root.innerHTML.length < 100) return false;\n\n // If we have a marker, verify screen content too\n if (markerText) {\n return root.textContent?.includes(markerText) ?? false;\n }\n return true;\n },\n [expectedScreenId, marker] as [string, string | undefined],\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n // Gather debug info on failure\n const debug = await this.appFrame!.evaluate(() => ({\n hash: window.location.hash,\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n expect.fail(\n `Expected screen \"${expectedScreenId}\" but got hash=\"${debug.hash}\", ` +\n `content: \"${debug.rootText}\"`,\n );\n }\n});",
|
||||
"lineNumber": 50
|
||||
},
|
||||
{
|
||||
"pattern": "l'URL contient {string}",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('l\\'URL contient {string}', async function (this: FestipodWorld, expected: string) {\n const hash = await this.appFrame!.evaluate(() => window.location.hash);\n expect(hash, `URL hash should contain \"${expected}\"`).to.include(expected);\n});",
|
||||
"lineNumber": 85
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur clique sur le bouton {string}",
|
||||
"keyword": "When",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur clique sur le bouton {string}', async function (this: FestipodWorld, buttonText: string) {\n // Click button matching the text inside the app iframe\n const button = this.appFrame!.locator(`button`, { hasText: buttonText });",
|
||||
"lineNumber": 90
|
||||
},
|
||||
{
|
||||
"pattern": "la galerie affiche le bouton {string}",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('la galerie affiche le bouton {string}', async function (this: FestipodWorld, buttonText: string) {\n // The app starts at the Gallery in e2e mode — verify button is visible\n const appeared = await this.appFrame!.waitForFunction(\n (text: string) => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return buttons.some(b => b.textContent?.includes(text));\n },\n buttonText,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n buttons: Array.from(document.querySelectorAll('button')).map(b => b.textContent?.trim()),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n expect.fail(\n `Button \"${buttonText}\" not found. Buttons: ${JSON.stringify(debug.buttons)}. Content: \"${debug.rootText}\"`,\n );\n }\n});",
|
||||
"lineNumber": 97
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur attend la fin du chargement",
|
||||
"keyword": "When",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n // Wait for the \"Chargement...\" button to disappear (bootstrap finished)\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n { timeout: 30000 },\n );\n // Extra wait for ORM to flush all microtasks and broker to process\n await this.appFrame!.waitForTimeout(2000);\n});",
|
||||
"lineNumber": 119
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran d'accueil affiche des événements",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran d\\'accueil affiche des événements', async function (this: FestipodWorld) {\n const appeared = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const text = root.textContent || '';\n // Home screen shows \"Mes événements à venir\" with event cards containing \"inscrits\" badges\n return text.includes('Mes événements à venir') && text.includes('inscrits');\n },\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n hash: window.location.hash,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(\n `Expected home screen with events (containing \"inscrits\") but got hash=\"${debug.hash}\", ` +\n `content: \"${debug.rootText}\"`,\n );\n }\n});",
|
||||
"lineNumber": 132
|
||||
},
|
||||
{
|
||||
"pattern": "l'application affiche la galerie",
|
||||
"keyword": "Then",
|
||||
"file": "connexion.steps.ts",
|
||||
"sourceCode": "Then('l\\'application affiche la galerie', async function (this: FestipodWorld) {\n const appeared = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hash = window.location.hash;\n // Gallery is at #/ or empty hash\n const isGalleryHash = hash === '#/' || hash === '' || hash === '#';\n // Gallery shows screen thumbnails — look for \"Tous les écrans\" or screen grid\n const isGalleryContent = root.textContent?.includes('Wireframe') ?? false;\n return isGalleryHash || isGalleryContent;\n },\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n hash: window.location.hash,\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n expect.fail(\n `Expected Gallery but got hash=\"${debug.hash}\", content: \"${debug.rootText}\"`,\n );\n }\n});",
|
||||
"lineNumber": 156
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran gère la redirection automatique après connexion",
|
||||
"keyword": "Then",
|
||||
@@ -105,7 +175,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"pattern": "un événement {string} existe",
|
||||
"keyword": "Given",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {\n const exists = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n return [...td.events].some((e: any) => e.title === title);\n },\n eventTitle,\n );\n expect(exists, `Event \"${eventTitle}\" should exist in the data layer`).to.be.true;\n});",
|
||||
"sourceCode": "Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {\n // Ensure wallet has data (seed if empty)\n await this.appFrame!.evaluate(() => {\n const td = (window as any).__testData;\n if (td.events.size === 0) td.loadTestData();\n });",
|
||||
"lineNumber": 10
|
||||
},
|
||||
{
|
||||
@@ -113,84 +183,84 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"keyword": "Given",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Given('l\\'utilisateur n\\'est pas inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
|
||||
"lineNumber": 21
|
||||
"lineNumber": 27
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur est inscrit à l'événement {string}",
|
||||
"keyword": "Given",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Given('l\\'utilisateur est inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
|
||||
"lineNumber": 32
|
||||
"lineNumber": 38
|
||||
},
|
||||
{
|
||||
"pattern": "l'événement {string} a {int} participants au départ",
|
||||
"keyword": "Given",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Given('l\\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {\n await this.appFrame!.evaluate(\n ([title, c]: [string, number]) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.updateEvent(event['@id'], { participantCount: c });",
|
||||
"lineNumber": 43
|
||||
"lineNumber": 49
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur s'inscrit à l'événement {string}",
|
||||
"keyword": "When",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur s\\'inscrit à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
|
||||
"lineNumber": 56
|
||||
"lineNumber": 62
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur se désinscrit de l'événement {string}",
|
||||
"keyword": "When",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur se désinscrit de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.leaveEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
|
||||
"lineNumber": 67
|
||||
"lineNumber": 73
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur essaie de s'inscrire une seconde fois à l'événement {string}",
|
||||
"keyword": "When",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "When('l\\'utilisateur essaie de s\\'inscrire une seconde fois à l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (event) td.joinEvent(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n});",
|
||||
"lineNumber": 78
|
||||
"lineNumber": 84
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur est participant de l'événement {string}",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'utilisateur est participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should be participating in \"${eventTitle}\"`).to.be.true;\n});",
|
||||
"lineNumber": 91
|
||||
"lineNumber": 97
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur n'est plus participant de l'événement {string}",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'utilisateur n\\'est plus participant de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const participating = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.isParticipating(event['@id'], td.currentUserId);\n },\n eventTitle,\n );\n expect(participating, `User should NOT be participating in \"${eventTitle}\"`).to.be.false;\n});",
|
||||
"lineNumber": 104
|
||||
"lineNumber": 110
|
||||
},
|
||||
{
|
||||
"pattern": "l'événement {string} compte {int} participants",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n return event?.participantCount ?? -1;\n },\n eventTitle,\n );\n expect(count, `Event \"${eventTitle}\" participant count`).to.equal(expectedCount);\n});",
|
||||
"lineNumber": 117
|
||||
"lineNumber": 123
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur apparaît dans la liste des participants de l'événement {string}",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'utilisateur apparaît dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should appear in participants of \"${eventTitle}\"`).to.be.true;\n});",
|
||||
"lineNumber": 129
|
||||
"lineNumber": 135
|
||||
},
|
||||
{
|
||||
"pattern": "l'utilisateur n'apparaît plus dans la liste des participants de l'événement {string}",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'utilisateur n\\'apparaît plus dans la liste des participants de l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const found = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return false;\n return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);\n },\n eventTitle,\n );\n expect(found, `User should NOT appear in participants of \"${eventTitle}\"`).to.be.false;\n});",
|
||||
"lineNumber": 142
|
||||
"lineNumber": 148
|
||||
},
|
||||
{
|
||||
"pattern": "l'inscription est idempotente pour l'événement {string}",
|
||||
"keyword": "Then",
|
||||
"file": "inscription.steps.ts",
|
||||
"sourceCode": "Then('l\\'inscription est idempotente pour l\\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {\n const count = await this.appFrame!.evaluate(\n (title) => {\n const td = (window as any).__testData;\n const event = [...td.events].find((e: any) => e.title === title);\n if (!event) return 0;\n return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;\n },\n eventTitle,\n );\n expect(count, 'User should have exactly one participation record').to.equal(1);\n});",
|
||||
"lineNumber": 155
|
||||
"lineNumber": 161
|
||||
},
|
||||
{
|
||||
"pattern": "je clique sur un événement",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* useShapeWithDefaults — wrapper around NextGraph ORM's useShape.
|
||||
*
|
||||
* Calls useShape with scope="" (whole dataset) and maps results to app types.
|
||||
* If the NG set is empty (not yet loaded or truly empty),
|
||||
* it returns the provided defaults.
|
||||
* Subscribes to the private store via did:ng:<private_store_id> scope,
|
||||
* which opens the store repo in the verifier (required for writes).
|
||||
* Maps results to app types. If the NG set is empty, returns defaults.
|
||||
*
|
||||
* Must only be called when NG is connected (inside NgDataProvider).
|
||||
*/
|
||||
@@ -11,7 +11,6 @@
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
|
||||
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
|
||||
/** Mapped items from NG store */
|
||||
items: AppT[];
|
||||
@@ -21,18 +20,16 @@ export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
|
||||
|
||||
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
storeNuri: string | undefined,
|
||||
defaults: AppT[],
|
||||
mapFromNg: (item: NgT) => AppT,
|
||||
shapesReady: boolean,
|
||||
): ShapeWithDefaults<NgT, AppT> {
|
||||
// scope="did:ng:i" means whole dataset
|
||||
const ngSet = useShape(shapeType, "did:ng:i") as DeepSignalSet<NgT>;
|
||||
// Before shapes are ready, always show defaults (static display)
|
||||
// After ready, show NG data even if empty (means user deleted everything)
|
||||
// Use private store NURI as scope (like expense-tracker-rdf).
|
||||
// This opens the store repo in the verifier, enabling writes.
|
||||
const ngSet = useShape(shapeType, storeNuri) as DeepSignalSet<NgT>;
|
||||
const usingDefaults = !shapesReady;
|
||||
const items = usingDefaults ? defaults : [...ngSet].map(mapFromNg);
|
||||
|
||||
console.log(`[useShapeWithDefaults] ${(shapeType as any).shape ?? 'unknown'}: ngSet.size=${ngSet.size}, shapesReady=${shapesReady}, using=${usingDefaults ? 'DEFAULTS' : 'NG data'}`);
|
||||
|
||||
return { items, ngSet };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { FestipodWorld } from './world';
|
||||
|
||||
setDefaultTimeout(30000);
|
||||
setDefaultTimeout(90000);
|
||||
|
||||
let browser: Browser;
|
||||
let browserContext: BrowserContext;
|
||||
@@ -164,10 +164,29 @@ async function ensureAuth(): Promise<void> {
|
||||
// Step 6: Wait for wallet to be created (redirects to #/wallet/login)
|
||||
console.log('[Auth] Step 6: Waiting for wallet creation to complete...');
|
||||
await page.waitForURL('**/#/wallet/login', { timeout: 30000 });
|
||||
// Give localStorage time to persist
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('[Auth] Wallet created successfully');
|
||||
// Step 7: Actually log in to trigger verifier bootstrap from remote broker.
|
||||
// The verifier starts with empty repos and must sync from the broker.
|
||||
// This first login populates localStorage with repo data so subsequent
|
||||
// sessions can load repos immediately via verifier.load().
|
||||
console.log('[Auth] Step 7: Logging in to bootstrap verifier repos...');
|
||||
const walletLink = page.getByText('Click here to login with your wallet');
|
||||
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await walletLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const loginPasswordInput = page.locator('input[type="password"]');
|
||||
await loginPasswordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginPasswordInput.fill(WALLET_PASSWORD);
|
||||
await loginPasswordInput.press('Enter');
|
||||
|
||||
// Wait for the session to establish and repos to bootstrap
|
||||
console.log('[Auth] Step 8: Waiting for verifier bootstrap to complete...');
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
console.log('[Auth] Wallet created and bootstrapped successfully');
|
||||
} finally {
|
||||
await authContext.close();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
|
||||
import { bootstrapWallet } from '../utils/ngBootstrap';
|
||||
import { ensureGraphNuri } from '../utils/ngGraph';
|
||||
|
||||
// ============================================================================
|
||||
// App — uses real providers (same tree as the real app)
|
||||
@@ -59,10 +60,11 @@ function ConnectedHarness() {
|
||||
const ngCtx = useNextGraph();
|
||||
const appData = useFestipodData();
|
||||
|
||||
// Raw DeepSignalSets for direct manipulation in tests
|
||||
const events = useShape(FpEventShapeType, 'did:ng:i') as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, 'did:ng:i') as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, 'did:ng:i') as DeepSignalSet<FpParticipation>;
|
||||
// Use private store NURI as scope (opens the repo for reads AND writes)
|
||||
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
|
||||
const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
|
||||
|
||||
const [bridgeReady, setBridgeReady] = useState(false);
|
||||
|
||||
@@ -108,11 +110,12 @@ function ConnectedHarness() {
|
||||
},
|
||||
|
||||
// --- Mutations (direct ngSet access) ---
|
||||
joinEvent(eventId: string, userId: string) {
|
||||
async joinEvent(eventId: string, userId: string) {
|
||||
const already = [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
if (already) return;
|
||||
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
|
||||
participations.add({
|
||||
'@graph': `did:ng:${session.private_store_id}`,
|
||||
'@graph': graph,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '',
|
||||
event: eventId,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { sessionPromise } from './ngSession';
|
||||
import { ensureGraphNuri } from './ngGraph';
|
||||
import {
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
@@ -20,14 +20,23 @@ export interface BootstrapResult {
|
||||
eventIdMap: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush ORM microtask batch and give the broker time to process.
|
||||
*
|
||||
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
|
||||
* flushes the pending batch to the NG engine. The short delay lets the
|
||||
* broker create the new repo/document before the next add.
|
||||
*/
|
||||
async function flushAndWait(ms = 100): Promise<void> {
|
||||
await Promise.resolve(); // flush ORM microtask batch
|
||||
await new Promise(r => setTimeout(r, ms)); // let broker process
|
||||
}
|
||||
|
||||
export async function bootstrapWallet(
|
||||
ngEvents: DeepSignalSet<FpEvent>,
|
||||
ngUsers: DeepSignalSet<FpUserProfile>,
|
||||
ngParticipations: DeepSignalSet<FpParticipation>,
|
||||
): Promise<BootstrapResult> {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
|
||||
// Already has data → returning user, nothing to seed
|
||||
if (ngEvents.size > 0 || ngUsers.size > 0) {
|
||||
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
|
||||
@@ -37,7 +46,12 @@ export async function bootstrapWallet(
|
||||
|
||||
console.log('[Bootstrap] First time for this wallet — seeding default data...');
|
||||
|
||||
// Seed users
|
||||
// Create (or get) a document in the private store for our data.
|
||||
// The ORM requires a real document NURI as @graph, not the store ID.
|
||||
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
|
||||
console.log('[Bootstrap] Using graph NURI:', graph);
|
||||
|
||||
// Seed users — one at a time with ORM flush between each
|
||||
const userIdMap = new Map<string, string>();
|
||||
for (const u of seedUsers) {
|
||||
ngUsers.add({
|
||||
@@ -50,12 +64,13 @@ export async function bootstrapWallet(
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
} as FpUserProfile);
|
||||
await flushAndWait();
|
||||
const added = [...ngUsers].find(nu => nu.username === u.username);
|
||||
if (added) userIdMap.set(u.id, added["@id"]);
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
|
||||
|
||||
// Seed events
|
||||
// Seed events — one at a time with ORM flush between each
|
||||
const eventIdMap = new Map<string, string>();
|
||||
for (const e of seedEvents) {
|
||||
ngEvents.add({
|
||||
@@ -72,12 +87,13 @@ export async function bootstrapWallet(
|
||||
hostName: e.hostName,
|
||||
hostInitials: e.hostInitials,
|
||||
} as FpEvent);
|
||||
await flushAndWait();
|
||||
const added = [...ngEvents].find(ne => ne.title === e.title);
|
||||
if (added) eventIdMap.set(e.id, added["@id"]);
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
|
||||
|
||||
// Seed participations with mapped IDs
|
||||
// Seed participations with mapped IDs — one at a time
|
||||
let partCount = 0;
|
||||
for (const p of seedParticipations) {
|
||||
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
|
||||
@@ -90,6 +106,7 @@ export async function bootstrapWallet(
|
||||
user: userIri,
|
||||
isConfirmed: p.isConfirmed,
|
||||
} as FpParticipation);
|
||||
await flushAndWait();
|
||||
partCount++;
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', partCount, 'participations');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* NextGraph graph NURI management.
|
||||
*
|
||||
* Returns the private store NURI as @graph for ORM entity creation.
|
||||
* This matches the expense-tracker-rdf approach: useShape with
|
||||
* private_store_id scope opens the repo, and writes target the same NURI.
|
||||
*/
|
||||
|
||||
import { sessionPromise } from './ngSession';
|
||||
|
||||
let cachedGraphNuri: string | undefined;
|
||||
|
||||
/**
|
||||
* Get the graph NURI for adding ORM entities.
|
||||
* Uses the private store NURI (same as expense-tracker-rdf).
|
||||
*/
|
||||
export async function ensureGraphNuri(
|
||||
...sets: Iterable<{ readonly "@graph": string }>[]
|
||||
): Promise<string> {
|
||||
if (cachedGraphNuri) return cachedGraphNuri;
|
||||
|
||||
// Reuse @graph from any existing entity if available
|
||||
for (const set of sets) {
|
||||
for (const item of set) {
|
||||
if (item["@graph"]) {
|
||||
cachedGraphNuri = item["@graph"];
|
||||
console.log('[ngGraph] Reusing existing graph NURI:', cachedGraphNuri);
|
||||
return cachedGraphNuri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use private store NURI (the repo is opened by useShape with this scope)
|
||||
const session = await sessionPromise;
|
||||
cachedGraphNuri = `did:ng:${session.private_store_id}`;
|
||||
console.log('[ngGraph] Using private store as graph:', cachedGraphNuri);
|
||||
return cachedGraphNuri;
|
||||
}
|
||||
@@ -27,13 +27,11 @@ export function init(): Promise<void> {
|
||||
console.log('[NG session] init() called — registering callback');
|
||||
initPromise = initNgWeb(
|
||||
async (event: any) => {
|
||||
console.log('[NG session] initNgWeb callback received, event:', JSON.stringify(Object.keys(event || {})));
|
||||
session = event.session;
|
||||
session!.ng ??= ng;
|
||||
console.log('[NG session] Session established, private_store_id:', session!.private_store_id);
|
||||
console.log('[NG session] Connected — private_store:', session!.private_store_id);
|
||||
resolveSessionPromise(session!);
|
||||
initNgSignals(ng, session!);
|
||||
console.log('[NG session] ORM signals initialized');
|
||||
},
|
||||
true,
|
||||
[]
|
||||
|
||||
Reference in New Issue
Block a user