From 6585fa8e4124d52a4f3d03369c17351808f14a14 Mon Sep 17 00:00:00 2001 From: hashicc <191911133+hashicc@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:52:02 -0400 Subject: [PATCH] Add tests for indexed db handler (#2759) --- .../api/addon/handlers/indexed-db-handler.js | 8 +- addons/api/package.json | 4 + .../unit/handlers/indexed-db-handler-test.js | 195 ++++++++++++++++++ yarn.lock | 3 + 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 addons/api/tests/unit/handlers/indexed-db-handler-test.js diff --git a/addons/api/addon/handlers/indexed-db-handler.js b/addons/api/addon/handlers/indexed-db-handler.js index 35f124e21..83fdef366 100644 --- a/addons/api/addon/handlers/indexed-db-handler.js +++ b/addons/api/addon/handlers/indexed-db-handler.js @@ -10,8 +10,6 @@ import { queryIndexedDb } from '../utils/indexed-db-query'; import { paginateResults } from '../utils/paginate-results'; import { hashCode } from '../utils/hash-code'; -const BATCH_LIMIT = 10000; - /** * Handler to sit in front of the API request layer * so we can handle any caching with indexedDb first @@ -19,11 +17,15 @@ const BATCH_LIMIT = 10000; export default class IndexedDbHandler { @service indexedDb; + batchLimit = 10_000; + constructor(context) { setOwner(this, getOwner(context)); } async request(context, next) { + const { batchLimit } = this; + switch (context.request.op) { case 'query': { const { store, data } = context.request; @@ -68,7 +70,7 @@ export default class IndexedDbHandler { payload = await adapter.query(store, schema, { ...remainingQuery, list_token: listToken, - batchLimit: BATCH_LIMIT, + batchLimit: batchLimit, }); // await the previous writeToIndexedDbPromise before writing to indexedDb again diff --git a/addons/api/package.json b/addons/api/package.json index 22e7cc890..8f86e8152 100644 --- a/addons/api/package.json +++ b/addons/api/package.json @@ -33,6 +33,9 @@ "miragejs": "^0.1.48", "tracked-built-ins": "^3.1.1" }, + "peerDependencies": { + "sinon": "^19.0.2" + }, "devDependencies": { "@babel/core": "^7.26.10", "@babel/eslint-parser": "^7.21.3", @@ -71,6 +74,7 @@ "prettier": "^3.0.0", "qunit": "^2.22.0", "qunit-dom": "^3.2.1", + "sinon": "^19.0.2", "webpack": "^5.94.0" }, "engines": { diff --git a/addons/api/tests/unit/handlers/indexed-db-handler-test.js b/addons/api/tests/unit/handlers/indexed-db-handler-test.js new file mode 100644 index 000000000..79f826657 --- /dev/null +++ b/addons/api/tests/unit/handlers/indexed-db-handler-test.js @@ -0,0 +1,195 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import IndexedDbHandler from 'api/handlers/indexed-db-handler'; +import RequestManager from '@ember-data/request'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { faker } from '@faker-js/faker'; +import sinon from 'sinon'; +import { assert } from '@ember/debug'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; + +function createPaginatedResponseHandler(mirageRecords, { pageSize }) { + assert('pageSize is required', pageSize); + const pagesCount = mirageRecords.length / pageSize; + + const results = []; + for (let i = 0; i < pagesCount; i++) { + const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize); + const isLastPage = i === pagesCount - 1; + const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`; + + results.push({ + items, + list_token, + response_type: !isLastPage ? 'delta' : 'complete', + }); + } + + function nextResult(listToken) { + const index = results.findIndex( + ({ list_token }) => list_token === listToken, + ); + const nextResult = index === -1 ? null : results[index + 1]; + return nextResult; + } + + // mirage handler compatible with paginated responses + return function (_schema, request) { + const listToken = request.queryParams.list_token; + if (!listToken) { + return results[0]; + } + + const result = nextResult(listToken); + + if (!result) { + return new Response( + 404, + {}, + `No results found for list token "${listToken}"`, + ); + } + + return result; + }; +} + +module('Unit | Handler | indexed-db-handler', function (hooks) { + setupTest(hooks); + setupIndexedDb(hooks); + setupMirage(hooks); + + let manager, + handler, + store, + applicationAdapter, + handlerSpy, + applicationAdapterSpy; + hooks.beforeEach(async function setupIndexHandler() { + store = this.owner.lookup('service:store'); + manager = new RequestManager(); + handler = new IndexedDbHandler(store); + applicationAdapter = store.adapterFor('application'); + + // spy on all handler and application adapter methods + handlerSpy = sinon.spy(handler); + applicationAdapterSpy = sinon.spy(applicationAdapter); + + store.requestManager = manager; + manager.use([handler]); + }); + + module('fetching and batch loading', function (hooks) { + // `testBatchLimit` and `serverResultPageLimit` are intentionally difference + // so that multiple calls to the server are required to fetch a single batch, + // and these multiples can be asserted in the tests individually + const testBatchLimit = 10; + const serverResultPageLimit = 5; + let aliases, aliasResponseHandlerSpy; + + hooks.beforeEach(function setupMirageData() { + handler.batchLimit = testBatchLimit; + + aliases = this.server.createList('alias', 50); + + const aliasResponseHandler = createPaginatedResponseHandler( + this.server.schema.aliases.all().models, + { pageSize: serverResultPageLimit }, + ); + + aliasResponseHandlerSpy = sinon.spy(aliasResponseHandler); + this.server.get('aliases', aliasResponseHandlerSpy); + }); + + test('it returns all data for a resource from the server', async function (assert) { + const results = await store.query('alias', {}); + + assert.strictEqual( + aliases.length, + results.length, + 'all records are returned', + ); + + const aliasesSortedByCreatedTime = [...aliases].sort( + (a, b) => new Date(b.created_time) - new Date(a.created_time), + ); + + assert.deepEqual( + aliasesSortedByCreatedTime.map(({ name }) => name), + results.map(({ name }) => name), + '`name` property matches between the returned results and the mirage records sorted by created_time', + ); + }); + + test('supports batching requests and writes to indexedDB', async function (assert) { + await store.query('alias', {}); + + assert.strictEqual( + handlerSpy.writeToIndexedDb.callCount, + aliases.length / testBatchLimit, + 'handler #writeToIndexedDb is called once for each batch', + ); + + assert.strictEqual( + applicationAdapterSpy.query.callCount, + aliases.length / testBatchLimit, + 'application adapter #query is called once for each batch', + ); + + assert.strictEqual( + aliasResponseHandlerSpy.callCount, + aliases.length / serverResultPageLimit, + 'server handler should be called once for each page', + ); + }); + }); + + module('sorting', function () { + test('it sorts by descending `created_time` by default', async function (assert) { + const target2020 = this.server.create('target', { + name: 'Target 2020', + created_time: '2020-01-01T00:00:00Z', + }); + + const target2021 = this.server.create('target', { + name: 'Target 2021', + created_time: '2021-01-01T00:00:00Z', + }); + + const target2022 = this.server.create('target', { + name: 'Target 2022', + created_time: '2022-01-01T00:00:00Z', + }); + + const target2023 = this.server.create('target', { + name: 'Target 2023', + created_time: '2023-01-01T00:00:00Z', + }); + + const shuffledTargets = faker.helpers.shuffle([ + target2020, + target2021, + target2022, + target2023, + ]); + + this.server.get( + 'targets', + createPaginatedResponseHandler(shuffledTargets, { + pageSize: 1, + }), + ); + + const results = await store.query('target', {}); + assert.deepEqual( + results.map(({ name }) => name), + ['Target 2023', 'Target 2022', 'Target 2021', 'Target 2020'], + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d3883181e..472ab0118 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7636,8 +7636,11 @@ __metadata: prettier: "npm:^3.0.0" qunit: "npm:^2.22.0" qunit-dom: "npm:^3.2.1" + sinon: "npm:^19.0.2" tracked-built-ins: "npm:^3.1.1" webpack: "npm:^5.94.0" + peerDependencies: + sinon: ^19.0.2 languageName: unknown linkType: soft