@vida-global/core 2.4.5 → 3.0.0
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/README.md +1 -0
- package/index.js +6 -1
- package/lib/activeRecord/README.md +4 -2
- package/lib/activeRecord/baseRecord.js +2 -2
- package/lib/activeRecord/db/migrationErrors.js +6 -0
- package/lib/activeRecord/db/migrator.js +27 -10
- package/lib/http/README.md +18 -4
- package/lib/http/client.js +13 -9
- package/lib/jobQueue/README.md +4 -0
- package/lib/jobQueue/queue.js +7 -0
- package/lib/search/README.md +232 -0
- package/lib/search/abstractSearchClient.js +188 -0
- package/lib/search/errors.js +31 -0
- package/lib/search/index.js +16 -0
- package/lib/search/openSearchClient.js +319 -0
- package/lib/search/query.js +163 -0
- package/lib/server/authorizer.js +42 -0
- package/lib/server/controllerMixins/validations.js +1 -0
- package/lib/server/index.js +3 -2
- package/lib/server/serverController.js +37 -3
- package/package.json +2 -1
- package/test/activeRecord/baseRecord.test.js +22 -0
- package/test/activeRecord/db/migrator.test.js +17 -5
- package/test/activeRecord/db/schema.test.js +15 -0
- package/test/http/client.test.js +42 -0
- package/test/http/helpers/client.js +15 -1
- package/test/jobQueue/helpers/bullmqMock.js +1 -0
- package/test/jobQueue/queue.test.js +11 -0
- package/test/search/abstractSearchClient.test.js +354 -0
- package/test/search/errors.test.js +41 -0
- package/test/search/helpers/searchClient.js +137 -0
- package/test/search/openSearchClient.test.js +514 -0
- package/test/search/query.test.js +286 -0
- package/test/search/serializer.test.js +202 -0
- package/test/server/authorizer.test.js +105 -0
- package/test/server/helpers/authorizer.js +19 -0
- package/test/server/serverController.test.js +77 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
class Query {
|
|
2
|
+
#client;
|
|
3
|
+
#conditions;
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
constructor(client, conditions) {
|
|
7
|
+
this.#client = client;
|
|
8
|
+
this.#conditions = conditions || this.constructor.EMPTY_CONDITIONS;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
static get EMPTY_CONDITIONS() {
|
|
13
|
+
return {and: [], or: [], not: [], options: {}};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
static get DEFAULTS() {
|
|
18
|
+
return {BOOST: 1, FUZZINESS: 'AUTO', SORT_DIRECTION: 'desc'};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async fetch(options) {
|
|
23
|
+
return await this.#client.fetch(this.conditions, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
get conditions() {
|
|
28
|
+
return structuredClone(this.#conditions);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/***********************************************************************************************
|
|
33
|
+
* OPERATORS
|
|
34
|
+
***********************************************************************************************/
|
|
35
|
+
and(...newConditions) {
|
|
36
|
+
return this.#applyConditions('and', newConditions);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
or(...newConditions) {
|
|
41
|
+
return this.#applyConditions('or', newConditions);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
not(...newConditions) {
|
|
46
|
+
return this.#applyConditions('not', newConditions);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
/***********************************************************************************************
|
|
51
|
+
* CONDITIONS
|
|
52
|
+
***********************************************************************************************/
|
|
53
|
+
equals(field, value) {
|
|
54
|
+
return this.and({operator: 'equals', field, value});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
in(field, values) {
|
|
59
|
+
return this.and({operator: 'in', field, value: values});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
range(field, bounds) {
|
|
64
|
+
return this.and({operator: 'range', field, value: bounds});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
exists(field) {
|
|
69
|
+
return this.and({operator: 'exists', field});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
missing(field) {
|
|
74
|
+
return this.and({operator: 'missing', field});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
matches(field, text, options = {}) {
|
|
79
|
+
const fuzziness = options.fuzziness || this.constructor.DEFAULTS.FUZZINESS;
|
|
80
|
+
return this.and({operator: 'matches', field, value: text, options: {fuzziness}});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
phrase(field, text) {
|
|
85
|
+
return this.and({operator: 'phrase', field, value: text});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
fullText(text, options = {}) {
|
|
90
|
+
const field = this.#buildBoostMap(options);
|
|
91
|
+
const wildcard = Boolean(options.wildcard);
|
|
92
|
+
return this.and({operator: 'fullText', field, value: text, options: {wildcard}});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
nested(path, query) {
|
|
97
|
+
return this.and({operator: 'nested', field: path, value: query.conditions});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/***********************************************************************************************
|
|
102
|
+
* OPTIONS
|
|
103
|
+
***********************************************************************************************/
|
|
104
|
+
limit(value) {
|
|
105
|
+
return this.#applyOption('limit', value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
offset(value) {
|
|
110
|
+
return this.#applyOption('offset', value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
minScore(value) {
|
|
115
|
+
return this.#applyOption('minScore', value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
sort(field, direction) {
|
|
120
|
+
const sortKey = {field, direction: direction || this.constructor.DEFAULTS.SORT_DIRECTION};
|
|
121
|
+
const sortKeys = [...(this.#conditions.options.sort || []), sortKey];
|
|
122
|
+
return this.#applyOption('sort', sortKeys);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/***********************************************************************************************
|
|
127
|
+
* CONDITION ASSEMBLY
|
|
128
|
+
***********************************************************************************************/
|
|
129
|
+
#applyConditions(key, newConditions) {
|
|
130
|
+
const conditions = this.conditions;
|
|
131
|
+
for (const newCondition of newConditions) {
|
|
132
|
+
conditions[key].push(this.#normalizeCondition(newCondition));
|
|
133
|
+
}
|
|
134
|
+
return new this.constructor(this.#client, conditions);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
#normalizeCondition(newCondition) {
|
|
139
|
+
if (newCondition instanceof Query) return newCondition.conditions;
|
|
140
|
+
return newCondition;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#applyOption(key, value) {
|
|
145
|
+
const conditions = this.conditions;
|
|
146
|
+
conditions.options[key] = value;
|
|
147
|
+
return new this.constructor(this.#client, conditions);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#buildBoostMap({fields, boost = {}}) {
|
|
152
|
+
const boostMap = {};
|
|
153
|
+
for (const field of fields) {
|
|
154
|
+
boostMap[field] = boost[field] || this.constructor.DEFAULTS.BOOST;
|
|
155
|
+
}
|
|
156
|
+
return boostMap;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
Query
|
|
163
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { camelize } = require('inflection');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Authorizer {
|
|
5
|
+
#currentUser;
|
|
6
|
+
#adminUser;
|
|
7
|
+
|
|
8
|
+
constructor(currentUser, adminUser) {
|
|
9
|
+
this.#currentUser = currentUser;
|
|
10
|
+
this.#adminUser = adminUser;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async can(action, scope) {
|
|
15
|
+
if (!scope) return false;
|
|
16
|
+
|
|
17
|
+
const cls = this.classForScope(scope);
|
|
18
|
+
let permissionMethod = camelize(`${cls.name}Permission`, true);
|
|
19
|
+
if (!this[permissionMethod]) permissionMethod = 'defaultPermission';
|
|
20
|
+
|
|
21
|
+
return await this[permissionMethod](action, scope);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
defaultPermission(action, scope) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
classForScope(scope) {
|
|
31
|
+
return !scope || scope.constructor == Function ? scope : scope.constructor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
get currentUser() { return this.#currentUser; }
|
|
36
|
+
get adminUser() { return this.#adminUser; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
Authorizer
|
|
42
|
+
}
|
|
@@ -38,6 +38,7 @@ const InstanceMethods = {
|
|
|
38
38
|
const errors = [];
|
|
39
39
|
|
|
40
40
|
if (parameterValidations?.optional && value === undefined) return errors;
|
|
41
|
+
if (parameterValidations?.nullable === true && value === null) return errors;
|
|
41
42
|
|
|
42
43
|
for (const [validationType, validationOptions] of Object.entries(parameterValidations)) {
|
|
43
44
|
const validationMethod = `validate${camelize(validationType)}`;
|
package/lib/server/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
const Errors = require('./errors');
|
|
2
|
+
const { ApiDocsGenerator } = require('./openApi/apiDocsGenerator');
|
|
3
|
+
const { Authorizer } = require('./authorizer');
|
|
1
4
|
const { VidaServer } = require('./server');
|
|
2
5
|
const { VidaServerController } = require('./serverController');
|
|
3
|
-
const Errors = require('./errors');
|
|
4
|
-
const { ApiDocsGenerator } = require('./openApi/apiDocsGenerator');
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
module.exports = {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
const APM
|
|
2
|
-
const {
|
|
3
|
-
const
|
|
1
|
+
const APM = require('../apm');
|
|
2
|
+
const { Authorizer } = require('./authorizer');
|
|
3
|
+
const { camelize } = require('inflection');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
4
5
|
const Errors = require('./errors');
|
|
5
6
|
const { logger } = require('../logger');
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class VidaServerController {
|
|
10
|
+
#authorizer;
|
|
9
11
|
#logger;
|
|
10
12
|
#params;
|
|
11
13
|
#rendered = false;
|
|
@@ -207,6 +209,38 @@ class VidaServerController {
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
|
|
212
|
+
/***********************************************************************************************
|
|
213
|
+
* AUTH
|
|
214
|
+
***********************************************************************************************/
|
|
215
|
+
async authorize(action, scope) {
|
|
216
|
+
const authorized = await this.can(action, scope);
|
|
217
|
+
if (!authorized) throw new this.Errors.Forbidden();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async can(action, scope) {
|
|
222
|
+
return await this.authorizer.can(action, scope);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
get authorizer() {
|
|
227
|
+
if (!this.#authorizer) {
|
|
228
|
+
this.#authorizer = new this.authorizerClass(...this.authorizerParameters);
|
|
229
|
+
}
|
|
230
|
+
return this.#authorizer;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
get authorizerParameters() {
|
|
235
|
+
return [this.currentUser, this.adminUser];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
get authorizerClass() {
|
|
240
|
+
return Authorizer;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
210
244
|
/***********************************************************************************************
|
|
211
245
|
* SECURITY
|
|
212
246
|
***********************************************************************************************/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vida-global/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Core libraries for supporting Vida development",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "ISC",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"js-yaml": "^3.14.2",
|
|
32
32
|
"mustache-express": "^1.2.8",
|
|
33
33
|
"newrelic": "^13.0.0",
|
|
34
|
+
"@opensearch-project/opensearch": "^3.0.0",
|
|
34
35
|
"pg": "^8.16.3",
|
|
35
36
|
"redis": "^4.0.0",
|
|
36
37
|
"response-time": "^2.3.3",
|
|
@@ -655,6 +655,28 @@ describe('BaseRecord', () => {
|
|
|
655
655
|
expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id2]}});
|
|
656
656
|
});
|
|
657
657
|
|
|
658
|
+
it ('sets each record with an expiry when expireSeconds is passed', async () => {
|
|
659
|
+
const key1 = `Person#find:${id1}`;
|
|
660
|
+
Person.findAll.mockImplementation(() => [new Person({[pk]:id1})]);
|
|
661
|
+
redisClient.mGet.mockImplementation(keys => [null]);
|
|
662
|
+
|
|
663
|
+
await Person._cachedFind([id1], {expireSeconds: 300});
|
|
664
|
+
|
|
665
|
+
expect(redisClient.mSet).not.toHaveBeenCalled();
|
|
666
|
+
expect(redisClient.set).toHaveBeenCalledTimes(1);
|
|
667
|
+
expect(redisClient.set).toHaveBeenCalledWith(key1, `AR:Person:{"${pk}":${id1}}`, {EX: 300});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it ('passes expireSeconds through from find', async () => {
|
|
671
|
+
const cacheSpy = jest.spyOn(Person, 'cachedFetch');
|
|
672
|
+
|
|
673
|
+
await Person.find([id1], {expireSeconds: 300});
|
|
674
|
+
|
|
675
|
+
expect(cacheSpy).toHaveBeenCalledWith(expect.any(Array), {clear: false, expireSeconds: 300},
|
|
676
|
+
expect.any(Function));
|
|
677
|
+
cacheSpy.mockRestore();
|
|
678
|
+
});
|
|
679
|
+
|
|
658
680
|
it ('returns an array of active record objects if an array was passed', async () => {
|
|
659
681
|
const p1 = new Person({[pk]: id1});
|
|
660
682
|
const p2 = new Person({[pk]: id2});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const
|
|
1
|
+
const Helpers = require('../helpers/migrator');
|
|
2
|
+
const { MigrationError } = require('../../../lib/activeRecord/db/migrationErrors');
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
afterEach(() => {
|
|
@@ -422,13 +423,13 @@ describe('Migrator', () => {
|
|
|
422
423
|
|
|
423
424
|
describe('Migrator#removeIndex', () => {
|
|
424
425
|
it.each([
|
|
425
|
-
['default options',
|
|
426
|
-
['concurrently', true, { concurrently: true, transaction: null }],
|
|
427
|
-
])('passes removeIndex options for %s', async (_label,
|
|
426
|
+
['default options', {}, { transaction: null }],
|
|
427
|
+
['concurrently', { concurrently: true }, { concurrently: true, transaction: null }],
|
|
428
|
+
])('passes removeIndex options for %s', async (_label, indexOptions, expectedOptions) => {
|
|
428
429
|
const tableName = Helpers.randomString();
|
|
429
430
|
const fields = [Helpers.randomString(), Helpers.randomString()];
|
|
430
431
|
|
|
431
|
-
await Helpers.migrator.removeIndex(tableName, fields,
|
|
432
|
+
await Helpers.migrator.removeIndex(tableName, fields, indexOptions);
|
|
432
433
|
|
|
433
434
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
|
|
434
435
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
|
|
@@ -453,6 +454,17 @@ describe('Migrator', () => {
|
|
|
453
454
|
});
|
|
454
455
|
|
|
455
456
|
|
|
457
|
+
describe('Migrator concurrent indexes inside a transaction', () => {
|
|
458
|
+
it.each([
|
|
459
|
+
['addIndex', async function() { await this.addIndex('users', ['team_id'], { concurrently: true }); }],
|
|
460
|
+
['removeIndex', async function() { await this.removeIndex('users', ['team_id'], { concurrently: true }); }],
|
|
461
|
+
])('throws when %s asks for concurrently', async (methodName, operation) => {
|
|
462
|
+
await expect(Helpers.runInMigration(operation)).rejects.toThrow(MigrationError);
|
|
463
|
+
expect(Helpers.mockQueryInterface[methodName]).not.toHaveBeenCalled();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
|
|
456
468
|
describe('Migrator#defaultIndexName', () => {
|
|
457
469
|
it ('builds idx_<table>_<columns> from a single column', () => {
|
|
458
470
|
expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
|
|
@@ -122,6 +122,21 @@ describe('activeRecord/db/schema', () => {
|
|
|
122
122
|
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
123
123
|
expect(schema.name.autoIncrement).toBeUndefined();
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
// A schema file is written from describeTable, and sequelize's postgres driver returns
|
|
128
|
+
// a BOOLEAN column's default as a javascript boolean rather than as text. Matching the
|
|
129
|
+
// sequence pattern against that boolean threw until e3dae1b replaced String#match with
|
|
130
|
+
// RegExp#test.
|
|
131
|
+
it.each([true, false, 5])('leaves a non-string defaultValue of %p alone', (defaultValue) => {
|
|
132
|
+
importSchema.mockImplementation(() => ({
|
|
133
|
+
flag: { type: 'BOOLEAN', defaultValue, allowNull: false }
|
|
134
|
+
}));
|
|
135
|
+
const schema = getActiveRecordSchema('t', 'main', 'postgres');
|
|
136
|
+
expect(schema.flag.autoIncrement).toBeUndefined();
|
|
137
|
+
expect(schema.flag.defaultValue).toBe(defaultValue);
|
|
138
|
+
expect(schema.flag.allowNull).toBe(false);
|
|
139
|
+
});
|
|
125
140
|
});
|
|
126
141
|
|
|
127
142
|
|
package/test/http/client.test.js
CHANGED
|
@@ -181,6 +181,48 @@ describe('HttpClient', () => {
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
describe('binary responses', () => {
|
|
185
|
+
const client = new HttpClient();
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
beforeEach(() => Helpers.mockFetchBinary());
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
it ('returns the exact bytes as a Buffer', async () => {
|
|
192
|
+
const result = await client.get(Helpers.apiUrl, { binaryResponse: true });
|
|
193
|
+
|
|
194
|
+
expect(result.status).toBe(200);
|
|
195
|
+
expect(Buffer.isBuffer(result.data)).toBe(true);
|
|
196
|
+
expect(result.data).toEqual(Helpers.binaryPayload);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
// The point of the option: a text read replaces every byte that is not valid UTF-8.
|
|
201
|
+
it ('never reads the body as text', async () => {
|
|
202
|
+
await client.get(Helpers.apiUrl, { binaryResponse: true });
|
|
203
|
+
|
|
204
|
+
const response = await fetch.mock.results[0].value;
|
|
205
|
+
expect(response.text).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
it ('reads the body as text without the option, losing the bytes', async () => {
|
|
210
|
+
const result = await client.get(Helpers.apiUrl);
|
|
211
|
+
|
|
212
|
+
expect(typeof result.data).toBe('string');
|
|
213
|
+
expect(Buffer.from(result.data)).not.toEqual(Helpers.binaryPayload);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
const verbs = [['post'], ['put'], ['delete']];
|
|
218
|
+
it.each(verbs)('applies to %s as well', async (methodName) => {
|
|
219
|
+
const result = await client[methodName](Helpers.apiUrl, { binaryResponse: true });
|
|
220
|
+
|
|
221
|
+
expect(result.data).toEqual(Helpers.binaryPayload);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
|
|
184
226
|
describe('abort/timeout support', () => {
|
|
185
227
|
const client = new HttpClient();
|
|
186
228
|
|
|
@@ -18,6 +18,10 @@ const apiUrl = `${baseUrl}${endpoint}`;
|
|
|
18
18
|
const responsePayload = randomPayload();
|
|
19
19
|
const responseBody = JSON.stringify(responsePayload);
|
|
20
20
|
|
|
21
|
+
// A RIFF header followed by byte sequences that are not valid UTF-8, so a text read cannot
|
|
22
|
+
// reproduce them.
|
|
23
|
+
const binaryPayload = Buffer.from([0x52, 0x49, 0x46, 0x46, 0xff, 0xfe, 0x00, 0x80]);
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
class ClientWithUrlRoot extends HttpClient {
|
|
23
27
|
get urlRoot() {
|
|
@@ -67,11 +71,19 @@ const mockFetchResponse = ({ body, contentType, status = 200 }) => {
|
|
|
67
71
|
get: jest.fn((headerName) => headerName == 'content-type' ? contentType : undefined),
|
|
68
72
|
},
|
|
69
73
|
status,
|
|
70
|
-
|
|
74
|
+
// Interpolated rather than passed through, so a Buffer body decodes the way a real text
|
|
75
|
+
// read would.
|
|
76
|
+
text: jest.fn().mockResolvedValue(`${body ?? ''}`),
|
|
77
|
+
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from(body ?? '')),
|
|
71
78
|
});
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
|
|
82
|
+
const mockFetchBinary = () => {
|
|
83
|
+
mockFetchResponse({ body: binaryPayload, contentType: 'audio/wav' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
75
87
|
/***************************************************************************************************
|
|
76
88
|
* ASSERTIONS
|
|
77
89
|
***************************************************************************************************/
|
|
@@ -109,11 +121,13 @@ module.exports = {
|
|
|
109
121
|
apiUrl,
|
|
110
122
|
basicAuthCredentials,
|
|
111
123
|
bearerToken,
|
|
124
|
+
binaryPayload,
|
|
112
125
|
ClientWithBasicAuth,
|
|
113
126
|
ClientWithBearerToken,
|
|
114
127
|
ClientWithUrlRoot,
|
|
115
128
|
endpoint,
|
|
116
129
|
expectRequest,
|
|
130
|
+
mockFetchBinary,
|
|
117
131
|
mockFetchResponse,
|
|
118
132
|
mockFetchSuccess,
|
|
119
133
|
randomPayload,
|
|
@@ -218,6 +218,17 @@ describe('Queue', () => {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
describe('#obliterate', () => {
|
|
222
|
+
it ('calls bullmq obliterate() with force so active jobs do not block it', async () => {
|
|
223
|
+
await queue.obliterate();
|
|
224
|
+
|
|
225
|
+
const instance = bullmqMock.instances.queues[0];
|
|
226
|
+
expect(instance.obliterate).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(instance.obliterate).toHaveBeenCalledWith({ force: true });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
|
|
221
232
|
describe('maximum concurrency', () => {
|
|
222
233
|
it ('sets the queue maximum', async () => {
|
|
223
234
|
const maximum = TestHelpers.Faker.Math.randomNumber(20);
|