Skip to content

Commit 6987e66

Browse files
committed
Merge feat/lib-3.3.0 → main: Plan 59 CMC integration (@pryv/cmc@1.1.1 + chat-aware Contact + FormSpec resolver)
2 parents 07bd4e6 + d64f2e5 commit 6987e66

8 files changed

Lines changed: 1017 additions & 22 deletions

File tree

package-lock.json

Lines changed: 27 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hds-lib",
3-
"version": "0.11.0",
3+
"version": "0.12.0",
44
"description": "Health Data Safe - Library",
55
"type": "module",
66
"engines": {
@@ -62,11 +62,12 @@
6262
"webpack-cli": "^6.0.1"
6363
},
6464
"dependencies": {
65-
"@pryv/monitor": "3.1.0",
66-
"@pryv/socket.io": "3.1.0",
65+
"@pryv/cmc": "1.1.1",
66+
"@pryv/monitor": "3.4.1",
67+
"@pryv/socket.io": "3.4.1",
6768
"ajv": "^8.20.0",
6869
"events": "^3.3.0",
69-
"pryv": "3.1.0",
70+
"pryv": "3.4.1",
7071
"short-unique-id": "^5.3.2"
7172
}
7273
}

tests/cmcFormSpec.test.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import * as cmcFormSpec from '../ts/cmc/formSpec.ts';
3+
import { Contact } from '../ts/appTemplates/Contact.ts';
4+
5+
/**
6+
* Unit tests for the Plan 59 Phase 5a FormSpec helpers + the
7+
* `hdsFormSpec` pass-through on Contact.aggregateCmc.
8+
*
9+
* I/O paths (saveFormSpec / loadFormSpec / mirror) are integration-tested
10+
* against a live api-server in the Phase 6 demo iteration loop.
11+
*/
12+
13+
describe('[CFSP] cmcFormSpec helpers', function () {
14+
function basicSpec (overrides = {}) {
15+
return {
16+
version: 1,
17+
title: { en: 'Hello World' },
18+
description: { en: 'desc' },
19+
consent: { en: 'I consent' },
20+
permissions: [{ streamId: 'health', level: 'read' }],
21+
sections: [{ key: 'sleep-section', type: 'recurring', name: { en: 'Sleep' }, itemKeys: ['sleep-duration'] }],
22+
...overrides
23+
};
24+
}
25+
26+
describe('[CFSC] constants', function () {
27+
it('[CFS01] FORM_SPEC_EVENT_TYPE matches the locked Q-F5 name', () => {
28+
assert.equal(cmcFormSpec.FORM_SPEC_EVENT_TYPE, 'hds:form-spec-v1');
29+
});
30+
31+
it('[CFS02] HDS_NOOP_STREAM_ID + permission match the brief', () => {
32+
assert.equal(cmcFormSpec.HDS_NOOP_STREAM_ID, 'hds-noop');
33+
assert.deepEqual(cmcFormSpec.HDS_NOOP_PERMISSION, { streamId: 'hds-noop', level: 'read' });
34+
});
35+
});
36+
37+
describe('[CFSD] deriveCmcPermissions', function () {
38+
it('[CFS10] returns the FormSpec permissions when non-empty', () => {
39+
const spec = basicSpec();
40+
const out = cmcFormSpec.deriveCmcPermissions(spec);
41+
assert.deepEqual(out, [{ streamId: 'health', level: 'read' }]);
42+
});
43+
44+
it('[CFS11] injects :hds:noop placeholder when permissions empty', () => {
45+
const spec = basicSpec({ permissions: [] });
46+
const out = cmcFormSpec.deriveCmcPermissions(spec);
47+
assert.equal(out.length, 1);
48+
assert.equal(out[0].streamId, 'hds-noop');
49+
assert.equal(out[0].level, 'read');
50+
});
51+
52+
it('[CFS12] filters out malformed permission entries before deciding empty', () => {
53+
const spec = basicSpec({ permissions: [{ streamId: '', level: 'read' }, null, { streamId: 'x' /* no level */ }] });
54+
const out = cmcFormSpec.deriveCmcPermissions(spec);
55+
// All filtered → placeholder injected
56+
assert.equal(out.length, 1);
57+
assert.equal(out[0].streamId, 'hds-noop');
58+
});
59+
});
60+
61+
describe('[CFSI] isChatOnlyFormSpec', function () {
62+
it('[CFS20] true on empty / placeholder-only specs', () => {
63+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(basicSpec({ permissions: [] })), true);
64+
});
65+
it('[CFS21] false when real permissions present', () => {
66+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(basicSpec()), false);
67+
});
68+
it('[CFS22] false for null / undefined input', () => {
69+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(null), false);
70+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(undefined), false);
71+
});
72+
});
73+
});
74+
75+
describe('[CTFS] Contact.aggregateCmc hdsFormSpec pass-through', function () {
76+
const SCOPE = ':_cmc:apps:hds-patient';
77+
78+
function counterpartyAccess (overrides = {}) {
79+
const cp = {
80+
username: 'drandy',
81+
host: 'demo.datasafe.dev',
82+
apiEndpoint: 'https://abctoken@drandy.demo.datasafe.dev/',
83+
remoteChatStreamId: 'r1',
84+
remoteCollectorStreamId: 'c1'
85+
};
86+
return {
87+
id: overrides.id || 'acc-1',
88+
permissions: [{ streamId: 'health', level: 'read' }],
89+
clientData: {
90+
cmc: {
91+
role: 'counterparty',
92+
appCode: 'hds-collector',
93+
features: { chat: true, systemMessaging: true },
94+
counterparty: cp
95+
}
96+
},
97+
...overrides
98+
};
99+
}
100+
101+
function acceptEvent (overrides = {}) {
102+
return {
103+
acceptEventId: overrides.acceptEventId || 'evt-1',
104+
counterparty: { username: 'drandy', host: 'demo.datasafe.dev' },
105+
appCode: 'hds-collector',
106+
scopeStreamId: SCOPE,
107+
acceptedAt: 1716000000,
108+
features: { chat: true, systemMessaging: true },
109+
...overrides
110+
};
111+
}
112+
113+
it('[CTFS1] surfaces hdsFormSpec from the matching accept event', () => {
114+
const spec = {
115+
version: 1,
116+
title: { en: 'Hello World' },
117+
description: { en: '' },
118+
permissions: [{ streamId: 'health', level: 'read' }],
119+
sections: []
120+
};
121+
const out = Contact.aggregateCmc(
122+
[counterpartyAccess()],
123+
[acceptEvent({ hdsFormSpec: spec })],
124+
SCOPE
125+
);
126+
assert.equal(out.length, 1);
127+
assert.equal(out[0].cmcRelationships[0].hdsFormSpec, spec);
128+
});
129+
130+
it('[CTFS2] hdsFormSpec defaults to null when accept event has none', () => {
131+
const out = Contact.aggregateCmc(
132+
[counterpartyAccess()],
133+
[acceptEvent()],
134+
SCOPE
135+
);
136+
assert.equal(out[0].cmcRelationships[0].hdsFormSpec, null);
137+
});
138+
139+
it('[CTFS3] cmcFormSpecs getter returns specs from all chat-enabled relationships', () => {
140+
const spec = { version: 1, title: { en: 'A' }, description: { en: '' }, permissions: [], sections: [{ key: 's', type: 'recurring', name: { en: 'S' }, itemKeys: [] }] };
141+
const out = Contact.aggregateCmc(
142+
[counterpartyAccess()],
143+
[acceptEvent({ hdsFormSpec: spec })],
144+
SCOPE
145+
);
146+
assert.deepEqual(out[0].cmcFormSpecs, [spec]);
147+
});
148+
149+
it('[CTFS4] cmcFormSections aggregates sections across relationships', () => {
150+
const spec1 = { version: 1, title: { en: 'A' }, description: { en: '' }, permissions: [], sections: [{ key: 's1', type: 'recurring', name: { en: 'S1' }, itemKeys: ['a'] }] };
151+
const spec2 = { version: 1, title: { en: 'B' }, description: { en: '' }, permissions: [], sections: [{ key: 's2', type: 'recurring', name: { en: 'S2' }, itemKeys: ['b'] }] };
152+
const a1 = counterpartyAccess({ id: 'a1' });
153+
const a2 = counterpartyAccess({ id: 'a2' });
154+
const out = Contact.aggregateCmc(
155+
[a1, a2],
156+
[
157+
acceptEvent({ acceptEventId: 'e1', hdsFormSpec: spec1 }),
158+
acceptEvent({ acceptEventId: 'e2', hdsFormSpec: spec2 })
159+
],
160+
SCOPE
161+
);
162+
// Both relationships share the same counterparty → one Contact, two relationships.
163+
// Both accept events match the same (counterparty, appCode) pair so both
164+
// relationships get the FIRST matching spec. That's the documented
165+
// behaviour — `matchingAccept` is `accepts.find(...)` (first match).
166+
assert.equal(out.length, 1);
167+
assert.equal(out[0].cmcRelationships.length, 2);
168+
assert.equal(out[0].cmcFormSections.length, 2);
169+
assert.deepEqual(out[0].cmcFormSections.map(s => s.key), ['s1', 's1']);
170+
});
171+
});

0 commit comments

Comments
 (0)