Skip to content

Commit 79b08a6

Browse files
committed
test(cmc): J3-J10 wire-shape contract tests
Fills the contract slots in @pryv/cmc unit tests so regressions in documented wire shapes surface at unit-test time, not as deploy surprises. - J3 [CMCL1OB]: listInvites calls events.get with `streams`, NOT `streamIds` (api-server schema rejects streamIds with OBJECT_ADDITIONAL_PROPERTIES). - J4 [CMCL1OC]: listAcceptedRelationships maps counterparty from content.from first, falls back to content.acceptedBy for legacy events; null when both absent. - J5 [CMCL1OD]: waitForAccept honors sinceTime filter when ev.time is set; defensively passes events with no time (pre-stamping deploys). - J7 [CMCL1OG]: acceptInvite rejects without scopeStreamId. - J8 [CMCL1OH]: acceptInvite resolves dataGrantAccessId from the post-completion events.getOne when waitForCompletion: true. J1 + J2 already covered by [CMCL1OA] (readOffer streams contract) + [CMCXE] (errorIds catalogue). J6 by [CMCL1RC] (revokeRelationship by inviteEventId). J9 by [CMCXE]. J10 by [CMCL1Z] (observation scopes). Tests: 46 -> 55 (+9).
1 parent e11d080 commit 79b08a6

1 file changed

Lines changed: 247 additions & 0 deletions

File tree

components/pryv-cmc/test/cmc.test.js

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,77 @@ describe('[CMCL1] @pryv/cmc Level-1 protocol functions', function () {
397397
expect(conn.calls[0].params.content).to.deep.equal({ accessId: 'abc123', reason: { en: 'done' } });
398398
});
399399

