Write the failing tests for direct sign-up to an event

This commit is contained in:
Sylvain Duchesne
2026-11-06 22:57:46 +01:00
parent f45fc564ec
commit 4abab5a55a
11 changed files with 909 additions and 11 deletions
@@ -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
@@ -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 <visibilité>
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"
@@ -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<void>(resolve => setTimeout(resolve, ms));
async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, timeoutMs = 20000): Promise<T> {
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<string> {
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<string> {
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<string[]> {
return (await readAttendees(pod, collectionUri)).map(linkId).filter((id): id is string => typeof id === 'string');
}
async function participationsOf(pod: ApodsSession): Promise<string[]> {
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<void> {
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<void> {
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<boolean> {
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' });
});
@@ -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<void>(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);
});
@@ -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']);
});
});
+139 -3
View File
@@ -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<Response> {
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<typeof view>) => 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<void> {
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);
+67
View File
@@ -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 }]);
});
+17
View File
@@ -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[] = [
+2
View File
@@ -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 {