@vida-global/core 2.5.0 → 3.0.1

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.
@@ -0,0 +1,354 @@
1
+ const { AbstractSearchClient } = require('../../lib/search/abstractSearchClient');
2
+ const { Query } = require('../../lib/search/query');
3
+ const TestHelpers = require('@vida-global/test-helpers');
4
+ const {
5
+ BACKEND_HOOKS,
6
+ buildSearchClient,
7
+ buildSearchClientClass
8
+ } = require('./helpers/searchClient');
9
+
10
+
11
+ const randomString = () => TestHelpers.Faker.Text.randomString();
12
+
13
+
14
+ describe('AbstractSearchClient', () => {
15
+ let client;
16
+
17
+
18
+ beforeEach(() => {
19
+ client = buildSearchClient();
20
+ client._hitsFromResponse.mockReturnValue({hits: []});
21
+ client._totalFromResponse.mockReturnValue(0);
22
+ });
23
+
24
+
25
+ afterEach(() => {
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+
30
+ describe('#constructor', () => {
31
+ it('cannot be constructed directly', () => {
32
+ expect(() => new AbstractSearchClient()).toThrow(/must be subclasses/);
33
+ });
34
+
35
+
36
+ it('stores the index suffix it is given', () => {
37
+ expect(buildSearchClient({indexSuffix: '2026-10'}).indexSuffix).toBe('2026-10');
38
+ });
39
+
40
+
41
+ it('leaves the index suffix undefined when none is given', () => {
42
+ expect(client.indexSuffix).toBeUndefined();
43
+ });
44
+ });
45
+
46
+
47
+ describe('#query', () => {
48
+ it('returns a Query bound to this client', async () => {
49
+ const query = client.query();
50
+ jest.spyOn(client, 'fetch').mockResolvedValue(null);
51
+
52
+ await query.fetch();
53
+
54
+ expect(query).toBeInstanceOf(Query);
55
+ expect(client.fetch).toHaveBeenCalledTimes(1);
56
+ });
57
+ });
58
+
59
+
60
+ describe('backend hooks', () => {
61
+ it.each(BACKEND_HOOKS.map(hook => [hook]))('%s is not implemented on the base class', (hook) => {
62
+ class BareClient extends AbstractSearchClient {}
63
+
64
+ expect(() => new BareClient()[hook]()).toThrow('Not Implemented');
65
+ });
66
+
67
+
68
+ it.each([
69
+ ['index'],
70
+ ['mapping'],
71
+ ])('%s is not implemented on the base class', (accessor) => {
72
+ class BareClient extends AbstractSearchClient {}
73
+
74
+ expect(() => new BareClient()[accessor]).toThrow('Not Implemented');
75
+ });
76
+ });
77
+
78
+
79
+ describe('#fetch', () => {
80
+ it('serializes, executes and shapes in order', async () => {
81
+ const conditions = client.query().equals('status', 'success').conditions;
82
+ const options = {indexWildcard: true};
83
+ const serialized = {bool: {}};
84
+ const rawResponse = {body: {}};
85
+
86
+ client._serializeConditions.mockReturnValue(serialized);
87
+ client._execute.mockResolvedValue(rawResponse);
88
+ client._totalFromResponse.mockReturnValue(12);
89
+
90
+ await expect(client.fetch(conditions, options)).resolves.toEqual({total: 12, results: []});
91
+
92
+ expect(client._serializeConditions).toHaveBeenCalledWith(conditions);
93
+ expect(client._execute).toHaveBeenCalledWith(serialized, options);
94
+ expect(client._hitsFromResponse).toHaveBeenCalledWith(rawResponse);
95
+ });
96
+
97
+
98
+ it('defaults the options to an empty object', async () => {
99
+ await client.fetch({});
100
+
101
+ expect(client._execute).toHaveBeenCalledWith(undefined, {});
102
+ });
103
+ });
104
+
105
+
106
+ describe('#_processResponse', () => {
107
+ it('reads the total and the hits through the backend hooks', () => {
108
+ const response = {body: 'whatever the backend returns'};
109
+ client._hitsFromResponse.mockReturnValue({hits: ['first', 'second']});
110
+ client._totalFromResponse.mockReturnValue(99);
111
+ jest.spyOn(client, '_buildResult').mockImplementation(hit => `built:${hit}`);
112
+
113
+ expect(client._processResponse(response)).toEqual({
114
+ total: 99,
115
+ results: ['built:first', 'built:second']
116
+ });
117
+ });
118
+ });
119
+
120
+
121
+ describe('#_buildResult', () => {
122
+ it('assembles one result from the three hit hooks', () => {
123
+ const hit = {anything: true};
124
+ client._idFromHit.mockReturnValue('abc');
125
+ client._scoreFromHit.mockReturnValue(7.5);
126
+ client._documentFromHit.mockReturnValue({name: 'stored'});
127
+
128
+ expect(client._buildResult(hit)).toEqual({
129
+ id: 'abc',
130
+ score: 7.5,
131
+ document: {name: 'stored'}
132
+ });
133
+ });
134
+ });
135
+
136
+
137
+ describe('#upsert', () => {
138
+ it('passes the id and the document to the backend', async () => {
139
+ const id = randomString();
140
+ const document = {name: randomString()};
141
+
142
+ await client.upsert(id, document);
143
+
144
+ expect(client._upsert).toHaveBeenCalledTimes(1);
145
+ expect(client._upsert).toHaveBeenCalledWith(id, document);
146
+ });
147
+ });
148
+
149
+
150
+ describe('#append', () => {
151
+ it('passes the document to the backend', async () => {
152
+ const document = {name: randomString()};
153
+
154
+ await client.append(document);
155
+
156
+ expect(client._append).toHaveBeenCalledTimes(1);
157
+ expect(client._append).toHaveBeenCalledWith(document);
158
+ });
159
+ });
160
+
161
+
162
+ describe('#bulkUpsert', () => {
163
+ it('passes the documents to the backend', async () => {
164
+ const documents = [{id: randomString()}, {id: randomString()}];
165
+
166
+ await client.bulkUpsert(documents);
167
+
168
+ expect(client._bulkUpsert).toHaveBeenCalledTimes(1);
169
+ expect(client._bulkUpsert).toHaveBeenCalledWith(documents);
170
+ });
171
+ });
172
+
173
+
174
+ describe('#delete', () => {
175
+ it('passes the id to the backend', async () => {
176
+ const id = randomString();
177
+
178
+ await client.delete(id);
179
+
180
+ expect(client._delete).toHaveBeenCalledTimes(1);
181
+ expect(client._delete).toHaveBeenCalledWith(id);
182
+ });
183
+ });
184
+
185
+
186
+ describe('#deleteWhere', () => {
187
+ const hitsFor = (...ids) => ({hits: ids.map(id => ({id}))});
188
+
189
+
190
+ beforeEach(() => {
191
+ client._idFromHit.mockImplementation(hit => hit.id);
192
+ });
193
+
194
+
195
+ it('deletes every id in every page until the search comes back empty', async () => {
196
+ client._hitsFromResponse
197
+ .mockReturnValueOnce(hitsFor('a', 'b'))
198
+ .mockReturnValueOnce(hitsFor('c'))
199
+ .mockReturnValueOnce(hitsFor());
200
+
201
+ await expect(client.deleteWhere(client.query())).resolves.toBe(3);
202
+
203
+ expect(client._delete.mock.calls).toEqual([['a'], ['b'], ['c']]);
204
+ });
205
+
206
+
207
+ it('limits each search page to the delete batch size', async () => {
208
+ await client.deleteWhere(client.query());
209
+
210
+ const [conditions] = client._serializeConditions.mock.calls[0];
211
+ expect(conditions.options.limit).toBe(500);
212
+ });
213
+
214
+
215
+ it('stops when a page repeats ids it has already deleted', async () => {
216
+ client._hitsFromResponse.mockReturnValue(hitsFor('a'));
217
+
218
+ await expect(client.deleteWhere(client.query())).resolves.toBe(1);
219
+
220
+ expect(client._delete).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+
224
+ it('stops and rethrows when a delete fails', async () => {
225
+ const failure = new Error(randomString());
226
+ client._hitsFromResponse.mockReturnValue(hitsFor('a', 'b', 'c'));
227
+ client._delete.mockResolvedValueOnce(null).mockRejectedValueOnce(failure);
228
+
229
+ await expect(client.deleteWhere(client.query())).rejects.toBe(failure);
230
+
231
+ expect(client._delete).toHaveBeenCalledTimes(2);
232
+ });
233
+ });
234
+
235
+
236
+ describe('#syncIndex', () => {
237
+ it('creates the index before pushing the mapping when it is missing', async () => {
238
+ client._indexExists.mockResolvedValue(false);
239
+
240
+ await client.syncIndex();
241
+
242
+ expect(client._createIndex).toHaveBeenCalledTimes(1);
243
+ expect(client._putMapping).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+
247
+ it('only pushes the mapping when the index already exists', async () => {
248
+ client._indexExists.mockResolvedValue(true);
249
+
250
+ await client.syncIndex();
251
+
252
+ expect(client._createIndex).not.toHaveBeenCalled();
253
+ expect(client._putMapping).toHaveBeenCalledTimes(1);
254
+ });
255
+ });
256
+
257
+
258
+ describe('.syncIndex', () => {
259
+ it('builds an instance with the given suffix and syncs it', async () => {
260
+ const MockSearchClient = buildSearchClientClass();
261
+ MockSearchClient.prototype._indexExists.mockResolvedValue(true);
262
+
263
+ await MockSearchClient.syncIndex('2026-10');
264
+
265
+ expect(MockSearchClient.prototype._putMapping).toHaveBeenCalledTimes(1);
266
+ });
267
+ });
268
+
269
+
270
+ describe('#shouldRetry', () => {
271
+ it('allows exactly numRetries extra attempts', () => {
272
+ const retryingClient = buildSearchClient({numRetries: 2});
273
+
274
+ expect(retryingClient.shouldRetry(1)).toBe(true);
275
+ expect(retryingClient.shouldRetry(2)).toBe(true);
276
+ expect(retryingClient.shouldRetry(3)).toBe(false);
277
+ });
278
+
279
+
280
+ it('allows nothing when numRetries is zero', () => {
281
+ expect(client.shouldRetry(1)).toBe(false);
282
+ });
283
+ });
284
+
285
+
286
+ describe('#_withRetry', () => {
287
+ const buildRetryingClient = (numRetries) => {
288
+ const retryingClient = buildSearchClient({numRetries});
289
+ retryingClient._hitsFromResponse.mockReturnValue({hits: []});
290
+ retryingClient._totalFromResponse.mockReturnValue(0);
291
+ return retryingClient;
292
+ };
293
+
294
+
295
+ it('does not retry by default', async () => {
296
+ const failure = new Error(randomString());
297
+ client._execute.mockRejectedValue(failure);
298
+
299
+ await expect(client.fetch({})).rejects.toBe(failure);
300
+
301
+ expect(client._execute).toHaveBeenCalledTimes(1);
302
+ });
303
+
304
+
305
+ it('retries up to numRetries and then succeeds', async () => {
306
+ const retryingClient = buildRetryingClient(2);
307
+ retryingClient._execute
308
+ .mockRejectedValueOnce(new Error(randomString()))
309
+ .mockResolvedValueOnce({body: {}});
310
+
311
+ await retryingClient.fetch({});
312
+
313
+ expect(retryingClient._execute).toHaveBeenCalledTimes(2);
314
+ });
315
+
316
+
317
+ it('gives up after numRetries and rethrows', async () => {
318
+ const retryingClient = buildRetryingClient(2);
319
+ const failure = new Error(randomString());
320
+ retryingClient._execute.mockRejectedValue(failure);
321
+
322
+ await expect(retryingClient.fetch({})).rejects.toBe(failure);
323
+
324
+ expect(retryingClient._execute).toHaveBeenCalledTimes(3);
325
+ });
326
+
327
+
328
+ it.each([
329
+ ['upsert', ['id', {}], '_upsert'],
330
+ ['delete', ['id'], '_delete'],
331
+ ])('%s is retried', async (method, args, hook) => {
332
+ const retryingClient = buildRetryingClient(1);
333
+ retryingClient[hook].mockRejectedValueOnce(new Error(randomString()));
334
+
335
+ await retryingClient[method](...args);
336
+
337
+ expect(retryingClient[hook]).toHaveBeenCalledTimes(2);
338
+ });
339
+
340
+
341
+ it.each([
342
+ ['append', [{}], '_append'],
343
+ ['bulkUpsert', [[{}]], '_bulkUpsert'],
344
+ ])('%s is never retried, because a resend would duplicate the document', async (method, args, hook) => {
345
+ const retryingClient = buildRetryingClient(3);
346
+ const failure = new Error(randomString());
347
+ retryingClient[hook].mockRejectedValue(failure);
348
+
349
+ await expect(retryingClient[method](...args)).rejects.toBe(failure);
350
+
351
+ expect(retryingClient[hook]).toHaveBeenCalledTimes(1);
352
+ });
353
+ });
354
+ });
@@ -0,0 +1,41 @@
1
+ const { BulkWriteError, UnsupportedOperationError } = require('../../lib/search/errors');
2
+ const TestHelpers = require('@vida-global/test-helpers');
3
+
4
+
5
+ const randomString = () => TestHelpers.Faker.Text.randomString();
6
+
7
+
8
+ describe('UnsupportedOperationError', () => {
9
+
10
+ describe('#constructor', () => {
11
+ it('keeps the message and names itself', () => {
12
+ const message = randomString();
13
+ const error = new UnsupportedOperationError(message);
14
+
15
+ expect(error).toBeInstanceOf(Error);
16
+ expect(error.message).toBe(message);
17
+ expect(error.name).toBe('UnsupportedOperationError');
18
+ });
19
+
20
+
21
+ it('keeps the error it was raised from', () => {
22
+ const cause = new Error(randomString());
23
+
24
+ expect(new UnsupportedOperationError('nope', {cause}).cause).toBe(cause);
25
+ });
26
+ });
27
+ });
28
+
29
+
30
+ describe('BulkWriteError', () => {
31
+
32
+ describe('#failures', () => {
33
+ it('carries the documents that could not be written', () => {
34
+ const failures = [{id: randomString(), reason: randomString()}];
35
+ const error = new BulkWriteError(randomString(), failures);
36
+
37
+ expect(error.name).toBe('BulkWriteError');
38
+ expect(error.failures).toEqual(failures);
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,137 @@
1
+ /***************************************************************************************************
2
+ * VARIABLES
3
+ ***************************************************************************************************/
4
+ const BACKEND_HOOKS = [
5
+ '_append',
6
+ '_bulkUpsert',
7
+ '_createIndex',
8
+ '_delete',
9
+ '_documentFromHit',
10
+ '_execute',
11
+ '_hitsFromResponse',
12
+ '_idFromHit',
13
+ '_indexExists',
14
+ '_putMapping',
15
+ '_scoreFromHit',
16
+ '_serializeConditions',
17
+ '_totalFromResponse',
18
+ '_upsert'
19
+ ];
20
+
21
+ const MOCK_ENDPOINT = 'https://mock.collection.us-east-1.aoss.amazonaws.com';
22
+ const MOCK_INDEX = 'mock-index';
23
+ const MOCK_MAPPING = {properties: {name: {type: 'keyword'}}};
24
+
25
+
26
+ /***************************************************************************************************
27
+ * MOCKS
28
+ ***************************************************************************************************/
29
+ function buildOpenSearchDouble() {
30
+ return {
31
+ bulk: jest.fn(),
32
+ delete: jest.fn(),
33
+ index: jest.fn(),
34
+ search: jest.fn(),
35
+ update: jest.fn(),
36
+ indices: {
37
+ create: jest.fn(),
38
+ exists: jest.fn(),
39
+ putMapping: jest.fn()
40
+ }
41
+ };
42
+ }
43
+
44
+
45
+ function buildSearchClientClass(settings = {}) {
46
+ const { AbstractSearchClient } = require('../../../lib/search/abstractSearchClient');
47
+
48
+ const numRetries = settings.numRetries || 0;
49
+ const deleteBatchSize = settings.deleteBatchSize || 500;
50
+
51
+ class MockSearchClient extends AbstractSearchClient {
52
+ get numRetries() { return numRetries; }
53
+ get deleteBatchSize() { return deleteBatchSize; }
54
+ get retryBackoffDelay() { return 0; }
55
+ get retryMaxDelay() { return 0; }
56
+ get index() { return MOCK_INDEX; }
57
+ get mapping() { return MOCK_MAPPING; }
58
+ }
59
+
60
+ for (const hook of BACKEND_HOOKS) {
61
+ MockSearchClient.prototype[hook] = jest.fn();
62
+ }
63
+
64
+ return MockSearchClient;
65
+ }
66
+
67
+
68
+ function buildSearchClient(settings = {}) {
69
+ return new (buildSearchClientClass(settings))(settings.indexSuffix);
70
+ }
71
+
72
+
73
+ function buildOpenSearchClientClass(settings = {}) {
74
+ const { OpenSearchClient } = require('../../../lib/search/openSearchClient');
75
+
76
+ const openSearchDouble = buildOpenSearchDouble();
77
+ const signerDouble = jest.fn(() => ({Connection: 'signed-connection'}));
78
+ const clientClassDouble = jest.fn(function () { return openSearchDouble; });
79
+ const numRetries = settings.numRetries || 0;
80
+
81
+ class MockOpenSearchClient extends OpenSearchClient {
82
+ get openSearchClientClass() { return clientClassDouble; }
83
+ get awsSignerFactory() { return signerDouble; }
84
+ get endpoint() { return MOCK_ENDPOINT; }
85
+ get index() { return MOCK_INDEX; }
86
+ get mapping() { return MOCK_MAPPING; }
87
+ get numRetries() { return numRetries; }
88
+ get retryBackoffDelay() { return 0; }
89
+ get retryMaxDelay() { return 0; }
90
+
91
+ get openSearchDouble() { return openSearchDouble; }
92
+ get clientClassDouble() { return clientClassDouble; }
93
+ get signerDouble() { return signerDouble; }
94
+ }
95
+
96
+ return MockOpenSearchClient;
97
+ }
98
+
99
+
100
+ function buildOpenSearchClient(settings = {}) {
101
+ return new (buildOpenSearchClientClass(settings))(settings.indexSuffix);
102
+ }
103
+
104
+
105
+ /***************************************************************************************************
106
+ * VARIABLE GENERATION
107
+ ***************************************************************************************************/
108
+ function buildSearchResponse(hits = [], total = hits.length) {
109
+ return {body: {hits: {total: {value: total}, hits}}};
110
+ }
111
+
112
+
113
+ function buildHit(id, source = {}, score = 1) {
114
+ return {_id: id, _score: score, _source: source};
115
+ }
116
+
117
+
118
+ function buildUnsupportedError() {
119
+ return Object.assign(new Error('Document ID is not supported for this collection'), {
120
+ statusCode: 400
121
+ });
122
+ }
123
+
124
+
125
+ module.exports = {
126
+ BACKEND_HOOKS,
127
+ MOCK_ENDPOINT,
128
+ MOCK_INDEX,
129
+ MOCK_MAPPING,
130
+ buildHit,
131
+ buildOpenSearchClient,
132
+ buildOpenSearchClientClass,
133
+ buildSearchClient,
134
+ buildSearchClientClass,
135
+ buildSearchResponse,
136
+ buildUnsupportedError
137
+ };