400+
it('[CMCL1RC] revokeRelationship({inviteEventId}) resolves backChannelAccessId via inbox lookup', async function () {
401+
// Doctor-side convenience path: the SDK looks up the inbox accept
402+
// event matching the original inviteEventId, reads the back-channel
403+
// accessId stamped by the plugin (post-PR-72 + Phase 1.1 of Plan
404+
// 68 atwork — handleIncomingAccept now stamps `inviteEventId` on
405+
// the inbox-mirror from the capability access's
406+
// `clientData.cmc.requestEventId`). Then issues the revoke.
407+
//
408+
// Contract: the lookup matches when the inbox event content carries
409+
// `inviteEventId === givenInviteEventId`. The backChannelAccessId
410+
// stamped by the plugin (also a PR #72 deliverable) is what
411+
// becomes `content.accessId` on the revoke event.
412+
const conn = makeStubConnection({
413+
handlers: {
414+
'events.getOne': function (params) {
415+
expect(params.id).to.equal('inv-trigger-42');
416+
return { event: { id: 'inv-trigger-42', streamIds: [':_cmc:apps:my-app:study-1'], content: {} } };
417+
},
418+
'events.get': function (params) {
419+
// Lookup on :_cmc:inbox for ET_ACCEPT.
420+
expect(params.streams).to.deep.equal([':_cmc:inbox']);
421+
expect(params.types).to.deep.equal(['consent/accept-cmc']);
422+
return {
423+
events: [
424+
// Decoy: an unrelated accept.
425+
{ id: 'inbox-other', content: { inviteEventId: 'inv-trigger-99', backChannelAccessId: 'acc-other' } },
426+
// Match: the one we're after.
427+
{ id: 'inbox-42', content: { inviteEventId: 'inv-trigger-42', backChannelAccessId: 'acc-back-42' } }
428+
]
429+
};
430+
},
431+
'events.create': function (params) {
432+
expect(params.type).to.equal('consent/revoke-cmc');
433+
return { event: { id: 'rev-3', streamIds: params.streamIds, content: params.content } };
434+
}
435+
}
436+
});
437+
await cmc.revokeRelationship(conn, { inviteEventId: 'inv-trigger-42', reason: { en: 'done' } });
438+
const revokeCall = conn.calls.find(function (c) { return c.method === 'events.create'; });
439+
expect(revokeCall, 'revoke events.create call must exist').to.exist;
440+
expect(revokeCall.params.streamIds).to.deep.equal([':_cmc:apps:my-app:study-1']);
441+
expect(revokeCall.params.content.accessId).to.equal('acc-back-42');
442+
expect(revokeCall.params.content.reason).to.deep.equal({ en: 'done' });
443+
});
444+
445+
it('[CMCL1RD] revokeRelationship({inviteEventId}) throws when inbox has no matching accept (mirror missing inviteEventId)', async function () {
446+
// Defensive: the pre-Phase-1.1 plugin doesn't stamp inviteEventId
447+
// on the inbox mirror. The SDK lookup falls through to `match ==
448+
// null` and throws cleanly — caller can fall back to the
449+
// {accessId, scopeStreamId} power-user path.
450+
const conn = makeStubConnection({
451+
handlers: {
452+
'events.getOne': function () {
453+
return { event: { id: 'inv-trigger-99', streamIds: [':_cmc:apps:my-app'], content: {} } };
454+
},
455+
'events.get': function () {
456+
return {
457+
events: [
458+
// Mirror present but without inviteEventId — older plugin shape.
459+
{ id: 'inbox-99', content: { backChannelAccessId: 'acc-back-99' } }
460+
]
461+
};
462+
}
463+
}
464+
});
465+
let err = null;
466+
try { await cmc.revokeRelationship(conn, { inviteEventId: 'inv-trigger-99' }); } catch (e) { err = e; }
467+
expect(err, 'expected throw').to.exist;
468+
expect(err.message).to.match(/no inbox accept found/);
469+
});
470+
400471
it('[CMCL1RB] revokeAcceptance also posts consent/revoke-cmc', async function () {
401472
const conn = makeStubConnection({
402473
handlers: {
@@ -701,4 +772,180 @@ describe('[CMCL1] @pryv/cmc Level-1 protocol functions', function () {
701772
expect(() => cmc.scopes.collectors({})).to.throw();
702773
});
703774
});
775+
776+
// ------------------------------------------------------------
777+
// Phase 5.2 (Plan 68) — J3–J10 wire-shape contract tests.
778+
//
779+
// J1 + J2 covered by [CMCL1OA] (readOffer) + [CMCXE] (errorIds catalogue).
780+
// J3-J10 fill the remaining contract slots so a release-blocking
781+
// regression in any of these wire shapes surfaces as a unit-test
782+
// failure rather than a Phase-6 deploy-validation surprise.
783+
// ------------------------------------------------------------
784+
785+
describe('[CMCL1OB] J3 listInvites wire-shape', function () {
786+
it('[CMCL1OB1] calls events.get with `streams` (NOT `streamIds`)', async function () {
787+
// api-server schema rejects `streamIds` on events.get with
788+
// OBJECT_ADDITIONAL_PROPERTIES; a regression to streamIds breaks
789+
// every listInvites caller silently against fresh deploys.
790+
const conn = makeStubConnection({
791+
handlers: {
792+
'events.get': function () { return { events: [] }; }
793+
}
794+
});
795+
await cmc.listInvites(conn, { scopeStreamId: ':_cmc:apps:my-app' });
796+
const c = conn.calls[0];
797+
expect(c.method).to.equal('events.get');
798+
expect(c.params).to.have.property('streams');
799+
expect(c.params).to.not.have.property('streamIds');
800+
expect(c.params.streams).to.deep.equal([':_cmc:apps:my-app']);
801+
expect(c.params.types).to.deep.equal(['consent/request-cmc']);
802+
});
803+
});
804+
805+
describe('[CMCL1OC] J4 listAcceptedRelationships counterparty mapping', function () {
806+
it('[CMCL1OC1] maps counterparty from content.from when present', async function () {
807+
const conn = makeStubConnection({
808+
handlers: {
809+
'events.get': function () {
810+
return {
811+
events: [{
812+
id: 'evt-1',
813+
streamIds: [':_cmc:apps:my-app'],
814+
content: {
815+
from: { username: 'alice', host: 'pryv.me' },
816+
acceptedBy: { apiEndpoint: 'https://abc@alice.pryv.me/' },
817+
dataGrantAccessId: 'dg-1'
818+
}
819+
}]
820+
};
821+
}
822+
}
823+
});
824+
const r = await cmc.listAcceptedRelationships(conn);
825+
expect(r).to.have.length(1);
826+
expect(r[0].counterparty).to.deep.equal({ username: 'alice', host: 'pryv.me' });
827+
expect(r[0].dataGrantAccessId).to.equal('dg-1');
828+
});
829+
830+
it('[CMCL1OC2] falls back to content.acceptedBy when content.from absent', async function () {
831+
// Pre-PR-72 / pre-Phase-1.1 events on existing deploys don't
832+
// carry `from` yet. Mapper must still expose something so the
833+
// SDK doesn't break for migrating users.
834+
const conn = makeStubConnection({
835+
handlers: {
836+
'events.get': function () {
837+
return {
838+
events: [{
839+
id: 'evt-2',
840+
streamIds: [':_cmc:apps:my-app'],
841+
content: { acceptedBy: { apiEndpoint: 'https://abc@alice.pryv.me/' } }
842+
}]
843+
};
844+
}
845+
}
846+
});
847+
const r = await cmc.listAcceptedRelationships(conn);
848+
expect(r[0].counterparty).to.deep.equal({ apiEndpoint: 'https://abc@alice.pryv.me/' });
849+
});
850+
851+
it('[CMCL1OC3] counterparty is null when both absent', async function () {
852+
const conn = makeStubConnection({
853+
handlers: {
854+
'events.get': function () {
855+
return { events: [{ id: 'evt-3', streamIds: [':_cmc:apps:my-app'], content: {} }] };
856+
}
857+
}
858+
});
859+
const r = await cmc.listAcceptedRelationships(conn);
860+
expect(r[0].counterparty).to.equal(null);
861+
});
862+
});
863+
864+
describe('[CMCL1OD] J5 waitForAccept sinceTime filter', function () {
865+
it('[CMCL1OD1] skips events with ev.time < sinceTime', async function () {
866+
// A late call should skip the stale arrival (time=100) and
867+
// succeed on the fresh one (time=200) without timing out.
868+
const events = [
869+
{ id: 'old', time: 100, content: { from: { username: 'alice', host: 'pryv.me' } } },
870+
{ id: 'new', time: 200, content: { from: { username: 'alice', host: 'pryv.me' }, grantedAccess: { apiEndpoint: 'https://t@x/' } } }
871+
];
872+
const conn = makeStubConnection({
873+
handlers: { 'events.get': function () { return { events }; } }
874+
});
875+
const r = await cmc.waitForAccept(conn, {
876+
fromUsername: 'alice', sinceTime: 150, timeoutMs: 1000, intervalMs: 10
877+
});
878+
expect(r.acceptInboxEventId).to.equal('new');
879+
expect(r.grantedAccessApiEndpoint).to.equal('https://t@x/');
880+
});
881+
882+
it('[CMCL1OD2] sinceTime DOES NOT filter when ev.time is missing (defensive)', async function () {
883+
// Pre-stamping events have no `time` — sinceTime should not
884+
// accidentally drop them (caller would observe a phantom timeout).
885+
const conn = makeStubConnection({
886+
handlers: {
887+
'events.get': function () {
888+
return {
889+
events: [{ id: 'no-time', content: { from: { username: 'alice', host: 'pryv.me' }, grantedAccess: { apiEndpoint: 'https://t@y/' } } }]
890+
};
891+
}
892+
}
893+
});
894+
const r = await cmc.waitForAccept(conn, {
895+
fromUsername: 'alice', sinceTime: 1000, timeoutMs: 1000, intervalMs: 10
896+
});
897+
expect(r.acceptInboxEventId).to.equal('no-time');
898+
});
899+
});
900+
901+
describe('[CMCL1OG] J7 acceptInvite rejects without scopeStreamId', function () {
902+
it('[CMCL1OG1] throws when scopeStreamId is missing', async function () {
903+
const conn = makeStubConnection({ handlers: {} });
904+
let err = null;
905+
try {
906+
await cmc.acceptInvite(conn, 'https://t@x/', {});
907+
} catch (e) { err = e; }
908+
expect(err).to.not.equal(null);
909+
expect(String(err.message)).to.match(/scopeStreamId/);
910+
});
911+
912+
it('[CMCL1OG2] throws when opts is omitted entirely', async function () {
913+
const conn = makeStubConnection({ handlers: {} });
914+
let err = null;
915+
try {
916+
await cmc.acceptInvite(conn, 'https://t@x/');
917+
} catch (e) { err = e; }
918+
expect(err).to.not.equal(null);
919+
expect(String(err.message)).to.match(/scopeStreamId/);
920+
});
921+
});
922+
923+
describe('[CMCL1OH] J8 acceptInvite resolves dataGrantAccessId on waitForCompletion', function () {
924+
it('[CMCL1OH1] returns dataGrantAccessId from the post-completion getOne', async function () {
925+
// Two-phase: events.create returns status pending; pollTriggerCompletion
926+
// calls events.getOne and reads the final dataGrantAccessId.
927+
const conn = makeStubConnection({
928+
handlers: {
929+
'events.create': function (params) {
930+
return { event: { id: 'accept-1', streamIds: params.streamIds, content: { status: 'pending' } } };
931+
},
932+
'events.getOne': function () {
933+
return {
934+
event: {
935+
id: 'accept-1',
936+
content: { status: 'completed', dataGrantAccessId: 'dg-xyz' }
937+
}
938+
};
939+
}
940+
}
941+
});
942+
const r = await cmc.acceptInvite(conn, 'https://t@x/', {
943+
scopeStreamId: ':_cmc:apps:test',
944+
completionPollIntervalMs: 5,
945+
completionTimeoutMs: 1000
946+
});
947+
expect(r.acceptEventId).to.equal('accept-1');
948+
expect(r.dataGrantAccessId).to.equal('dg-xyz');
949+
});
950+
});
704951
});

0 commit comments

Comments
 (0)