@learncard/network-brain-service 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/accesslayer/boost/read.d.ts.map +1 -1
  3. package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
  4. package/dist/accesslayer/profile/create.d.ts.map +1 -1
  5. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  6. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  7. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  8. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  9. package/dist/index.js +2 -2
  10. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  11. package/dist/models/index.d.ts.map +1 -1
  12. package/dist/publish-metafile.json +1 -1
  13. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  14. package/package.json +19 -16
  15. package/project.json +18 -0
  16. package/scripts/backfill-profile-search.ts +79 -0
  17. package/scripts/seed-demo-persona.ts +6 -1
  18. package/src/accesslayer/boost/read.ts +171 -34
  19. package/src/accesslayer/boost/relationships/read.ts +163 -126
  20. package/src/accesslayer/profile/create.ts +2 -1
  21. package/src/accesslayer/profile/read.ts +23 -3
  22. package/src/accesslayer/profile/search-fields.ts +69 -0
  23. package/src/accesslayer/profile/update.ts +7 -1
  24. package/src/models/credential-refresh-constraints.test.ts +44 -0
  25. package/src/models/credential-refresh-constraints.ts +29 -3
  26. package/src/models/index.ts +15 -4
  27. package/src/seed/seedSkillFrameworks.ts +4 -1
  28. package/test/boost.familial-equivalence.spec.ts +482 -0
  29. package/test/boost.recipientsJoin.spec.ts +508 -0
  30. package/test/profile-search.spec.ts +307 -0
  31. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  32. package/vitest.profile-search-neo4j.config.ts +20 -0
