Skip to content

Commit 5f8fa71

Browse files
committed
feat(plan-59 Phase 5a): Contact.aggregateCmc + CMC types
Phase 5a keystone — patient-side Contact built from CMC primitives, no legacy CollectorClient dependency. - New types: CmcCounterpartyAccess, CmcAcceptedRelationship, CmcRelationship. - New fields on Contact: counterparty, kind ('person'|'service'|'unknown'), cmcRelationships[]. - New getters: cmcIsActive, cmcHasChat, cmcChatStreams, cmcAllPermissions. - New static aggregator: Contact.aggregateCmc(accesses, accepts, scope) — groups patient-side counterparty accesses by (username, host) into Contacts. - Q-C1 lock applied: cmcDetectKind uses 'hds-bridge-' prefix on appCode. - Q-C2 lock applied: accept events without a matching access are dropped (ghosts from doctor-side revoke); access is source of truth. - Q-C4 lock applied: aggregator pulls counterparty apiEndpoint + remote stream ids from access.clientData.cmc.counterparty (not accept events). Tests: 12 new specs (CTM1-CTMC) cover detection, grouping, ghost filtering, dedup, chat filtering. 501/501 pass; tsc clean. Design briefs: - _plans/59-pryv-cmc-integration-atwork/design-briefs/contact-cmc.md (Q-C1..C5 resolved + design-only Q-C3 proposed). - _plans/59-pryv-cmc-integration-atwork/design-briefs/formspec.md (Q-F1..F3 resolved + design-only Q-F4..F7 proposed). Legacy CollectorClient path untouched — Phase 10 deletion.
1 parent 0275136 commit 5f8fa71

2 files changed

Lines changed: 408 additions & 1 deletion

File tree

