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:
Sylvain Duchesne
2026-03-17 11:50:45 +01:00
parent 708cbeead8
commit ea8fbcf8b7
15 changed files with 590 additions and 95 deletions
@@ -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(
() => {
+121 -38
View File
@@ -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>