@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,482 @@
|
|
|
1
|
+
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
|
|
2
|
+
import { BindParam, QueryBuilder } from 'neogma';
|
|
3
|
+
|
|
4
|
+
import { neogma } from '@instance';
|
|
5
|
+
import { Boost, BoostInstance, Profile } from '@models';
|
|
6
|
+
import { countFamilialBoosts, getFamilialBoosts } from '@accesslayer/boost/read';
|
|
7
|
+
import {
|
|
8
|
+
buildWhereForQueryBuilder,
|
|
9
|
+
convertObjectRegExpToNeo4j,
|
|
10
|
+
convertQueryResultToPropertiesObjectArray,
|
|
11
|
+
} from '@helpers/neo4j.helpers';
|
|
12
|
+
import { inflateObject } from '@helpers/objects.helpers';
|
|
13
|
+
import { BoostQuery } from '@learncard/types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Old-vs-new comparison for familial boosts.
|
|
17
|
+
*
|
|
18
|
+
* `reference*` below is the familial query as it was before it was rewritten to expand outward
|
|
19
|
+
* from the start boost (one MATCH over every Boost, filtered by OR-ed path predicates). Every case
|
|
20
|
+
* runs both forms against the same small graphs and expects the same answer.
|
|
21
|
+
*
|
|
22
|
+
* Ordering among boosts with the same CREATED_BY date is unspecified in both forms, so result sets
|
|
23
|
+
* are compared as sorted multisets, and cursor pages compare the dates on each page (always
|
|
24
|
+
* deterministic) plus the ids where no dates tie.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type WhereQuery = Parameters<typeof buildWhereForQueryBuilder>[1];
|
|
28
|
+
|
|
29
|
+
type Generations = {
|
|
30
|
+
parentGenerations: number;
|
|
31
|
+
childGenerations: number;
|
|
32
|
+
includeExtendedFamily: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const familyPredicate = ({
|
|
36
|
+
parentGenerations,
|
|
37
|
+
childGenerations,
|
|
38
|
+
includeExtendedFamily,
|
|
39
|
+
}: Generations): string => {
|
|
40
|
+
const p = Number.isFinite(parentGenerations) ? parentGenerations : '';
|
|
41
|
+
const c = Number.isFinite(childGenerations) ? childGenerations : '';
|
|
42
|
+
|
|
43
|
+
return `
|
|
44
|
+
((start)<-[:PARENT_OF*1..${p}]-(boost) OR
|
|
45
|
+
(start)-[:PARENT_OF*1..${c}]->(boost) OR
|
|
46
|
+
(start)<-[:PARENT_OF${includeExtendedFamily ? `*1..${p}` : ''}]-(:Boost)-[:PARENT_OF${
|
|
47
|
+
includeExtendedFamily ? `*1..${c}` : ''
|
|
48
|
+
}]->(boost) AND boost <> start)`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const referenceGetFamilialBoosts = async (
|
|
52
|
+
id: string,
|
|
53
|
+
generations: Generations,
|
|
54
|
+
{ limit, cursor, query = {} }: { limit: number; cursor?: string; query?: BoostQuery }
|
|
55
|
+
) => {
|
|
56
|
+
const { whereClause, params } = buildWhereForQueryBuilder(
|
|
57
|
+
'boost',
|
|
58
|
+
convertObjectRegExpToNeo4j(query) as WhereQuery
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const _query = new QueryBuilder(new BindParam({ cursor, ...params }))
|
|
62
|
+
.match({ model: Boost, where: { id }, identifier: 'start' })
|
|
63
|
+
.match({ model: Boost, identifier: 'boost' })
|
|
64
|
+
.where(`${familyPredicate(generations)}\nAND ${whereClause}`)
|
|
65
|
+
.raw('MATCH (boost)-[createdBy:CREATED_BY]-()');
|
|
66
|
+
|
|
67
|
+
const results = convertQueryResultToPropertiesObjectArray<{
|
|
68
|
+
boost: Record<string, unknown>;
|
|
69
|
+
createdBy: { date: string };
|
|
70
|
+
}>(
|
|
71
|
+
await (cursor ? _query.where('createdBy.date < $cursor') : _query)
|
|
72
|
+
.return('DISTINCT boost, createdBy')
|
|
73
|
+
.orderBy('createdBy.date DESC')
|
|
74
|
+
.limit(limit)
|
|
75
|
+
.run()
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return results.map(result => ({
|
|
79
|
+
...(inflateObject(result.boost) as Record<string, unknown>),
|
|
80
|
+
id: String(result.boost.id),
|
|
81
|
+
created: result.createdBy.date,
|
|
82
|
+
}));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const referenceCountFamilialBoosts = async (
|
|
86
|
+
id: string,
|
|
87
|
+
generations: Generations,
|
|
88
|
+
query: BoostQuery = {}
|
|
89
|
+
): Promise<number> => {
|
|
90
|
+
const { whereClause, params } = buildWhereForQueryBuilder(
|
|
91
|
+
'boost',
|
|
92
|
+
convertObjectRegExpToNeo4j(query) as WhereQuery
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = await new QueryBuilder(new BindParam({ ...params }))
|
|
96
|
+
.match({ model: Boost, where: { id }, identifier: 'start' })
|
|
97
|
+
.match({ model: Boost, identifier: 'boost' })
|
|
98
|
+
.where(`${familyPredicate(generations)}\nAND ${whereClause}`)
|
|
99
|
+
.return('COUNT(DISTINCT boost) AS count')
|
|
100
|
+
.run();
|
|
101
|
+
|
|
102
|
+
return Number(result.records[0]?.get('count') ?? 0);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const asBoost = (id: string) => ({ id }) as unknown as BoostInstance;
|
|
106
|
+
|
|
107
|
+
const projection = (rows: Array<{ id: string; created: string }>) =>
|
|
108
|
+
rows.map(row => `${row.created}|${row.id}`).sort();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Graph shapes (every PARENT_OF points parent -> child). `date` is the CREATED_BY date; `null`
|
|
112
|
+
* leaves the boost without one, and an array gives it several. `labels` overrides `:Boost` so a
|
|
113
|
+
* non-Boost node can sit on a PARENT_OF path.
|
|
114
|
+
*/
|
|
115
|
+
type FixtureNode = {
|
|
116
|
+
id: string;
|
|
117
|
+
category?: string;
|
|
118
|
+
status?: string;
|
|
119
|
+
date?: string | string[] | null;
|
|
120
|
+
labels?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const nodes: FixtureNode[] = [];
|
|
124
|
+
const edges: Array<[string, string]> = [];
|
|
125
|
+
|
|
126
|
+
const day = (n: number) => `2025-01-${String((n % 28) + 1).padStart(2, '0')}T00:00:00.000Z`;
|
|
127
|
+
let dateCounter = 0;
|
|
128
|
+
const uniqueDate = () => {
|
|
129
|
+
dateCounter += 1;
|
|
130
|
+
return `2025-03-01T00:00:${String(Math.floor(dateCounter / 60)).padStart(2, '0')}.${String(
|
|
131
|
+
dateCounter % 60
|
|
132
|
+
).padStart(3, '0')}Z`;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const node = (id: string, extra: Omit<FixtureNode, 'id'> = {}) => {
|
|
136
|
+
nodes.push({ category: 'A', status: 'LIVE', date: uniqueDate(), ...extra, id });
|
|
137
|
+
return id;
|
|
138
|
+
};
|
|
139
|
+
const edge = (parent: string, child: string) => edges.push([parent, child]);
|
|
140
|
+
|
|
141
|
+
// Tree: root -> 3 children -> 3 grandchildren each -> 2 great-grandchildren each
|
|
142
|
+
const tree = node('tree-root', { category: 'Root' });
|
|
143
|
+
['a', 'b', 'c'].forEach((x, i) => {
|
|
144
|
+
const child = node(`tree-${x}`, { category: i === 0 ? 'Few' : 'B' });
|
|
145
|
+
edge(tree, child);
|
|
146
|
+
[1, 2, 3].forEach(y => {
|
|
147
|
+
const grandchild = node(`tree-${x}${y}`, {
|
|
148
|
+
category: y === 1 ? 'A' : 'B',
|
|
149
|
+
status: y === 3 ? 'DRAFT' : 'LIVE',
|
|
150
|
+
});
|
|
151
|
+
edge(child, grandchild);
|
|
152
|
+
[1, 2].forEach(z => edge(grandchild, node(`tree-${x}${y}${z}`, { category: 'C' })));
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Cycles and a self-loop
|
|
157
|
+
['cyc-a', 'cyc-b', 'cyc-c'].forEach(id => node(id, { category: 'Few' }));
|
|
158
|
+
edge('cyc-a', 'cyc-b');
|
|
159
|
+
edge('cyc-b', 'cyc-c');
|
|
160
|
+
edge('cyc-c', 'cyc-a');
|
|
161
|
+
node('cyc-d', { category: 'B' });
|
|
162
|
+
edge('cyc-a', 'cyc-d');
|
|
163
|
+
edge('cyc-d', 'cyc-d');
|
|
164
|
+
node('cyc-e');
|
|
165
|
+
edge('cyc-d', 'cyc-e');
|
|
166
|
+
|
|
167
|
+
// Several paths to one node, and a parallel edge
|
|
168
|
+
['mp-r', 'mp-x', 'mp-y', 'mp-z', 'mp-w', 'mp-v'].forEach(id => node(id, { category: 'B' }));
|
|
169
|
+
edge('mp-r', 'mp-x');
|
|
170
|
+
edge('mp-r', 'mp-x');
|
|
171
|
+
edge('mp-r', 'mp-y');
|
|
172
|
+
edge('mp-x', 'mp-z');
|
|
173
|
+
edge('mp-y', 'mp-z');
|
|
174
|
+
edge('mp-z', 'mp-w');
|
|
175
|
+
edge('mp-y', 'mp-v');
|
|
176
|
+
|
|
177
|
+
// Non-Boost nodes on PARENT_OF paths. nb-s's parent is a non-Boost node, so set-based cousin
|
|
178
|
+
// shortcuts must not apply there.
|
|
179
|
+
node('nb-x', { category: 'Few' });
|
|
180
|
+
node('nb-w', { labels: 'Thing' });
|
|
181
|
+
node('nb-s');
|
|
182
|
+
node('nb-z', { category: 'Few' });
|
|
183
|
+
node('nb-y');
|
|
184
|
+
node('nb-q', { category: 'Few' });
|
|
185
|
+
edge('nb-x', 'nb-w');
|
|
186
|
+
edge('nb-w', 'nb-s');
|
|
187
|
+
edge('nb-w', 'nb-z');
|
|
188
|
+
edge('nb-x', 'nb-y');
|
|
189
|
+
edge('nb-s', 'nb-q');
|
|
190
|
+
node('nb-m', { labels: 'Thing' });
|
|
191
|
+
node('nb-t', { category: 'Few' });
|
|
192
|
+
edge('tree-a', 'nb-m');
|
|
193
|
+
edge('nb-m', 'nb-t');
|
|
194
|
+
|
|
195
|
+
// Missing and duplicate CREATED_BY relationships
|
|
196
|
+
node('cb-root', { category: 'B' });
|
|
197
|
+
node('cb-none', { date: null });
|
|
198
|
+
node('cb-two', { date: [day(3), day(4)] });
|
|
199
|
+
node('cb-same', { date: [day(5), day(5)] });
|
|
200
|
+
node('cb-leaf', { category: 'Few' });
|
|
201
|
+
edge('cb-root', 'cb-none');
|
|
202
|
+
edge('cb-root', 'cb-two');
|
|
203
|
+
edge('cb-root', 'cb-same');
|
|
204
|
+
edge('cb-two', 'cb-leaf');
|
|
205
|
+
|
|
206
|
+
// Equal dates between siblings
|
|
207
|
+
node('tie-root', { category: 'B' });
|
|
208
|
+
[1, 2, 3, 4, 5].forEach(n => edge('tie-root', node(`tie-${n}`, { date: day(9) })));
|
|
209
|
+
|
|
210
|
+
// Isolated boost
|
|
211
|
+
node('iso', { category: 'Few' });
|
|
212
|
+
|
|
213
|
+
// Wide parent: more indexed candidates than the per-candidate path takes ("Many"), plus a few
|
|
214
|
+
// ("Few" and "Rare") that also exist outside the family
|
|
215
|
+
node('wide', { category: 'B' });
|
|
216
|
+
for (let n = 0; n < 30; n += 1) edge('wide', node(`wide-${n}`, { category: 'Many' }));
|
|
217
|
+
['wide-f1', 'wide-f2', 'wide-f3'].forEach(id => edge('wide', node(id, { category: 'Rare' })));
|
|
218
|
+
node('rare-outside', { category: 'Rare' });
|
|
219
|
+
|
|
220
|
+
// Deep chain with a side child on every level
|
|
221
|
+
for (let n = 0; n <= 20; n += 1) node(`deep-${n}`, { category: n % 5 === 0 ? 'Few' : 'Deep' });
|
|
222
|
+
for (let n = 0; n < 20; n += 1) edge(`deep-${n + 1}`, `deep-${n}`);
|
|
223
|
+
for (let n = 1; n <= 20; n += 1) edge(`deep-${n}`, node(`deep-side-${n}`, { category: 'Side' }));
|
|
224
|
+
|
|
225
|
+
const starts = [
|
|
226
|
+
'tree-root',
|
|
227
|
+
'tree-a',
|
|
228
|
+
'tree-b2',
|
|
229
|
+
'tree-c31',
|
|
230
|
+
'cyc-a',
|
|
231
|
+
'cyc-d',
|
|
232
|
+
'mp-z',
|
|
233
|
+
'mp-x',
|
|
234
|
+
'nb-s',
|
|
235
|
+
'nb-t',
|
|
236
|
+
'cb-two',
|
|
237
|
+
'tie-3',
|
|
238
|
+
'iso',
|
|
239
|
+
'wide-4',
|
|
240
|
+
'deep-0',
|
|
241
|
+
'deep-10',
|
|
242
|
+
'missing-boost',
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const generationSets: Generations[] = [
|
|
246
|
+
{ parentGenerations: 1, childGenerations: 1, includeExtendedFamily: false },
|
|
247
|
+
{ parentGenerations: 2, childGenerations: 1, includeExtendedFamily: false },
|
|
248
|
+
{ parentGenerations: 1, childGenerations: 3, includeExtendedFamily: false },
|
|
249
|
+
{ parentGenerations: Infinity, childGenerations: 1, includeExtendedFamily: false },
|
|
250
|
+
{ parentGenerations: 1, childGenerations: Infinity, includeExtendedFamily: false },
|
|
251
|
+
{ parentGenerations: 1, childGenerations: 1, includeExtendedFamily: true },
|
|
252
|
+
{ parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true },
|
|
253
|
+
{ parentGenerations: Infinity, childGenerations: Infinity, includeExtendedFamily: true },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const filters: Array<[string, BoostQuery]> = [
|
|
257
|
+
['no filter', {}],
|
|
258
|
+
['category, few matches', { category: 'Few' }],
|
|
259
|
+
['category, more than 16 matches', { category: 'Many' }],
|
|
260
|
+
['category, absent', { category: 'Absent' }],
|
|
261
|
+
['category $in', { category: { $in: ['Rare', 'Few', 'Side'] } }],
|
|
262
|
+
['id', { id: 'tree-a1' }],
|
|
263
|
+
['category and status', { category: 'B', status: 'DRAFT' }],
|
|
264
|
+
['status only', { status: 'DRAFT' }],
|
|
265
|
+
['document-level $or', { $or: [{ category: 'Few' }, { category: 'Side' }] } as BoostQuery],
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const genLabel = (g: Generations) =>
|
|
269
|
+
`p${g.parentGenerations} c${g.childGenerations}${g.includeExtendedFamily ? ' extended' : ''}`;
|
|
270
|
+
|
|
271
|
+
const cases = starts.flatMap(start =>
|
|
272
|
+
generationSets.flatMap(generations =>
|
|
273
|
+
filters.map(([filterName, query]) => ({ start, generations, filterName, query }))
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
describe('Familial boosts match the previous query', () => {
|
|
278
|
+
beforeAll(async () => {
|
|
279
|
+
await Boost.delete({ detach: true, where: {} });
|
|
280
|
+
await Profile.delete({ detach: true, where: {} });
|
|
281
|
+
await neogma.queryRunner.run('MATCH (n:Thing) DETACH DELETE n', {});
|
|
282
|
+
|
|
283
|
+
await neogma.queryRunner.run(
|
|
284
|
+
`CREATE (:Profile {profileId: 'familial-creator', did: 'did:test:familial-creator'})`,
|
|
285
|
+
{}
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
for (const n of nodes) {
|
|
289
|
+
await neogma.queryRunner.run(
|
|
290
|
+
`MATCH (creator:Profile {profileId: 'familial-creator'})
|
|
291
|
+
CREATE (b:${n.labels ?? 'Boost'} {id: $id, name: $id, category: $category, status: $status, type: 'test', boost: '{}'})
|
|
292
|
+
WITH b, creator
|
|
293
|
+
UNWIND $dates AS date
|
|
294
|
+
CREATE (b)-[:CREATED_BY {date: date}]->(creator)`,
|
|
295
|
+
{
|
|
296
|
+
id: n.id,
|
|
297
|
+
category: n.category,
|
|
298
|
+
status: n.status,
|
|
299
|
+
dates: n.date === null ? [] : Array.isArray(n.date) ? n.date : [n.date],
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const [parent, child] of edges) {
|
|
305
|
+
await neogma.queryRunner.run(
|
|
306
|
+
`MATCH (p {id: $parent}) MATCH (c {id: $child}) CREATE (p)-[:PARENT_OF]->(c)`,
|
|
307
|
+
{ parent, child }
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
afterAll(async () => {
|
|
313
|
+
await Boost.delete({ detach: true, where: {} });
|
|
314
|
+
await Profile.delete({ detach: true, where: {} });
|
|
315
|
+
await neogma.queryRunner.run('MATCH (n:Thing) DETACH DELETE n', {});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('covers every start, generation setting and filter', () => {
|
|
319
|
+
expect(cases.length).toBe(starts.length * generationSets.length * filters.length);
|
|
320
|
+
expect(cases.length).toBe(1224);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Split per start so each case stays well under Vitest's 30 s timeout
|
|
324
|
+
describe.each(starts)('from %s', start => {
|
|
325
|
+
const startCases = cases.filter(c => c.start === start);
|
|
326
|
+
|
|
327
|
+
it(`counts match on ${startCases.length} cases`, async () => {
|
|
328
|
+
const diffs: string[] = [];
|
|
329
|
+
|
|
330
|
+
for (const { start, generations, filterName, query } of startCases) {
|
|
331
|
+
const expected = await referenceCountFamilialBoosts(start, generations, query);
|
|
332
|
+
const actual = await countFamilialBoosts(asBoost(start), { ...generations, query });
|
|
333
|
+
|
|
334
|
+
if (actual !== expected) {
|
|
335
|
+
diffs.push(
|
|
336
|
+
`${start} ${genLabel(generations)} ${filterName}: ${actual} != ${expected}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
expect(diffs).toEqual([]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it(`full result sets match on ${startCases.length} cases`, async () => {
|
|
345
|
+
const diffs: string[] = [];
|
|
346
|
+
|
|
347
|
+
for (const { start, generations, filterName, query } of startCases) {
|
|
348
|
+
const expected = await referenceGetFamilialBoosts(start, generations, {
|
|
349
|
+
limit: 1000,
|
|
350
|
+
query,
|
|
351
|
+
});
|
|
352
|
+
const actual = await getFamilialBoosts(asBoost(start), {
|
|
353
|
+
...generations,
|
|
354
|
+
limit: 1000,
|
|
355
|
+
query,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const dates = actual.map(row => row.created);
|
|
359
|
+
const sortedDesc = [...dates].sort().reverse();
|
|
360
|
+
|
|
361
|
+
if (JSON.stringify(projection(actual)) !== JSON.stringify(projection(expected))) {
|
|
362
|
+
diffs.push(`${start} ${genLabel(generations)} ${filterName}: rows differ`);
|
|
363
|
+
} else if (JSON.stringify(dates) !== JSON.stringify(sortedDesc)) {
|
|
364
|
+
diffs.push(`${start} ${genLabel(generations)} ${filterName}: not date-ordered`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
expect(diffs).toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it(`cursor pages of 2 match on ${startCases.length} cases`, async () => {
|
|
372
|
+
const diffs: string[] = [];
|
|
373
|
+
let pages = 0;
|
|
374
|
+
|
|
375
|
+
for (const { start, generations, filterName, query } of startCases) {
|
|
376
|
+
const all = await referenceGetFamilialBoosts(start, generations, {
|
|
377
|
+
limit: 1000,
|
|
378
|
+
query,
|
|
379
|
+
});
|
|
380
|
+
const dateCounts = new Map<string, number>();
|
|
381
|
+
all.forEach(row =>
|
|
382
|
+
dateCounts.set(row.created, (dateCounts.get(row.created) ?? 0) + 1)
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
let cursor: string | undefined;
|
|
386
|
+
|
|
387
|
+
for (let page = 0; page < 8; page += 1) {
|
|
388
|
+
const expected = await referenceGetFamilialBoosts(start, generations, {
|
|
389
|
+
limit: 2,
|
|
390
|
+
cursor,
|
|
391
|
+
query,
|
|
392
|
+
});
|
|
393
|
+
const actual = await getFamilialBoosts(asBoost(start), {
|
|
394
|
+
...generations,
|
|
395
|
+
limit: 2,
|
|
396
|
+
cursor,
|
|
397
|
+
query,
|
|
398
|
+
});
|
|
399
|
+
pages += 1;
|
|
400
|
+
|
|
401
|
+
const label = `${start} ${genLabel(generations)} ${filterName} page ${page}`;
|
|
402
|
+
const expectedDates = expected.map(row => row.created);
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
JSON.stringify(actual.map(row => row.created)) !==
|
|
406
|
+
JSON.stringify(expectedDates)
|
|
407
|
+
) {
|
|
408
|
+
diffs.push(`${label}: dates differ`);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Ids are only deterministic when no other row shares the date
|
|
413
|
+
const uniqueIds = (rows: typeof expected) =>
|
|
414
|
+
rows.filter(row => dateCounts.get(row.created) === 1).map(row => row.id);
|
|
415
|
+
|
|
416
|
+
if (JSON.stringify(uniqueIds(actual)) !== JSON.stringify(uniqueIds(expected))) {
|
|
417
|
+
diffs.push(`${label}: ids differ`);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (expected.length < 2) break;
|
|
422
|
+
|
|
423
|
+
cursor = expected[expected.length - 1]!.created;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// A missing or isolated start still fetches one (empty or short) page per case
|
|
428
|
+
expect(pages).toBeGreaterThanOrEqual(startCases.length);
|
|
429
|
+
expect(diffs).toEqual([]);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it.each([
|
|
434
|
+
['tree-a', { parentGenerations: 1, childGenerations: 2, includeExtendedFamily: false }, {}],
|
|
435
|
+
[
|
|
436
|
+
'deep-10',
|
|
437
|
+
{ parentGenerations: Infinity, childGenerations: 1, includeExtendedFamily: true },
|
|
438
|
+
{},
|
|
439
|
+
],
|
|
440
|
+
[
|
|
441
|
+
'wide-4',
|
|
442
|
+
{ parentGenerations: 1, childGenerations: 1, includeExtendedFamily: false },
|
|
443
|
+
{ category: 'Rare' },
|
|
444
|
+
],
|
|
445
|
+
['mp-z', { parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true }, {}],
|
|
446
|
+
[
|
|
447
|
+
'tree-a2',
|
|
448
|
+
{ parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true },
|
|
449
|
+
{ category: 'Few' },
|
|
450
|
+
],
|
|
451
|
+
] as Array<[string, Generations, BoostQuery]>)(
|
|
452
|
+
'returns the same boosts, in order, for %s',
|
|
453
|
+
async (start, generations, query) => {
|
|
454
|
+
const expected = await referenceGetFamilialBoosts(start, generations, {
|
|
455
|
+
limit: 1000,
|
|
456
|
+
query,
|
|
457
|
+
});
|
|
458
|
+
const actual = await getFamilialBoosts(asBoost(start), {
|
|
459
|
+
...generations,
|
|
460
|
+
limit: 1000,
|
|
461
|
+
query,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// These families have no tied dates, so the order is fully determined
|
|
465
|
+
expect(new Set(expected.map(row => row.created)).size).toBe(expected.length);
|
|
466
|
+
expect(expected.length).toBeGreaterThan(1);
|
|
467
|
+
expect(actual).toEqual(expected);
|
|
468
|
+
|
|
469
|
+
const expectedPage = await referenceGetFamilialBoosts(start, generations, {
|
|
470
|
+
limit: 2,
|
|
471
|
+
query,
|
|
472
|
+
});
|
|
473
|
+
const actualPage = await getFamilialBoosts(asBoost(start), {
|
|
474
|
+
...generations,
|
|
475
|
+
limit: 2,
|
|
476
|
+
query,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
expect(actualPage).toEqual(expectedPage);
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
});
|