@learncard/network-brain-service 4.0.2 → 4.0.4
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.
- package/CHANGELOG.md +49 -0
- package/dist/accesslayer/boost/read.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- package/dist/accesslayer/profile/create.d.ts.map +1 -1
- package/dist/accesslayer/profile/read.d.ts.map +1 -1
- package/dist/accesslayer/profile/search-fields.d.ts +16 -0
- package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
- package/dist/accesslayer/profile/update.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +19 -16
- package/project.json +18 -0
- package/scripts/backfill-profile-search.ts +79 -0
- package/scripts/seed-demo-persona.ts +6 -1
- package/src/accesslayer/boost/read.ts +171 -34
- package/src/accesslayer/boost/relationships/read.ts +163 -126
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- package/src/accesslayer/profile/create.ts +2 -1
- package/src/accesslayer/profile/read.ts +23 -3
- package/src/accesslayer/profile/search-fields.ts +69 -0
- package/src/accesslayer/profile/update.ts +7 -1
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/models/credential-refresh-constraints.test.ts +44 -0
- package/src/models/credential-refresh-constraints.ts +29 -3
- package/src/models/index.ts +15 -4
- package/src/routes/workflows.ts +8 -1
- package/src/seed/seedSkillFrameworks.ts +4 -1
- package/test/boost.familial-equivalence.spec.ts +482 -0
- package/test/boost.recipientsJoin.spec.ts +508 -0
- package/test/consentflow.spec.ts +101 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/profile-search.spec.ts +307 -0
- package/vitest.boost-recipients-neo4j.config.ts +21 -0
- package/vitest.config.ts +1 -0
- 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
|
+
});
|
package/test/consentflow.spec.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { vi, describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Wraps the real signer so a test can inspect the credential it was handed.
|
|
4
|
+
const signingAuthorityMocks = vi.hoisted(() => ({ issueCredential: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('@helpers/signingAuthority.helpers', async importOriginal => {
|
|
7
|
+
const actual = await importOriginal<typeof import('@helpers/signingAuthority.helpers')>();
|
|
8
|
+
signingAuthorityMocks.issueCredential.mockImplementation(
|
|
9
|
+
actual.issueCredentialWithSigningAuthority
|
|
10
|
+
);
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
issueCredentialWithSigningAuthority: signingAuthorityMocks.issueCredential,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
2
17
|
import {
|
|
3
18
|
promiscuousTerms,
|
|
4
19
|
minimalContract,
|
|
@@ -1951,6 +1966,92 @@ describe('Consent Flow Contracts', () => {
|
|
|
1951
1966
|
expect(hasAutoBoostTx).toBeTruthy();
|
|
1952
1967
|
});
|
|
1953
1968
|
|
|
1969
|
+
describe('first-consent auto-boost issuance dates', () => {
|
|
1970
|
+
// A malformed date seen on a production Boost template; DIDKit refuses to sign it.
|
|
1971
|
+
const MALFORMED_DATE = '2025-10-19+053008:18:16';
|
|
1972
|
+
const TEMPLATE_DATE = '2025-10-19T08:18:16.000Z';
|
|
1973
|
+
|
|
1974
|
+
const consentWithAutoBoost = async (credential: Record<string, unknown>) => {
|
|
1975
|
+
await userA.clients.fullAuth.profile.registerSigningAuthority({
|
|
1976
|
+
endpoint: 'http://localhost:5000/api',
|
|
1977
|
+
name: 'mysa',
|
|
1978
|
+
did: 'did:key:z6MkitsQTk2GDNYXAFckVcQHtC68S9j9ruVFYWrixM6RG5Mw',
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
const boostUri = await userA.clients.fullAuth.boost.createBoost({
|
|
1982
|
+
credential: credential as typeof testUnsignedBoost,
|
|
1983
|
+
category: 'Achievement',
|
|
1984
|
+
name: 'Auto-issued Boost',
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
const contractUri =
|
|
1988
|
+
await userA.clients.fullAuth.contracts.createConsentFlowContract({
|
|
1989
|
+
contract: normalContract,
|
|
1990
|
+
name: 'Auto Boost Contract',
|
|
1991
|
+
autoboosts: [
|
|
1992
|
+
{
|
|
1993
|
+
boostUri,
|
|
1994
|
+
signingAuthority: {
|
|
1995
|
+
endpoint: 'http://localhost:5000/api',
|
|
1996
|
+
name: 'mysa',
|
|
1997
|
+
},
|
|
1998
|
+
},
|
|
1999
|
+
],
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
signingAuthorityMocks.issueCredential.mockClear();
|
|
2003
|
+
const before = Date.now();
|
|
2004
|
+
const { termsUri } = await userB.clients.fullAuth.contracts.consentToContract({
|
|
2005
|
+
contractUri,
|
|
2006
|
+
terms: normalFullTerms,
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
expect(signingAuthorityMocks.issueCredential).toHaveBeenCalledTimes(1);
|
|
2010
|
+
const unsigned = signingAuthorityMocks.issueCredential.mock.calls[0]![1];
|
|
2011
|
+
const { records } =
|
|
2012
|
+
await userB.clients.fullAuth.contracts.getCredentialsForContract({ termsUri });
|
|
2013
|
+
|
|
2014
|
+
return { unsigned, records, before };
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const expectFreshDate = (value: unknown, before: number) => {
|
|
2018
|
+
expect(typeof value).toBe('string');
|
|
2019
|
+
expect(value).toBe(new Date(value as string).toISOString());
|
|
2020
|
+
expect(Date.parse(value as string)).toBeGreaterThanOrEqual(before);
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
it('restamps a malformed issuanceDate on a VC 1.1 template', async () => {
|
|
2024
|
+
const { unsigned, records, before } = await consentWithAutoBoost({
|
|
2025
|
+
...testUnsignedBoost,
|
|
2026
|
+
issuanceDate: MALFORMED_DATE,
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
expectFreshDate(unsigned.issuanceDate, before);
|
|
2030
|
+
// Consent swallows signing errors, so a missing record means signing failed.
|
|
2031
|
+
expect(records).toHaveLength(1);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
it('drops a leftover issuanceDate on a VC 2.0 template', async () => {
|
|
2035
|
+
const { unsigned, records, before } = await consentWithAutoBoost({
|
|
2036
|
+
'@context': [
|
|
2037
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
2038
|
+
'https://ctx.learncard.com/boosts/1.0.1.json',
|
|
2039
|
+
],
|
|
2040
|
+
type: ['VerifiableCredential', 'BoostCredential'],
|
|
2041
|
+
issuer: 'did:example:issuer',
|
|
2042
|
+
validFrom: TEMPLATE_DATE,
|
|
2043
|
+
issuanceDate: TEMPLATE_DATE,
|
|
2044
|
+
name: 'VC 2.0 Auto Boost',
|
|
2045
|
+
credentialSubject: { id: 'did:example:subject' },
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
expectFreshDate(unsigned.validFrom, before);
|
|
2049
|
+
// The VC 2.0 context doesn't define issuanceDate, so DIDKit won't sign with it.
|
|
2050
|
+
expect(unsigned).not.toHaveProperty('issuanceDate');
|
|
2051
|
+
expect(records).toHaveLength(1);
|
|
2052
|
+
});
|
|
2053
|
+
});
|
|
2054
|
+
|
|
1954
2055
|
it('should auto-issue boosts when reconsenting to a contract with auto-boosts', async () => {
|
|
1955
2056
|
await userA.clients.fullAuth.profile.registerSigningAuthority({
|
|
1956
2057
|
endpoint: 'http://localhost:5000/api',
|