1033 lines
50 KiB
JavaScript
1033 lines
50 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('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);
|
|
}
|
|
});
|
|
});
|