@learncard/network-brain-service 4.0.2 → 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 +14 -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 +4 -1
  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,307 @@
1
+ import { ProfileVisibilityEnum } from '@learncard/types';
2
+
3
+ import { neogma } from '@instance';
4
+ import { Profile } from '@models';
5
+ import { searchProfiles } from '@accesslayer/profile/read';
6
+ import { backfillProfileDisplayNameLower } from '@accesslayer/profile/search-fields';
7
+
8
+ import { getClient } from './helpers/getClient';
9
+
10
+ const noAuthClient = getClient();
11
+
12
+ let didCounter = 0;
13
+ const newClient = () =>
14
+ getClient({
15
+ did: `did:test:profile-search-${++didCounter}`,
16
+ isChallengeValid: true,
17
+ scope: '*:*',
18
+ });
19
+
20
+ const createProfile = async (
21
+ profileId: string,
22
+ displayName?: string,
23
+ extra: Record<string, unknown> = {}
24
+ ) => {
25
+ const client = newClient();
26
+ await client.profile.createProfile({
27
+ profileId,
28
+ ...(displayName !== undefined && { displayName }),
29
+ ...extra,
30
+ });
31
+ return client;
32
+ };
33
+
34
+ const ids = (results: { profileId: string }[]) => results.map(result => result.profileId);
35
+
36
+ describe('Profile search (text-index backed, case-insensitive)', () => {
37
+ beforeAll(async () => {
38
+ await Profile.delete({ detach: true, where: {} });
39
+
40
+ await createProfile('alice', 'Alice Alison');
41
+ await createProfile('mko', 'McKenzie Ortiz');
42
+ await createProfile('bobby-tables', 'Robert');
43
+ await createProfile('dotcom', 'Dot.Com (Test)');
44
+ await createProfile('elodie', 'élodie Martin');
45
+ await createProfile('noname');
46
+
47
+ await createProfile('hidden-private', 'Hidden Private', { isPrivate: true });
48
+
49
+ const connectionsOnly = await createProfile('hidden-connections', 'Hidden Connections');
50
+ await connectionsOnly.profile.updateProfile({
51
+ profileVisibility: ProfileVisibilityEnum.enum.connections_only,
52
+ });
53
+
54
+ const privateVisibility = await createProfile('hidden-visibility', 'Hidden Visibility');
55
+ await privateVisibility.profile.updateProfile({
56
+ profileVisibility: ProfileVisibilityEnum.enum.private,
57
+ });
58
+
59
+ await newClient().profile.createServiceProfile({
60
+ profileId: 'hidden-service',
61
+ displayName: 'Hidden Service',
62
+ });
63
+
64
+ // Created out of order so the ordering assertion means something
65
+ for (const n of [5, 2, 7, 1, 4, 6, 3]) {
66
+ await createProfile(`pager${n}`, `Pager Person 0${n}`);
67
+ }
68
+ }, 30_000);
69
+
70
+ afterAll(async () => {
71
+ await Profile.delete({ detach: true, where: {} });
72
+ });
73
+
74
+ it('matches on profileId, whatever the casing of the input', async () => {
75
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'bobby' }))).toEqual([
76
+ 'bobby-tables',
77
+ ]);
78
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'BOBBY-T' }))).toEqual([
79
+ 'bobby-tables',
80
+ ]);
81
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'NoNaMe' }))).toEqual([
82
+ 'noname',
83
+ ]);
84
+ });
85
+
86
+ it('matches on displayName case-insensitively, including mid-word and across spaces', async () => {
87
+ for (const input of [
88
+ 'ALICE',
89
+ 'alice alison',
90
+ 'mckenzie',
91
+ 'MCKENZIE',
92
+ 'kenZIE oRt',
93
+ 'rober',
94
+ ]) {
95
+ const results = await noAuthClient.profile.searchProfiles({ input });
96
+ expect(results.length, input).toBeGreaterThan(0);
97
+ }
98
+
99
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'kenZIE oRt' }))).toEqual([
100
+ 'mko',
101
+ ]);
102
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ROBER' }))).toEqual([
103
+ 'bobby-tables',
104
+ ]);
105
+ });
106
+
107
+ it('returns a profile that matches both fields once', async () => {
108
+ const results = await noAuthClient.profile.searchProfiles({ input: 'ALI' });
109
+
110
+ expect(ids(results)).toEqual(['alice']);
111
+ });
112
+
113
+ it('lowercases non-ASCII letters too', async () => {
114
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ÉLODIE' }))).toEqual([
115
+ 'elodie',
116
+ ]);
117
+ });
118
+
119
+ it('treats regex metacharacters literally', async () => {
120
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'DOT.COM' }))).toEqual([
121
+ 'dotcom',
122
+ ]);
123
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: '(test)' }))).toEqual([
124
+ 'dotcom',
125
+ ]);
126
+ // `.` used to be a regex wildcard here; now it only matches a literal dot
127
+ expect(await noAuthClient.profile.searchProfiles({ input: 'r.bert' })).toHaveLength(0);
128
+ // An unbalanced bracket used to be an invalid regex
129
+ await expect(noAuthClient.profile.searchProfiles({ input: '[' })).resolves.toEqual([]);
130
+ });
131
+
132
+ it('leaves out private, non-public and service profiles', async () => {
133
+ const results = await noAuthClient.profile.searchProfiles({ input: 'hidden' });
134
+
135
+ expect(results).toHaveLength(0);
136
+ });
137
+
138
+ it('includes service profiles only when asked', async () => {
139
+ const results = await noAuthClient.profile.searchProfiles({
140
+ input: 'HIDDEN',
141
+ includeServiceProfiles: true,
142
+ });
143
+
144
+ expect(ids(results)).toEqual(['hidden-service']);
145
+ });
146
+
147
+ it('leaves out blacklisted profileIds', async () => {
148
+ const results = await searchProfiles('pager', { blacklist: ['pager1', 'pager2'] });
149
+
150
+ expect(ids(results)).toEqual(['pager3', 'pager4', 'pager5', 'pager6', 'pager7']);
151
+ });
152
+
153
+ it('orders by displayName and applies the limit after merging both fields', async () => {
154
+ const firstThree = await noAuthClient.profile.searchProfiles({ input: 'PAGER', limit: 3 });
155
+ const all = await noAuthClient.profile.searchProfiles({ input: 'pager person', limit: 50 });
156
+
157
+ expect(ids(firstThree)).toEqual(['pager1', 'pager2', 'pager3']);
158
+ expect(ids(all)).toEqual([
159
+ 'pager1',
160
+ 'pager2',
161
+ 'pager3',
162
+ 'pager4',
163
+ 'pager5',
164
+ 'pager6',
165
+ 'pager7',
166
+ ]);
167
+ });
168
+
169
+ it('still returns everything visible for an empty search', async () => {
170
+ const results = await noAuthClient.profile.searchProfiles({ input: '', limit: 50 });
171
+
172
+ expect(results.map(result => result.profileId).sort()).toEqual(
173
+ [
174
+ 'alice',
175
+ 'bobby-tables',
176
+ 'dotcom',
177
+ 'elodie',
178
+ 'mko',
179
+ 'noname',
180
+ 'pager1',
181
+ 'pager2',
182
+ 'pager3',
183
+ 'pager4',
184
+ 'pager5',
185
+ 'pager6',
186
+ 'pager7',
187
+ ].sort()
188
+ );
189
+ });
190
+
191
+ it('keeps the lowercased copy in sync when displayName changes', async () => {
192
+ const client = await createProfile('renamer', 'Original Name');
193
+
194
+ await client.profile.updateProfile({ displayName: 'Zebra Crossing' });
195
+
196
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ZEBRA' }))).toEqual([
197
+ 'renamer',
198
+ ]);
199
+ expect(await noAuthClient.profile.searchProfiles({ input: 'original' })).toHaveLength(0);
200
+
201
+ // Updates that don't touch displayName leave the copy alone
202
+ await client.profile.updateProfile({ shortBio: 'hi' });
203
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'zebra' }))).toEqual([
204
+ 'renamer',
205
+ ]);
206
+ });
207
+
208
+ it('does not expose the lowercased copy in search results', async () => {
209
+ const [result] = await noAuthClient.profile.searchProfiles({ input: 'mko' });
210
+
211
+ expect(result).toBeDefined();
212
+ expect(result).not.toHaveProperty('displayNameLower');
213
+ });
214
+
215
+ it('finds pre-existing profiles by displayName only after the backfill runs', async () => {
216
+ await createProfile('legacy-user', 'Quokka Legacy');
217
+ // Simulate a profile written before displayNameLower existed
218
+ await neogma.queryRunner.run(
219
+ 'MATCH (p:Profile {profileId: "legacy-user"}) REMOVE p.displayNameLower'
220
+ );
221
+
222
+ expect(await noAuthClient.profile.searchProfiles({ input: 'quokka' })).toHaveLength(0);
223
+ // profileId matches don't depend on the backfill
224
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'LEGACY-U' }))).toEqual([
225
+ 'legacy-user',
226
+ ]);
227
+
228
+ const session = neogma.driver.session();
229
+ try {
230
+ const updated = await backfillProfileDisplayNameLower(query => session.run(query), {
231
+ batchSize: 2,
232
+ });
233
+ expect(updated).toBe(1);
234
+
235
+ // Idempotent: nothing left to do on a second run
236
+ const again = await backfillProfileDisplayNameLower(query => session.run(query));
237
+ expect(again).toBe(0);
238
+ } finally {
239
+ await session.close();
240
+ }
241
+
242
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'QUOKKA' }))).toEqual([
243
+ 'legacy-user',
244
+ ]);
245
+ });
246
+ });
247
+
248
+ describe('Profile search normalization (context-independent casing)', () => {
249
+ beforeAll(async () => {
250
+ await Profile.delete({ detach: true, where: {} });
251
+
252
+ // Cypher toLower is context-sensitive for Greek: Σ becomes ς at the end of a word and σ
253
+ // elsewhere, so the stored name and a substring of it can lowercase differently.
254
+ await createProfile('anastasia', 'ΑΝΑΣΤΑΣΙΑ');
255
+ await createProfile('nikos', 'Νίκος Παππάς');
256
+ }, 30_000);
257
+
258
+ afterAll(async () => {
259
+ await Profile.delete({ detach: true, where: {} });
260
+ });
261
+
262
+ it('matches a Greek prefix whose last letter is a capital sigma', async () => {
263
+ // Stored as αναστασια; ΑΝΑΣ used to lowercase to ανας (final sigma) and miss
264
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ΑΝΑΣ' }))).toEqual([
265
+ 'anastasia',
266
+ ]);
267
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ανας' }))).toEqual([
268
+ 'anastasia',
269
+ ]);
270
+ });
271
+
272
+ it('matches Greek searches ending in a sigma, whichever sigma form either side uses', async () => {
273
+ // Mid-name: the input's trailing Σ is word-final, the stored one isn't
274
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ΣΤΑΣ' }))).toEqual([
275
+ 'anastasia',
276
+ ]);
277
+ // Name suffix typed with a medial σ against a stored final ς (Νίκος, Παππάς)
278
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ίκοσ' }))).toEqual([
279
+ 'nikos',
280
+ ]);
281
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ΠΠΆΣ' }))).toEqual([
282
+ 'nikos',
283
+ ]);
284
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'παππάσ' }))).toEqual([
285
+ 'nikos',
286
+ ]);
287
+ });
288
+
289
+ it('backfills the normalized form over a copy written by the previous lowercasing', async () => {
290
+ // Simulate a profile written by the previous build: plain toLower, final sigma kept
291
+ await neogma.queryRunner.run(
292
+ 'MATCH (p:Profile {profileId: "nikos"}) SET p.displayNameLower = toLower(p.displayName)'
293
+ );
294
+ expect(await noAuthClient.profile.searchProfiles({ input: 'ίκοσ' })).toHaveLength(0);
295
+
296
+ const session = neogma.driver.session();
297
+ try {
298
+ expect(await backfillProfileDisplayNameLower(query => session.run(query))).toBe(1);
299
+ } finally {
300
+ await session.close();
301
+ }
302
+
303
+ expect(ids(await noAuthClient.profile.searchProfiles({ input: 'ίκοσ' }))).toEqual([
304
+ 'nikos',
305
+ ]);
306
+ });
307
+ });
@@ -0,0 +1,21 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import { createVitestConfig, serviceIntegrationPreset } from '../../../vitest.shared';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ // The boost recipient queries are only meaningful against real Cypher (send/Profile joins,
8
+ // cursors, OPTIONAL MATCH), so this suite runs against a disposable Neo4j instead of the mocked
9
+ // unit setup.
10
+ export default createVitestConfig(serviceIntegrationPreset, {
11
+ test: {
12
+ globalSetup: './test-setup.ts',
13
+ include: ['test/boost.recipientsJoin.spec.ts'],
14
+ alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },
15
+ env: {
16
+ IS_E2E_TEST: 'true',
17
+ DOMAIN_NAME: 'localhost%3A3000',
18
+ TRACE_CONSOLE: 'false',
19
+ },
20
+ },
21
+ });
@@ -0,0 +1,20 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import { createVitestConfig, serviceIntegrationPreset } from '../../../vitest.shared';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ // Profile search depends on real Cypher semantics (TEXT-index CONTAINS, toLower casing rules), so
8
+ // it runs against a disposable Neo4j instead of the mocked unit setup.
9
+ export default createVitestConfig(serviceIntegrationPreset, {
10
+ test: {
11
+ globalSetup: './test-setup.ts',
12
+ include: ['test/profile-search.spec.ts'],
13
+ alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },
14
+ env: {
15
+ IS_E2E_TEST: 'true',
16
+ DOMAIN_NAME: 'localhost%3A3000',
17
+ TRACE_CONSOLE: 'false',
18
+ },
19
+ },
20
+ });