From 4abab5a55a99e250f50649a87ed7609c2f38214f Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 6 Nov 2026 22:57:46 +0100 Subject: [PATCH] Write the failing tests for direct sign-up to an event --- backend/services/registration.service.test.js | 101 +++++++- scripts/e2e-rights-matrix.ts | 46 ++++ .../event/features/e2e-activitypods.feature | 92 ++++++++ .../us-7-inscription-evenement.feature | 60 ++++- .../steps/e2e/inscription-evenement.steps.ts | 220 ++++++++++++++++++ .../steps/ui/inscription-evenement.steps.ts | 95 ++++++++ src/shared/context/festipodDataBase.test.ts | 78 +++++++ src/shared/context/refineData.test.tsx | 142 ++++++++++- src/shared/context/refineDerive.test.ts | 67 ++++++ src/shared/data/seedData.ts | 17 ++ src/shared/support/world.ts | 2 + 11 files changed, 909 insertions(+), 11 deletions(-) create mode 100644 src/modules/event/steps/e2e/inscription-evenement.steps.ts create mode 100644 src/modules/event/steps/ui/inscription-evenement.steps.ts diff --git a/backend/services/registration.service.test.js b/backend/services/registration.service.test.js index f027c9a..ca2ee99 100644 --- a/backend/services/registration.service.test.js +++ b/backend/services/registration.service.test.js @@ -185,11 +185,6 @@ describe('registration — refusals write nothing and record nothing', () => { await refused(setup({ acts: [j], stored: { [MP1]: { ...meetingPoint(MP1), type: 'Note' } } }), j, { contains: 'is not a Event' }); }); - test('an Event with no as:context (no event to anchor the URN)', async () => { - const j = join(MP1); - await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { context: undefined }) } }), j, { contains: 'as:context' }); - }); - test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => { const j = join(MP1); await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' }); @@ -847,3 +842,99 @@ describe('attendance-ledger — the order of acts', () => { expect(() => lastAttendanceQuery(MP1, BOB)).not.toThrow(); }); }); + +describe('registration — a direct sign-up: a Join naming an event itself', () => { + const DAVE = ALICE.replace('/alice', '/dave'); + const PLAIN = `${ALICE}/data/events/plain`; + const plainEvent = (uri, extra = {}) => meetingPoint(uri, { context: undefined, name: `Evénement ${uri.split('/').pop()}`, ...extra }); + const plainWorld = (acts, options = {}) => setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) }, ...options }); + const plainUrn = (who = BOB, event = PLAIN) => memberUrn({ participantUri: who, eventUri: event, secret: SECRETS[who] }); + + test("the member URN, computed with the event as anchor, joins the event's own list and the event joins the participant's collection", async () => { + const j = join(PLAIN); + const w = plainWorld([j]); + await emitJoin(w, j); + + expect(attendees(w, PLAIN)).toEqual([plainUrn()]); + expect(attendees(w, PLAIN)).not.toContain(BOB); + const add = w.calls.find(c => c.action === 'attendees.add'); + expect(add.params).toEqual({ collectionUri: attendeesOf(PLAIN), itemUri: plainUrn(), actorUri: ALICE }); + expect(participations(w)).toEqual([PLAIN]); + const added = w.calls.find(c => c.action === 'pod-collections.add'); + expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: PLAIN, actorUri: BOB }); + expect(ledgerOf(w, PLAIN)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: plainUrn() }); + expect(w.logs.error).toEqual([]); + }); + + test("a direct sign-up notifies nobody: the declarer of an event is not its host", async () => { + const j = join(PLAIN); + const w = plainWorld([j]); + await emitJoin(w, j); + expect(attendees(w, PLAIN)).toEqual([plainUrn()]); + expect(w.calls.some(c => c.action === 'pod-notifications.send')).toBe(false); + expect(w.state.notifications).toEqual([]); + }); + + test("the participant carries the SAME URN in the event's own list and in its meeting points' lists (R5, R6)", async () => { + const festival = plainEvent(EVENT1, { 'dc:creator': DAVE }); + const acts = [join(EVENT1), join(MP1), join(MP2)]; + const w = setup({ acts, stored: { [EVENT1]: festival } }); + for (const a of acts) await emitJoin(w, a); + expect(attendees(w, EVENT1)).toEqual([urnOf(BOB, MP1)]); + expect(attendees(w, MP1)).toEqual(attendees(w, EVENT1)); + expect(attendees(w, MP2)).toEqual(attendees(w, EVENT1)); + expect(participations(w).sort()).toEqual([EVENT1, MP1, MP2].sort()); + }); + + test('signing up twice leaves one entry in the list and one in the collection (R1)', async () => { + const first = join(PLAIN, T0, BOB, 1); + const again = join(PLAIN, T1, BOB, 2); + const w = plainWorld([first, again]); + await emitJoin(w, first); + await emitJoin(w, again); + expect(attendees(w, PLAIN)).toEqual([plainUrn()]); + expect(participations(w)).toEqual([PLAIN]); + expect(w.logs.error).toEqual([]); + }); + + test("a Leave naming the event removes the URN from its list and the event from the participant's collection (R8)", async () => { + const j = join(PLAIN); + const l = leave(PLAIN); + const w = plainWorld([j, l]); + await emitJoin(w, j); + await emitLeave(w, l); + expect(attendees(w, PLAIN)).toEqual([]); + expect(participations(w)).toEqual([]); + expect(ledgerOf(w, PLAIN).change).toBe('leave'); + }); + + test("leaving the event leaves the participant's meeting point memberships alone, and the other way round", async () => { + const acts = [join(PLAIN), join(MP1), leave(PLAIN, T1)]; + const w = setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) } }); + await emitJoin(w, acts[0]); + await emitJoin(w, acts[1]); + expect(participations(w).sort()).toEqual([PLAIN, MP1].sort()); + await emitLeave(w, acts[2]); + expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]); + expect(participations(w)).toEqual([MP1]); + }); + + test('a stored object that is not an Event stays refused', async () => { + const j = join(PLAIN); + const w = plainWorld([j], { stored: { [PLAIN]: { ...plainEvent(PLAIN), type: 'Note' } } }); + await emitJoin(w, j); + expect(writes(w.calls)).toEqual([]); + expect(w.logs.warn.length).toBeGreaterThan(0); + }); + + test("an event not in the pod of its dc:creator, or naming no creator, stays refused", async () => { + const j = join(PLAIN); + for (const extra of [{ 'dc:creator': MALLORY }, { 'dc:creator': undefined }]) { + const w = plainWorld([j], { stored: { [PLAIN]: plainEvent(PLAIN, extra) } }); + await emitJoin(w, j); + expect(writes(w.calls)).toEqual([]); + expect(attendees(w, PLAIN)).toEqual([]); + expect(w.logs.warn.length).toBeGreaterThan(0); + } + }); +}); diff --git a/scripts/e2e-rights-matrix.ts b/scripts/e2e-rights-matrix.ts index 9628cc2..eef81c5 100644 --- a/scripts/e2e-rights-matrix.ts +++ b/scripts/e2e-rights-matrix.ts @@ -118,12 +118,14 @@ import { tmpdir } from 'node:os'; import { join as joinPath } from 'node:path'; import { chromium, type Browser, type Page } from 'playwright'; import { + appActorUri, attendeesCollectionUri, linkId, MEMBER_URN_PREFIX, memberUrnOf, participationSecretOf, participationsCollectionOf, + postOutboxActivity, recordUri, asRecord, type ApodsSession, @@ -1291,6 +1293,49 @@ async function leaveAfterContact(): Promise { }); } +/** + * A direct sign-up to the event itself (FP-001): Alice posts a Join naming E, the way + * the app does; her participations collection then lists E, under the same audience as + * a meeting point membership — her /contacts group only. + */ +async function directSignup(): Promise { + const G = 'direct sign-up to E (Alice; Bob her contact, Carol not)'; + const E = (): string => need(S.E, 'E'); + const listsE = async (): Promise<{ ok: boolean; items: string[] }> => { + const alice = need(S.alice, 'Alice'); + const uri = need(await ownParticipations(alice), 'her participations collection'); + const r = await refIn(uri, E(), true, alice.token); + return { ok: r.ok, items: r.items }; + }; + await m.hard('direct.join', G, 'Alice posts a Join naming E, to the app actor alone; her own participations collection lists E', 'items ∋ E within 30 s (her read)', async () => { + await postOutboxActivity(need(S.alice, 'Alice'), 'Join', E(), appActorUri(config)); + const r = await listsE(); + return { ok: r.ok, actual: `items=${JSON.stringify(r.items)}` }; + }); + await m.hard('direct.non-contact-refused', G, "a direct sign-up is not listable by a non-contact: Carol is refused Alice's participations collection although it now lists E (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => { + const alice = need(S.alice, 'Alice'); + const listed = await listsE(); + need(listed.ok, 'the direct sign-up landed (direct.join)'); + const refusal = await collectionRefusal(need(await ownParticipations(alice), 'her participations collection'), need(S.carol, 'Carol').token, alice, AUTHN); + return { ok: refusal.ok, actual: refusal.actual }; + }); + await m.hard('direct.contact-lists', G, "a contact lists the direct sign-ups: Bob, her contact, reads Alice's participations collection (linked from her profile) and finds E", '200, items ∋ E', async () => { + need(S.contacts, 'the contact pair (contact.accepted)'); + const profile = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links"); + const link = need(participationsCollectionOf(parseJson((await http('GET', profile, { token: need(S.bob, 'Bob').token })).text)), "the participations link on Alice's profile, read by Bob"); + const r = await poll(() => http('GET', link, { token: need(S.bob, 'Bob').token }), v => v.status === 200 && itemUris(parseJson(v.text)).includes(E()), 30000); + const items = itemUris(parseJson(r.text)); + return { ok: r.status === 200 && items.includes(E()), actual: `HTTP ${r.status} items=${JSON.stringify(items)}` }; + }); + await m.hard('direct.leave', G, 'Alice posts a Leave naming E: her participations collection no longer lists it (the group leaves the event as it found it)', 'items ∌ E within 30 s (her read)', async () => { + const alice = need(S.alice, 'Alice'); + await postOutboxActivity(alice, 'Leave', E(), appActorUri(config)); + const uri = need(await ownParticipations(alice), 'her participations collection'); + const r = await refIn(uri, E(), false, alice.token); + return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` }; + }); +} + /** * Who reads a person's participations is her /contacts WAC group, through ONE @@ -1845,6 +1890,7 @@ async function cleanupAccounts(): Promise { await contactChannel(); await afterContact(); await leaveAfterContact(); + await directSignup(); await visibility(); await forgedContact(); await contactChanges(); diff --git a/src/modules/event/features/e2e-activitypods.feature b/src/modules/event/features/e2e-activitypods.feature index 67ac3a0..6a165a3 100644 --- a/src/modules/event/features/e2e-activitypods.feature +++ b/src/modules/event/features/e2e-activitypods.feature @@ -59,3 +59,95 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale Quand la session rejoint le point de rencontre Alors le compteur du point de rencontre compte cette inscription Et la liste des participants rend ce participant comme anonyme + + # --- Inscription directe à un événement (FP-001) --- + # + # Un `as:Join` dont l'objet est l'événement lui-même suit le même canal qu'un Join de + # point de rencontre : l'URN de membre (ancrée sur l'événement) entre dans la + # collection `apods:attendees` de l'événement, et l'URI de l'événement dans la + # collection des participations de la session. L'inscription à l'événement est + # dérivée : directe, ou membre d'un de ses points de rencontre. Chaque scénario quitte + # ce qu'il a rejoint, même en cas d'échec. + + @inscription-evenement + Scénario: Rejoindre puis quitter un événement met à jour sa collection apods:attendees + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un événement "Concert direct" avec sa collection apods:attendees + Quand la session s'inscrit à l'événement + Alors la collection apods:attendees de l'événement contient la session + Et la collection des participations de la session contient l'événement + Quand la session se désinscrit de l'événement + Alors la collection apods:attendees de l'événement ne contient plus la session + Et la collection des participations de la session ne contient plus l'événement + Et une relecture ultérieure ne réinscrit pas la session + + @inscription-evenement + Scénario: L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees + Et l'événement a aussi sa collection apods:attendees + Quand la session s'inscrit à l'événement + Et la session rejoint le point de rencontre + Alors la collection apods:attendees de l'événement contient la session + Et la collection apods:attendees contient la session + Et l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre + + @inscription-evenement + Scénario: Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees + Quand la session rejoint le point de rencontre + Alors la session est inscrite à l'événement + Quand la session quitte le point de rencontre + Alors la session n'est plus inscrite à l'événement + Et la collection des participations de la session ne contient pas l'événement + + @inscription-evenement + Scénario: Quitter le dernier point de rencontre garde l'inscription directe + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees + Et l'événement a aussi sa collection apods:attendees + Quand la session s'inscrit à l'événement + Et la session rejoint le point de rencontre + Et la session quitte le point de rencontre + Alors la collection des participations de la session ne contient plus le point de rencontre + Et la session est inscrite à l'événement + Et la collection apods:attendees de l'événement contient la session + + @inscription-evenement + Scénario: Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees + Et l'événement a aussi sa collection apods:attendees + Quand la session s'inscrit à l'événement + Et la session rejoint le point de rencontre + Et la session quitte l'événement et chacun de ses points de rencontre + Alors la session n'est plus inscrite à l'événement + Et la collection apods:attendees de l'événement ne contient plus la session + Et la collection apods:attendees ne contient plus la session + Et une relecture ultérieure ne réinscrit pas la session + + @browser @inscription-evenement + Scénario: Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation + Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test" + Et l'application Festipod est installée sur le Pod de la session + Et un point de rencontre "Rdv parcours" rattaché à un événement, avec sa collection apods:attendees + Et l'événement a aussi sa collection apods:attendees + Et "alice" est connectée dans l'application avec le mot de passe "Test1test" + Quand dans l'application, j'ouvre l'événement + Et dans l'application, j'appuie sur "S'inscrire à l'événement" + Alors l'application affiche "✓ Inscrit à l'événement" + Quand dans l'application, j'appuie sur "S'inscrire" du point de rencontre "Rdv parcours" + Et dans l'application, j'appuie sur "Se désinscrire de l'événement" + Alors l'application affiche "Se désinscrire de l'événement ?" + Et l'application affiche "Rdv parcours" + Quand dans l'application, j'appuie sur "Confirmer la désinscription" + Alors l'application affiche "S'inscrire à l'événement" + Quand je recharge l'application + Alors l'application affiche "S'inscrire à l'événement" + Et la session n'est plus inscrite à l'événement diff --git a/src/modules/event/features/us-7-inscription-evenement.feature b/src/modules/event/features/us-7-inscription-evenement.feature index 2004433..9cbf2d1 100644 --- a/src/modules/event/features/us-7-inscription-evenement.feature +++ b/src/modules/event/features/us-7-inscription-evenement.feature @@ -15,9 +15,63 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement Étant donné que je suis sur la page "détail événement" Alors l'écran affiche les informations de l'événement - Scénario: Voir le bouton d'inscription sur l'écran - Étant donné que je suis sur la page "détail événement" - Alors je peux m'inscrire à l'événement + Scénario: Voir le bouton d'inscription à l'événement + Étant donné que je suis sur le détail de l'événement "Formation CNV" + Alors je vois le bouton "S'inscrire à l'événement" + Et je ne vois pas le bouton "Se désinscrire de l'événement" + + Scénario: Un inscrit direct voit son inscription à l'événement + Étant donné que je suis sur le détail de l'événement "Atelier low-tech" + Alors je vois le bouton "Se désinscrire de l'événement" + Et l'écran affiche "✓ Inscrit à l'événement" + Et je ne vois pas le bouton "S'inscrire à l'événement" + + Scénario: Être inscrit à un point de rencontre affiche l'inscription à l'événement + Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris" + Alors l'écran affiche "✓ Inscrit à l'événement" + Et je vois le bouton "Se désinscrire de l'événement" + Et je ne vois pas le bouton "S'inscrire à l'événement" + + Scénario: Se désinscrire d'un événement avec points de rencontre demande confirmation + Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris" + Quand j'appuie sur "Se désinscrire de l'événement" + Alors une fenêtre de confirmation "Se désinscrire de l'événement ?" est ouverte + Et la fenêtre de confirmation propose "Confirmer la désinscription" et "Annuler" + + Scénario: La confirmation de désinscription précise les points de rencontre quittés + Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris" + Quand j'appuie sur "Se désinscrire de l'événement" + Alors la fenêtre de confirmation liste les points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place" + Et la fenêtre de confirmation avertit qu'ils seront aussi quittés + + Scénario: Annuler la confirmation ne change rien + Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris" + Quand j'appuie sur "Se désinscrire de l'événement" + Et j'appuie sur "Annuler" + Alors aucune fenêtre de confirmation n'est ouverte + Et l'écran affiche "✓ Inscrit à l'événement" + Et je suis toujours inscrit aux points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place" + + Scénario: Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation + Étant donné que je suis sur le détail de l'événement "Atelier low-tech" + Quand j'appuie sur "Se désinscrire de l'événement" + Alors aucune fenêtre de confirmation n'est ouverte + + Scénario: Quitter un point de rencontre ne demande pas de confirmation + Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris" + Quand j'appuie sur le bouton "✓ Inscrit" d'un point de rencontre + Alors aucune fenêtre de confirmation n'est ouverte + Et je vois le bouton "Se désinscrire de l'événement" + + Plan du Scénario: Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre + Étant donné que je suis sur le détail de l'événement "<événement>" + Alors le bouton "Inviter" est + + Exemples: + | événement | visibilité | + | Rencontre des Colibris | affiché | + | Atelier low-tech | affiché | + | Formation CNV | absent | Scénario: Rechercher un événement existant Étant donné que je suis sur la page "découvrir" diff --git a/src/modules/event/steps/e2e/inscription-evenement.steps.ts b/src/modules/event/steps/e2e/inscription-evenement.steps.ts new file mode 100644 index 0000000..79637ec --- /dev/null +++ b/src/modules/event/steps/e2e/inscription-evenement.steps.ts @@ -0,0 +1,220 @@ +import { After, Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; +import type { ApodsSession } from '../../../../shared/support/activitypodsStack'; +import { + apodsStackConfig, + appActorUri, + attendeesCollectionUri, + ensureParticipationSecret, + linkId, + memberUrnOf, + participationSecretOf, + participationsCollectionOf, + postOutboxActivity, + readAttendees, + readResource, +} from '../../../../shared/support/activitypodsStack'; +import { driveLogin, type DriveContext } from '../../../../../scripts/e2e-rights-matrix-support'; + +const JSON_CONTEXT = 'https://activitypods.org/context.json'; +const POLL_MS = 500; + +function session(world: FestipodWorld): ApodsSession { + if (!world.apods.session) throw new Error('No Pod session — a "session Pod locale" step must run first'); + return world.apods.session; +} + +function required(value: string | undefined, what: string): string { + if (!value) throw new Error(`${what} is unset — a prior step must have created it`); + return value; +} + +const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function until(read: () => Promise, done: (value: T) => boolean, timeoutMs = 20000): Promise { + const end = Date.now() + timeoutMs; + let value = await read(); + while (!done(value) && Date.now() < end) { + await pause(POLL_MS); + value = await read(); + } + return value; +} + +async function attachedCollection(pod: ApodsSession, resourceUri: string): Promise { + const found = await until( + async () => attendeesCollectionUri(await readResource(pod, resourceUri)), + uri => uri !== undefined, + ); + if (!found) throw new Error(`${resourceUri} never gained an apods:attendees collection`); + return found; +} + +async function memberUrnAtEvent(world: FestipodWorld): Promise { + const pod = session(world); + const profileUri = required(linkId((await readResource(pod, pod.webId))['url']), "the session's profile"); + const secret = required(participationSecretOf(await readResource(pod, profileUri)), "the session's participation secret"); + return memberUrnOf(pod.webId, required(world.apods.anchorUri, 'event URI'), secret); +} + +async function membersOf(pod: ApodsSession, collectionUri: string): Promise { + return (await readAttendees(pod, collectionUri)).map(linkId).filter((id): id is string => typeof id === 'string'); +} + +async function participationsOf(pod: ApodsSession): Promise { + const profileUri = linkId((await readResource(pod, pod.webId))['url']); + const collection = profileUri ? participationsCollectionOf(await readResource(pod, profileUri)) : undefined; + return collection ? membersOf(pod, collection) : []; +} + +function targets(world: FestipodWorld): string[] { + return [world.apods.anchorUri, world.apods.meetingPointUri].filter((uri): uri is string => uri !== undefined); +} + +async function expectMember(world: FestipodWorld, collectionUri: string, urn: string, present: boolean): Promise { + const pod = session(world); + const members = await until(() => membersOf(pod, collectionUri), found => found.includes(urn) === present); + expect(members.includes(urn), present ? 'the member URN is listed' : 'the member URN is gone').to.equal(present); + expect(members, 'a list never holds a WebID').to.not.include(pod.webId); +} + +async function expectParticipation(world: FestipodWorld, uri: string, present: boolean): Promise { + const pod = session(world); + const items = await until(() => participationsOf(pod), found => found.includes(uri) === present); + expect(items.includes(uri), present ? `${uri} is in my participations` : `${uri} is not in my participations`).to.equal(present); +} + +async function registered(world: FestipodWorld): Promise { + const items = await participationsOf(session(world)); + return targets(world).some(uri => items.includes(uri)); +} + +After({ tags: '@inscription-evenement', timeout: 60000 }, async function (this: FestipodWorld) { + if (!this.apods.session) return; + const to = appActorUri(apodsStackConfig()); + for (const uri of targets(this)) { + await postOutboxActivity(this.apods.session, 'Leave', uri, to).catch(() => undefined); + } +}); + +Given('un événement {string} avec sa collection apods:attendees', async function (this: FestipodWorld, title: string) { + const pod = session(this); + const container = `${pod.storage}/as/event`; + const response = await fetch(container, { + method: 'POST', + headers: { 'Content-Type': 'application/ld+json', Accept: 'application/ld+json', Authorization: `Bearer ${pod.token}` }, + body: JSON.stringify({ '@context': JSON_CONTEXT, type: 'Event', name: title, startTime: '2026-11-01T18:00:00Z' }), + }); + const location = response.headers.get('Location') ?? response.headers.get('location'); + if (!response.ok || !location) throw new Error(`Create in ${container} failed: HTTP ${response.status}`); + this.apods.anchorUri = location; + this.apods.eventCollectionUri = await attachedCollection(pod, location); +}); + +Given("l'événement a aussi sa collection apods:attendees", async function (this: FestipodWorld) { + this.apods.eventCollectionUri = await attachedCollection(session(this), required(this.apods.anchorUri, 'event URI')); +}); + +When("la session s'inscrit à l'événement", async function (this: FestipodWorld) { + const pod = session(this); + await ensureParticipationSecret(pod); + await postOutboxActivity(pod, 'Join', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig())); +}); + +When("la session se désinscrit de l'événement", async function (this: FestipodWorld) { + await postOutboxActivity(session(this), 'Leave', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig())); +}); + +When("la session quitte l'événement et chacun de ses points de rencontre", async function (this: FestipodWorld) { + const pod = session(this); + const to = appActorUri(apodsStackConfig()); + await Promise.all(targets(this).map(uri => postOutboxActivity(pod, 'Leave', uri, to))); +}); + +Then("la collection apods:attendees de l'événement contient la session", async function (this: FestipodWorld) { + await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), true); +}); + +Then("la collection apods:attendees de l'événement ne contient plus la session", async function (this: FestipodWorld) { + await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), false); +}); + +Then("la collection des participations de la session contient l'événement", async function (this: FestipodWorld) { + await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), true); +}); + +Then(/^la collection des participations de la session ne contient (?:plus|pas) l'événement$/, async function (this: FestipodWorld) { + await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), false); +}); + +Then('la collection des participations de la session ne contient plus le point de rencontre', async function (this: FestipodWorld) { + await expectParticipation(this, required(this.apods.meetingPointUri, 'meeting point URI'), false); +}); + +Then("la session est inscrite à l'événement", async function (this: FestipodWorld) { + const found = await until(() => registered(this), value => value); + expect(found, 'the session is registered to the event, directly or through a meeting point').to.be.true; +}); + +Then("la session n'est plus inscrite à l'événement", async function (this: FestipodWorld) { + const found = await until(() => registered(this), value => !value); + expect(found, 'the session is registered to neither the event nor any of its meeting points').to.be.false; +}); + +Then("l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre", async function (this: FestipodWorld) { + const pod = session(this); + const urn = await memberUrnAtEvent(this); + const inEvent = await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection")); + const inMeetingPoint = await membersOf(pod, required(this.apods.collectionUri, "the meeting point's attendees collection")); + expect(inEvent).to.include(urn); + expect(inMeetingPoint).to.include(urn); +}); + +Then('une relecture ultérieure ne réinscrit pas la session', async function (this: FestipodWorld) { + await pause(3000); + expect(await registered(this), 'a later read does not register the session back').to.be.false; + const urn = await memberUrnAtEvent(this); + const pod = session(this); + expect(await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection"))).to.not.include(urn); +}); + +// ── The journey, in the real app ─────────────────────────────────────────── + +const stackUrl = (value: string | undefined, fallback: string) => (value || fallback).replace(/\/+$/, ''); + +Given('{string} est connectée dans l\'application avec le mot de passe {string}', async function (this: FestipodWorld, username: string, password: string) { + const ctx: DriveContext = { + app: required(this.apods.appUrl, 'app URL'), + podFront: stackUrl(process.env.POD_FRONTEND_URL, 'http://localhost:5000'), + hops: [], + errors: [], + consoleErrors: [], + pageErrors: [], + deadline: Date.now() + 5 * 60 * 1000, + }; + const outcome = await driveLogin(this.page!, username, username, password, ctx); + expect(outcome.ok, outcome.detail).to.be.true; +}); + +When('dans l\'application, j\'ouvre l\'événement', async function (this: FestipodWorld) { + const url = `${required(this.apods.appUrl, 'app URL')}/events/${encodeURIComponent(required(this.apods.anchorUri, 'event URI'))}`; + await this.page!.goto(url, { waitUntil: 'domcontentloaded' }); +}); + +When('dans l\'application, j\'appuie sur {string}', async function (this: FestipodWorld, label: string) { + await this.page!.getByRole('button', { name: label, exact: true }).first().click({ timeout: 30000 }); +}); + +When('dans l\'application, j\'appuie sur {string} du point de rencontre {string}', async function (this: FestipodWorld, label: string, title: string) { + const card = this.page!.locator('div', { hasText: title }).filter({ has: this.page!.getByRole('button', { name: label, exact: true }) }).last(); + await card.getByRole('button', { name: label, exact: true }).click({ timeout: 30000 }); +}); + +Then('l\'application affiche {string}', async function (this: FestipodWorld, text: string) { + await this.page!.getByText(text, { exact: false }).first().waitFor({ state: 'visible', timeout: 30000 }); +}); + +When("je recharge l'application", async function (this: FestipodWorld) { + await this.page!.reload({ waitUntil: 'domcontentloaded' }); +}); diff --git a/src/modules/event/steps/ui/inscription-evenement.steps.ts b/src/modules/event/steps/ui/inscription-evenement.steps.ts new file mode 100644 index 0000000..776e339 --- /dev/null +++ b/src/modules/event/steps/ui/inscription-evenement.steps.ts @@ -0,0 +1,95 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; +import { renderScreen } from '../../../../shared/test-harness/renderHelper'; +import { seedEvents } from '../../../../shared/data/seedData'; + +const DIALOG_TITLE = "Se désinscrire de l'événement ?"; + +const settle = () => new Promise(resolve => setTimeout(resolve, 50)); + +function buttons(world: FestipodWorld): HTMLButtonElement[] { + expect(world.renderedDoc, 'Screen should be rendered').to.not.be.null; + return Array.from(world.renderedDoc!.querySelectorAll('button')) as HTMLButtonElement[]; +} + +function buttonLabelled(world: FestipodWorld, label: string): HTMLButtonElement | undefined { + return buttons(world).find(b => (b.textContent ?? '').trim() === label); +} + +function dialogOpen(world: FestipodWorld): boolean { + return world.getDomText().includes(DIALOG_TITLE); +} + +Given('je suis sur le détail de l\'événement {string}', async function (this: FestipodWorld, title: string) { + const event = seedEvents.find(e => e.title === title); + expect(event, `No seed event titled "${title}"`).to.not.be.undefined; + this.currentScreenId = 'event-detail'; + this.renderedDoc = await renderScreen('event-detail', `/events/${event!.id}`); +}); + +When('j\'appuie sur {string}', async function (this: FestipodWorld, label: string) { + const button = buttonLabelled(this, label); + expect(button, `Button "${label}" should be on screen`).to.not.be.undefined; + button!.click(); + await settle(); +}); + +When('j\'appuie sur le bouton {string} d\'un point de rencontre', async function (this: FestipodWorld, label: string) { + const button = buttonLabelled(this, label); + expect(button, `Meeting point button "${label}" should be on screen`).to.not.be.undefined; + button!.click(); + await settle(); +}); + +Then('je vois le bouton {string}', async function (this: FestipodWorld, label: string) { + expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined; +}); + +Then('je ne vois pas le bouton {string}', async function (this: FestipodWorld, label: string) { + expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined; +}); + +Then('le bouton {string} est affiché', async function (this: FestipodWorld, label: string) { + expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined; +}); + +Then('le bouton {string} est absent', async function (this: FestipodWorld, label: string) { + expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined; +}); + +Then('l\'écran affiche {string}', async function (this: FestipodWorld, text: string) { + expect(this.getDomText(), `Screen should show "${text}"`).to.include(text); +}); + +Then('une fenêtre de confirmation {string} est ouverte', async function (this: FestipodWorld, title: string) { + expect(this.getDomText(), `Confirmation "${title}" should be open`).to.include(title); +}); + +Then('la fenêtre de confirmation propose {string} et {string}', async function (this: FestipodWorld, confirm: string, cancel: string) { + expect(buttonLabelled(this, confirm), `Button "${confirm}" should be in the dialog`).to.not.be.undefined; + expect(buttonLabelled(this, cancel), `Button "${cancel}" should be in the dialog`).to.not.be.undefined; +}); + +Then('la fenêtre de confirmation liste les points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) { + expect(dialogOpen(this), 'The confirmation should be open').to.be.true; + const text = this.getDomText(); + expect(text, 'The dialog should name the first meeting point').to.include(`suivants : ${first}`); + expect(text, 'The dialog should name the second meeting point').to.include(second); +}); + +Then('la fenêtre de confirmation avertit qu\'ils seront aussi quittés', async function (this: FestipodWorld) { + expect(this.getDomText()).to.include('En confirmant, vous en serez aussi désinscrit.'); +}); + +Then('aucune fenêtre de confirmation n\'est ouverte', async function (this: FestipodWorld) { + expect(dialogOpen(this), 'No confirmation should be open').to.be.false; +}); + +Then('je suis toujours inscrit aux points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) { + const text = this.getDomText(); + expect(text).to.include(first); + expect(text).to.include(second); + const joined = buttons(this).filter(b => (b.textContent ?? '').trim() === '✓ Inscrit'); + expect(joined.length, 'Both meeting points should still show as joined').to.equal(2); +}); diff --git a/src/shared/context/festipodDataBase.test.ts b/src/shared/context/festipodDataBase.test.ts index 950cdd2..734c3ff 100644 --- a/src/shared/context/festipodDataBase.test.ts +++ b/src/shared/context/festipodDataBase.test.ts @@ -183,3 +183,81 @@ describe('safeUserUsername', () => { expect(safeUserUsername(user(''))).toBe(''); }); }); + +describe('getEventRegistration', () => { + const E = 'event-1'; + const M1 = 'mp-1'; + const M2 = 'mp-2'; + const events: FpEventData[] = [{ id: E, title: 'Concert', description: '', date: '2026-10-10', location: 'Lyon', participantCount: 0 }]; + const users: FpUserData[] = [{ id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' }]; + const meetingPoints: FpMeetingPointData[] = [ + { id: M1, eventId: E, location: 'Café', time: '' }, + { id: M2, eventId: E, location: 'Gare', time: '' }, + ]; + const part = (target: string, userId = 'user-1'): FpParticipationData => ({ id: `${target}|${userId}`, eventId: target, userId, isConfirmed: true }); + const registration = (participations: FpParticipationData[]) => + buildQueries(events, users, participations, meetingPoints, [] as FpFriendshipData[], 'user-1').getEventRegistration(E); + + test('signed up to nothing: not registered', () => { + expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false }); + }); + + test('signed up directly: registered, through no meeting point (R1)', () => { + expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true }); + }); + + test('a member of one meeting point: registered without any direct sign-up (R2)', () => { + expect(registration([part(M1)])).toEqual({ direct: false, meetingPointIds: [M1], registered: true }); + }); + + test('a member of two meeting points lists both (R2)', () => { + const found = registration([part(M1), part(M2)]); + expect(found.direct).toBe(false); + expect([...found.meetingPointIds].sort()).toEqual([M1, M2]); + expect(found.registered).toBe(true); + }); + + test('direct and a member of a meeting point: both facts are kept', () => { + expect(registration([part(E), part(M1)])).toEqual({ direct: true, meetingPointIds: [M1], registered: true }); + }); + + test('leaving one of two meeting points keeps the registration (R3)', () => { + expect(registration([part(M2)])).toEqual({ direct: false, meetingPointIds: [M2], registered: true }); + }); + + test('leaving the last meeting point without a direct sign-up ends the registration (R3)', () => { + expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false }); + }); + + test('leaving the last meeting point while signed up directly keeps the registration (R3)', () => { + expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true }); + }); + + test("someone else's sign-ups never count as mine", () => { + expect(registration([part(E, 'user-2'), part(M1, 'user-2')])).toEqual({ direct: false, meetingPointIds: [], registered: false }); + }); + + test('a meeting point of another event never registers me to this one', () => { + const other: FpMeetingPointData[] = [{ id: 'mp-9', eventId: 'event-2', location: 'Ailleurs', time: '' }]; + const found = buildQueries(events, users, [part('mp-9')], [...meetingPoints, ...other], [] as FpFriendshipData[], 'user-1').getEventRegistration(E); + expect(found.registered).toBe(false); + }); +}); + +describe('getUserEvents with direct sign-ups and meeting point memberships', () => { + const events: FpEventData[] = [ + { id: 'event-1', title: 'Direct', description: '', date: '', location: '', participantCount: 0 }, + { id: 'event-2', title: 'Par un point', description: '', date: '', location: '', participantCount: 0 }, + { id: 'event-3', title: 'Aucun', description: '', date: '', location: '', participantCount: 0 }, + ]; + const meetingPoints: FpMeetingPointData[] = [{ id: 'mp-2', eventId: 'event-2', location: 'Café', time: '' }]; + const participations: FpParticipationData[] = [ + { id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true }, + { id: 'p2', eventId: 'mp-2', userId: 'user-1', isConfirmed: true }, + ]; + const queries = buildQueries(events, [], participations, meetingPoints, [] as FpFriendshipData[], 'user-1'); + + test("my events are those I signed up to directly and those of the meeting points I belong to (R1, R2)", () => { + expect(queries.getUserEvents('user-1').map(e => e.id).sort()).toEqual(['event-1', 'event-2']); + }); +}); diff --git a/src/shared/context/refineData.test.tsx b/src/shared/context/refineData.test.tsx index 25e7dcf..15f82cd 100644 --- a/src/shared/context/refineData.test.tsx +++ b/src/shared/context/refineData.test.tsx @@ -60,6 +60,8 @@ const CAROL = 'http://carol.pod.test/carol'; const E = 'http://bob.pod.test/bob/data/events/concert'; const M = 'http://bob.pod.test/bob/data/events/concert-bar'; const C = 'http://bob.pod.test/bob/data/events/concert-bar/attendees'; +/** CE: the event's OWN attendee list — where the direct sign-ups are listed. */ +const CE = 'http://bob.pod.test/bob/data/events/concert/attendees'; /** M2: the event's SECOND meeting point, with its own attendee list C2. */ const M2 = 'http://bob.pod.test/bob/data/events/concert-terrasse'; const C2 = 'http://bob.pod.test/bob/data/events/concert-terrasse/attendees'; @@ -80,6 +82,7 @@ const CAROL_URN_E = urnOf(CAROL, E, CAROL_SECRET); const DAVE_URN_E = urnOf(DAVE, E, DAVE_SECRET); const EVENT_RECORD = { id: E, type: 'Event', name: 'Concert', startTime: '2026-11-01T20:00:00', 'dc:creator': BOB, to: 'as:Public' }; +const EVENT_RECORD_LISTED = { ...EVENT_RECORD, 'apods:attendees': CE }; const MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' }; const MP2_RECORD = { id: M2, type: 'Event', location: 'En terrasse', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C2, to: 'as:Public' }; /** E2: an event Bob DECLARED — known to this session only through the catalog's owner filter. */ @@ -106,6 +109,11 @@ const server = { /** Attendee lists of M and M2, and how many reads of either were served. */ members: [] as string[], members2: [] as string[], + /** The event's own attendee list, and whether the event is served with its link. */ + eventMembers: [] as string[], + eventListed: false, + /** Objects whose Join/Leave the outbox refuses. */ + refused: [] as string[], reads: 0, /** Alice's own list (`own`, with M's list link once `ownLinked`). */ own: [] as JsonRecord[], @@ -140,6 +148,8 @@ function json(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/ld+json' } }); } +const eventRecord = () => (server.eventListed ? EVENT_RECORD_LISTED : EVENT_RECORD); + async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; if (init?.signal?.aborted) throw new DOMException('aborted', 'AbortError'); @@ -147,6 +157,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom if (method === 'POST') { if (url === ALICE_OUTBOX) { const body = JSON.parse(String(init?.body)) as JsonRecord; + if (server.refused.includes(String(body['object']))) return new Response(null, { status: 500 }); server.posted.push(body); server.log.push(String(body['type'])); return new Response(null, { status: 201 }); @@ -158,7 +169,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom const owner = new URL(url).searchParams.get('owner'); const items = owner ? (owner === BOB ? server.bobDeclared : []) - : (server.secondMp ? [EVENT_RECORD, MP_RECORD, MP2_RECORD] : [EVENT_RECORD, MP_RECORD]); + : (server.secondMp ? [eventRecord(), MP_RECORD, MP2_RECORD] : [eventRecord(), MP_RECORD]); return json({ type: 'OrderedCollection', items }); } switch (url) { @@ -177,7 +188,8 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom case BOB_PARTICIPATIONS: server.bobParticipationsReads++; return json({ id: BOB_PARTICIPATIONS, type: 'Collection', items: [...server.bobParticipations] }); - case E: return json(EVENT_RECORD); + case E: return json(eventRecord()); + case CE: return json({ id: CE, type: 'Collection', items: [...server.eventMembers] }); case E2: return json(E2_RECORD); case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED); case M2: return json(MP2_RECORD); @@ -224,7 +236,7 @@ const alicePod = { * collection — created and linked from her profile at her first Join. */ function backendProcesses(op: 'join' | 'leave', mp: string): void { - const list = mp === M ? 'members' : 'members2'; + const list = mp === E ? 'eventMembers' : mp === M ? 'members' : 'members2'; if (op === 'join') { if (!server[list].includes(ALICE_URN_E)) server[list] = [...server[list], ALICE_URN_E]; if (!server.myParticipations.includes(mp)) server.myParticipations = [...server.myParticipations, mp]; @@ -323,11 +335,20 @@ async function settledView(match: (v: ReturnType) => boolean, timeo return current; } +type Registration = { direct: boolean; meetingPointIds: string[]; registered: boolean }; +const registration = (): Registration => { + const found = latest?.getEventRegistration(E); + return found ? { ...found, meetingPointIds: [...found.meetingPointIds].sort() } : { direct: false, meetingPointIds: [], registered: false }; +}; + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); interface Scenario { members?: string[]; members2?: string[]; + /** The event's own attendee list; the event is served with its link only when this is given. */ + eventMembers?: string[]; + refused?: string[]; own?: JsonRecord[]; mpLinked?: boolean; secondMp?: boolean; @@ -345,6 +366,9 @@ interface Scenario { function render(s: Scenario = {}): void { server.members = s.members ?? []; server.members2 = s.members2 ?? []; + server.eventMembers = s.eventMembers ?? []; + server.eventListed = s.eventMembers !== undefined; + server.refused = s.refused ?? []; server.reads = 0; server.own = s.own ?? []; server.ownLinked = false; @@ -729,3 +753,115 @@ test('a contact whose profile links no participations collection: asking for the expect(latest!.getUserEvents(BOB)).toEqual([]); expect(server.bobParticipationsReads).toBe(0); }, 30000); + +// --- signing up to the event itself ------------------------------------------------- + +const NOT_REGISTERED: Registration = { direct: false, meetingPointIds: [], registered: false }; + +test('joining an event directly: the secret first, then ONE Join naming the event, addressed to the app actor alone; the event shows on my home (R1)', async () => { + await mount({ eventMembers: [] }); + + await latest!.joinEvent(E); + + expect(server.log).toEqual(['profile-write', 'Join']); + expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: E, to: `${BACKEND}/as/actor/app` }]); + await until(() => registration().direct, 'the direct sign-up reflected at once'); + expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true }); + + backendProcesses('join', E); + await until(() => latest?.getEvent(E)?.participantCount === 1, 'the event counting my sign-up'); + expect(view().mp).toBe(0); + expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true }); + await until(() => latest?.getUserEvents(ALICE).some(e => e.id === E) === true, 'the event on my home'); +}, 30000); + +test('a refused direct Join rejects, and the status shown stays "not registered" (R7)', async () => { + await mount({ eventMembers: [], secret: ALICE_SECRET, myParticipations: [], refused: [E] }); + + await expect(latest!.joinEvent(E)).rejects.toThrow(); + await land(); + + expect(server.posted).toEqual([]); + expect(registration()).toEqual(NOT_REGISTERED); +}, 30000); + +test('an event joined only through a meeting point: registered with no direct sign-up and no act naming the event (R2)', async () => { + render({ eventMembers: [], members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M] }); + + await until(() => registration().registered, 'registered through the meeting point'); + + expect(registration()).toEqual({ direct: false, meetingPointIds: [M], registered: true }); + expect(server.posted).toEqual([]); +}, 30000); + +test("another member's direct sign-up in the event's list counts at the event and in no meeting point (R5)", async () => { + render({ eventMembers: [BOB_URN_E, CAROL_URN_E], members: [CAROL_URN_E] }); + + await until(() => latest?.getEvent(E)?.participantCount === 2, 'the event counting both persons'); + + expect(view()).toEqual({ event: 2, mp: 1, joined: false }); +}, 30000); + +test("a contact signed up directly is recognised in the event's own list, and a contact's participations naming the event list it among their events (R6)", async () => { + render({ + eventMembers: [BOB_URN_E], + contacts: [BOB], + contactProfiles: bobProfile({ secret: BOB_SECRET, participations: BOB_PARTICIPATIONS }), + bobParticipations: [E], + }); + + await until(() => latest?.getEventParticipants(E).some(u => u.id === BOB) === true, 'Bob recognised from the event list'); + latest!.openUserEvents(BOB); + await until(() => latest?.getUserEvents(BOB).some(e => e.id === E) === true, "the event among Bob's events"); +}, 30000); + +// --- leaving the event: the cascade belongs to the data context ----------------------- + +async function mountRegistered(s: Scenario): Promise { + render({ eventMembers: [ALICE_URN_E], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [E, M, M2], ...s }); + await until(() => registration().direct && registration().meetingPointIds.length === 2, 'registered directly and through both meeting points'); +} + +test('leaving an event posts a Leave for the direct sign-up and one for each meeting point joined, and shows the user no longer registered (R4)', async () => { + await mountRegistered({}); + + await latest!.leaveEvent(E); + + expect(server.posted.map(body => body['type'])).toEqual(['Leave', 'Leave', 'Leave']); + expect(server.posted.map(body => body['object']).sort()).toEqual([E, M, M2].sort()); + expect(server.profileWrites).toEqual([]); + await until(() => !registration().registered, 'the status following the withdrawal'); + expect(registration()).toEqual(NOT_REGISTERED); +}, 30000); + +test('leaving an event held only through meeting points posts no Leave naming the event (R4)', async () => { + render({ eventMembers: [], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [M, M2] }); + await until(() => registration().meetingPointIds.length === 2, 'registered through both meeting points'); + + await latest!.leaveEvent(E); + + expect(server.posted.map(body => body['object']).sort()).toEqual([M, M2].sort()); +}, 30000); + +test('a partly failed withdrawal rejects naming the targets that failed, and the status shows what remains (R7)', async () => { + await mountRegistered({ refused: [M2] }); + + const failure = await latest!.leaveEvent(E).then(() => undefined, (error: unknown) => error as Error); + + expect(failure).toBeInstanceOf(Error); + expect(failure!.message).toContain(M2); + expect(failure!.message).not.toContain(M); + expect(server.posted.map(body => body['object']).sort()).toEqual([E, M].sort()); + backendProcesses('leave', E); + backendProcesses('leave', M); + await until(() => !registration().direct && registration().meetingPointIds.length === 1, 'only M2 remaining'); + expect(registration()).toEqual({ direct: false, meetingPointIds: [M2], registered: true }); +}, 30000); + +test('leaving an event the user holds nothing of rejects and posts nothing', async () => { + await mount({ eventMembers: [] }); + + await expect(latest!.leaveEvent(E)).rejects.toThrow(); + + expect(server.posted).toEqual([]); +}, 30000); diff --git a/src/shared/context/refineDerive.test.ts b/src/shared/context/refineDerive.test.ts index 11009f9..99ef677 100644 --- a/src/shared/context/refineDerive.test.ts +++ b/src/shared/context/refineDerive.test.ts @@ -380,3 +380,70 @@ test('the re-read schedule is finite and increasing', () => { const previous = [0, ...MEMBERSHIP_REREAD_DELAYS_MS]; expect(MEMBERSHIP_REREAD_DELAYS_MS.every((delay, i) => delay > (previous[i] ?? delay))).toBe(true); }); + +const eventList = (items: unknown[]) => ({ id: `${EVENT}/attendees`, totalItems: items.length, items }); + +test("the event's own attendee list becomes participations keyed by the event", () => { + const aliceUrn = URN('a'.repeat(64)); + const derived = deriveContainer( + [{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) }], + bindings, + ); + expect(derived.participations).toEqual([{ id: `${EVENT}|${aliceUrn}`, eventId: EVENT, userId: aliceUrn, isConfirmed: true }]); + expect(derived.totals.get(EVENT)).toBe(1); +}); + +test("the event's count is the distinct URNs over its own list and its meeting points' lists", () => { + const mp2 = 'http://localhost:3000/alice/data/mp-2'; + const aliceUrn = URN('a'.repeat(64)); + const bobUrn = URN('b'.repeat(64)); + const carolUrn = URN('c'.repeat(64)); + const derived = deriveContainer( + [ + { id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, carolUrn]) }, + { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [aliceUrn, bobUrn] } }, + { id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [bobUrn] } }, + ], + bindings, + ); + expect(derived.events[0]?.participantCount).toBe(3); +}); + +test('a person signed up directly and in a meeting point counts once, at the event and in the meeting point', () => { + const aliceUrn = URN('a'.repeat(64)); + const bobUrn = URN('b'.repeat(64)); + const derived = deriveContainer( + [ + { id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, bobUrn]) }, + { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [aliceUrn] } }, + ], + bindings, + ); + expect(derived.events[0]?.participantCount).toBe(2); + expect(derived.meetingPoints[0]?.participantCount).toBe(1); +}); + +test("a direct sign-up counts at the event but in no meeting point", () => { + const aliceUrn = URN('a'.repeat(64)); + const derived = deriveContainer( + [ + { id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) }, + { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 0, items: [] } }, + ], + bindings, + ); + expect(derived.events[0]?.participantCount).toBe(1); + expect(derived.meetingPoints[0]?.participantCount).toBe(0); +}); + +test('a pending direct sign-up adds one to the event and shows a participation keyed by the event', () => { + const derived = deriveContainer( + [{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([]) }], + bindings, + ); + const pending: PendingActs = recordMembershipAct(new Map(), EVENT, 'join', 0, derived.totals.get(EVENT)); + const adjusted = countsWithOverlay(derived, pending, ALICE); + expect(adjusted.events[0]?.participantCount).toBe(1); + const overlaid = applyMembershipOverlay(derived.participations, overlayOfPendingActs(pending), ALICE); + expect(overlaid).toEqual([{ id: `${EVENT}|${ALICE}`, eventId: EVENT, userId: ALICE, isConfirmed: true }]); +}); diff --git a/src/shared/data/seedData.ts b/src/shared/data/seedData.ts index b262f58..c2bfa36 100644 --- a/src/shared/data/seedData.ts +++ b/src/shared/data/seedData.ts @@ -196,6 +196,9 @@ export const seedParticipations: FpParticipationData[] = [ { id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true }, // Thomas participates in event 1 { id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true }, + // Marie is a member of both meeting points of event 5, and not signed up to it directly + { id: 'part-6', eventId: 'mp-3', userId: 'user-1', isConfirmed: true }, + { id: 'part-7', eventId: 'mp-4', userId: 'user-1', isConfirmed: true }, ]; export const seedMeetingPoints: FpMeetingPointData[] = [ @@ -211,6 +214,20 @@ export const seedMeetingPoints: FpMeetingPointData[] = [ location: 'Station de métro Bellecour', time: '15h30', }, + { + id: 'mp-3', + eventId: 'event-5', + title: 'Covoiturage depuis la gare', + location: 'Gare Part-Dieu', + time: '18h00', + }, + { + id: 'mp-4', + eventId: 'event-5', + title: 'Départ à pied de la place', + location: 'Place Carnot', + time: '18h30', + }, ]; export const seedFriendships: FpFriendshipData[] = [ diff --git a/src/shared/support/world.ts b/src/shared/support/world.ts index 013573e..1cf8d63 100644 --- a/src/shared/support/world.ts +++ b/src/shared/support/world.ts @@ -19,6 +19,8 @@ export interface ApodsScenarioState { anchorUri?: string; meetingPointUri?: string; collectionUri?: string; + /** The `apods:attendees` collection of the anchor event itself (direct sign-ups). */ + eventCollectionUri?: string; } export interface FestipodWorld extends World {