@@ -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 ( / n o i n b o x a c c e p t f o u n d / ) ;
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 ( / s c o p e S t r e a m I d / ) ;
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 ( / s c o p e S t r e a m I d / ) ;
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