@@ -0,0 +1,508 @@
1
+ import { describe, it, beforeAll, beforeEach, afterAll, expect } from 'vitest';
2
+
3
+ import { getUser } from './helpers/getClient';
4
+ import { sendBoost, testUnsignedBoost } from './helpers/send';
5
+
6
+ import { neogma } from '@instance';
7
+ import { Profile, Boost, Credential, Role } from '@models';
8
+ import { getBoostByUri } from '@accesslayer/boost/read';
9
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
10
+ import {
11
+ getBoostRecipients,
12
+ getConnectedBoostRecipients,
13
+ countConnectedBoostRecipients,
14
+ getBoostRecipientsWithChildren,
15
+ countBoostRecipientsWithChildren,
16
+ } from '@accesslayer/boost/relationships/read';
17
+
18
+ /**
19
+ * Access-layer coverage for the boost recipient queries that join each CREDENTIAL_SENT to the
20
+ * recipient Profile by `sent.to`: list, count, pagination, and pending (unaccepted) recipients.
21
+ *
22
+ * Sends are written directly so dates, statuses, repeat sends, and sends to profileIds with no
23
+ * Profile are exact.
24
+ */
25
+
26
+ let userA: Awaited<ReturnType<typeof getUser>>;
27
+ let userB: Awaited<ReturnType<typeof getUser>>;
28
+
29
+ const domain = 'localhost%3A3000';
30
+ const minute = (k: number) => new Date(Date.UTC(2026, 0, 1, 0, k)).toISOString();
31
+
32
+ type SeedSend = {
33
+ to: string;
34
+ k: number;
35
+ accepted?: boolean;
36
+ sentStatus?: string;
37
+ receivedStatus?: string;
38
+ from?: string;
39
+ };
40
+
41
+ const createProfiles = async (profileIds: string[]) => {
42
+ await neogma.queryRunner.run(
43
+ `UNWIND $profileIds AS id
44
+ CREATE (:Profile {profileId: id, did: 'did:example:' + id, displayName: 'Name ' + id})`,
45
+ { profileIds }
46
+ );
47
+ };
48
+
49
+ const seedSends = async (boostId: string, sends: SeedSend[]) => {
50
+ await neogma.queryRunner.run(
51
+ `MATCH (b:Boost {id: $boostId})
52
+ UNWIND $rows AS row
53
+ MATCH (s:Profile {profileId: row.from})
54
+ CREATE (c:Credential {id: row.id, credential: '{}'})-[:INSTANCE_OF]->(b)
55
+ CREATE (s)-[sent:CREDENTIAL_SENT {to: row.to, date: row.date}]->(c)
56
+ SET sent.status = row.sentStatus
57
+ WITH c, row WHERE row.accepted
58
+ MATCH (r:Profile {profileId: row.to})
59
+ CREATE (c)-[received:CREDENTIAL_RECEIVED {from: row.from, date: row.date}]->(r)
60
+ SET received.status = row.receivedStatus`,
61
+ {
62
+ boostId,
63
+ rows: sends.map(send => ({
64
+ id: `${boostId}-${send.k}`,
65
+ from: send.from ?? 'usera',
66
+ to: send.to,
67
+ date: minute(send.k),
68
+ accepted: Boolean(send.accepted),
69
+ sentStatus: send.sentStatus ?? null,
70
+ receivedStatus: send.receivedStatus ?? null,
71
+ })),
72
+ }
73
+ );
74
+ };
75
+
76
+ const connect = async (profileId: string, targets: string[]) => {
77
+ await neogma.queryRunner.run(
78
+ `MATCH (a:Profile {profileId: $profileId})
79
+ UNWIND $targets AS target
80
+ MATCH (b:Profile {profileId: target})
81
+ CREATE (a)-[:CONNECTED_WITH]->(b)`,
82
+ { profileId, targets }
83
+ );
84
+ };
85
+
86
+ const createBoost = async (category = 'Main') => {
87
+ const uri = await userA.clients.fullAuth.boost.createBoost({
88
+ credential: testUnsignedBoost,
89
+ category,
90
+ });
91
+ return { uri, boost: (await getBoostByUri(uri))! };
92
+ };
93
+
94
+ /** Same paging the getBoostRecipients route does: limit + 1, cursor = sent date of the last kept row */
95
+ const walkBySentDate = async <T extends { sent: string }>(
96
+ fetchPage: (limit: number, cursor?: string) => Promise<T[]>,
97
+ limit: number
98
+ ) => {
99
+ const pages: T[][] = [];
100
+ let cursor: string | undefined;
101
+
102
+ for (let i = 0; i < 20; i++) {
103
+ const records = await fetchPage(limit + 1, cursor);
104
+ const hasMore = records.length > limit;
105
+ pages.push(records.slice(0, limit));
106
+ cursor = records.at(hasMore ? -2 : -1)?.sent;
107
+ if (!hasMore) break;
108
+ }
109
+
110
+ return pages;
111
+ };
112
+
113
+ type RecipientQuery = NonNullable<Parameters<typeof getBoostRecipients>[1]['query']>;
114
+
115
+ const ids = (records: Array<{ to: { profileId: string } }>) =>
116
+ records.map(record => record.to.profileId);
117
+
118
+ /**
119
+ * Main boost, sent by usera unless noted:
120
+ * k0 r0 accepted k5 r4 accepted, sent suspended
121
+ * k1 r1 pending k6 r1 accepted (repeat send)
122
+ * k2 no Profile, pending k7 r5 pending, sent by senderb
123
+ * k3 r2 accepted, received revoked
124
+ * k4 r3 pending, sent revoked
125
+ * k8 r6 accepted, received suspended
126
+ * k9 r7 accepted
127
+ */
128
+ const MAIN_SENDS: SeedSend[] = [
129
+ { k: 0, to: 'r0', accepted: true },
130
+ { k: 1, to: 'r1' },
131
+ { k: 2, to: 'no-profile-1' },
132
+ { k: 3, to: 'r2', accepted: true, receivedStatus: 'revoked' },
133
+ { k: 4, to: 'r3', sentStatus: 'revoked' },
134
+ { k: 5, to: 'r4', accepted: true, sentStatus: 'suspended' },
135
+ { k: 6, to: 'r1', accepted: true },
136
+ { k: 7, to: 'r5', from: 'senderb' },
137
+ { k: 8, to: 'r6', accepted: true, receivedStatus: 'suspended' },
138
+ { k: 9, to: 'r7', accepted: true },
139
+ ];
140
+
141
+ describe('AccessLayer: boost recipient queries', () => {
142
+ beforeAll(async () => {
143
+ userA = await getUser('a'.repeat(64));
144
+ userB = await getUser('b'.repeat(64));
145
+ });
146
+
147
+ beforeEach(async () => {
148
+ await Profile.delete({ detach: true, where: {} });
149
+ await Boost.delete({ detach: true, where: {} });
150
+ await Credential.delete({ detach: true, where: {} });
151
+ await Role.delete({ detach: true, where: {} });
152
+
153
+ await userA.clients.fullAuth.profile.createProfile({ profileId: 'usera' });
154
+ await userB.clients.fullAuth.profile.createProfile({ profileId: 'userb' });
155
+ await createProfiles([
156
+ 'senderb',
157
+ 'viewer',
158
+ 'r0',
159
+ 'r1',
160
+ 'r2',
161
+ 'r3',
162
+ 'r4',
163
+ 'r5',
164
+ 'r6',
165
+ 'r7',
166
+ 'r8',
167
+ ]);
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await Profile.delete({ detach: true, where: {} });
172
+ await Boost.delete({ detach: true, where: {} });
173
+ await Credential.delete({ detach: true, where: {} });
174
+ await Role.delete({ detach: true, where: {} });
175
+ });
176
+
177
+ describe('getBoostRecipients', () => {
178
+ it('lists pending and accepted recipients in send order, skipping revoked sends and unknown profiles', async () => {
179
+ const { boost } = await createBoost();
180
+ await seedSends(boost.id, MAIN_SENDS);
181
+
182
+ const records = await getBoostRecipients(boost, { limit: 50, domain });
183
+
184
+ expect(ids(records)).toEqual(['r0', 'r1', 'r4', 'r5', 'r6', 'r7']);
185
+
186
+ const byId = Object.fromEntries(records.map(record => [record.to.profileId, record]));
187
+ // A repeat send keeps the earliest row, which here is still pending
188
+ expect(byId.r1!.sent).toBe(minute(1));
189
+ expect(byId.r1!.received).toBeUndefined();
190
+ expect(byId.r0!.received).toBe(minute(0));
191
+ expect(byId.r4!.status).toBe('suspended');
192
+ expect(byId.r6!.status).toBe('suspended');
193
+ expect(byId.r5!.from).toBe('senderb');
194
+ expect(byId.r0!.uri).toContain(`${boost.id}-0`);
195
+ });
196
+
197
+ it('only lists accepted recipients when includeUnacceptedBoosts is false', async () => {
198
+ const { boost } = await createBoost();
199
+ await seedSends(boost.id, MAIN_SENDS);
200
+
201
+ const records = await getBoostRecipients(boost, {
202
+ limit: 50,
203
+ domain,
204
+ includeUnacceptedBoosts: false,
205
+ });
206
+
207
+ expect(ids(records)).toEqual(['r0', 'r4', 'r1', 'r6', 'r7']);
208
+ expect(records.find(record => record.to.profileId === 'r1')!.sent).toBe(minute(6));
209
+ });
210
+
211
+ it('starts after the cursor before deduplicating repeat sends', async () => {
212
+ const { boost } = await createBoost();
213
+ await seedSends(boost.id, MAIN_SENDS);
214
+
215
+ const records = await getBoostRecipients(boost, {
216
+ limit: 50,
217
+ domain,
218
+ cursor: minute(3),
219
+ });
220
+
221
+ expect(ids(records)).toEqual(['r4', 'r1', 'r5', 'r6', 'r7']);
222
+ expect(records[1]!.sent).toBe(minute(6));
223
+ });
224
+
225
+ it('pages through pending and accepted recipients with the route cursor', async () => {
226
+ const { boost } = await createBoost();
227
+ await seedSends(
228
+ boost.id,
229
+ ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6'].map((to, k) => ({
230
+ k,
231
+ to,
232
+ accepted: k % 2 === 0,
233
+ }))
234
+ );
235
+
236
+ for (const [limit, expected] of [
237
+ [2, [['r0', 'r1'], ['r2', 'r3'], ['r4', 'r5'], ['r6']]],
238
+ [3, [['r0', 'r1', 'r2'], ['r3', 'r4', 'r5'], ['r6']]],
239
+ [7, [['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6']]],
240
+ ] as const) {
241
+ const pages = await walkBySentDate(
242
+ (pageLimit, cursor) =>
243
+ getBoostRecipients(boost, { limit: pageLimit, cursor, domain }),
244
+ limit
245
+ );
246
+ expect(pages.map(page => ids(page))).toEqual(expected);
247
+ }
248
+
249
+ const acceptedPages = await walkBySentDate(
250
+ (pageLimit, cursor) =>
251
+ getBoostRecipients(boost, {
252
+ limit: pageLimit,
253
+ cursor,
254
+ domain,
255
+ includeUnacceptedBoosts: false,
256
+ }),
257
+ 2
258
+ );
259
+ expect(acceptedPages.map(page => ids(page))).toEqual([
260
+ ['r0', 'r2'],
261
+ ['r4', 'r6'],
262
+ ]);
263
+ });
264
+
265
+ it('filters by recipient profileId the way the recipient-list visibility check calls it', async () => {
266
+ const { boost } = await createBoost();
267
+ await seedSends(boost.id, MAIN_SENDS);
268
+
269
+ const lookup = (query: RecipientQuery) =>
270
+ getBoostRecipients(boost, { limit: 1, domain, query });
271
+
272
+ expect(ids(await lookup({ profileId: 'r4' }))).toEqual(['r4']);
273
+ expect(ids(await lookup({ profileId: 'r1' }))).toEqual(['r1']);
274
+ expect(await lookup({ profileId: 'r3' })).toEqual([]);
275
+ expect(await lookup({ profileId: 'r2' })).toEqual([]);
276
+ expect(await lookup({ profileId: 'no-profile-1' })).toEqual([]);
277
+ expect(await lookup({ profileId: 'viewer' })).toEqual([]);
278
+
279
+ const several = await getBoostRecipients(boost, {
280
+ limit: 10,
281
+ domain,
282
+ query: { profileId: { $in: ['r5', 'r7', 'r3', 'viewer'] } },
283
+ });
284
+ expect(ids(several)).toEqual(['r5', 'r7']);
285
+
286
+ const either = await getBoostRecipients(boost, {
287
+ limit: 10,
288
+ domain,
289
+ query: {
290
+ $or: [{ profileId: 'r0' }, { displayName: 'Name r6' }],
291
+ } as unknown as RecipientQuery,
292
+ });
293
+ expect(ids(either)).toEqual(['r0', 'r6']);
294
+ });
295
+
296
+ it('filters by sender', async () => {
297
+ const { boost } = await createBoost();
298
+ await seedSends(boost.id, MAIN_SENDS);
299
+
300
+ const fromB = await getBoostRecipients(boost, { limit: 10, domain, from: 'senderb' });
301
+ expect(ids(fromB)).toEqual(['r5']);
302
+
303
+ const fromA = await getBoostRecipients(boost, {
304
+ limit: 2,
305
+ domain,
306
+ from: 'usera',
307
+ cursor: minute(0),
308
+ });
309
+ // The send with no Profile and the two revoked sends never take a LIMIT slot
310
+ expect(ids(fromA)).toEqual(['r1', 'r4']);
311
+ });
312
+
313
+ it('lists a real pending send and then the accepted one', async () => {
314
+ const { uri, boost } = await createBoost();
315
+
316
+ await sendBoost(
317
+ { profileId: 'usera', user: userA },
318
+ { profileId: 'userb', user: userB },
319
+ uri,
320
+ false
321
+ );
322
+
323
+ const pending = await getBoostRecipients(boost, { limit: 10, domain });
324
+ expect(ids(pending)).toEqual(['userb']);
325
+ expect(pending[0]!.received).toBeUndefined();
326
+ expect(
327
+ await getBoostRecipients(boost, {
328
+ limit: 10,
329
+ domain,
330
+ includeUnacceptedBoosts: false,
331
+ })
332
+ ).toEqual([]);
333
+
334
+ await sendBoost(
335
+ { profileId: 'usera', user: userA },
336
+ { profileId: 'userb', user: userB },
337
+ uri
338
+ );
339
+
340
+ const accepted = await getBoostRecipients(boost, {
341
+ limit: 10,
342
+ domain,
343
+ includeUnacceptedBoosts: false,
344
+ });
345
+ expect(ids(accepted)).toEqual(['userb']);
346
+ expect(accepted[0]!.received).toBeDefined();
347
+ });
348
+ });
349
+
350
+ describe('connected recipients', () => {
351
+ it('lists, pages, and counts recipients connected to the requester', async () => {
352
+ const { boost } = await createBoost();
353
+ await seedSends(boost.id, MAIN_SENDS);
354
+ await connect('viewer', ['r0', 'r1', 'r3', 'r4', 'r6']);
355
+ const viewer = (await getProfileByProfileId('viewer'))!;
356
+
357
+ // No revoked filter and no repeat-send dedupe here, as before
358
+ const all = await getConnectedBoostRecipients(viewer, boost, { limit: 50, domain });
359
+ expect(all.map(record => [record.to.profileId, record.sent])).toEqual([
360
+ ['r0', minute(0)],
361
+ ['r1', minute(1)],
362
+ ['r3', minute(4)],
363
+ ['r4', minute(5)],
364
+ ['r1', minute(6)],
365
+ ['r6', minute(8)],
366
+ ]);
367
+
368
+ const pages = await walkBySentDate(
369
+ (limit, cursor) =>
370
+ getConnectedBoostRecipients(viewer, boost, { limit, cursor, domain }),
371
+ 4
372
+ );
373
+ expect(pages.map(page => ids(page))).toEqual([
374
+ ['r0', 'r1', 'r3', 'r4'],
375
+ ['r1', 'r6'],
376
+ ]);
377
+
378
+ const accepted = await getConnectedBoostRecipients(viewer, boost, {
379
+ limit: 50,
380
+ domain,
381
+ includeUnacceptedBoosts: false,
382
+ });
383
+ expect(ids(accepted)).toEqual(['r0', 'r4', 'r1', 'r6']);
384
+
385
+ const filtered = await getConnectedBoostRecipients(viewer, boost, {
386
+ limit: 50,
387
+ domain,
388
+ query: { displayName: 'Name r4' },
389
+ });
390
+ expect(ids(filtered)).toEqual(['r4']);
391
+
392
+ expect(await countConnectedBoostRecipients(viewer, boost, {})).toBe(5);
393
+ expect(
394
+ await countConnectedBoostRecipients(viewer, boost, {
395
+ includeUnacceptedBoosts: false,
396
+ })
397
+ ).toBe(4);
398
+ expect(
399
+ await countConnectedBoostRecipients(viewer, boost, {
400
+ query: { displayName: 'Name r3' },
401
+ })
402
+ ).toBe(1);
403
+ expect(
404
+ await countConnectedBoostRecipients(viewer, boost, {
405
+ includeUnacceptedBoosts: false,
406
+ query: { profileId: { $in: ['r1', 'r3'] } },
407
+ })
408
+ ).toBe(1);
409
+ });
410
+
411
+ it('counts zero when the requester has no connected recipients', async () => {
412
+ const { boost } = await createBoost();
413
+ await seedSends(boost.id, MAIN_SENDS);
414
+ const viewer = (await getProfileByProfileId('viewer'))!;
415
+
416
+ expect(await getConnectedBoostRecipients(viewer, boost, { limit: 10, domain })).toEqual(
417
+ []
418
+ );
419
+ expect(await countConnectedBoostRecipients(viewer, boost, {})).toBe(0);
420
+ });
421
+ });
422
+
423
+ describe('recipients with children', () => {
424
+ const seedFamily = async () => {
425
+ const { uri: parentUri, boost: parent } = await createBoost('Parent');
426
+ const childUri = await userA.clients.fullAuth.boost.createChildBoost({
427
+ parentUri,
428
+ boost: { credential: testUnsignedBoost, category: 'Child' },
429
+ });
430
+ const child = (await getBoostByUri(childUri))!;
431
+
432
+ await seedSends(parent.id, MAIN_SENDS);
433
+ await seedSends(child.id, [
434
+ { k: 20, to: 'r0', accepted: true },
435
+ { k: 21, to: 'r8' },
436
+ { k: 22, to: 'no-profile-2' },
437
+ { k: 23, to: 'r2', accepted: true, receivedStatus: 'revoked' },
438
+ ]);
439
+
440
+ return { parent, child };
441
+ };
442
+
443
+ it('groups pending and accepted recipients by profile across child boosts', async () => {
444
+ const { parent } = await seedFamily();
445
+
446
+ const records = await getBoostRecipientsWithChildren(parent, { limit: 50, domain });
447
+
448
+ // r2 only has revoked received rows, which are dropped after the page is read
449
+ expect(ids(records)).toEqual(['r0', 'r1', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8']);
450
+ expect(records[0]!.boostUris).toHaveLength(2);
451
+
452
+ const accepted = await getBoostRecipientsWithChildren(parent, {
453
+ limit: 50,
454
+ domain,
455
+ includeUnacceptedBoosts: false,
456
+ });
457
+ expect(ids(accepted)).toEqual(['r0', 'r1', 'r4', 'r6', 'r7']);
458
+
459
+ const filtered = await getBoostRecipientsWithChildren(parent, {
460
+ limit: 50,
461
+ domain,
462
+ profileQuery: { profileId: { $in: ['r8', 'r0', 'no-profile-2'] } },
463
+ });
464
+ expect(ids(filtered)).toEqual(['r0', 'r8']);
465
+ });
466
+
467
+ it('pages by profileId cursor', async () => {
468
+ const { parent } = await seedFamily();
469
+
470
+ const page = await getBoostRecipientsWithChildren(parent, {
471
+ limit: 2,
472
+ cursor: 'r3',
473
+ domain,
474
+ });
475
+ expect(ids(page)).toEqual(['r4', 'r5']);
476
+
477
+ const tail = await getBoostRecipientsWithChildren(parent, {
478
+ limit: 50,
479
+ cursor: 'r5',
480
+ domain,
481
+ });
482
+ expect(ids(tail)).toEqual(['r6', 'r7', 'r8']);
483
+
484
+ const first = await getBoostRecipientsWithChildren(parent, { limit: 1, domain });
485
+ expect(ids(first)).toEqual(['r0']);
486
+ });
487
+
488
+ it('counts distinct recipients with existing profiles', async () => {
489
+ const { parent } = await seedFamily();
490
+
491
+ expect(await countBoostRecipientsWithChildren(parent, {})).toBe(9);
492
+ expect(
493
+ await countBoostRecipientsWithChildren(parent, { includeUnacceptedBoosts: false })
494
+ ).toBe(6);
495
+ expect(
496
+ await countBoostRecipientsWithChildren(parent, {
497
+ profileQuery: { profileId: 'r2' },
498
+ })
499
+ ).toBe(1);
500
+ expect(
501
+ await countBoostRecipientsWithChildren(parent, {
502
+ includeUnacceptedBoosts: false,
503
+ profileQuery: { profileId: { $in: ['r3', 'r5', 'r8', 'r6'] } },
504
+ })
505
+ ).toBe(1);
506
+ });
507
+ });
508
+ });