diff --git a/addons/api/addon/handlers/client-daemon-handler.js b/addons/api/addon/handlers/client-daemon-handler.js index d8380a634..2fe61c995 100644 --- a/addons/api/addon/handlers/client-daemon-handler.js +++ b/addons/api/addon/handlers/client-daemon-handler.js @@ -7,6 +7,7 @@ import { inject as service } from '@ember/service'; import { getOwner, setOwner } from '@ember/application'; import { pluralize } from 'ember-inflector'; import { generateMQLExpression } from '../utils/mql-query'; +import { paginateResults } from '../utils/paginate-results'; /** * Not all types are yet supported by the client daemon so we'll @@ -19,31 +20,6 @@ const supportedTypes = { session: ['id', 'type', 'status', 'endpoint', 'scope_id', 'target_id'], }; -/** - * Takes an array and the current page and pagesize to calculate the correct - * number of results to return to the caller. - * - * @param array - * @param page - * @param pageSize - * @returns {[*]} - */ -const paginateResults = (array, page, pageSize) => { - const length = array?.length; - if (!array || length === 0) { - return []; - } - if (!page || !pageSize) { - return array; - } - - const offset = (page - 1) * pageSize; - const start = Math.min(length - 1, offset); - const end = Math.min(length, offset + pageSize); - - return array.slice(start, end); -}; - const fetchControllerData = async (context, next) => { const { data } = context.request; const { query: originalQuery } = data; diff --git a/addons/api/addon/handlers/indexed-db-handler.js b/addons/api/addon/handlers/indexed-db-handler.js index a10802c11..57baecdc0 100644 --- a/addons/api/addon/handlers/indexed-db-handler.js +++ b/addons/api/addon/handlers/indexed-db-handler.js @@ -1,7 +1,8 @@ import { modelIndexes } from 'api/services/indexed-db'; import { inject as service } from '@ember/service'; import { getOwner, setOwner } from '@ember/application'; -import { pluralize } from 'ember-inflector'; +import { queryIndexedDb } from '../utils/indexed-db-query'; +import { paginateResults } from '../utils/paginate-results'; /** * Handler to sit in front of the API request layer @@ -21,22 +22,23 @@ export default class IndexedDbHandler { const { type, query, options: { pushToStore = true } = {} } = data; const supportedModels = Object.keys(modelIndexes); const { db: indexedDb } = this.indexedDb ?? {}; - const pluralizedType = pluralize(type); // Go through normal flow if we don't yet support the model // or if we don't have an indexedDb instance - if (!supportedModels.includes(pluralizedType) || !indexedDb) { + if (!supportedModels.includes(type) || !indexedDb) { return next(context.request); } - const listToken = await indexedDb.tokens.get(pluralizedType); + let { page, pageSize, query: queryObj, ...remainingQuery } = query; + + const listToken = await indexedDb.token.get(type); const adapter = store.adapterFor(type); const schema = store.modelFor(type); let payload; try { payload = await adapter.query(store, schema, { - ...query, + ...remainingQuery, scope_id: 'global', recursive: true, list_token: listToken?.token, @@ -47,11 +49,11 @@ export default class IndexedDbHandler { // clear the DB and try again without a token. // TODO: Probably should check for 400 and bad token error specifically instead? if (e?.errors[0].status !== 429) { - await indexedDb[pluralizedType].clear(); - await indexedDb.tokens.delete(pluralizedType); + await indexedDb[type].clear(); + await indexedDb.token.delete(type); payload = await adapter.query(store, schema, { - ...query, + ...remainingQuery, scope_id: 'global', recursive: true, list_token: null, @@ -62,14 +64,14 @@ export default class IndexedDbHandler { } // Store the token we just got back from the payload - await indexedDb.tokens.put({ - id: pluralizedType, + await indexedDb.token.put({ + id: type, token: payload.list_token, }); // Remove any records from the DB if the API indicates they've been deleted if (payload.removed_ids?.length > 0) { - await indexedDb[pluralizedType].bulkDelete(payload.removed_ids); + await indexedDb[type].bulkDelete(payload.removed_ids); } const serializer = store.serializerFor(type); @@ -84,18 +86,20 @@ export default class IndexedDbHandler { const { data: payloadData } = normalizedPayload; // Store the new data we just got back from the API refresh - await indexedDb[pluralizedType].bulkPut( - payloadData.map((datum) => this.indexedDb.normalizeData(datum, true)), + const items = payloadData.map((datum) => + this.indexedDb.normalizeData(datum, true), + ); + await indexedDb[type].bulkPut(items); + + const indexedDbResults = await queryIndexedDb( + indexedDb, + type, + queryObj, ); - // TODO: Filter here, this is just a placeholder for now - const dbRecords = ( - await indexedDb[pluralizedType] - .where('attributes.scope.scope_id') - .equals(query.scope_id) - .toArray() - ).map(this.indexedDb.normalizeData); - const recordCount = await indexedDb[pluralizedType].count(); + const dbRecords = paginateResults(indexedDbResults, page, pageSize).map( + this.indexedDb.normalizeData, + ); // Return the raw data if we don't push to the store. let records = dbRecords.map((record) => ({ @@ -111,7 +115,7 @@ export default class IndexedDbHandler { // This isn't conventional but is better than returning an ArrayProxy // or EmberArray since the ember store query method asserts it has to be an array // so we can't just return an object. - records.meta = { totalItems: recordCount }; + records.meta = { totalItems: indexedDbResults.length }; return records; } default: diff --git a/addons/api/addon/services/indexed-db.js b/addons/api/addon/services/indexed-db.js index 30e9aadd1..9aa96e556 100644 --- a/addons/api/addon/services/indexed-db.js +++ b/addons/api/addon/services/indexed-db.js @@ -1,4 +1,4 @@ -import Service, { inject as service } from '@ember/service'; +import Service from '@ember/service'; import Dexie from 'dexie'; import { typeOf } from '@ember/utils'; @@ -7,9 +7,11 @@ import { typeOf } from '@ember/utils'; // this index. Increment the version number of the database whenever // indexes change. export const modelIndexes = { - tokens: '&id, token', - targets: '&id, attributes.type, attributes.name, attributes.scope.scope_id', - sessions: '&id, attributes.type, attributes.scope.scope_id', + token: '&id, token', + target: + '&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.address, attributes.scope.scope_id', + session: + '&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id', }; export const formatDbName = (userId, clusterUrl) => @@ -46,7 +48,7 @@ const unconvertBooleansInObject = (result, object) => { if (typeOf(value) === 'object') { result[key] = unconvertBooleansInObject({}, value); } else if (value === 'true' || value === 'false') { - result[key] = value === 'true' ? true : false; + result[key] = value === 'true'; } else { result[key] = value; } @@ -60,9 +62,6 @@ const unconvertBooleansInObject = (result, object) => { * `setup` from the application root and */ export default class IndexedDbService extends Service { - // =services - @service session; - // =attributes #db; get db() { diff --git a/addons/api/addon/utils/indexed-db-query.js b/addons/api/addon/utils/indexed-db-query.js new file mode 100644 index 000000000..8a3443eee --- /dev/null +++ b/addons/api/addon/utils/indexed-db-query.js @@ -0,0 +1,263 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { get } from '@ember/object'; +import { modelIndexes } from '../services/indexed-db'; + +/** + * Takes a POJO representing a filter query and builds a query to + * query indexedDb with. Note that the first filter is important as that + * will use the actual index in indexedDb while any subsequent ones will be less efficient. + * + * @example + * const filter = queryIndexedDb(indexedDb, type, { + * search: generated', + * filters: { + * scope_id: { + * logicalOperator: 'and', + * values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }], + * }, + * }, + * }); + */ +export async function queryIndexedDb(indexedDb, resource, query) { + let { search, filters = {} } = query ?? {}; + let filterCollection = indexedDb[resource]; + + search = getSearchObject(search, resource); + filters = cleanObject(filters); + + if (Object.keys(filters).length) { + // Remove the first item to use as the initial collection and then reduce the remaining ones + const entries = Object.entries(filters); + const [firstKey, firstFilterArrayOrObject] = entries.shift(); + + filterCollection = await entries.reduce( + async (currCollection, [key, filterArrayOrObject]) => { + // Grab the results from the set of one filters to be `AND` together with the rest of the filters + const filterResults = await buildInitialWhereClause({ + table: indexedDb[resource], + key, + filterArrayOrObject, + }).primaryKeys(); + const resultIdsSet = new Set(filterResults); + + return (await currCollection).and((record) => { + return resultIdsSet.has(record.id); + }); + }, + buildInitialWhereClause({ + key: firstKey, + table: indexedDb[resource], + filterArrayOrObject: firstFilterArrayOrObject, + }), + ); + } + + if (search) { + const { text, fields } = search; + + filterCollection = filterCollection.filter((record) => + fields + .map((key) => get(record, key)) + .some((element) => { + if (typeof element === 'string') { + return element?.toLowerCase().includes(text?.toLowerCase()); + } + }), + ); + } + + // OrderBy has to be used on where clauses while if it's a + // collection already we have to use sortBy. We'll also sort by + // descending order based on created time to match the API as any + // "or" clause can change the order + if (filterCollection.orderBy) { + return filterCollection.orderBy(getKey('created_time')).reverse().toArray(); + } + return filterCollection.reverse().sortBy(getKey('created_time')); +} + +const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => { + const filterValueArray = Array.isArray(filterArrayOrObject) + ? filterArrayOrObject + : filterArrayOrObject.values; + + const { logicalOperator } = filterArrayOrObject; + + // Short circuit if all the filter types were equals or notEquals since indexedDb + // can more efficiently query if we pass all values at once + const checkAllFilterType = (type) => + filterValueArray.every((obj) => Object.keys(obj)[0] === type); + if (checkAllFilterType('equals')) { + return table + .where(getKey(key)) + .anyOf(filterValueArray.map((obj) => obj['equals'])); + } + if (checkAllFilterType('notEquals')) { + return table + .where(getKey(key)) + .noneOf(filterValueArray.map((obj) => obj['notEquals'])); + } + + // Remove the first item to use as the initial where clause and then reduce the remaining ones + const [firstOperation, firstFilterValue] = Object.entries( + filterValueArray[0], + )[0]; + + // Not all are filters are equals or notEquals, + // manually build the initial where clause and resulting collections + return filterValueArray.slice(1).reduce( + (result, currObj) => { + const [operation, value] = Object.entries(currObj)[0]; + + return buildIndexedDbCollection({ + key: getKey(key), + value, + collection: result, + operation, + logicalOperator, + }); + }, + buildIndexedDbWhere({ + table, + key: getKey(key), + operation: firstOperation, + value: firstFilterValue, + }), + ); +}; + +/** + * Builds the initial where clause for indexed DB to filter on. Note that this makes + * the first filter important as that will do the real filtering from indexed DB while + * any subsequent filter has to be done in memory. + */ +const buildIndexedDbWhere = ({ table, key, value, operation }) => { + const baseWhereClause = table.where(key); + switch (operation) { + case 'contains': + // We return an error due to not being able to support contains on first filter due to performance. + throw new Error('Contains is not supported as the first filter.'); + case 'gt': + return baseWhereClause.above(value); + case 'gte': + return baseWhereClause.aboveOrEqual(value); + case 'lt': + return baseWhereClause.below(value); + case 'lte': + return baseWhereClause.belowOrEqual(value); + case 'notEquals': + return baseWhereClause.notEqual(value); + default: + return baseWhereClause.equals(value); + } +}; + +/** + * Continues building the queries for indexed DB to filter on. Note that these are not true + * indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries + * to indexed DB for logical "or" queries that get deduped. + */ +const buildIndexedDbCollection = ({ + key, + value, + collection, + operation, + logicalOperator, +}) => { + if (logicalOperator === 'and') { + switch (operation) { + case 'contains': + return collection.and((record) => get(record, key)?.includes(value)); + case 'gt': + return collection.and((record) => get(record, key) > value); + case 'gte': + return collection.and((record) => get(record, key) >= value); + case 'lt': + return collection.and((record) => get(record, key) < value); + case 'lte': + return collection.and((record) => get(record, key) <= value); + case 'notEquals': + return collection.and((record) => get(record, key) !== value); + default: + return collection.and((record) => get(record, key) === value); + } + } + + switch (operation) { + case 'contains': + // We return an error due to not being able to support contains on this filter due to performance. + throw new Error( + 'Contains is not supported as a filter option with "or" operator.', + ); + case 'gt': + return collection.or(key).above(value); + case 'gte': + return collection.or(key).aboveOrEqual(value); + case 'lt': + return collection.or(key).below(value); + case 'lte': + return collection.or(key).belowOrEqual(value); + case 'notEquals': + return collection.or(key).notEqual(value); + default: + return collection.or(key).equals(value); + } +}; + +/** + * Translates the fields passed in from the UI for resources to the keys indexed DB knows them as. + * Usually it's the path of the actual normalized model. + */ +const getKey = (key) => { + switch (key) { + case 'scope_id': + return `attributes.scope.${key}`; + case 'id': + return key; + default: + return `attributes.${key}`; + } +}; + +/** + * Cleans the object to remove any keys that have a value of an empty array. This makes + * it easier so we don't have to worry about empty arrays getting queried in indexed db. + */ +const cleanObject = (obj) => { + if (!obj) { + return {}; + } + + return Object.entries(obj).reduce((result, [key, value]) => { + if (Array.isArray(value)) { + if (value.length > 0) { + result[key] = value; + } + } else if (value.values.length > 0) { + result[key] = value; + } + + return result; + }, {}); +}; + +/** + * Makes the search object that is expected when a user searches. Uses + * the indexes we setup in indexed db. + */ +const getSearchObject = (text, resource) => { + if (!text) { + return null; + } + + return { + text, + fields: modelIndexes[resource] + .split(', ') + .map((index) => index.replace('&', '')), + }; +}; diff --git a/addons/api/addon/utils/mql-query.js b/addons/api/addon/utils/mql-query.js index 876a1c933..4203347d4 100644 --- a/addons/api/addon/utils/mql-query.js +++ b/addons/api/addon/utils/mql-query.js @@ -9,8 +9,8 @@ * * @example * const filter = generateMQLFilterExpression({ - * name: [{ contains: 'search text' }], - * scope: [{ equals: 'scope1'}, { equals: 'scope2' }], + * name: { logicalOperator: 'and', values: [{ notEquals: 'search text' }, { notEquals: 'test' }] }, + * scope_id: [{ equals: 'scope1'}, { equals: 'scope2' }], * }); * * // (name % "search text") and (scope_id = "scope1" or scope_id = "scope2") @@ -21,10 +21,10 @@ export function generateMQLFilterExpression(filterObj) { } const expressions = Object.entries(filterObj) - .map(([key, filterObjValue]) => { - const filterValueArray = Array.isArray(filterObjValue) - ? filterObjValue - : filterObjValue.values; + .map(([key, filterArrayOrObject]) => { + const filterValueArray = Array.isArray(filterArrayOrObject) + ? filterArrayOrObject + : filterArrayOrObject.values; let clauses; const filterValues = filterValueArray.map((filterObjValue) => { @@ -53,7 +53,7 @@ export function generateMQLFilterExpression(filterObj) { }); // Default to `or` if we don't have a logical operator - const { logicalOperator } = filterObjValue; + const { logicalOperator } = filterArrayOrObject; if (logicalOperator === 'and') { clauses = and(filterValues); } else { diff --git a/addons/api/addon/utils/paginate-results.js b/addons/api/addon/utils/paginate-results.js new file mode 100644 index 000000000..6b36630cf --- /dev/null +++ b/addons/api/addon/utils/paginate-results.js @@ -0,0 +1,24 @@ +/** + * Takes an array and the current page and pagesize to calculate the correct + * number of results to return to the caller. + * + * @param array + * @param page + * @param pageSize + * @returns {[*]} + */ +export const paginateResults = (array, page, pageSize) => { + const length = array?.length; + if (!array || length === 0) { + return []; + } + if (!page || !pageSize) { + return array; + } + + const offset = (page - 1) * pageSize; + const start = Math.min(length - 1, offset); + const end = Math.min(length, offset + pageSize); + + return array.slice(start, end); +}; diff --git a/addons/api/app/utils/mql-query.js b/addons/api/app/utils/mql-query.js deleted file mode 100644 index 9ee971591..000000000 --- a/addons/api/app/utils/mql-query.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -export { default } from 'api/utils/mql-query'; diff --git a/addons/api/package.json b/addons/api/package.json index 08fadd5f6..c335ade94 100644 --- a/addons/api/package.json +++ b/addons/api/package.json @@ -31,7 +31,6 @@ "ember-cli-mirage": "^2.4.0", "ember-data": "~4.12.0", "ember-fetch": "^8.1.1", - "ember-auto-import": "^2.6.3", "tracked-built-ins": "^3.1.1" }, "devDependencies": { @@ -65,6 +64,7 @@ "eslint-plugin-ember": "^11.11.1", "eslint-plugin-n": "^16.3.1", "eslint-plugin-qunit": "^8.0.1", + "@faker-js/faker": "^8.0.2", "js-bexpr": "hashicorp/js-bexpr#9b4a4b54d85eba67fdfc0990133d1518d890b1e1", "loader.js": "^4.7.0", "prettier": "^3.0.0", diff --git a/addons/api/tests/dummy/config/environment.js b/addons/api/tests/dummy/config/environment.js index 374e072c8..24c1c160d 100644 --- a/addons/api/tests/dummy/config/environment.js +++ b/addons/api/tests/dummy/config/environment.js @@ -28,6 +28,10 @@ module.exports = function (environment) { host: '', namespace: 'v1', }, + + 'ember-cli-mirage': { + directory: './mirage', + }, }; if (environment === 'development') { diff --git a/addons/api/tests/dummy/mirage/config.js b/addons/api/tests/dummy/mirage/config.js deleted file mode 100644 index 669328cdb..000000000 --- a/addons/api/tests/dummy/mirage/config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { createServer } from 'miragejs'; - -export default function (config) { - return createServer({ ...config }); -} diff --git a/addons/api/tests/dummy/mirage/scenarios/default.js b/addons/api/tests/dummy/mirage/scenarios/default.js deleted file mode 100644 index 23c86d030..000000000 --- a/addons/api/tests/dummy/mirage/scenarios/default.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -export default function (/* server */) { - /* - Seed your development database using your factories. - This data will not be loaded in your tests. - */ - // server.createList('post', 10); -} diff --git a/addons/api/tests/dummy/mirage/serializers/application.js b/addons/api/tests/dummy/mirage/serializers/application.js deleted file mode 100644 index 93128a0ef..000000000 --- a/addons/api/tests/dummy/mirage/serializers/application.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { JSONAPISerializer } from 'miragejs'; - -export default JSONAPISerializer.extend({}); diff --git a/addons/api/tests/unit/adapters/application-test.js b/addons/api/tests/unit/adapters/application-test.js index 354084efc..629b87128 100644 --- a/addons/api/tests/unit/adapters/application-test.js +++ b/addons/api/tests/unit/adapters/application-test.js @@ -100,7 +100,7 @@ module('Unit | Adapter | application', function (hooks) { test('it can request records through the store from a specified scope', async function (assert) { assert.expect(1); const store = this.owner.lookup('service:store'); - this.server.get('/v1/groups', (_, { queryParams: { scope_id } }) => { + this.server.get('/groups', (_, { queryParams: { scope_id } }) => { assert.strictEqual( scope_id, 'p_456', diff --git a/addons/api/tests/unit/models/auth-method-test.js b/addons/api/tests/unit/models/auth-method-test.js index c00e39ce3..94865cd9f 100644 --- a/addons/api/tests/unit/models/auth-method-test.js +++ b/addons/api/tests/unit/models/auth-method-test.js @@ -29,19 +29,16 @@ module('Unit | Model | auth method', function (hooks) { test('it has a `changeState` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post( - '/v1/auth-methods/123abc:change-state', - (schema, request) => { - const body = JSON.parse(request.requestBody); - assert.deepEqual(body, { - attributes: { - state: 'foobar', - }, - version: 1, - }); - return { id: '123abc' }; - }, - ); + this.server.post('/auth-methods/123abc:change-state', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + attributes: { + state: 'foobar', + }, + version: 1, + }); + return { id: '123abc' }; + }); store.push({ data: { id: '123abc', diff --git a/addons/api/tests/unit/models/base-test.js b/addons/api/tests/unit/models/base-test.js index 82b8a2dcb..f1f9ebefc 100644 --- a/addons/api/tests/unit/models/base-test.js +++ b/addons/api/tests/unit/models/base-test.js @@ -80,7 +80,7 @@ module('Unit | Model | base', function (hooks) { attributes: {}, }, }); - this.server.patch('/v1/users/1', () => { + this.server.patch('users/1', () => { assert.ok(true, 'Correctly scoped update record URL was requested.'); return { id: 1 }; }); @@ -112,17 +112,17 @@ module('Unit | Model | base', function (hooks) { assert.expect(3); const store = this.owner.lookup('service:store'); const model = store.createRecord('user', { scopeID: 'o_123' }); - this.server.post('/v1/users', () => { + this.server.post('users', () => { assert.ok(true, 'Correctly scoped create record URL was requested.'); return { id: 'u_123' }; }); await model.save(); - this.server.patch('/v1/users/u_123', () => { + this.server.patch('users/u_123', () => { assert.ok(true, 'Correctly scoped update record URL was requested.'); return { id: 'u_123' }; }); await model.save(); - this.server.delete('/v1/users/u_123', () => { + this.server.delete('users/u_123', () => { assert.ok(true, 'Correctly scoped delete record URL was requested.'); return {}; }); @@ -135,17 +135,17 @@ module('Unit | Model | base', function (hooks) { const customScopeID = 'global'; const scope = { scope_id: 'o_123' }; const model = store.createRecord('user', { scope }); - this.server.post('/v1/users', () => { + this.server.post('users', () => { assert.ok(true, 'Correctly scoped create record URL was requested.'); return { id: 'u_123' }; }); await model.save({ adapterOptions: { scopeID: customScopeID } }); - this.server.patch('/v1/users/u_123', () => { + this.server.patch('users/u_123', () => { assert.ok(true, 'Correctly scoped update record URL was requested.'); return { id: 'u_123' }; }); await model.save({ adapterOptions: { scopeID: customScopeID } }); - this.server.delete('/v1/users/u_123', () => { + this.server.delete('users/u_123', () => { assert.ok(true, 'Correctly scoped delete record URL was requested.'); return {}; }); @@ -158,7 +158,7 @@ module('Unit | Model | base', function (hooks) { const method = 'my-custom-method'; const scope = { scope_id: 'o_123' }; const model = store.createRecord('user', { scope }); - this.server.post(`/v1/users:${method}`, () => { + this.server.post(`users:${method}`, () => { assert.ok(true, 'Correctly scoped create record URL was requested.'); return { id: 'u_123' }; }); diff --git a/addons/api/tests/unit/models/group-test.js b/addons/api/tests/unit/models/group-test.js index 246ccdb2a..3ba8e1ce7 100644 --- a/addons/api/tests/unit/models/group-test.js +++ b/addons/api/tests/unit/models/group-test.js @@ -63,7 +63,7 @@ module('Unit | Model | group', function (hooks) { test('it has an `addMembers` method that groups a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/groups/123abc:add-members', (schema, request) => { + this.server.post('/groups/123abc:add-members', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { member_ids: ['123_abc', 'foobar'], @@ -94,7 +94,7 @@ module('Unit | Model | group', function (hooks) { test('it has a `removeMembers` method that groups a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/groups/123abc:remove-members', (schema, request) => { + this.server.post('/groups/123abc:remove-members', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { member_ids: ['1', '3'], @@ -125,7 +125,7 @@ module('Unit | Model | group', function (hooks) { test('it has a `removeMember` method that deletes a single member set using `removeMembers` method', async function (assert) { assert.expect(1); - this.server.post('/v1/groups/123abc:remove-members', (schema, request) => { + this.server.post('/groups/123abc:remove-members', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { member_ids: ['3'], diff --git a/addons/api/tests/unit/models/host-set-test.js b/addons/api/tests/unit/models/host-set-test.js index 62de0ae99..2c241c36c 100644 --- a/addons/api/tests/unit/models/host-set-test.js +++ b/addons/api/tests/unit/models/host-set-test.js @@ -13,7 +13,7 @@ module('Unit | Model | host set', function (hooks) { test('it has an `addHosts` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/host-sets/123abc:add-hosts', (schema, request) => { + this.server.post('/host-sets/123abc:add-hosts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { host_ids: ['123_abc', 'foobar'], @@ -44,7 +44,7 @@ module('Unit | Model | host set', function (hooks) { test('it has an `addHost` method that adds a single host using `addHosts` method', async function (assert) { assert.expect(1); - this.server.post('/v1/host-sets/123abc:add-hosts', (schema, request) => { + this.server.post('/host-sets/123abc:add-hosts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { host_ids: ['foobar'], @@ -75,7 +75,7 @@ module('Unit | Model | host set', function (hooks) { test('it has a `removeHosts` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/host-sets/123abc:remove-hosts', (schema, request) => { + this.server.post('/host-sets/123abc:remove-hosts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { host_ids: ['3'], @@ -106,7 +106,7 @@ module('Unit | Model | host set', function (hooks) { test('it has a `removeHost` method that removes a single host using `removeHosts` method', async function (assert) { assert.expect(1); - this.server.post('/v1/host-sets/123abc:remove-hosts', (schema, request) => { + this.server.post('/host-sets/123abc:remove-hosts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { host_ids: ['3'], diff --git a/addons/api/tests/unit/models/role-test.js b/addons/api/tests/unit/models/role-test.js index 3d537113b..465ad4b92 100644 --- a/addons/api/tests/unit/models/role-test.js +++ b/addons/api/tests/unit/models/role-test.js @@ -19,7 +19,7 @@ module('Unit | Model | role', function (hooks) { test('it has a `saveGrantStrings` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/roles/123abc:set-grants', (schema, request) => { + this.server.post('/roles/123abc:set-grants', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { grant_strings: ['foo', 'bar'], @@ -50,7 +50,7 @@ module('Unit | Model | role', function (hooks) { test('it has an `addPrincipals` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/roles/123abc:add-principals', (schema, request) => { + this.server.post('/roles/123abc:add-principals', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { principal_ids: ['123_abc', 'foobar'], @@ -85,17 +85,14 @@ module('Unit | Model | role', function (hooks) { test('it has a `removePrincipals` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post( - '/v1/roles/123abc:remove-principals', - (schema, request) => { - const body = JSON.parse(request.requestBody); - assert.deepEqual(body, { - principal_ids: ['1', '3', '5'], - version: 1, - }); - return { id: '123abc' }; - }, - ); + this.server.post('/roles/123abc:remove-principals', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + principal_ids: ['1', '3', '5'], + version: 1, + }); + return { id: '123abc' }; + }); const store = this.owner.lookup('service:store'); store.push({ data: { @@ -123,17 +120,14 @@ module('Unit | Model | role', function (hooks) { test('it has a `removePrincipal` method that deletes a single principal using `removePrincipals` method', async function (assert) { assert.expect(1); - this.server.post( - '/v1/roles/123abc:remove-principals', - (schema, request) => { - const body = JSON.parse(request.requestBody); - assert.deepEqual(body, { - principal_ids: ['3'], - version: 1, - }); - return { id: '123abc' }; - }, - ); + this.server.post('/roles/123abc:remove-principals', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + principal_ids: ['3'], + version: 1, + }); + return { id: '123abc' }; + }); const store = this.owner.lookup('service:store'); store.push({ data: { diff --git a/addons/api/tests/unit/models/scope-test.js b/addons/api/tests/unit/models/scope-test.js index 603a25852..c660ae47a 100644 --- a/addons/api/tests/unit/models/scope-test.js +++ b/addons/api/tests/unit/models/scope-test.js @@ -13,7 +13,7 @@ module('Unit | Model | scope', function (hooks) { test('it may have a scope fragment', async function (assert) { const store = this.owner.lookup('service:store'); - this.server.get('/v1/scopes', () => ({ + this.server.get('/scopes', () => ({ items: [ { id: 'global', type: 'global' }, { id: 'o_1', type: 'org', scope: { scope_id: 'global' } }, @@ -72,7 +72,7 @@ module('Unit | Model | scope', function (hooks) { test('it has an `attachStoragePolicy` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); this.server.post( - '/v1/scopes/123abc:attach-storage-policy', + '/scopes/123abc:attach-storage-policy', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { @@ -106,7 +106,7 @@ module('Unit | Model | scope', function (hooks) { test('it has a `detachStoragePolicy` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); this.server.post( - '/v1/scopes/123abc:detach-storage-policy', + '/scopes/123abc:detach-storage-policy', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { diff --git a/addons/api/tests/unit/models/target-test.js b/addons/api/tests/unit/models/target-test.js index 75762a22d..272a854f0 100644 --- a/addons/api/tests/unit/models/target-test.js +++ b/addons/api/tests/unit/models/target-test.js @@ -79,17 +79,14 @@ module('Unit | Model | target', function (hooks) { test('it has an `addHostSources` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post( - '/v1/targets/123abc:add-host-sources', - (schema, request) => { - const body = JSON.parse(request.requestBody); - assert.deepEqual(body, { - host_source_ids: ['123_abc', 'foobar'], - version: 1, - }); - return { id: '123abc' }; - }, - ); + this.server.post('/targets/123abc:add-host-sources', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + host_source_ids: ['123_abc', 'foobar'], + version: 1, + }); + return { id: '123abc' }; + }); const store = this.owner.lookup('service:store'); store.push({ data: { @@ -117,7 +114,7 @@ module('Unit | Model | target', function (hooks) { test('it has a `removeHostSources` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); this.server.post( - '/v1/targets/123abc:remove-host-sources', + '/targets/123abc:remove-host-sources', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { @@ -154,7 +151,7 @@ module('Unit | Model | target', function (hooks) { test('it has a `removeHostSource` method that deletes a single host set using `removeHostSources` method', async function (assert) { assert.expect(1); this.server.post( - '/v1/targets/123abc:remove-host-sources', + '/targets/123abc:remove-host-sources', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { @@ -283,7 +280,7 @@ module('Unit | Model | target', function (hooks) { test('it has an `addBrokeredCredentialSources` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); this.server.post( - '/v1/targets/123abc:add-credential-sources', + '/targets/123abc:add-credential-sources', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { @@ -317,7 +314,7 @@ module('Unit | Model | target', function (hooks) { test('it has a `removeBrokeredCredentialSources` method that targets a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); this.server.post( - '/v1/targets/123abc:remove-credential-sources', + '/targets/123abc:remove-credential-sources', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { @@ -351,7 +348,7 @@ module('Unit | Model | target', function (hooks) { test('it has a `removeBrokeredCredentialSource` method that deletes a single credential library using `removeBrokeredCredentialSources` method', async function (assert) { assert.expect(1); this.server.post( - '/v1/targets/123abc:remove-credential-sources', + '/targets/123abc:remove-credential-sources', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { diff --git a/addons/api/tests/unit/models/user-test.js b/addons/api/tests/unit/models/user-test.js index 5f5112138..dc10e6715 100644 --- a/addons/api/tests/unit/models/user-test.js +++ b/addons/api/tests/unit/models/user-test.js @@ -13,7 +13,7 @@ module('Unit | Model | user', function (hooks) { test('it has an `addAccounts` method that users a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/users/123abc:add-accounts', (schema, request) => { + this.server.post('/users/123abc:add-accounts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { account_ids: ['123_abc', 'foobar'], @@ -44,7 +44,7 @@ module('Unit | Model | user', function (hooks) { test('it has a `removeAccounts` method that users a specific POST API endpoint and serialization', async function (assert) { assert.expect(1); - this.server.post('/v1/users/123abc:remove-accounts', (schema, request) => { + this.server.post('/users/123abc:remove-accounts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { account_ids: ['1', '3'], @@ -75,7 +75,7 @@ module('Unit | Model | user', function (hooks) { test('it has a `removeAccount` method that deletes a single account set using `removeAccounts` method', async function (assert) { assert.expect(1); - this.server.post('/v1/users/123abc:remove-accounts', (schema, request) => { + this.server.post('/users/123abc:remove-accounts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { account_ids: ['3'], @@ -122,7 +122,7 @@ module('Unit | Model | user', function (hooks) { test('it defaults to null for readonly attributes when response does not have fields', async function (assert) { assert.expect(3); - this.server.post('/v1/users/123abc:remove-accounts', (schema, request) => { + this.server.post('/users/123abc:remove-accounts', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { account_ids: ['3'], diff --git a/addons/api/tests/unit/models/worker-test.js b/addons/api/tests/unit/models/worker-test.js index 440adfdc2..3cece9acb 100644 --- a/addons/api/tests/unit/models/worker-test.js +++ b/addons/api/tests/unit/models/worker-test.js @@ -15,7 +15,7 @@ module('Unit | Model | worker', function (hooks) { assert.expect(2); const scopeId = 'global'; const workerGeneratedAuthToken = 'token'; - this.server.post('/v1/workers:create:worker-led', (schema, request) => { + this.server.post('/workers:create:worker-led', (schema, request) => { const body = JSON.parse(request.requestBody); assert.deepEqual(body, { scope_id: scopeId, diff --git a/addons/api/tests/unit/serializers/auth-method-test.js b/addons/api/tests/unit/serializers/auth-method-test.js index 918f28740..70c51cd88 100644 --- a/addons/api/tests/unit/serializers/auth-method-test.js +++ b/addons/api/tests/unit/serializers/auth-method-test.js @@ -379,7 +379,7 @@ module('Unit | Serializer | auth method', function (hooks) { const dryRun = true; const issuer = 'http://www.example.net'; const maxAge = 500; - this.server.get('/v1/auth-methods/oidc123', () => ({ + this.server.get('/auth-methods/oidc123', () => ({ attributes: { account_claim_maps: ['from=to', 'foo=bar'], claims_scopes: ['profile', 'email'], @@ -447,7 +447,7 @@ module('Unit | Serializer | auth method', function (hooks) { const clientCerticate = 'certificate-1234'; const startTls = true; const insecureTls = false; - this.server.get('/v1/auth-methods/ldap123', () => ({ + this.server.get('/auth-methods/ldap123', () => ({ attributes: { account_attribute_maps: ['from=to', 'foo=bar'], urls: [ldapUrl], diff --git a/addons/api/tests/unit/utils/indexed-db-query-test.js b/addons/api/tests/unit/utils/indexed-db-query-test.js new file mode 100644 index 000000000..ae2ad038b --- /dev/null +++ b/addons/api/tests/unit/utils/indexed-db-query-test.js @@ -0,0 +1,490 @@ +import { queryIndexedDb } from 'api/utils/indexed-db-query'; +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; +import { pluralize } from 'ember-inflector'; +import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; + +const seedIndexDb = async (resource, store, indexedDb, server) => { + const resourceData = server.schema[pluralize(resource)].all().models; + const serializedData = resourceData.map((resource) => + JSON.parse(JSON.stringify(server.serializerOrRegistry.serialize(resource))), + ); + const serializer = store.serializerFor(resource); + const schema = store.modelFor(resource); + const normalizedPayload = serializer.normalizeResponse( + store, + schema, + { items: serializedData }, + null, + 'query', + ); + + await indexedDb.db[resource].bulkPut( + normalizedPayload.data.map((datum) => indexedDb.normalizeData(datum, true)), + ); +}; + +module('Unit | Utility | indexed-db-query', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupIndexedDb(hooks); + + let indexedDb; + let store; + + let scope1; + let scope2; + const resource = 'target'; + + hooks.beforeEach(function () { + indexedDb = this.owner.lookup('service:indexed-db'); + store = this.owner.lookup('service:store'); + scope1 = this.server.create('scope', { + id: 'p_123', + type: 'project', + }); + scope2 = this.server.create('scope', { + id: 'p_456', + type: 'project', + }); + this.server.createList(resource, 5, { + type: TYPE_TARGET_SSH, + scopeId: scope1.id, + }); + this.server.createList(resource, 5, { + type: TYPE_TARGET_TCP, + scopeId: scope2.id, + }); + }); + + test('it filters by single field', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: [{ equals: TYPE_TARGET_SSH }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "and" logicalOperator', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'and', + values: [{ equals: TYPE_TARGET_SSH }], + }, + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "or" logicalOperator', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'or', + values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }], + }, + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it filters by multiple fields', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: [{ equals: TYPE_TARGET_SSH }], + scope_id: [{ equals: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by multiple fields with "or" logicalOperator', async function (assert) { + this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id }); + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'or', + values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }], + }, + scope_id: [{ equals: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + assert.strictEqual(result.length, 6); + }); + + test('it filters by multiple fields with "and" logicalOperator', async function (assert) { + this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id }); + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'and', + values: [{ equals: TYPE_TARGET_SSH }, { notEquals: TYPE_TARGET_TCP }], + }, + scope_id: [{ equals: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + assert.strictEqual(result.length, 5); + }); + + test('it filters by multiple fields and uses two "or" logicalOperators', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'or', + values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }], + }, + scope_id: { + logicalOperator: 'or', + values: [{ equals: scope1.id }, { equals: scope2.id }], + }, + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + assert.strictEqual(result.length, 10); + }); + + test('it filters by multiple fields and uses two "and" logicalOperators', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: { + logicalOperator: 'and', + values: [{ notEquals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }], + }, + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope2.id }, { notEquals: scope1.id }], + }, + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + assert.strictEqual(result.length, 5); + }); + + test('it cannot filter with "contains" as initial operation', async function (assert) { + assert.expect(1); + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ contains: scope1.id }], + }, + }; + + try { + await queryIndexedDb(indexedDb.db, resource, query); + } catch (e) { + assert.strictEqual( + e.message, + 'Contains is not supported as the first filter.', + ); + } + }); + + test('it cannot filter with "contains" as subsequent operation', async function (assert) { + assert.expect(2); + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope1.id }, { contains: scope2.id.slice(0, 2) }], + }, + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + try { + await queryIndexedDb(indexedDb.db, resource, query); + } catch (e) { + assert.strictEqual( + e.message, + 'Contains is not supported as a filter option with "or" operator.', + ); + } + }); + + // TODO: Modify test cases that use comparison opertators to compare fields that + // are of type number. Additionally, add test case for searching on boolean fields. + test('it filters by single field with "gt" as initial operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ gt: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "gt" as subsequent operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope2.id }, { gt: scope1.id }], + }, + }, + }; + + let result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "gte" as initial operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ gte: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it filters by single field with "gte" as subsequent operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope2.id }, { gte: scope1.id }], + }, + }, + }; + + let result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it filters by single field with "lt" as initial operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ lt: scope2.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "lt" as subsequent operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope1.id }, { lt: scope2.id }], + }, + }, + }; + + let result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "lte" as initial operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ lte: scope2.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it filters by single field with "lte" as subsequent operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope1.id }, { lte: scope2.id }], + }, + }, + }; + + let result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it filters by single field with "notEquals" as initial operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: [{ notEquals: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by single field with "notEquals" as subsequent operation', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + scope_id: { + logicalOperator: 'and', + values: [{ equals: scope2.id }, { notEquals: scope1.id }], + }, + }, + }; + + let result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + + query.filters.scope_id.logicalOperator = 'or'; + + result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters and returns zero results', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + filters: { + type: [{ equals: 'unknown' }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 0); + }); + + test('it searches with a query that matches a field exactly', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { search: scope1.id }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it searches with a query that matches a field partially', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { search: scope2.id.slice(0, 2) }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 10); + }); + + test('it searches and returns no results with a query that matches no fields', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { search: `${scope1.id}456789` }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 0); + }); + + test('it filters by a single field and searches', async function (assert) { + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + search: scope1.id, + filters: { + type: [{ equals: TYPE_TARGET_SSH }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 5); + }); + + test('it filters by multiple fields and searches', async function (assert) { + const target = this.server.create(resource, { + name: 'Generated_ssh_target', + type: TYPE_TARGET_SSH, + scopeId: scope1.id, + }); + await seedIndexDb(resource, store, indexedDb, this.server); + const query = { + search: target.name, + filters: { + type: [{ equals: TYPE_TARGET_SSH }], + scope_id: [{ equals: scope1.id }], + }, + }; + + const result = await queryIndexedDb(indexedDb.db, resource, query); + + assert.strictEqual(result.length, 1); + }); +});