const { test, expect, describe } = require('bun:test'); const registration = require('./registration.service'); const { createKeyedSerializer } = require('./keyed-serializer'); const { lastAttendanceQuery, recordAttendanceUpdate, isNewerAct, attendanceSubject } = require('./attendance-ledger'); const ALICE = 'http://localhost:3000/alice'; // the organizer const BOB = 'http://localhost:3000/bob'; // the joiner const MALLORY = 'http://localhost:3000/mallory'; const MP = `${ALICE}/data/events/mp1`; const C = `${ALICE}/data/collections/mp1-attendees`; const JOIN = `${BOB}/data/activities/join-1`; const LEAVE = `${BOB}/data/activities/leave-1`; const BOB_CONTACTS = `${BOB}/data/collections/contacts`; const meetingPoint = (extra = {}) => ({ id: MP, type: 'Event', name: 'Entrée nord', 'dc:creator': ALICE, 'apods:attendees': C, to: 'as:Public', ...extra }); // The pod stamps `published` on every activity posted to an outbox. const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:00.000Z' }; const leave = { id: LEAVE, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:05.000Z' }; /** * A pod (uri → stored body, 404 otherwise) plus the collection's state and the backend's attendance * ledger (answering exactly the queries `attendance-ledger.js` builds); records every call. * `addDelay` makes `attendees.add` slow; `readDelays` (uri → ms) makes some pod reads slow, to expose * interleavings. `ledger` seeds the last act applied per member. */ function world({ stored = {}, items = [], addDelay = 0, readDelays = {}, ledger = {} } = {}) { const calls = []; const state = { items: [...items], ledger: { ...ledger } }; const members = [BOB, MALLORY, ALICE]; // Every act the store could be asked to record: the stored Join/Leave activities. const acts = Object.values(stored) .filter(body => body.type === 'Join' || body.type === 'Leave') .map(body => ({ activityUri: body.id, published: body.published, change: body.type.toLowerCase() })); const ctx = { call: async (action, params) => { calls.push({ action, params }); switch (action) { case 'pod-resources.get': { if (readDelays[params.resourceUri]) await new Promise(resolve => setTimeout(resolve, readDelays[params.resourceUri])); const body = stored[params.resourceUri]; return body ? { ok: true, status: 200, body } : { ok: false, status: 404 }; } case 'triplestore.query': { const member = members.find(m => params.query === lastAttendanceQuery(MP, m)); if (!member) throw new Error(`unexpected query ${params.query}`); const last = state.ledger[member]; return last ? [{ act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } }] : []; } case 'triplestore.update': { for (const m of members) { const act = acts.find(a => params.query === recordAttendanceUpdate(MP, m, a)); if (act) { state.ledger[m] = act; return undefined; } } throw new Error(`unexpected update ${params.query}`); } case 'pod-collections.getItems': return [...state.items]; case 'attendees.add': if (addDelay) await new Promise(resolve => setTimeout(resolve, addDelay)); state.items.push(params.itemUri); return undefined; case 'attendees.remove': state.items = state.items.filter(item => item !== params.itemUri); return undefined; case 'pod-wac-groups.getUriFromCollectionUri': return `${params.collectionUri}-group`; default: return undefined; } } }; return { ctx, calls, state }; } function service({ timeoutMs } = {}) { const logs = { info: [], warn: [], error: [] }; const svc = { name: 'registration', logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) } }; if (registration.created) registration.created.call(svc); if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs }); for (const [name, fn] of Object.entries(registration.methods)) svc[name] = fn.bind(svc); return { svc, logs }; } const receiveJoin = (svc, ctx, hint) => registration.activities.join.onReceive.call(svc, ctx, hint, ALICE); const receiveLeave = (svc, ctx, hint) => registration.activities.leave.onReceive.call(svc, ctx, hint, ALICE); const emitJoin = (svc, ctx, hint) => registration.activities.join.onEmit.call(svc, ctx, hint, BOB); const adds = calls => calls.filter(c => c.action === 'attendees.add').map(c => c.params); describe("registration — a Join received in the organizer's inbox adds the joiner the pod names", () => { test('the normal case: the stored joiner is added to the STORED meeting point\'s collection', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } }); await receiveJoin(service().svc, ctx, join); expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]); }); test('the webhook body is ignored: neither its actor nor its embedded attendees collection is used', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } }); const body = { id: JOIN, type: 'Join', actor: MALLORY, object: { id: MP, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }; await receiveJoin(service().svc, ctx, body); expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]); }); test('a Join the pod does not store (webhook only) adds no one', async () => { const { ctx, calls } = world({ stored: { [MP]: meetingPoint() } }); const { svc, logs } = service(); await receiveJoin(svc, ctx, { ...join, object: meetingPoint() }); expect(adds(calls)).toEqual([]); expect(logs.warn[0]).toContain('ignored a Join'); }); test('a Join about a meeting point outside the organizer\'s pod adds no one', async () => { const bobsMp = `${BOB}/data/events/mp`; const { ctx, calls } = world({ stored: { [JOIN]: { ...join, object: bobsMp }, [bobsMp]: { ...meetingPoint(), id: bobsMp } } }); await receiveJoin(service().svc, ctx, join); expect(adds(calls)).toEqual([]); }); test('a stored meeting point naming a collection outside the organizer\'s pod: nothing written', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'apods:attendees': `${MALLORY}/data/c` }) } }); const { svc, logs } = service(); await receiveJoin(svc, ctx, join); expect(adds(calls)).toEqual([]); expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true); }); test('a stored object that is not an event (a Note) adds no one', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: { id: MP, type: 'Note', 'apods:attendees': C } } }); await receiveJoin(service().svc, ctx, join); expect(adds(calls)).toEqual([]); }); test('an already attending joiner is not added twice', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] }); await receiveJoin(service().svc, ctx, join); expect(adds(calls)).toEqual([]); }); }); describe("registration — a Leave received in the organizer's inbox removes only its stored emitter", () => { test('the stored leaver is removed; a body claiming another actor changes nothing to who is removed', async () => { const { ctx, state } = world({ stored: { [LEAVE]: leave, [MP]: meetingPoint() }, items: [BOB, MALLORY] }); await receiveLeave(service().svc, ctx, { ...leave, actor: MALLORY }); expect(state.items).toEqual([MALLORY]); }); test('a Leave the pod does not store removes no one', async () => { const { ctx, state } = world({ stored: { [MP]: meetingPoint() }, items: [BOB] }); await receiveLeave(service().svc, ctx, leave); expect(state.items).toEqual([BOB]); }); test('a Join and a Leave of one meeting point never interleave (the join cannot land after the leave)', async () => { const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 30 }); const { svc } = service(); await Promise.all([receiveJoin(svc, ctx, join), receiveLeave(svc, ctx, leave)]); expect(state.items).toEqual([]); }); }); describe('registration — attendance follows the participant\'s LATEST act, whatever order notifications run in', () => { // The reviewer's probe: the Join's verification read is slower, so the Leave reaches the lock first. test('join then leave, the Leave handled first: ends NOT attending', async () => { const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, readDelays: { [JOIN]: 40 } }); const { svc } = service(); const j = receiveJoin(svc, ctx, { id: JOIN }); // the Join webhook first await new Promise(resolve => setTimeout(resolve, 5)); // the Leave webhook 5 ms later const l = receiveLeave(svc, ctx, { id: LEAVE }); await Promise.all([j, l]); expect(state.items).toEqual([]); }); test('leave then join, the Join handled first: ends attending', async () => { const earlyLeave = { ...leave, published: '2026-10-13T09:00:00.000Z' }; // she left, then joined again const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: earlyLeave, [MP]: meetingPoint() }, items: [BOB], readDelays: { [LEAVE]: 40 } }); const { svc } = service(); const l = receiveLeave(svc, ctx, { id: LEAVE }); await new Promise(resolve => setTimeout(resolve, 5)); const j = receiveJoin(svc, ctx, { id: JOIN }); await Promise.all([l, j]); expect(state.items).toEqual([BOB]); }); test('a replayed older Join after a later Leave changes nothing (the ledger says the Leave is newer)', async () => { const { ctx, state, calls } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, ledger: { [BOB]: { activityUri: LEAVE, published: leave.published, change: 'leave' } } }); const { svc, logs } = service(); await receiveJoin(svc, ctx, { id: JOIN }); expect(state.items).toEqual([]); expect(adds(calls)).toEqual([]); expect(logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true); }); test('every applied act is recorded, even when the collection already matched it', async () => { const { ctx, state } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] }); await receiveJoin(service().svc, ctx, { id: JOIN }); expect(state.ledger[BOB]).toEqual({ activityUri: JOIN, published: join.published, change: 'join' }); }); test('an act without a pod stamp cannot be ordered: nothing written', async () => { const unstamped = { ...join, published: undefined }; const { ctx, calls } = world({ stored: { [JOIN]: unstamped, [MP]: meetingPoint() } }); const { svc, logs } = service(); await receiveJoin(svc, ctx, { id: JOIN }); expect(adds(calls)).toEqual([]); expect(calls.some(c => c.action === 'triplestore.update')).toBe(false); expect(logs.warn.some(m => m.includes('no pod stamp'))).toBe(true); }); }); 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 // 50 ms timeout and lands at 120 ms; nothing follows. The late write is the latest act: it must stand. const L0 = { id: `${BOB}/data/activities/leave-0`, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' }; const J0 = { id: `${BOB}/data/activities/join-0`, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' }; test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => { const { ctx, state } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [L0.id]: L0 }, addDelay: 120, ledger: { [BOB]: { activityUri: L0.id, published: L0.published, change: 'leave' } } }); const { svc } = service({ timeoutMs: 50 }); await receiveJoin(svc, ctx, join); await new Promise(resolve => setTimeout(resolve, 300)); expect(state.items).toEqual([BOB]); }); test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => { const slowRemove = world({ stored: { [LEAVE]: leave, [MP]: meetingPoint(), [J0.id]: J0 }, items: [BOB], ledger: { [BOB]: { activityUri: J0.id, published: J0.published, change: 'join' } } }); const { ctx, state } = slowRemove; const call = ctx.call; ctx.call = async (action, params) => { if (action === 'attendees.remove') await new Promise(resolve => setTimeout(resolve, 120)); // lands late return call(action, params); }; const { svc } = service({ timeoutMs: 50 }); await receiveLeave(svc, ctx, leave); await new Promise(resolve => setTimeout(resolve, 300)); expect(state.items).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 { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 120 }); const { svc } = service({ timeoutMs: 50 }); const j = receiveJoin(svc, ctx, join); await new Promise(resolve => setTimeout(resolve, 10)); const l = receiveLeave(svc, ctx, leave); await Promise.all([j, l]); await new Promise(resolve => setTimeout(resolve, 250)); expect(state.ledger[BOB].change).toBe('leave'); // the newer act is the recorded one expect(state.items).toEqual([BOB]); // the late add of the older Join stands — no re-alignment }); }); describe("registration — a Join emitted from the joiner's outbox adds the meeting point's real organizer to her contacts", () => { const bobActor = { id: BOB, type: 'Person', 'apods:contacts': BOB_CONTACTS }; const added = calls => calls.filter(c => c.action === 'pod-wac-groups.addMember').map(c => c.params); test('the normal case: the stored creator of a meeting point in her own pod', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } }); await emitJoin(service().svc, ctx, join); expect(added(calls)).toEqual([{ groupUri: `${BOB_CONTACTS}-group`, memberUri: ALICE, actorUri: BOB }]); }); test('a creator named in the webhook body is ignored (Mallory cannot get into Bob\'s contacts)', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } }); await emitJoin(service().svc, ctx, { ...join, object: { id: MP, type: 'Event', 'dc:creator': MALLORY } }); expect(added(calls).map(p => p.memberUri)).toEqual([ALICE]); }); test('a stored creator who does not own the pod holding the meeting point is refused', async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'dc:creator': MALLORY }), [BOB]: bobActor } }); await emitJoin(service().svc, ctx, join); expect(added(calls)).toEqual([]); }); test('a Join stored in another actor\'s pod is not Bob\'s', async () => { const forged = `${MALLORY}/data/activities/j`; const { ctx, calls } = world({ stored: { [forged]: { ...join, id: forged }, [MP]: meetingPoint(), [BOB]: bobActor } }); await emitJoin(service().svc, ctx, forged); expect(added(calls)).toEqual([]); }); test("a contacts collection outside the joiner's pod is not written", async () => { const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: { ...bobActor, 'apods:contacts': `${MALLORY}/data/c` } } }); await emitJoin(service().svc, ctx, join); expect(added(calls)).toEqual([]); }); }); describe('registration — matchers never throw', () => { test('a fetch failure while matching reports no match', async () => { const { svc, logs } = service(); const failing = async () => { throw new Error('proxy unreachable'); }; for (const key of ['join', 'leave']) { const result = await registration.activities[key].match.call(svc, { type: key === 'join' ? 'Join' : 'Leave', object: MP }, failing); expect(result.match).toBe(false); } expect(logs.error).toHaveLength(2); }); }); describe('attendance-ledger — the order of acts', () => { const at = (published, activityUri = JOIN) => ({ 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, participant), under a URN the store accepts', () => { expect(attendanceSubject(MP, BOB)).not.toBe(attendanceSubject(MP, MALLORY)); expect(attendanceSubject(MP, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/); expect(() => lastAttendanceQuery(MP, BOB)).not.toThrow(); }); });