const { test, expect, describe } = require('bun:test'); const registration = require('./registration.service'); const { memberUrn } = require('./member-urn'); const { lastAttendanceQuery, isNewerAct, attendanceSubject } = require('./attendance-ledger'); const { hasOwnPublicRead, ownReaders } = require('./public-read-acl'); const { world, activity, meetingPoint, profile, profileOf, groupOf, contactsOf, attendeesOf, createdCollectionAcl, APP, ALICE, BOB, CAROL, MALLORY, EVENT1, EVENT2, SECRETS, FP_SECRET, FP_PARTICIPATIONS } = require('./participation-world'); // Alice created three meeting points: two on the festival (EVENT1), one on the concert (EVENT2). const MP1 = `${ALICE}/data/events/mp1`; const MP2 = `${ALICE}/data/events/mp2`; const MP3 = `${ALICE}/data/events/mp3`; const MEETING_POINTS = { [MP1]: meetingPoint(MP1), [MP2]: meetingPoint(MP2), [MP3]: meetingPoint(MP3, { context: EVENT2 }) }; const EVENT_OF = { [MP1]: EVENT1, [MP2]: EVENT1, [MP3]: EVENT2 }; const T0 = '2026-10-29T10:00:00.000Z'; const T1 = '2026-10-29T10:00:05.000Z'; const T2 = '2026-10-29T10:00:10.000Z'; const join = (mp, published = T0, who = BOB, n = 1) => activity('Join', who, mp, published, n); const leave = (mp, published = T1, who = BOB, n = 1) => activity('Leave', who, mp, published, n); const urnOf = (who, mp = MP1) => memberUrn({ participantUri: who, eventUri: EVENT_OF[mp], secret: SECRETS[who] }); /** The world with Alice's meeting points and the given stored activities. */ function setup({ acts = [], stored = {}, ...options } = {}) { return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options }); } const emitJoin = (w, act) => w.deliver('join', act); const emitLeave = (w, act) => w.deliver('leave', act); const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || []; const participationsUri = (w, who = BOB) => { const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS]; return link && link.id; }; const participations = (w, who = BOB) => w.state.items[participationsUri(w, who)] || []; const ledgerOf = (w, mp = MP1, who = BOB) => w.state.ledger[attendanceSubject(mp, who)]; const WRITES = [ 'attendees.add', 'attendees.remove', 'pod-collections.add', 'pod-collections.remove', 'pod-collections.createAndAttach', 'pod-permissions.add', 'signature.proxy.query', 'triplestore.update', 'pod-notifications.send' ]; const writes = calls => calls.filter(c => WRITES.includes(c.action)); /** An existing participations collection of `who`, linked from their profile, with the given ACL. */ function existingCollection(who, acl) { const uri = `${who}/data/as/collection/participations-existing`; return { uri, stored: { [profileOf(who)]: profile(who, { [FP_PARTICIPATIONS]: { id: uri } }) }, acls: { [uri]: acl }, items: { [uri]: [] } }; } /** The ACL of a collection readable by the participant's `/contacts` WAC group, the app keeping its Control. */ const protectedAcl = (uri, group = groupOf(BOB)) => [ { '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Read`, 'acl:mode': 'acl:Read', 'acl:agentGroup': group }, { '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP } ]; describe("registration — a Join delivered to the app's inbox", () => { test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => { const j = join(MP1); const w = setup({ acts: [j] }); await emitJoin(w, j); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(attendees(w)).not.toContain(BOB); // never the WebID const add = w.calls.find(c => c.action === 'attendees.add'); expect(add.params).toEqual({ collectionUri: attendeesOf(MP1), itemUri: urnOf(BOB), actorUri: ALICE }); expect(w.state.created).toHaveLength(1); expect(participations(w)).toEqual([MP1]); const added = w.calls.find(c => c.action === 'pod-collections.add'); expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: MP1, actorUri: BOB }); expect(ledgerOf(w)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) }); expect(w.state.notifications).toHaveLength(1); expect(w.logs.error).toEqual([]); }); test('the delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => { const j = join(MP1); const w = setup({ acts: [j] }); await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(attendees(w, MP2)).toEqual([]); expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined(); }); test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => { const j = join(MP1); const forged = { ...join(MP1, T0, MALLORY), actor: BOB }; const w = setup({ acts: [j, forged] }); await emitJoin(w, { ...j, actor: MALLORY }); await emitJoin(w, forged); expect(writes(w.calls)).toEqual([]); expect(attendees(w)).toEqual([]); }); test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => { // Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy // naming MP2's attendee list and a name of its choosing. const j = join(MP1); const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) }); const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } }); await emitJoin(w, j); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(attendees(w, MP2)).toEqual([]); expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']); expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true); }); test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => { const j = join(MP1); for (const options of [ { fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` }, { stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` }, { stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' } ]) { const { contains, stored = {}, ...rest } = options; const w = setup({ acts: [j], stored, ...rest }); await emitJoin(w, j); expect(writes(w.calls)).toEqual([]); expect(w.logs.warn.some(m => m.includes(contains))).toBe(true); } }); test("the act is handled on reception in the app's inbox only: no outbox handler", () => { expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin); expect(registration.activities.join.onEmit).toBeUndefined(); expect(registration.activities.leave.onEmit).toBeUndefined(); expect(typeof registration.activities.join.onReceive).toBe('function'); expect(typeof registration.activities.leave.onReceive).toBe('function'); }); }); describe('registration — refusals write nothing and record nothing', () => { const refused = async (w, act, { log = 'warn', contains } = {}) => { await emitJoin(w, act); expect(writes(w.calls)).toEqual([]); if (contains) expect(w.logs[log].some(m => m.includes(contains))).toBe(true); }; test('a profile without a participation secret', async () => { const j = join(MP1); await refused(setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_SECRET]: undefined }) } }), j, { contains: 'no usable participation secret' }); }); test('a malformed secret (uppercase, short, non-hex, several values)', async () => { for (const secret of [SECRETS[BOB].toUpperCase(), SECRETS[BOB].slice(2), 'z'.repeat(64), [SECRETS[BOB], SECRETS[CAROL]]]) { const j = join(MP1); await refused(setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_SECRET]: secret }) } }), j, { contains: 'no usable participation secret' }); } }); test('a stored object that is not an Event (a Note)', async () => { const j = join(MP1); await refused(setup({ acts: [j], stored: { [MP1]: { ...meetingPoint(MP1), type: 'Note' } } }), j, { contains: 'is not a Event' }); }); test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => { const j = join(MP1); await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' }); await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': undefined }) } }), j, { contains: 'dc:creator' }); }); test('an actor whose profile lies outside their pod', async () => { const j = join(MP1); const w = setup({ acts: [j], stored: { [BOB]: { id: BOB, type: 'Person', url: profileOf(CAROL), 'apods:contacts': `${BOB}/contacts` } } }); await refused(w, j, { contains: 'names no single profile in their pod' }); }); test('a profile linking a participations collection outside the participant\'s pod: ignored, the act refused', async () => { const j = join(MP1); const foreign = `${MALLORY}/data/as/collection/x`; const w = setup({ acts: [j], stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: { id: foreign } }) } }); await refused(w, j, { log: 'error', contains: 'NOT recorded' }); expect(w.state.items[foreign]).toBeUndefined(); }); test('an act without a pod stamp cannot be ordered', async () => { const j = { ...join(MP1), published: undefined }; await refused(setup({ acts: [j] }), j, { contains: 'no pod stamp' }); }); test('a Join the pod does not store (webhook only)', async () => { const j = join(MP1); await refused(setup(), j, { contains: 'ignored a Join' }); }); }); describe('registration — the member URN: one per person and event, computed by the backend', () => { test('the same person on two meeting points of one event → the same URN; on another event → another', async () => { const acts = [join(MP1), join(MP2), join(MP3)]; const w = setup({ acts }); for (const a of acts) await emitJoin(w, a); expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]); expect(attendees(w, MP2)).toEqual(attendees(w, MP1)); expect(attendees(w, MP3)).toEqual([urnOf(BOB, MP3)]); expect(attendees(w, MP3)).not.toEqual(attendees(w, MP1)); expect(participations(w).sort()).toEqual([MP1, MP2, MP3].sort()); }); test('two people on one meeting point → two URNs, each derived from its own WebID and secret', async () => { const acts = [join(MP1), join(MP1, T0, CAROL)]; const w = setup({ acts }); for (const a of acts) await emitJoin(w, a); expect(attendees(w).sort()).toEqual([urnOf(BOB), urnOf(CAROL)].sort()); expect(urnOf(BOB)).not.toBe(urnOf(CAROL)); }); }); describe('registration — the participations collection', () => { test('created ONCE under two concurrent Joins of one participant (two meeting points, two locks)', async () => { const acts = [join(MP1), join(MP2)]; const w = setup({ acts, delays: { create: 30 } }); await Promise.all(acts.map(a => emitJoin(w, a))); expect(w.state.created).toHaveLength(1); expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toEqual({ id: w.state.created[0] }); expect(participations(w).sort()).toEqual([MP1, MP2].sort()); }); test('on creation: the pod\'s public read is removed, then the /contacts group granted read — before the meeting point is added', async () => { const j = join(MP1); const w = setup({ acts: [j] }); await emitJoin(w, j); const acl = w.state.acls[participationsUri(w)]; expect(hasOwnPublicRead(acl)).toBe(false); expect(ownReaders(acl)).toEqual({ agent: [], agentClass: [], agentGroup: [groupOf(BOB)] }); const order = w.calls.map(c => c.action).filter(a => ['pod-permissions.add', 'signature.proxy.query', 'pod-collections.add'].includes(a)); expect(order).toEqual(['signature.proxy.query', 'pod-permissions.add', 'pod-collections.add']); const grant = w.calls.find(c => c.action === 'pod-permissions.add'); expect(grant.params).toMatchObject({ agentPredicate: 'acl:agentGroup', agentUri: groupOf(BOB), mode: 'acl:Read', actorUri: BOB }); }); test('an existing, already protected collection is reused: no creation, no ACL write', async () => { const existing = existingCollection(BOB, null); existing.acls[existing.uri] = protectedAcl(existing.uri); const j = join(MP1); const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items }); await emitJoin(w, j); expect(w.calls.some(c => c.action === 'pod-collections.createAndAttach')).toBe(false); expect(w.calls.some(c => c.action === 'pod-permissions.add' || c.action === 'signature.proxy.query')).toBe(false); expect(w.state.items[existing.uri]).toEqual([MP1]); }); test('an existing collection still world-readable and not shared with the contacts is fixed at the Join', async () => { const existing = existingCollection(BOB, null); existing.acls[existing.uri] = createdCollectionAcl(existing.uri); const j = join(MP1); const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items }); await emitJoin(w, j); expect(w.calls.some(c => c.action === 'pod-collections.createAndAttach')).toBe(false); expect(hasOwnPublicRead(w.state.acls[existing.uri])).toBe(false); expect(w.state.items[existing.uri]).toEqual([MP1]); }); test('a public read the pod refuses to remove: nothing written, NOT recorded, loudly logged — the next act retries', async () => { const j = join(MP1); const w = setup({ acts: [j], fail: { putStatus: 403 } }); await emitJoin(w, j); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); expect(w.calls.some(c => c.action === 'pod-collections.add')).toBe(false); expect(ledgerOf(w)).toBeUndefined(); expect(w.state.notifications).toEqual([]); expect(w.logs.error.some(m => m.includes('public read') && m.includes('NOT recorded'))).toBe(true); // The pod accepts the change again: the same Join, notified again, is applied in full. const retry = world({ stored: { ...w.state.stored }, items: w.state.items, acls: w.state.acls }); await emitJoin(retry, j); expect(attendees(retry)).toEqual([urnOf(BOB)]); expect(hasOwnPublicRead(retry.state.acls[participationsUri(retry)])).toBe(false); expect(participations(retry)).toEqual([MP1]); expect(ledgerOf(retry)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) }); }); test('a public read INHERITED from the container cannot be removed here: the act is not recorded', async () => { const existing = existingCollection(BOB, null); existing.acls[existing.uri] = [ ...protectedAcl(existing.uri), { '@id': `http://localhost:3000/_acl/bob/data/as/collection#DefaultRead`, 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' } ]; const j = join(MP1); const w = setup({ acts: [j], stored: existing.stored, acls: existing.acls, items: existing.items }); await emitJoin(w, j); expect(w.state.items[existing.uri]).toEqual([]); expect(ledgerOf(w)).toBeUndefined(); expect(w.logs.error.some(m => m.includes('inherited'))).toBe(true); }); test('a refused group grant or an unreadable ACL: the act is not recorded', async () => { for (const fail of [{ grant: true }, { aclRead: true }]) { const j = join(MP1); const w = setup({ acts: [j], fail }); await emitJoin(w, j); expect(attendees(w)).toEqual([]); expect(ledgerOf(w)).toBeUndefined(); expect(w.state.notifications).toEqual([]); } }); test('a participations write the pod refuses (the call resolves) leaves the act unrecorded (member sticky) and unnotified', async () => { const j = join(MP1); const w = setup({ acts: [j], fail: { participationsWrite: true } }); await emitJoin(w, j); expect(w.state.refusedWrites.map(r => r.action)).toEqual(['pod-collections.add']); // The act (its URI/stamp/kind) is unrecorded, but the member URN was persisted before the writes: // the entry carries only that sticky member, so the next act reuses it instead of a second URN. expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) }); expect(w.state.notifications).toEqual([]); expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes(`does not hold ${MP1}`))).toBe(true); // The accepted residual: the list in the creator's pod was written; the next act re-applies both. expect(attendees(w)).toEqual([urnOf(BOB)]); const retry = world({ stored: { ...w.state.stored }, items: w.state.items, acls: w.state.acls }); await emitJoin(retry, j); expect(participations(retry)).toEqual([MP1]); expect(ledgerOf(retry).change).toBe('join'); expect(retry.state.notifications).toHaveLength(1); // the state the ledger held flips now }); test('a Leave removes the meeting point from the participant\'s collection', async () => { const j = join(MP1); const l = leave(MP1); const w = setup({ acts: [j, l] }); await emitJoin(w, j); await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); expect(ledgerOf(w).change).toBe('leave'); const removed = w.calls.find(c => c.action === 'pod-collections.remove'); expect(removed.params).toEqual({ collectionUri: participationsUri(w), itemUri: MP1, actorUri: BOB }); }); test('a Leave when the participant has no collection: the list is updated, nothing created, the act recorded', async () => { const l = leave(MP1); const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] } }); await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(w.calls.some(c => ['pod-collections.createAndAttach', 'pod-collections.remove', 'pod-permissions.add'].includes(c.action))).toBe(false); expect(ledgerOf(w).change).toBe('leave'); }); }); describe('registration — a pod failure the platform hides is never counted as applied', () => { // The platform's `pod-collections.getItems` answers [] on a failed read, and its add/remove resolve // when the pod refuses the PATCH: the backend reads with the status checked and reads every write back. const listed = (w, mp = MP1) => ({ [attendanceSubject(mp, BOB)]: { activityUri: join(mp, '2026-10-29T09:00:00.000Z', BOB, 0).id, published: '2026-10-29T09:00:00.000Z', change: 'join', member: urnOf(BOB, mp) } }); const notRecorded = (w, contains) => expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes(contains))).toBe(true); test("a Leave while the creator's pod fails the list read: NOT recorded, the URN stays, nobody notified — the same Leave, notified again, applies it", async () => { const l = leave(MP1); const existing = existingCollection(BOB, protectedAcl(`${BOB}/data/as/collection/participations-existing`)); existing.items[existing.uri] = [MP1]; const fail = { read: { [attendeesOf(MP1)]: 500 } }; const w = setup({ acts: [l], stored: existing.stored, acls: existing.acls, items: { ...existing.items, [attendeesOf(MP1)]: [urnOf(BOB)] }, ledger: listed(), fail }); // What the platform's own read would have said: nobody listed. expect(await w.ctx.call('pod-collections.getItems', { collectionUri: attendeesOf(MP1), actorUri: ALICE })).toEqual([]); await emitLeave(w, l); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(w.state.items[existing.uri]).toEqual([MP1]); // nothing written to either collection expect(writes(w.calls)).toEqual([]); expect(ledgerOf(w).change).toBe('join'); expect(w.state.notifications).toEqual([]); notRecorded(w, `${attendeesOf(MP1)} (HTTP 500)`); delete fail.read; await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(w.state.items[existing.uri]).toEqual([]); expect(ledgerOf(w)).toMatchObject({ activityUri: l.id, change: 'leave' }); expect(w.state.notifications).toHaveLength(1); }); test('a Join whose list write the pod refuses: NOT recorded, nothing notified, the meeting point kept out of the participant\'s collection — the same Join, notified again, applies it', async () => { const j = join(MP1); const fail = { attendeesWrite: true }; const w = setup({ acts: [j], fail }); await emitJoin(w, j); expect(w.state.refusedWrites.map(r => r.action)).toEqual(['attendees.add']); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); expect(w.calls.some(c => c.action === 'pod-collections.add')).toBe(false); expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) }); // the sticky member only expect(w.state.notifications).toEqual([]); notRecorded(w, `does not hold ${urnOf(BOB)}`); fail.attendeesWrite = false; await emitJoin(w, j); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(participations(w)).toEqual([MP1]); expect(ledgerOf(w)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: urnOf(BOB) }); expect(w.state.notifications).toHaveLength(1); }); test('a Leave whose list removal the pod refuses: NOT recorded, the URN stays, nobody notified', async () => { const l = leave(MP1); const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] }, ledger: listed(), fail: { attendeesWrite: true } }); await emitLeave(w, l); expect(w.state.refusedWrites.map(r => r.action)).toEqual(['attendees.remove']); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(ledgerOf(w).change).toBe('join'); expect(w.state.notifications).toEqual([]); notRecorded(w, `still holds ${urnOf(BOB)}`); }); test('a Leave whose participations removal the pod refuses: NOT recorded, not notified; the list removal stands (the accepted residual) and the next Leave completes it', async () => { const j = join(MP1); const l = leave(MP1); const fail = {}; const w = setup({ acts: [j, l], fail }); await emitJoin(w, j); fail.participationsWrite = true; await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([MP1]); expect(ledgerOf(w).change).toBe('join'); expect(w.state.notifications).toHaveLength(1); // the Join's only notRecorded(w, `still holds ${MP1}`); fail.participationsWrite = false; await emitLeave(w, l); expect(participations(w)).toEqual([]); expect(ledgerOf(w).change).toBe('leave'); expect(w.state.notifications).toHaveLength(2); // the state the ledger held flips now }); test('a write that cannot be read back is not counted: NOT recorded, not notified', async () => { const j = join(MP1); const w = setup({ acts: [j], fail: { read: { [`${BOB}/data/as/collection/participations-1`]: 403 } } }); await emitJoin(w, j); expect(participations(w)).toEqual([MP1]); // the write landed, but nothing could show it expect(ledgerOf(w)).toEqual({ member: urnOf(BOB) }); expect(w.state.notifications).toEqual([]); notRecorded(w, 'participations-1 (HTTP 403)'); }); test('a collection the pod creates but does not link to the profile is never used: nothing written, nothing recorded — the next Join links a new one', async () => { const j = join(MP1); const fail = { attach: true }; const w = setup({ acts: [j], fail }); await emitJoin(w, j); expect(w.state.created).toHaveLength(1); expect(participationsUri(w)).toBeUndefined(); expect(w.state.items[w.state.created[0]]).toEqual([]); expect(attendees(w)).toEqual([]); expect(w.calls.some(c => ['attendees.add', 'pod-collections.add', 'pod-permissions.add', 'signature.proxy.query'].includes(c.action))).toBe(false); expect(ledgerOf(w)).toBeUndefined(); expect(w.state.notifications).toEqual([]); expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes('did not link it'))).toBe(true); fail.attach = false; await emitJoin(w, j); expect(w.state.created).toHaveLength(2); expect(participationsUri(w)).toBe(w.state.created[1]); expect(participations(w)).toEqual([MP1]); expect(w.state.items[w.state.created[0]]).toEqual([]); // the unlinked one stays unused expect(attendees(w)).toEqual([urnOf(BOB)]); expect(ledgerOf(w).change).toBe('join'); }); }); describe("registration — attendance follows the participant's LATEST act, whatever order notifications run in", () => { test('a stale act (the ledger holds a newer Leave) changes nothing and notifies no one', async () => { const j = join(MP1); const l = leave(MP1); const w = setup({ acts: [j, l], ledger: { [attendanceSubject(MP1, BOB)]: { activityUri: l.id, published: T1, change: 'leave', member: urnOf(BOB) } } }); await emitJoin(w, j); expect(writes(w.calls)).toEqual([]); expect(w.logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true); }); test('the same Join notified twice (in turn, then concurrently): applied once, notified once', async () => { const j = join(MP1); let w = setup({ acts: [j] }); await emitJoin(w, j); await emitJoin(w, j); expect(w.calls.filter(c => c.action === 'attendees.add')).toHaveLength(1); expect(w.state.notifications).toHaveLength(1); w = setup({ acts: [j] }); await Promise.all([emitJoin(w, j), emitJoin(w, j)]); expect(w.calls.filter(c => c.action === 'attendees.add')).toHaveLength(1); expect(w.state.notifications).toHaveLength(1); }); test('join then leave, the Leave handled first: ends NOT attending, out of the collection', async () => { const j = join(MP1); const l = leave(MP1); const w = setup({ acts: [j, l], delays: { [`read:${j.id}`]: 40 } }); const joining = emitJoin(w, j); await new Promise(resolve => setTimeout(resolve, 5)); await Promise.all([joining, emitLeave(w, l)]); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); expect(ledgerOf(w).change).toBe('leave'); }); test('leave then join, the Join handled first: ends attending, in the collection', async () => { const l = leave(MP1, T0); const j = join(MP1, T1); const w = setup({ acts: [j, l], delays: { [`read:${l.id}`]: 60 } }); const leaving = emitLeave(w, l); await new Promise(resolve => setTimeout(resolve, 5)); await Promise.all([leaving, emitJoin(w, j)]); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(participations(w)).toEqual([MP1]); expect(ledgerOf(w).change).toBe('join'); }); test('a newer Join while already attending is recorded but notifies no one; so is a Leave of someone who never joined', async () => { const j1 = join(MP1, T0); const j2 = join(MP1, T1, BOB, 2); let w = setup({ acts: [j1, j2] }); await emitJoin(w, j1); await emitJoin(w, j2); expect(ledgerOf(w).activityUri).toBe(j2.id); expect(w.state.notifications).toHaveLength(1); const l = leave(MP1); w = setup({ acts: [l] }); await emitLeave(w, l); expect(ledgerOf(w).change).toBe('leave'); expect(w.state.notifications).toEqual([]); }); }); describe('registration — the creator is notified ALWAYS anonymously (the contacts list is forgeable)', () => { const notified = async ({ change = 'join', fail } = {}) => { const j = join(MP1); const l = leave(MP1); const w = setup({ acts: [j, l], fail }); await emitJoin(w, j); if (change === 'leave') await emitLeave(w, l); return w; }; test('a Join: anonymous — no name, no WebID, no activity; the participant\'s contacts list is never read', async () => { const w = await notified(); const [n] = w.state.notifications; expect(n.recipientUri).toBe(ALICE); // Alice is the creator; Bob the participant expect(n.template.title.fr).toContain('Un participant a rejoint'); expect(n.template.title.en).toContain('A participant joined'); expect(JSON.stringify(n.template)).not.toContain('participantName'); expect(n).not.toHaveProperty('participantName'); expect(n).not.toHaveProperty('activity'); expect(JSON.stringify(n)).not.toContain(BOB); // never the participant's WebID // The only read of a /contacts collection a naming notification would have made is gone. const readsOf = uri => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri); expect(readsOf(contactsOf(BOB))).toEqual([]); }); test('a Leave: the withdrawal wording, also anonymous', async () => { const w = await notified({ change: 'leave' }); const n = w.state.notifications[1]; expect(n.template.title.fr).toContain('Un participant a quitté'); expect(n.template.title.en).toContain('A participant left'); expect(n).not.toHaveProperty('participantName'); expect(n).not.toHaveProperty('activity'); }); test('the meeting point is named through template parameters; the link opens its event\'s meeting points', async () => { const w = await notified(); const [n] = w.state.notifications; expect(n.meetingPointName).toBe('Rendez-vous mp1'); expect(n.eventUri).toBe(EVENT1); expect(n.context).toBe(MP1); expect(n.template.title.fr).toContain('{{{meetingPointName}}}'); expect(n.template.title.en).toContain('{{{meetingPointName}}}'); expect(n.template.actions).toEqual([{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]); }); test('a notification failure does not fail the act', async () => { const w = await notified({ fail: { notify: true } }); expect(ledgerOf(w).change).toBe('join'); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(w.logs.error.some(m => m.includes('Could not notify'))).toBe(true); }); test('a creator joining their own meeting point is not notified', async () => { const j = join(MP1, T0, ALICE); const w = setup({ acts: [j] }); await emitJoin(w, j); expect(attendees(w)).toEqual([urnOf(ALICE)]); expect(w.state.notifications).toEqual([]); }); }); describe('registration — a write in flight at the timeout (no re-alignment: decided limit)', () => { // Bob joined then left (Leave L0 recorded). He acts once more; that act's write is sent before the // timeout and lands after it; nothing follows. The late write is the latest act: it must stand. const L0 = leave(MP1, '2026-10-29T09:00:00.000Z', BOB, 0); const J0 = join(MP1, '2026-10-29T09:00:00.000Z', BOB, 0); const subject = () => attendanceSubject(MP1, BOB); test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => { const j = join(MP1); const w = setup({ acts: [j, L0], delays: { 'attendees.add': 150 }, timeoutMs: 80, ledger: { [subject()]: { activityUri: L0.id, published: L0.published, change: 'leave', member: urnOf(BOB) } } }); await emitJoin(w, j); await new Promise(resolve => setTimeout(resolve, 300)); expect(attendees(w)).toEqual([urnOf(BOB)]); }); test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => { const l = leave(MP1); const w = setup({ acts: [l, J0], items: { [attendeesOf(MP1)]: [urnOf(BOB)] }, delays: { 'attendees.remove': 150 }, timeoutMs: 80, ledger: { [subject()]: { activityUri: J0.id, published: J0.published, change: 'join', member: urnOf(BOB) } } }); await emitLeave(w, l); await new Promise(resolve => setTimeout(resolve, 300)); expect(attendees(w)).toEqual([]); }); test('KNOWN LIMIT (documented in updateAttendance): an older Join whose add lands after a newer Leave stays applied until the next act', async () => { const j = join(MP1); const l = leave(MP1); const w = setup({ acts: [j, l], delays: { 'attendees.add': 150 }, timeoutMs: 80 }); const joining = emitJoin(w, j); await new Promise(resolve => setTimeout(resolve, 10)); await Promise.all([joining, emitLeave(w, l)]); await new Promise(resolve => setTimeout(resolve, 300)); expect(ledgerOf(w).change).toBe('leave'); // the newer act is the recorded one expect(attendees(w)).toEqual([urnOf(BOB)]); // the late add of the older Join stands — no re-alignment }); }); describe('registration — a participant who rewrites their profile secret between acts stays ONE member', () => { const rotate = (w, who, secret) => { w.state.stored[profileOf(who)] = profile(who, { [FP_SECRET]: secret }); }; const urnWith = (who, secret, mp = MP1) => memberUrn({ participantUri: who, eventUri: EVENT_OF[mp], secret }); const secretN = n => String(n).repeat(64); test('Join, rotate, Join: one URN in the list; then Leave empties it', async () => { const acts = [1, 2, 3, 4, 5].map(n => join(MP1, `2026-10-29T10:00:0${n}.000Z`, BOB, n)); const l = leave(MP1, '2026-10-29T10:00:09.000Z', BOB, 9); const w = setup({ acts: [...acts, l] }); for (const [i, a] of acts.entries()) { rotate(w, BOB, secretN(i + 1)); await emitJoin(w, a); } expect(attendees(w)).toEqual([urnWith(BOB, secretN(1))]); expect(w.state.notifications).toHaveLength(1); expect(ledgerOf(w)).toEqual({ activityUri: acts[4].id, published: acts[4].published, change: 'join', member: urnWith(BOB, secretN(1)) }); rotate(w, BOB, secretN(6)); await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); expect(ledgerOf(w)).toEqual({ activityUri: l.id, published: l.published, change: 'leave', member: urnWith(BOB, secretN(1)) }); }); test('Join, rotate, Leave, Join: still the FIRST URN — the member is sticky per pair, never recomputed', async () => { const j1 = join(MP1, T0, BOB, 1); const l = leave(MP1, T1, BOB, 1); const j2 = join(MP1, T2, BOB, 2); const w = setup({ acts: [j1, l, j2] }); await emitJoin(w, j1); rotate(w, BOB, secretN(7)); await emitLeave(w, l); expect(attendees(w)).toEqual([]); await emitJoin(w, j2); // The ledger kept the URN chosen at the first Join; the rotated secret is never hashed again. expect(attendees(w)).toEqual([urnOf(BOB)]); expect(attendees(w)).not.toContain(urnWith(BOB, secretN(7))); }); // THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left // unrecorded; the participant rewrites their secret and joins again — that must not list a second URN. const deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP); test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => { const j1 = join(MP1, T0, BOB, 1); const j2 = join(MP1, T1, BOB, 2); const l = leave(MP1, T2, BOB, 3); const fail = { participationsWrite: true }; const w = setup({ acts: [j1, j2, l], fail }); await emitJoin(w, j1); expect(attendees(w)).toEqual([urnOf(BOB)]); // the add landed… expect(ledgerOf(w).activityUri).toBeUndefined(); // …the act did not get recorded fail.participationsWrite = false; rotate(w, BOB, secretN(4)); await emitJoin(w, j2); expect(attendees(w)).toEqual([urnOf(BOB)]); // ONE URN — not a second one from the new secret expect(ledgerOf(w)).toEqual({ activityUri: j2.id, published: T1, change: 'join', member: urnOf(BOB) }); await emitLeave(w, l); expect(attendees(w)).toEqual([]); expect(participations(w)).toEqual([]); }); test('partial failure at the ledger record (after both writes) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => { const j1 = join(MP1, T0, BOB, 1); const j2 = join(MP1, T1, BOB, 2); const l = leave(MP1, T2, BOB, 3); const w = setup({ acts: [j1, j2, l] }); let failRecord = true; const ctx = { call: async (action, params) => { if (action === 'triplestore.update' && failRecord && params.query.includes('attendanceAct')) { failRecord = false; throw new Error('the ledger record failed'); } return w.ctx.call(action, params); } }; await deliverWith(w, ctx, 'join', j1); expect(attendees(w)).toEqual([urnOf(BOB)]); expect(ledgerOf(w).activityUri).toBeUndefined(); rotate(w, BOB, secretN(5)); await deliverWith(w, ctx, 'join', j2); expect(attendees(w)).toEqual([urnOf(BOB)]); await deliverWith(w, ctx, 'leave', l); expect(attendees(w)).toEqual([]); }); test('a member persist still in flight at the timeout + secret rewrite + Join: the late write replaces nothing — ONE URN; a Leave empties the list', async () => { const j1 = join(MP1, T0, BOB, 1); const j2 = join(MP1, T1, BOB, 2); const l = leave(MP1, T2, BOB, 3); const w = setup({ acts: [j1, j2, l], timeoutMs: 80 }); let slowOnce = true; const ctx = { call: async (action, params) => { if (action === 'triplestore.update' && slowOnce && !params.query.includes('attendanceAct')) { slowOnce = false; await new Promise(resolve => setTimeout(resolve, 150)); // lands after the 80 ms limit } return w.ctx.call(action, params); } }; const first = deliverWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight await new Promise(resolve => setTimeout(resolve, 100)); rotate(w, BOB, secretN(6)); await deliverWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN await first; await new Promise(resolve => setTimeout(resolve, 150)); expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]); expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op await deliverWith(w, ctx, 'leave', l); expect(attendees(w)).toEqual([]); }); test('a Join of an attending participant re-adds the recorded URN when it is missing from the list', async () => { const j1 = join(MP1, T0, BOB, 1); const j2 = join(MP1, T1, BOB, 2); const w = setup({ acts: [j1, j2] }); await emitJoin(w, j1); w.state.items[attendeesOf(MP1)] = []; rotate(w, BOB, secretN(8)); await emitJoin(w, j2); expect(attendees(w)).toEqual([urnOf(BOB)]); }); test('a Leave with no Join recorded computes the URN from the current secret', async () => { const l = leave(MP1); const w = setup({ acts: [l], items: { [attendeesOf(MP1)]: [urnOf(BOB)] } }); await emitLeave(w, l); expect(attendees(w)).toEqual([]); }); test('two participants never share an entry', async () => { const jb = join(MP1, T0, BOB); const jc = join(MP1, T0, CAROL); const w = setup({ acts: [jb, jc] }); await emitJoin(w, jb); await emitJoin(w, jc); expect(attendanceSubject(MP1, BOB)).not.toBe(attendanceSubject(MP1, CAROL)); expect(ledgerOf(w, MP1, BOB).member).toBe(urnOf(BOB)); expect(ledgerOf(w, MP1, CAROL).member).toBe(urnOf(CAROL)); }); test('an entry under the old (meeting point, URN) key is not found: nothing recorded', async () => { const j = join(MP1); const old = attendanceSubject(MP1, urnOf(BOB)); const w = setup({ acts: [j], ledger: { [old]: { activityUri: `${BOB}/zz`, published: '2030-01-01T00:00:00.000Z', change: 'leave', member: urnOf(BOB) } } }); await emitJoin(w, j); expect(attendees(w)).toEqual([urnOf(BOB)]); }); }); describe('registration — matchers never throw', () => { test('routing is type-only: the object is never fetched; a garbage activity reports no match', async () => { const logs = { error: [] }; const svc = { name: 'registration', logger: { error: m => logs.error.push(m) } }; const failing = async () => { throw new Error('proxy unreachable'); }; for (const [key, type] of [['join', 'Join'], ['leave', 'Leave']]) { const routed = await registration.activities[key].match.call(svc, { type, object: MP1 }, failing); expect(routed.match).toBe(true); } const unmatched = await registration.activities.join.match.call(svc, { type: 'Create', object: MP1 }, failing); expect(unmatched.match).toBe(false); expect(logs.error).toHaveLength(0); }); }); describe('attendance-ledger — the order of acts', () => { const at = (published, activityUri = join(MP1).id) => ({ activityUri, published }); test('a later stamp is newer; an earlier one is not; a tie is broken by the activity URI', () => { expect(isNewerAct(at('2026-10-13T10:00:01Z'), at('2026-10-13T10:00:00Z'))).toBe(true); expect(isNewerAct(at('2026-10-13T09:59:59Z'), at('2026-10-13T10:00:00Z'))).toBe(false); expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/b`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(true); expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/a`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(false); // the same act twice }); test('the first act is always newer; an unstamped act never is', () => { expect(isNewerAct(at('2026-10-13T10:00:00Z'), undefined)).toBe(true); expect(isNewerAct(at(undefined), undefined)).toBe(false); expect(isNewerAct(at('not a date'), undefined)).toBe(false); }); test('one entry per (meeting point, member URN), under a URN the store accepts', () => { expect(attendanceSubject(MP1, BOB)).not.toBe(attendanceSubject(MP1, CAROL)); expect(attendanceSubject(MP1, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/); expect(() => lastAttendanceQuery(MP1, BOB)).not.toThrow(); }); }); describe('registration — a direct sign-up: a Join naming an event itself', () => { const DAVE = ALICE.replace('/alice', '/dave'); const PLAIN = `${ALICE}/data/events/plain`; const plainEvent = (uri, extra = {}) => meetingPoint(uri, { context: undefined, name: `Evénement ${uri.split('/').pop()}`, ...extra }); const plainWorld = (acts, options = {}) => setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) }, ...options }); const plainUrn = (who = BOB, event = PLAIN) => memberUrn({ participantUri: who, eventUri: event, secret: SECRETS[who] }); test("the member URN, computed with the event as anchor, joins the event's own list and the event joins the participant's collection", async () => { const j = join(PLAIN); const w = plainWorld([j]); await emitJoin(w, j); expect(attendees(w, PLAIN)).toEqual([plainUrn()]); expect(attendees(w, PLAIN)).not.toContain(BOB); const add = w.calls.find(c => c.action === 'attendees.add'); expect(add.params).toEqual({ collectionUri: attendeesOf(PLAIN), itemUri: plainUrn(), actorUri: ALICE }); expect(participations(w)).toEqual([PLAIN]); const added = w.calls.find(c => c.action === 'pod-collections.add'); expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: PLAIN, actorUri: BOB }); expect(ledgerOf(w, PLAIN)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: plainUrn() }); expect(w.logs.error).toEqual([]); }); const readsOf = (w, uri) => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri); const expectAnonymous = (w, n) => { expect(n.recipientUri).toBe(ALICE); expect(JSON.stringify(n.template)).not.toContain('participantName'); expect(n).not.toHaveProperty('participantName'); expect(n).not.toHaveProperty('activity'); expect(JSON.stringify(n)).not.toContain(BOB); expect(readsOf(w, contactsOf(BOB))).toEqual([]); }; test("a direct sign-up notifies the event's declarer, anonymously", async () => { const j = join(PLAIN); const w = plainWorld([j]); await emitJoin(w, j); expect(attendees(w, PLAIN)).toEqual([plainUrn()]); expect(w.state.notifications).toHaveLength(1); const [n] = w.state.notifications; expectAnonymous(w, n); expect(n.template.title.fr).toContain('Un participant a rejoint'); expect(n.template.title.en).toContain('A participant joined'); expect(n.eventUri).toBe(PLAIN); expect(Object.values(n)).toContain('Evénement plain'); expect(JSON.stringify(n.template)).not.toContain('Evénement plain'); expect(n.template.title.fr).toContain('{{{'); expect(n.template.actions).toHaveLength(1); expect(n.template.actions[0].link).toContain('/events/{{encodeUri eventUri}}'); expect(n.template.actions[0].link).not.toContain('meeting-points'); }); test('a Leave naming the event notifies the declarer, anonymously', async () => { const j = join(PLAIN); const l = leave(PLAIN); const w = plainWorld([j, l]); await emitJoin(w, j); await emitLeave(w, l); expect(w.state.notifications).toHaveLength(2); const n = w.state.notifications[1]; expectAnonymous(w, n); expect(n.template.title.fr).toContain('Un participant a quitté'); expect(n.template.title.en).toContain('A participant left'); expect(n.eventUri).toBe(PLAIN); }); test('a repeated direct Join notifies once; a Leave of someone never signed up notifies no one', async () => { const first = join(PLAIN, T0, BOB, 1); const again = join(PLAIN, T1, BOB, 2); const stray = leave(PLAIN, T1, CAROL, 1); const w = plainWorld([first, again, stray]); await emitJoin(w, first); await emitJoin(w, again); expect(w.state.notifications).toHaveLength(1); await emitLeave(w, stray); expect(w.state.notifications).toHaveLength(1); }); test('a failed declarer notification does not fail the direct sign-up', async () => { const j = join(PLAIN); const w = plainWorld([j], { fail: { notify: true } }); await emitJoin(w, j); expect(ledgerOf(w, PLAIN).change).toBe('join'); expect(attendees(w, PLAIN)).toEqual([plainUrn()]); expect(participations(w)).toEqual([PLAIN]); expect(w.logs.error.some(m => m.includes('Could not notify'))).toBe(true); }); test('leaving the event and its meeting point notifies the declarer and the host, each once', async () => { const DAVE_EVENT = `${DAVE}/data/events/festival`; const acts = [join(DAVE_EVENT), join(MP1), leave(DAVE_EVENT, T1), leave(MP1, T1)]; const w = setup({ acts, stored: { [DAVE_EVENT]: plainEvent(DAVE_EVENT, { 'dc:creator': DAVE }), [MP1]: meetingPoint(MP1, { context: DAVE_EVENT }) } }); await emitJoin(w, acts[0]); await emitJoin(w, acts[1]); w.state.notifications.length = 0; await emitLeave(w, acts[2]); await emitLeave(w, acts[3]); expect(w.state.notifications).toHaveLength(2); expect(w.state.notifications.map(n => n.recipientUri).sort()).toEqual([ALICE, DAVE].sort()); for (const n of w.state.notifications) { expect(n.template.title.fr).toContain('Un participant a quitté'); expect(n).not.toHaveProperty('participantName'); expect(JSON.stringify(n)).not.toContain(BOB); } }); test('the same person carries a different URN on another event (R7e)', async () => { const OTHER = `${ALICE}/data/events/other`; const a = join(PLAIN, T0, BOB, 1); const b = join(OTHER, T0, BOB, 2); const w = setup({ acts: [a, b], stored: { [PLAIN]: plainEvent(PLAIN), [OTHER]: plainEvent(OTHER) } }); await emitJoin(w, a); await emitJoin(w, b); expect(attendees(w, PLAIN)).toEqual([plainUrn(BOB, PLAIN)]); expect(attendees(w, OTHER)).toEqual([plainUrn(BOB, OTHER)]); expect(attendees(w, PLAIN)[0]).not.toBe(attendees(w, OTHER)[0]); }); test("the participant carries the SAME URN in the event's own list and in its meeting points' lists (R5, R6)", async () => { const festival = plainEvent(EVENT1, { 'dc:creator': DAVE }); const acts = [join(EVENT1), join(MP1), join(MP2)]; const w = setup({ acts, stored: { [EVENT1]: festival } }); for (const a of acts) await emitJoin(w, a); expect(attendees(w, EVENT1)).toEqual([urnOf(BOB, MP1)]); expect(attendees(w, MP1)).toEqual(attendees(w, EVENT1)); expect(attendees(w, MP2)).toEqual(attendees(w, EVENT1)); expect(participations(w).sort()).toEqual([EVENT1, MP1, MP2].sort()); }); test('signing up twice leaves one entry in the list and one in the collection (R1)', async () => { const first = join(PLAIN, T0, BOB, 1); const again = join(PLAIN, T1, BOB, 2); const w = plainWorld([first, again]); await emitJoin(w, first); await emitJoin(w, again); expect(attendees(w, PLAIN)).toEqual([plainUrn()]); expect(participations(w)).toEqual([PLAIN]); expect(w.logs.error).toEqual([]); }); test("a Leave naming the event removes the URN from its list and the event from the participant's collection (R8)", async () => { const j = join(PLAIN); const l = leave(PLAIN); const w = plainWorld([j, l]); await emitJoin(w, j); await emitLeave(w, l); expect(attendees(w, PLAIN)).toEqual([]); expect(participations(w)).toEqual([]); expect(ledgerOf(w, PLAIN).change).toBe('leave'); }); test("leaving the event leaves the participant's meeting point memberships alone, and the other way round", async () => { const acts = [join(PLAIN), join(MP1), leave(PLAIN, T1)]; const w = setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) } }); await emitJoin(w, acts[0]); await emitJoin(w, acts[1]); expect(participations(w).sort()).toEqual([PLAIN, MP1].sort()); await emitLeave(w, acts[2]); expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]); expect(participations(w)).toEqual([MP1]); }); test('a stored object that is not an Event stays refused', async () => { const j = join(PLAIN); const w = plainWorld([j], { stored: { [PLAIN]: { ...plainEvent(PLAIN), type: 'Note' } } }); await emitJoin(w, j); expect(writes(w.calls)).toEqual([]); expect(w.logs.warn.length).toBeGreaterThan(0); }); test("an event not in the pod of its dc:creator, or naming no creator, stays refused", async () => { const j = join(PLAIN); for (const extra of [{ 'dc:creator': MALLORY }, { 'dc:creator': undefined }]) { const w = plainWorld([j], { stored: { [PLAIN]: plainEvent(PLAIN, extra) } }); await emitJoin(w, j); expect(writes(w.calls)).toEqual([]); expect(attendees(w, PLAIN)).toEqual([]); expect(w.logs.warn.length).toBeGreaterThan(0); } }); });