@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,286 @@
1
+ const { Query } = require('../../lib/search/query');
2
+ const TestHelpers = require('@vida-global/test-helpers');
3
+
4
+
5
+ const randomString = () => TestHelpers.Faker.Text.randomString();
6
+ const randomNumber = () => TestHelpers.Faker.Math.randomNumber();
7
+
8
+
9
+ describe('Query', () => {
10
+ let client;
11
+ let query;
12
+
13
+
14
+ beforeEach(() => {
15
+ client = { fetch: jest.fn() };
16
+ query = new Query(client);
17
+ });
18
+
19
+
20
+ afterEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+
25
+ describe('#constructor', () => {
26
+ it('starts with an empty condition tree', () => {
27
+ expect(query.conditions).toEqual({and: [], or: [], not: [], options: {}});
28
+ });
29
+
30
+
31
+ it('adopts the conditions it is given', () => {
32
+ const conditions = {and: [{operator: 'exists', field: 'a'}], or: [], not: [], options: {}};
33
+
34
+ expect(new Query(client, conditions).conditions).toEqual(conditions);
35
+ });
36
+
37
+
38
+ it('does not share the empty tree between instances', () => {
39
+ new Query(client).conditions.and.push('mutated');
40
+
41
+ expect(new Query(client).conditions.and).toEqual([]);
42
+ });
43
+ });
44
+
45
+
46
+ describe('#conditions', () => {
47
+ it('returns a clone the caller cannot use to mutate the query', () => {
48
+ const conditions = query.conditions;
49
+ conditions.and.push('mutated');
50
+
51
+ expect(query.conditions.and).toEqual([]);
52
+ });
53
+ });
54
+
55
+
56
+ describe('#fetch', () => {
57
+ it('passes the conditions and the options to the client', async () => {
58
+ const options = {indexWildcard: true};
59
+ const expected = query.equals('status', 'success');
60
+
61
+ await expected.fetch(options);
62
+
63
+ expect(client.fetch).toHaveBeenCalledTimes(1);
64
+ expect(client.fetch).toHaveBeenCalledWith(expected.conditions, options);
65
+ });
66
+
67
+
68
+ it('returns whatever the client returns', async () => {
69
+ const response = {total: randomNumber(), results: []};
70
+ client.fetch.mockResolvedValue(response);
71
+
72
+ await expect(query.fetch()).resolves.toBe(response);
73
+ });
74
+
75
+
76
+ it('does not hand the client a reference to its own conditions', async () => {
77
+ await query.equals('status', 'success').fetch();
78
+
79
+ const [passedConditions] = client.fetch.mock.calls[0];
80
+ passedConditions.and.push('mutated');
81
+
82
+ expect(query.conditions.and).toEqual([]);
83
+ });
84
+ });
85
+
86
+
87
+ describe('operators', () => {
88
+ const leaf = (field) => ({operator: 'exists', field});
89
+
90
+
91
+ it.each([
92
+ ['and'],
93
+ ['or'],
94
+ ['not'],
95
+ ])('%s returns a new Query and leaves the original alone', (operator) => {
96
+ const result = query[operator](leaf('a'));
97
+
98
+ expect(result).toBeInstanceOf(Query);
99
+ expect(result).not.toBe(query);
100
+ expect(query.conditions[operator]).toEqual([]);
101
+ });
102
+
103
+
104
+ it.each([
105
+ ['and'],
106
+ ['or'],
107
+ ['not'],
108
+ ])('%s accepts several conditions in one call', (operator) => {
109
+ const result = query[operator](leaf('a'), leaf('b'));
110
+
111
+ expect(result.conditions[operator]).toEqual([leaf('a'), leaf('b')]);
112
+ });
113
+
114
+
115
+ it('unwraps a Query argument into its condition tree', () => {
116
+ const group = new Query(client).equals('from', 'x').equals('to', 'y');
117
+
118
+ expect(query.or(group).conditions.or).toEqual([group.conditions]);
119
+ });
120
+
121
+
122
+ it('merges repeated or calls into one group', () => {
123
+ const result = query.or(leaf('a')).or(leaf('b'));
124
+
125
+ expect(result.conditions.or).toEqual([leaf('a'), leaf('b')]);
126
+ });
127
+ });
128
+
129
+
130
+ describe('conditions', () => {
131
+ it('equals builds a term leaf', () => {
132
+ const value = randomString();
133
+
134
+ expect(query.equals('status', value).conditions.and).toEqual([
135
+ {operator: 'equals', field: 'status', value}
136
+ ]);
137
+ });
138
+
139
+
140
+ it('in builds a leaf holding every value', () => {
141
+ const values = [randomString(), randomString()];
142
+
143
+ expect(query.in('roomId', values).conditions.and).toEqual([
144
+ {operator: 'in', field: 'roomId', value: values}
145
+ ]);
146
+ });
147
+
148
+
149
+ it('range builds a leaf holding the bounds', () => {
150
+ const bounds = {gte: 1, lte: 10};
151
+
152
+ expect(query.range('timestamp', bounds).conditions.and).toEqual([
153
+ {operator: 'range', field: 'timestamp', value: bounds}
154
+ ]);
155
+ });
156
+
157
+
158
+ it('exists builds a leaf with no value', () => {
159
+ expect(query.exists('canceledAt').conditions.and).toEqual([
160
+ {operator: 'exists', field: 'canceledAt'}
161
+ ]);
162
+ });
163
+
164
+
165
+ it('missing builds a leaf with no value', () => {
166
+ expect(query.missing('canceledAt').conditions.and).toEqual([
167
+ {operator: 'missing', field: 'canceledAt'}
168
+ ]);
169
+ });
170
+
171
+
172
+ it('matches defaults fuzziness to AUTO', () => {
173
+ const text = randomString();
174
+
175
+ expect(query.matches('message', text).conditions.and).toEqual([
176
+ {operator: 'matches', field: 'message', value: text, options: {fuzziness: 'AUTO'}}
177
+ ]);
178
+ });
179
+
180
+
181
+ it('matches keeps a fuzziness the caller supplies', () => {
182
+ const text = randomString();
183
+
184
+ expect(query.matches('message', text, {fuzziness: 2}).conditions.and).toEqual([
185
+ {operator: 'matches', field: 'message', value: text, options: {fuzziness: 2}}
186
+ ]);
187
+ });
188
+
189
+
190
+ it('phrase builds a leaf with no options', () => {
191
+ const text = randomString();
192
+
193
+ expect(query.phrase('message', text).conditions.and).toEqual([
194
+ {operator: 'phrase', field: 'message', value: text}
195
+ ]);
196
+ });
197
+
198
+
199
+ it('fullText gives every unboosted field a weight of one', () => {
200
+ const text = randomString();
201
+
202
+ expect(query.fullText(text, {fields: ['message', 'summary']}).conditions.and).toEqual([
203
+ {
204
+ operator: 'fullText',
205
+ field: {message: 1, summary: 1},
206
+ value: text,
207
+ options: {wildcard: false}
208
+ }
209
+ ]);
210
+ });
211
+
212
+
213
+ it('fullText applies the boosts it is given', () => {
214
+ const text = randomString();
215
+ const options = {fields: ['message', 'summary'], boost: {summary: 5}, wildcard: true};
216
+
217
+ expect(query.fullText(text, options).conditions.and).toEqual([
218
+ {
219
+ operator: 'fullText',
220
+ field: {message: 1, summary: 5},
221
+ value: text,
222
+ options: {wildcard: true}
223
+ }
224
+ ]);
225
+ });
226
+
227
+
228
+ it('nested stores the sub-query condition tree', () => {
229
+ const subQuery = new Query(client).matches('addresses.address', 'home');
230
+
231
+ expect(query.nested('addresses', subQuery).conditions.and).toEqual([
232
+ {operator: 'nested', field: 'addresses', value: subQuery.conditions}
233
+ ]);
234
+ });
235
+
236
+
237
+ it('chains several conditions onto the and list in order', () => {
238
+ const result = query.equals('status', 'success').exists('summary');
239
+
240
+ expect(result.conditions.and).toEqual([
241
+ {operator: 'equals', field: 'status', value: 'success'},
242
+ {operator: 'exists', field: 'summary'}
243
+ ]);
244
+ });
245
+ });
246
+
247
+
248
+ describe('options', () => {
249
+ it.each([
250
+ ['limit'],
251
+ ['offset'],
252
+ ['minScore'],
253
+ ])('%s stores the value and returns a new Query', (option) => {
254
+ const value = randomNumber();
255
+ const result = query[option](value);
256
+
257
+ expect(result).not.toBe(query);
258
+ expect(result.conditions.options[option]).toBe(value);
259
+ });
260
+
261
+
262
+ it('sort defaults the direction to desc', () => {
263
+ expect(query.sort('timestamp').conditions.options.sort).toEqual([
264
+ {field: 'timestamp', direction: 'desc'}
265
+ ]);
266
+ });
267
+
268
+
269
+ it('sort appends each key in the order it was added', () => {
270
+ const result = query.sort('score', 'asc').sort('timestamp');
271
+
272
+ expect(result.conditions.options.sort).toEqual([
273
+ {field: 'score', direction: 'asc'},
274
+ {field: 'timestamp', direction: 'desc'}
275
+ ]);
276
+ });
277
+
278
+
279
+ it('keeps options and conditions together through a chain', () => {
280
+ const result = query.equals('status', 'success').limit(50).offset(100);
281
+
282
+ expect(result.conditions.and).toHaveLength(1);
283
+ expect(result.conditions.options).toEqual({limit: 50, offset: 100});
284
+ });
285
+ });
286
+ });
@@ -0,0 +1,202 @@
1
+ const { buildOpenSearchClient } = require('./helpers/searchClient');
2
+ const TestHelpers = require('@vida-global/test-helpers');
3
+
4
+
5
+ /***************************************************************************************************
6
+ * Every query here is one of vida.live's hand-written OpenSearch bodies, rebuilt through the
7
+ * condition vocabulary. The expectations are the corrected bodies: vida.live keys two match_phrase
8
+ * clauses on "message^10", which is not a field name, so those clauses have never matched.
9
+ ***************************************************************************************************/
10
+ const MESSAGE_FIELDS = ['message', 'fromUser', 'toUser', 'summary', 'callDialog'];
11
+ const MESSAGE_BOOST = {message: 4, fromUser: 5, toUser: 5, summary: 5, callDialog: 5};
12
+
13
+ const ACCOUNT_FIELDS = [
14
+ 'username',
15
+ 'vidaPhoneNumber',
16
+ 'details.description',
17
+ 'details.name',
18
+ 'details.orgName',
19
+ 'details.resellerName',
20
+ 'details.partnerName'
21
+ ];
22
+ const ACCOUNT_BOOST = {username: 10, vidaPhoneNumber: 10};
23
+
24
+
25
+ const randomString = () => TestHelpers.Faker.Text.randomString();
26
+ const randomNumber = () => TestHelpers.Faker.Math.randomNumber();
27
+
28
+
29
+ describe('OpenSearchClient serialization of vida.live queries', () => {
30
+ let client;
31
+
32
+
33
+ beforeEach(() => {
34
+ client = buildOpenSearchClient();
35
+ });
36
+
37
+
38
+ describe('textSearchMessage', () => {
39
+ const buildQuery = (queryText, userId) => {
40
+ const userMatch = client.query().or(
41
+ client.query().equals('from', userId),
42
+ client.query().equals('to', userId),
43
+ client.query().equals('user', userId)
44
+ );
45
+
46
+ const keywordMatch = client.query().or(
47
+ client.query().fullText(queryText, {
48
+ fields: MESSAGE_FIELDS,
49
+ boost: MESSAGE_BOOST,
50
+ wildcard: true
51
+ }),
52
+ client.query().phrase('message', queryText),
53
+ ...MESSAGE_FIELDS.map(field => client.query().matches(field, queryText))
54
+ );
55
+
56
+ return client.query().equals('status', 'success').and(userMatch, keywordMatch);
57
+ };
58
+
59
+
60
+ it('builds the same must/should tree the hand-written body builds', () => {
61
+ const queryText = randomString();
62
+ const userId = randomString();
63
+
64
+ const body = client._serializeConditions(buildQuery(queryText, userId).conditions);
65
+
66
+ expect(body.query).toEqual({
67
+ bool: {
68
+ must: [
69
+ {term: {status: {value: 'success'}}},
70
+ {
71
+ bool: {
72
+ should: [
73
+ {term: {from: {value: userId}}},
74
+ {term: {to: {value: userId}}},
75
+ {term: {user: {value: userId}}}
76
+ ],
77
+ minimum_should_match: 1
78
+ }
79
+ },
80
+ {
81
+ bool: {
82
+ should: [
83
+ {
84
+ query_string: {
85
+ query: `*${queryText}*`,
86
+ fields: [
87
+ 'message^4',
88
+ 'fromUser^5',
89
+ 'toUser^5',
90
+ 'summary^5',
91
+ 'callDialog^5'
92
+ ]
93
+ }
94
+ },
95
+ {match_phrase: {message: {query: queryText}}},
96
+ ...MESSAGE_FIELDS.map(field => ({
97
+ match: {[field]: {query: queryText, fuzziness: 'AUTO'}}
98
+ }))
99
+ ],
100
+ minimum_should_match: 1
101
+ }
102
+ }
103
+ ],
104
+ must_not: []
105
+ }
106
+ });
107
+ });
108
+
109
+
110
+ it('asks for an exact total and sends no paging keys', () => {
111
+ const body = client._serializeConditions(buildQuery('x', 'y').conditions);
112
+
113
+ expect(body.track_total_hits).toBe(true);
114
+ expect(body).not.toHaveProperty('size');
115
+ expect(body).not.toHaveProperty('sort');
116
+ });
117
+ });
118
+
119
+
120
+ describe('searchAccountsWithFilters', () => {
121
+ const buildQuery = (queryText, organizationId, pageSize, pageNumber) => {
122
+ const textMatch = client.query().or(
123
+ client.query().fullText(queryText, {
124
+ fields: ACCOUNT_FIELDS,
125
+ boost: ACCOUNT_BOOST,
126
+ wildcard: true
127
+ }),
128
+ client.query().phrase('details.description', queryText),
129
+ client.query().phrase('details.name', queryText)
130
+ );
131
+
132
+ return client.query()
133
+ .and(textMatch)
134
+ .equals('organizationId.keyword', organizationId)
135
+ .range('timestamp', {gte: 1, lte: 9})
136
+ .exists('configIssues.count')
137
+ .not(
138
+ client.query().exists('unregistered'),
139
+ client.query().equals('tempUser', true)
140
+ )
141
+ .sort('timestamp', 'desc')
142
+ .limit(pageSize)
143
+ .offset(pageNumber * pageSize);
144
+ };
145
+
146
+
147
+ it('keeps every filter in must and both exclusions in must_not', () => {
148
+ const queryText = randomString();
149
+ const organizationId = randomString();
150
+
151
+ const body = client._serializeConditions(buildQuery(queryText, organizationId, 25, 2).conditions);
152
+
153
+ expect(body.query.bool.must).toEqual([
154
+ {
155
+ bool: {
156
+ should: [
157
+ {
158
+ query_string: {
159
+ query: `*${queryText}*`,
160
+ fields: [
161
+ 'username^10',
162
+ 'vidaPhoneNumber^10',
163
+ 'details.description^1',
164
+ 'details.name^1',
165
+ 'details.orgName^1',
166
+ 'details.resellerName^1',
167
+ 'details.partnerName^1'
168
+ ]
169
+ }
170
+ },
171
+ {match_phrase: {'details.description': {query: queryText}}},
172
+ {match_phrase: {'details.name': {query: queryText}}}
173
+ ],
174
+ minimum_should_match: 1
175
+ }
176
+ },
177
+ {term: {'organizationId.keyword': {value: organizationId}}},
178
+ {range: {timestamp: {gte: 1, lte: 9}}},
179
+ {exists: {field: 'configIssues.count'}}
180
+ ]);
181
+
182
+ expect(body.query.bool.must_not).toEqual([
183
+ {exists: {field: 'unregistered'}},
184
+ {term: {tempUser: {value: true}}}
185
+ ]);
186
+ });
187
+
188
+
189
+ it('turns the page number and page size into from and size', () => {
190
+ const pageSize = randomNumber() + 1;
191
+ const pageNumber = randomNumber();
192
+
193
+ const body = client._serializeConditions(
194
+ buildQuery('x', 'y', pageSize, pageNumber).conditions
195
+ );
196
+
197
+ expect(body.size).toBe(pageSize);
198
+ expect(body.from).toBe(pageNumber * pageSize);
199
+ expect(body.sort).toEqual([{timestamp: {order: 'desc'}}]);
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,105 @@
1
+ const { Authorizer } = require('../../lib/server/authorizer');
2
+ const { Gadget, TestAuthorizer, Widget } = require('./helpers/authorizer');
3
+ const TestHelpers = require('@vida-global/test-helpers');
4
+
5
+
6
+ afterEach(() => {
7
+ jest.restoreAllMocks();
8
+ });
9
+
10
+
11
+ describe('Authorizer', () => {
12
+ describe('#can', () => {
13
+ let action;
14
+ let authorizer;
15
+
16
+ beforeEach(() => {
17
+ action = TestHelpers.Faker.Text.randomString();
18
+ authorizer = new TestAuthorizer();
19
+ });
20
+
21
+ it ('returns false when there is no scope', async () => {
22
+ await expect(authorizer.can(action)).resolves.toBe(false);
23
+ });
24
+
25
+ it ('dispatches to the permission method named for the class of the scope instance', async () => {
26
+ const scope = new Widget();
27
+ const widgetSpy = jest.spyOn(authorizer, 'widgetPermission');
28
+
29
+ await authorizer.can(action, scope);
30
+
31
+ expect(widgetSpy).toHaveBeenCalledTimes(1);
32
+ expect(widgetSpy).toHaveBeenCalledWith(action, scope);
33
+ });
34
+
35
+ it ('dispatches to the same permission method when the scope is the class itself', async () => {
36
+ const widgetSpy = jest.spyOn(authorizer, 'widgetPermission');
37
+
38
+ await authorizer.can(action, Widget);
39
+
40
+ expect(widgetSpy).toHaveBeenCalledTimes(1);
41
+ expect(widgetSpy).toHaveBeenCalledWith(action, Widget);
42
+ });
43
+
44
+ it ('falls back to defaultPermission when no method matches the class of the scope', async () => {
45
+ const scope = new Gadget();
46
+ const defaultSpy = jest.spyOn(authorizer, 'defaultPermission');
47
+
48
+ await authorizer.can(action, scope);
49
+
50
+ expect(defaultSpy).toHaveBeenCalledTimes(1);
51
+ expect(defaultSpy).toHaveBeenCalledWith(action, scope);
52
+ });
53
+
54
+ it ('returns the value from a synchronous permission method', async () => {
55
+ jest.spyOn(authorizer, 'widgetPermission').mockReturnValue(true);
56
+
57
+ await expect(authorizer.can(action, new Widget())).resolves.toBe(true);
58
+ });
59
+
60
+ it ('returns the resolved value from an asynchronous permission method', async () => {
61
+ jest.spyOn(authorizer, 'widgetPermission').mockResolvedValue(true);
62
+
63
+ await expect(authorizer.can(action, new Widget())).resolves.toBe(true);
64
+ });
65
+ });
66
+
67
+
68
+ describe('#defaultPermission', () => {
69
+ it ('returns false', () => {
70
+ const authorizer = new Authorizer();
71
+ const action = TestHelpers.Faker.Text.randomString();
72
+
73
+ expect(authorizer.defaultPermission(action, new Widget())).toBe(false);
74
+ });
75
+ });
76
+
77
+
78
+ describe('#classForScope', () => {
79
+ const authorizer = new Authorizer();
80
+
81
+ it ('returns the constructor when the scope is an instance', () => {
82
+ expect(authorizer.classForScope(new Widget())).toBe(Widget);
83
+ });
84
+
85
+ it ('returns the class when the scope is a class', () => {
86
+ expect(authorizer.classForScope(Widget)).toBe(Widget);
87
+ });
88
+
89
+ it ('returns the scope unchanged when the scope is falsy', () => {
90
+ expect(authorizer.classForScope(null)).toBeNull();
91
+ });
92
+ });
93
+
94
+
95
+ describe('#currentUser / #adminUser', () => {
96
+ it ('exposes the users passed to the constructor', () => {
97
+ const currentUser = {id: TestHelpers.Faker.Text.randomString()};
98
+ const adminUser = {id: TestHelpers.Faker.Text.randomString()};
99
+ const authorizer = new Authorizer(currentUser, adminUser);
100
+
101
+ expect(authorizer.currentUser).toBe(currentUser);
102
+ expect(authorizer.adminUser).toBe(adminUser);
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,19 @@
1
+ const { Authorizer } = require('../../../lib/server/authorizer');
2
+
3
+
4
+ class Widget {}
5
+
6
+
7
+ class Gadget {}
8
+
9
+
10
+ class TestAuthorizer extends Authorizer {
11
+ widgetPermission() {}
12
+ }
13
+
14
+
15
+ module.exports = {
16
+ Gadget,
17
+ TestAuthorizer,
18
+ Widget
19
+ }