9e2df2478b
A participation activity is now addressed to the Festipod app actor alone and handled on reception in the app's inbox, the way the ActivityPods developer advised: use the ActivityPub protocol rather than a side channel. Any compatible sender may deliver; the inbox accepts only the activity's own signer, and the backend still requires the activity to lie in that actor's pod and acts on the stored copy. The meeting point is read again in its creator's pod before anything is written: read through the participant's pod, a foreign server's proxy chose the attendee list and the notification's name. Confidentiality is unchanged: the Join stays readable by its author and the app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app, a named exception to festipod-only-reference-app-patterns.
850 lines
42 KiB
JavaScript
850 lines
42 KiB
JavaScript
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('an Event with no as:context (no event to anchor the URN)', async () => {
|
|
const j = join(MP1);
|
|
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { context: undefined }) } }), j, { contains: 'as:context' });
|
|
});
|
|
|
|
test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => {
|
|
const j = join(MP1);
|
|
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' });
|
|
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();
|
|
});
|
|
});
|