tests/contact.test.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,4 +428,169 @@ describe('[CTCT] Contact class', function () {
428428
assert.equal(c.collectorClients.length, 1);
429429
});
430430
});
431+
432+
// ---- Plan 59 Phase 5a — CMC aggregator ---- //
433+
434+
describe('[CTCM] CMC Contact aggregator', function () {
435+
const SCOPE = ':_cmc:apps:hds-patient';
436+
437+
function counterpartyAccess (overrides = {}) {
438+
const cp = {
439+
username: 'drandy',
440+
host: 'demo.datasafe.dev',
441+
apiEndpoint: 'https://abctoken@drandy.demo.datasafe.dev/',
442+
remoteChatStreamId: ':_cmc:apps:hds-collector:foo:chats:pthdstest--demo-datasafe-dev',
443+
remoteCollectorStreamId: ':_cmc:apps:hds-collector:foo:collectors:pthdstest--demo-datasafe-dev',
444+
...(overrides.counterpartyOverrides || {})
445+
};
446+
const cmc = {
447+
role: 'counterparty',
448+
appCode: 'hds-collector',
449+
features: { chat: true, systemMessaging: true },
450+
counterparty: cp,
451+
...(overrides.cmcOverrides || {})
452+
};
453+
return {
454+
id: overrides.id || 'acc-cp-1',
455+
apiEndpoint: 'irrelevant',
456+
permissions: overrides.permissions ?? [{ streamId: 'health', level: 'read' }],
457+
deleted: overrides.deleted ?? null,
458+
clientData: { cmc }
459+
};
460+
}
461+
462+
function acceptEvent (overrides = {}) {
463+
return {
464+
acceptEventId: overrides.acceptEventId || 'evt-accept-1',
465+
counterparty: overrides.counterparty || { username: 'drandy', host: 'demo.datasafe.dev' },
466+
appCode: overrides.appCode || 'hds-collector',
467+
scopeStreamId: SCOPE,
468+
acceptedAt: overrides.acceptedAt ?? 1716000000,
469+
features: overrides.features || { chat: true, systemMessaging: true },
470+
backChannelAccessId: overrides.backChannelAccessId || 'acc-cp-1'
471+
};
472+
}
473+
474+
it('[CTM1] cmcDetectKind splits person vs service by hds-bridge- prefix', () => {
475+
assert.equal(Contact.cmcDetectKind('hds-collector'), 'person');
476+
assert.equal(Contact.cmcDetectKind('hds-patient'), 'person');
477+
assert.equal(Contact.cmcDetectKind('hds-bridge-mira'), 'service');
478+
assert.equal(Contact.cmcDetectKind('hds-bridge-athenahealth'), 'service');
479+
assert.equal(Contact.cmcDetectKind(null), 'unknown');
480+
assert.equal(Contact.cmcDetectKind(undefined), 'unknown');
481+
assert.equal(Contact.cmcDetectKind(''), 'unknown');
482+
});
483+
484+
it('[CTM2] aggregateCmc returns empty when no counterparty accesses', () => {
485+
const out = Contact.aggregateCmc([], [], SCOPE);
486+
assert.deepEqual(out, []);
487+
});
488+
489+
it('[CTM3] aggregateCmc skips deleted accesses', () => {
490+
const a = counterpartyAccess({ deleted: { reason: 'revoked' } });
491+
const out = Contact.aggregateCmc([a], [acceptEvent()], SCOPE);
492+
assert.deepEqual(out, []);
493+
});
494+
495+
it('[CTM4] aggregateCmc skips accesses without cmc.role === counterparty', () => {
496+
const a = counterpartyAccess({ cmcOverrides: { role: 'requester' } });
497+
const out = Contact.aggregateCmc([a], [], SCOPE);
498+
assert.deepEqual(out, []);
499+
});
500+
501+
it('[CTM5] aggregateCmc builds one Contact + one relationship from one access', () => {
502+
const a = counterpartyAccess();
503+
const out = Contact.aggregateCmc([a], [acceptEvent()], SCOPE);
504+
assert.equal(out.length, 1);
505+
const c = out[0];
506+
assert.equal(c.counterparty.username, 'drandy');
507+
assert.equal(c.counterparty.host, 'demo.datasafe.dev');
508+
assert.equal(c.kind, 'person');
509+
assert.equal(c.cmcRelationships.length, 1);
510+
const rel = c.cmcRelationships[0];
511+
assert.equal(rel.accessId, 'acc-cp-1');
512+
assert.equal(rel.acceptEventId, 'evt-accept-1');
513+
assert.equal(rel.counterpartyApiEndpoint, 'https://abctoken@drandy.demo.datasafe.dev/');
514+
assert.equal(rel.appCode, 'hds-collector');
515+
assert.deepEqual(rel.features, { chat: true, systemMessaging: true });
516+
assert.equal(rel.acceptedAt, 1716000000);
517+
// Local chat stream uses cmc.counterpartySlug (lowercase + host dots → hyphens, port stripped)
518+
assert.equal(rel.localChatStreamId, ':_cmc:apps:hds-patient:chats:drandy--demo-datasafe-dev');
519+
});
520+
521+
it('[CTM6] aggregateCmc groups multiple accesses from the same counterparty into one Contact', () => {
522+
const a1 = counterpartyAccess({ id: 'acc-cp-1' });
523+
const a2 = counterpartyAccess({ id: 'acc-cp-2', permissions: [{ streamId: 'sleep', level: 'read' }] });
524+
const out = Contact.aggregateCmc([a1, a2], [acceptEvent()], SCOPE);
525+
assert.equal(out.length, 1);
526+
assert.equal(out[0].cmcRelationships.length, 2);
527+
});
528+
529+
it('[CTM7] aggregateCmc puts different counterparties into different Contacts', () => {
530+
const a1 = counterpartyAccess({ id: 'acc-cp-1' });
531+
const a2 = counterpartyAccess({
532+
id: 'acc-cp-2',
533+
counterpartyOverrides: { username: 'drother', host: 'demo.datasafe.dev' }
534+
});
535+
const out = Contact.aggregateCmc([a1, a2], [], SCOPE);
536+
assert.equal(out.length, 2);
537+
});
538+
539+
it('[CTM8] aggregateCmc marks bridge contacts as kind=service', () => {
540+
const a = counterpartyAccess({
541+
cmcOverrides: { appCode: 'hds-bridge-mira', features: { chat: false } },
542+
counterpartyOverrides: { username: 'bridgemiratest', host: 'demo.datasafe.dev' }
543+
});
544+
const out = Contact.aggregateCmc([a], [], SCOPE);
545+
assert.equal(out.length, 1);
546+
assert.equal(out[0].kind, 'service');
547+
assert.equal(out[0].counterparty.username, 'bridgemiratest');
548+
});
549+
550+
it('[CTM9] aggregateCmc tolerates missing accept events (acceptedAt: null)', () => {
551+
const a = counterpartyAccess();
552+
const out = Contact.aggregateCmc([a], [], SCOPE);
553+
assert.equal(out.length, 1);
554+
assert.equal(out[0].cmcRelationships[0].acceptedAt, null);
555+
assert.equal(out[0].cmcRelationships[0].acceptEventId, null);
556+
});
557+
558+
it('[CTMA] aggregateCmc drops ghost accept events (Q-C2): accepts without matching access', () => {
559+
// Doctor revoked → patient access deleted, but the accept event still exists.
560+
// aggregator must not create a Contact from the ghost accept event.
561+
const out = Contact.aggregateCmc([], [acceptEvent()], SCOPE);
562+
assert.deepEqual(out, []);
563+
});
564+
565+
it('[CTMB] cmcAllPermissions dedupes across relationships', () => {
566+
const a1 = counterpartyAccess({
567+
id: 'a1',
568+
permissions: [{ streamId: 'health', level: 'read' }, { streamId: 'sleep', level: 'read' }]
569+
});
570+
const a2 = counterpartyAccess({
571+
id: 'a2',
572+
permissions: [{ streamId: 'sleep', level: 'read' }, { streamId: 'mood', level: 'contribute' }]
573+
});
574+
const out = Contact.aggregateCmc([a1, a2], [], SCOPE);
575+
const perms = out[0].cmcAllPermissions;
576+
assert.equal(perms.length, 3);
577+
assert.deepEqual(perms.map(p => p.streamId).sort(), ['health', 'mood', 'sleep']);
578+
});
579+
580+
it('[CTMC] cmcChatStreams only includes chat-enabled relationships', () => {
581+
const a1 = counterpartyAccess({
582+
id: 'a1',
583+
cmcOverrides: { role: 'counterparty', appCode: 'hds-collector', features: { chat: true, systemMessaging: false }, counterparty: { username: 'drandy', host: 'demo.datasafe.dev', apiEndpoint: 'https://t@drandy.x/', remoteChatStreamId: 'r1', remoteCollectorStreamId: 'c1' } }
584+
});
585+
const a2 = counterpartyAccess({
586+
id: 'a2',
587+
cmcOverrides: { role: 'counterparty', appCode: 'hds-collector', features: { chat: false }, counterparty: { username: 'drandy', host: 'demo.datasafe.dev', apiEndpoint: 'https://t@drandy.x/', remoteChatStreamId: null, remoteCollectorStreamId: null } }
588+
});
589+
const out = Contact.aggregateCmc([a1, a2], [], SCOPE);
590+
const streams = out[0].cmcChatStreams;
591+
assert.equal(streams.length, 1);
592+
assert.equal(streams[0].read, 'r1');
593+
assert.equal(streams[0].accessId, 'a1');
594+
});
595+
});
431596
});

0 commit comments

Comments
 (0)