Write the failing tests for direct sign-up to an event
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user