Skip to content

Commit 9f13d74

Browse files
committed
adding test for contacts
1 parent 0951b67 commit 9f13d74

1 file changed

Lines changed: 398 additions & 0 deletions

File tree

tests/contact.test.js

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import { Contact } from '../ts/appTemplates/Contact.ts';
3+
4+
/**
5+
* Unit tests for Contact class — pure logic, no Pryv connection needed.
6+
*/
7+
8+
// ---- Test helpers ---- //
9+
10+
function makeSource (overrides = {}) {
11+
return {
12+
remoteUsername: 'dr-alice',
13+
displayName: 'Dr. Alice',
14+
chatStreams: null,
15+
appStreamId: null,
16+
permissions: [{ streamId: 'health', level: 'read' }],
17+
status: 'Active',
18+
type: 'collector',
19+
accessId: 'acc-1',
20+
...overrides
21+
};
22+
}
23+
24+
function makeBridgeSource (overrides = {}) {
25+
return makeSource({
26+
remoteUsername: null,
27+
displayName: 'bridge-mira',
28+
chatStreams: null,
29+
appStreamId: 'bridge-mira-app',
30+
permissions: [{ streamId: '*', level: 'manage' }],
31+
status: 'active',
32+
type: 'bridge',
33+
accessId: 'acc-bridge-1',
34+
...overrides
35+
});
36+
}
37+
38+
describe('[CTCT] Contact class', function () {
39+
// ---- constructor & addSource ---- //
40+
41+
describe('[CTCS] constructor & addSource', function () {
42+
it('[CTA1] should create empty contact', () => {
43+
const c = new Contact('dr-alice', 'Dr. Alice');
44+
assert.equal(c.remoteUsername, 'dr-alice');
45+
assert.equal(c.displayName, 'Dr. Alice');
46+
assert.deepEqual(c.sources, []);
47+
assert.deepEqual(c.collectorClients, []);
48+
assert.deepEqual(c.invites, []);
49+
assert.deepEqual(c.accessObjects, []);
50+
});
51+
52+
it('[CTA2] should add source and keep displayName when already better', () => {
53+
const c = new Contact('dr-alice', 'Dr. Alice');
54+
const src = makeSource({ displayName: 'dr-alice' });
55+
c.addSource(src);
56+
assert.equal(c.sources.length, 1);
57+
assert.equal(c.displayName, 'Dr. Alice'); // kept — was already better
58+
});
59+
60+
it('[CTA3] should upgrade displayName from username to real name', () => {
61+
const c = new Contact('dr-alice', 'dr-alice');
62+
c.addSource(makeSource({ displayName: 'Dr. Alice Martin' }));
63+
assert.equal(c.displayName, 'Dr. Alice Martin');
64+
});
65+
});
66+
67+
// ---- addAccessObject ---- //
68+
69+
describe('[CTAO] addAccessObject', function () {
70+
it('[CTA4] should add access objects and dedup by id', () => {
71+
const c = new Contact('dr-alice', 'Dr. Alice');
72+
const a1 = { id: 'a1', permissions: [] };
73+
const a2 = { id: 'a2', permissions: [] };
74+
c.addAccessObject(a1);
75+
c.addAccessObject(a2);
76+
c.addAccessObject(a1); // duplicate
77+
assert.equal(c.accessObjects.length, 2);
78+
});
79+
});
80+
81+
// ---- status getter ---- //
82+
83+
describe('[CTST] status', function () {
84+
it('[CTA5] should return Active when any source is active', () => {
85+
const c = new Contact('u', 'U');
86+
c.addSource(makeSource({ status: 'Incoming' }));
87+
c.addSource(makeSource({ status: 'Active', accessId: 'acc-2' }));
88+
assert.equal(c.status, 'Active');
89+
});
90+
91+
it('[CTA6] should return Incoming when no active but has incoming', () => {
92+
const c = new Contact('u', 'U');
93+
c.addSource(makeSource({ status: 'Incoming' }));
94+
c.addSource(makeSource({ status: 'Deactivated', accessId: 'acc-2' }));
95+
assert.equal(c.status, 'Incoming');
96+
});
97+
98+
it('[CTA7] should return first source status as fallback', () => {
99+
const c = new Contact('u', 'U');
100+
c.addSource(makeSource({ status: 'Refused' }));
101+
assert.equal(c.status, 'Refused');
102+
});
103+
104+
it('[CTA8] should return null for empty contact', () => {
105+
const c = new Contact('u', 'U');
106+
assert.equal(c.status, null);
107+
});
108+
});
109+
110+
// ---- chatStreams & hasChat ---- //
111+
112+
describe('[CTCH] chatStreams & hasChat', function () {
113+
it('[CTA9] should return null when no source has chat', () => {
114+
const c = new Contact('u', 'U');
115+
c.addSource(makeSource());
116+
assert.equal(c.chatStreams, null);
117+
assert.equal(c.hasChat, false);
118+
});
119+
120+
it('[CTAA] should return chat from first source that has it', () => {
121+
const c = new Contact('u', 'U');
122+
c.addSource(makeSource());
123+
c.addSource(makeSource({
124+
accessId: 'acc-2',
125+
chatStreams: { main: 'chat-dr', incoming: 'chat-dr-in' }
126+
}));
127+
assert.deepEqual(c.chatStreams, { main: 'chat-dr', incoming: 'chat-dr-in' });
128+
assert.equal(c.hasChat, true);
129+
});
130+
});
131+
132+
// ---- appStreamIds ---- //
133+
134+
describe('[CTAP] appStreamIds', function () {
135+
it('[CTAB] should return empty for collector-only contact', () => {
136+
const c = new Contact('u', 'U');
137+
c.addSource(makeSource());
138+
assert.deepEqual(c.appStreamIds, []);
139+
});
140+
141+
it('[CTAC] should collect unique appStreamIds', () => {
142+
const c = new Contact(null, 'bridge');
143+
c.addSource(makeBridgeSource({ appStreamId: 'app-1' }));
144+
c.addSource(makeBridgeSource({ appStreamId: 'app-1', accessId: 'acc-b2' })); // dup
145+
c.addSource(makeBridgeSource({ appStreamId: 'app-2', accessId: 'acc-b3' }));
146+
assert.deepEqual(c.appStreamIds, ['app-1', 'app-2']);
147+
});
148+
});
149+
150+
// ---- allPermissions ---- //
151+
152+
describe('[CTPM] allPermissions', function () {
153+
it('[CTAD] should aggregate permissions and dedup', () => {
154+
const c = new Contact('u', 'U');
155+
c.addSource(makeSource({ permissions: [{ streamId: 'health', level: 'read' }] }));
156+
c.addSource(makeSource({
157+
accessId: 'acc-2',
158+
permissions: [
159+
{ streamId: 'health', level: 'read' }, // dup
160+
{ streamId: 'diary', level: 'contribute' }
161+
]
162+
}));
163+
const perms = c.allPermissions;
164+
assert.equal(perms.length, 2);
165+
assert.deepEqual(perms[0], { streamId: 'health', level: 'read' });
166+
assert.deepEqual(perms[1], { streamId: 'diary', level: 'contribute' });
167+
});
168+
169+
it('[CTAE] should skip Deactivated and Refused sources', () => {
170+
const c = new Contact('u', 'U');
171+
c.addSource(makeSource({ status: 'Deactivated', permissions: [{ streamId: 'a', level: 'read' }] }));
172+
c.addSource(makeSource({ status: 'Refused', accessId: 'acc-2', permissions: [{ streamId: 'b', level: 'read' }] }));
173+
c.addSource(makeSource({ status: 'Active', accessId: 'acc-3', permissions: [{ streamId: 'c', level: 'read' }] }));
174+
assert.equal(c.allPermissions.length, 1);
175+
assert.equal(c.allPermissions[0].streamId, 'c');
176+
});
177+
});
178+
179+
// ---- isActive, isPerson ---- //
180+
181+
describe('[CTIA] isActive & isPerson', function () {
182+
it('[CTAF] isActive true when any source is Active', () => {
183+
const c = new Contact('u', 'U');
184+
c.addSource(makeSource({ status: 'Deactivated' }));
185+
c.addSource(makeSource({ status: 'Active', accessId: 'acc-2' }));
186+
assert.equal(c.isActive, true);
187+
});
188+
189+
it('[CTAG] isActive true for lowercase "active" (bridges)', () => {
190+
const c = new Contact(null, 'bridge');
191+
c.addSource(makeBridgeSource({ status: 'active' }));
192+
assert.equal(c.isActive, true);
193+
});
194+
195+
it('[CTAH] isActive false when no active source', () => {
196+
const c = new Contact('u', 'U');
197+
c.addSource(makeSource({ status: 'Deactivated' }));
198+
assert.equal(c.isActive, false);
199+
});
200+
201+
it('[CTAI] isPerson true for username contacts, false for bridges', () => {
202+
const person = new Contact('dr-alice', 'Dr. Alice');
203+
const bridge = new Contact(null, 'bridge-mira');
204+
assert.equal(person.isPerson, true);
205+
assert.equal(bridge.isPerson, false);
206+
});
207+
});
208+
209+
// ---- collectorSources / bridgeSources / accessIds ---- //
210+
211+
describe('[CTFN] source filters & accessIds', function () {
212+
it('[CTAJ] should filter collector and bridge sources', () => {
213+
const c = new Contact('u', 'U');
214+
c.addSource(makeSource({ type: 'collector', accessId: 'c1' }));
215+
c.addSource(makeSource({ type: 'bridge', accessId: 'b1' }));
216+
c.addSource(makeSource({ type: 'other', accessId: 'o1' }));
217+
assert.equal(c.collectorSources.length, 1);
218+
assert.equal(c.bridgeSources.length, 1);
219+
});
220+
221+
it('[CTAK] should collect non-null accessIds', () => {
222+
const c = new Contact('u', 'U');
223+
c.addSource(makeSource({ accessId: 'a1' }));
224+
c.addSource(makeSource({ accessId: null }));
225+
c.addSource(makeSource({ accessId: 'a3' }));
226+
assert.deepEqual(c.accessIds, ['a1', 'a3']);
227+
});
228+
});
229+
230+
// ---- stream cache & event filtering ---- //
231+
232+
describe('[CTSC] initStreamCache & eventIsAccessible', function () {
233+
const streamsById = {
234+
health: { id: 'health', children: [{ id: 'health-bp', children: [] }] },
235+
diary: { id: 'diary', children: [] },
236+
'health-bp': { id: 'health-bp', children: [] }
237+
};
238+
239+
it('[CTAL] eventIsAccessible returns false before initStreamCache', () => {
240+
const c = new Contact('u', 'U');
241+
assert.equal(c.eventIsAccessible({ streamIds: ['health'] }), false);
242+
});
243+
244+
it('[CTAM] should match events in permitted streams and children', () => {
245+
const c = new Contact('u', 'U');
246+
c.addAccessObject({ id: 'a1', permissions: [{ streamId: 'health', level: 'read' }] });
247+
c.initStreamCache(streamsById);
248+
assert.equal(c.eventIsAccessible({ streamIds: ['health'] }), true);
249+
assert.equal(c.eventIsAccessible({ streamIds: ['health-bp'] }), true);
250+
assert.equal(c.eventIsAccessible({ streamIds: ['diary'] }), false);
251+
});
252+
253+
it('[CTAN] wildcard permission matches everything', () => {
254+
const c = new Contact('u', 'U');
255+
c.addAccessObject({ id: 'a1', permissions: [{ streamId: '*', level: 'manage' }] });
256+
c.initStreamCache(streamsById);
257+
assert.equal(c.eventIsAccessible({ streamIds: ['anything'] }), true);
258+
});
259+
260+
it('[CTAO2] should skip deleted accesses', () => {
261+
const c = new Contact('u', 'U');
262+
c.addAccessObject({ id: 'a1', deleted: true, permissions: [{ streamId: 'health', level: 'read' }] });
263+
c.initStreamCache(streamsById);
264+
assert.equal(c.eventIsAccessible({ streamIds: ['health'] }), false);
265+
});
266+
267+
it('[CTAP2] should handle events with no streamIds', () => {
268+
const c = new Contact('u', 'U');
269+
c.addAccessObject({ id: 'a1', permissions: [{ streamId: 'health', level: 'read' }] });
270+
c.initStreamCache(streamsById);
271+
assert.equal(c.eventIsAccessible({}), false);
272+
assert.equal(c.eventIsAccessible({ streamIds: null }), false);
273+
});
274+
});
275+
276+
// ---- eventIsFromContact ---- //
277+
278+
describe('[CTEF] eventIsFromContact', function () {
279+
it('[CTAQ] should match events modified by contact access', () => {
280+
const c = new Contact('u', 'U');
281+
c.addAccessObject({ id: 'a1' });
282+
c.addAccessObject({ id: 'a2' });
283+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a1' }), true);
284+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2' }), true);
285+
assert.equal(c.eventIsFromContact({ modifiedBy: 'other' }), false);
286+
});
287+
});
288+
289+
// ---- sourceFromAccess (static) ---- //
290+
291+
describe('[CTSF] sourceFromAccess', function () {
292+
it('[CTAR] should create bridge source from access with appStreamId', () => {
293+
const access = {
294+
id: 'acc-1',
295+
name: 'bridge-mira',
296+
permissions: [{ streamId: '*', level: 'manage' }],
297+
clientData: { appStreamId: 'bridge-mira-app' }
298+
};
299+
const src = Contact.sourceFromAccess(access);
300+
assert.equal(src.type, 'bridge');
301+
assert.equal(src.appStreamId, 'bridge-mira-app');
302+
assert.equal(src.displayName, 'bridge-mira');
303+
assert.equal(src.remoteUsername, null);
304+
assert.equal(src.status, 'active');
305+
assert.equal(src.accessId, 'acc-1');
306+
});
307+
308+
it('[CTAS] should create "other" source for plain access', () => {
309+
const access = { id: 'acc-2', name: 'some-app', permissions: [] };
310+
const src = Contact.sourceFromAccess(access);
311+
assert.equal(src.type, 'other');
312+
assert.equal(src.appStreamId, null);
313+
});
314+
315+
it('[CTAT] should handle deleted access', () => {
316+
const access = { id: 'acc-3', name: 'old', deleted: true, permissions: [] };
317+
const src = Contact.sourceFromAccess(access);
318+
assert.equal(src.status, 'Deleted');
319+
});
320+
321+
it('[CTAU] should default displayName to Unknown', () => {
322+
const access = { id: 'acc-4', permissions: [] };
323+
const src = Contact.sourceFromAccess(access);
324+
assert.equal(src.displayName, 'Unknown');
325+
});
326+
});
327+
328+
// ---- groupByContact (static) ---- //
329+
330+
describe('[CTGR] groupByContact', function () {
331+
it('[CTAV] should group sources by username', () => {
332+
const sources = [
333+
makeSource({ remoteUsername: 'dr-alice', accessId: 'a1', displayName: 'dr-alice' }),
334+
makeSource({ remoteUsername: 'dr-bob', accessId: 'a2', displayName: 'Dr. Bob' }),
335+
makeSource({ remoteUsername: 'dr-alice', accessId: 'a3', displayName: 'Dr. Alice Martin' })
336+
];
337+
const contacts = Contact.groupByContact(sources);
338+
assert.equal(contacts.length, 2);
339+
const alice = contacts.find(c => c.remoteUsername === 'dr-alice');
340+
assert.equal(alice.sources.length, 2);
341+
// displayName upgrades from username to real name via addSource logic
342+
assert.equal(alice.displayName, 'Dr. Alice Martin');
343+
});
344+
345+
it('[CTAW] should create standalone contacts for null username (bridges)', () => {
346+
const sources = [
347+
makeBridgeSource({ displayName: 'bridge-mira', accessId: 'b1' }),
348+
makeBridgeSource({ displayName: 'bridge-redcap', accessId: 'b2' })
349+
];
350+
const contacts = Contact.groupByContact(sources);
351+
assert.equal(contacts.length, 2);
352+
contacts.forEach(c => assert.equal(c.remoteUsername, null));
353+
});
354+
355+
it('[CTAX] should mix person and bridge contacts', () => {
356+
const sources = [
357+
makeSource({ remoteUsername: 'dr-alice', accessId: 'a1' }),
358+
makeBridgeSource({ accessId: 'b1' }),
359+
makeSource({ remoteUsername: 'dr-alice', accessId: 'a2' })
360+
];
361+
const contacts = Contact.groupByContact(sources);
362+
assert.equal(contacts.length, 2); // 1 alice + 1 bridge
363+
const alice = contacts.find(c => c.remoteUsername === 'dr-alice');
364+
assert.equal(alice.sources.length, 2);
365+
});
366+
367+
it('[CTAY] should return empty array for empty input', () => {
368+
assert.deepEqual(Contact.groupByContact([]), []);
369+
});
370+
});
371+
372+
// ---- addInvite (doctor side) ---- //
373+
374+
describe('[CTIV] addInvite', function () {
375+
it('[CTAZ] should add invite and dedup by key', () => {
376+
const c = new Contact('patient1', 'Patient 1');
377+
const collector1 = { id: 'col1' };
378+
const invite1 = { key: 'inv1' };
379+
const invite2 = { key: 'inv2' };
380+
c.addInvite(collector1, invite1);
381+
c.addInvite(collector1, invite1); // dup
382+
c.addInvite(collector1, invite2);
383+
assert.equal(c.invites.length, 2);
384+
});
385+
});
386+
387+
// ---- addCollectorClient ---- //
388+
389+
describe('[CTCC] addCollectorClient', function () {
390+
it('[CTB1] should dedup by reference', () => {
391+
const c = new Contact('u', 'U');
392+
const cc = { status: 'Active' };
393+
c.addCollectorClient(cc);
394+
c.addCollectorClient(cc); // same ref
395+
assert.equal(c.collectorClients.length, 1);
396+
});
397+
});
398+
});

0 commit comments

Comments
 (0)