/** * Copyright IBM Corp. 2021, 2026 * SPDX-License-Identifier: BUSL-1.1 */ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import SqliteHandler from 'api/handlers/sqlite-handler'; import RequestManager from '@ember-data/request'; import { setupMirage } from 'dummy/tests/helpers/mirage'; import { faker } from '@faker-js/faker'; import sinon from 'sinon'; import { assert } from '@ember/debug'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; import TargetModel from 'api/models/target'; function createPaginatedResponseHandler(mirageRecords, { pageSize }) { assert('pageSize is required', pageSize); assert('mirage server on `this` is required', this.server); 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: items.map((model) => this.server.serializerOrRegistry.serialize(model), ), 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 | sqlite-handler', function (hooks) { setupTest(hooks); setupMirage(hooks); setupSqlite(hooks); let manager, scope, sqliteHandler, store, applicationAdapter, sqliteHandlerSpy; hooks.beforeEach(async function setupSqliteHandler() { store = this.owner.lookup('service:store'); manager = new RequestManager(); sqliteHandler = new SqliteHandler(store); applicationAdapter = store.adapterFor('application'); // spy on handler methods sqliteHandlerSpy = sinon.spy(sqliteHandler); store.requestManager = manager; manager.use([sqliteHandler]); scope = this.server.create('scope', { type: 'project', name: 'Project Scope', }); }); hooks.afterEach(async function cleanUpSqliteHandler() { sinon.restore(); }); 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 targets, targetsResponseHandlerSpy; hooks.beforeEach(function setupMirageData() { sqliteHandler.batchLimit = testBatchLimit; targets = this.server.createList('target', 50, { scope }); // This is to just add a number to each target name to guarantee uniqueness in // case there was a duplicate rather than increasing the number of words generated by faker. // Because we have a UNIQUE constraint on target names and we do UPSERTS, if there's a // duplicate name we'll end up replacing it in the DB and then have fewer targets // than expected and flaky tests. for (let i = 0; i < targets.length; i++) { targets[i].name = targets[i].name + i; } const targetResponseHandler = createPaginatedResponseHandler.call( this, this.server.schema.targets.all().models, { pageSize: serverResultPageLimit }, ); targetsResponseHandlerSpy = sinon.spy(targetResponseHandler); this.server.get('targets', targetsResponseHandlerSpy); }); test('it returns all data for a resource from the server', async function (assert) { const results = await store.query('target', {}); assert.strictEqual( targets.length, results.length, 'all records are returned', ); }); test('supports batching requests and writes to SQLite', async function (assert) { const applicationAdapterSpy = sinon.spy(applicationAdapter); await store.query('target', {}); assert.strictEqual( sqliteHandlerSpy.writeToDb.callCount, targets.length / testBatchLimit, 'handler #writeToDb is called once for each batch', ); assert.strictEqual( applicationAdapterSpy.query.callCount, targets.length / testBatchLimit, 'application adapter #query is called once for each batch', ); assert.strictEqual( targetsResponseHandlerSpy.callCount, targets.length / serverResultPageLimit, 'server handler should be called once for each page', ); }); test('it loads from SQLite only and does not fetch from api, when peekDb option is used', async function (assert) { const callCounts = { get writeToDb() { return sqliteHandlerSpy.writeToDb.callCount; }, get targetResponseHandler() { return targetsResponseHandlerSpy.callCount; }, }; // before first query, no data has been fetched or written to sqlite // the call counts should therefore be 0 assert.strictEqual(callCounts.targetResponseHandler, 0); assert.strictEqual(callCounts.writeToDb, 0); const results = await store.query('target', {}); assert.strictEqual(results.length, 50); // call counts expected after first query const expectedWriteToSqliteCallCount = 5; const expectedResponseHandlerCallCount = 10; // first `store.query()` calls api and writes batches to sqlite assert.strictEqual( callCounts.targetResponseHandler, expectedResponseHandlerCallCount, ); assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount); await store.query('target', {}, { peekDb: true }); assert.strictEqual(results.length, 50); // second run does not increase call counts to api or writes to sqlite assert.strictEqual( callCounts.targetResponseHandler, expectedResponseHandlerCallCount, ); assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount); }); test('it stores and retrieves tokens when storeToken is true (default)', async function (assert) { const sqliteSpy = sinon.spy(sqliteHandler.sqlite); await store.query('target', {}); assert.strictEqual( sqliteSpy.insertResource.withArgs('token').callCount, targets.length / testBatchLimit, ); assert.strictEqual( sqliteSpy.insertResource.withArgs('target').callCount, targets.length / testBatchLimit, ); }); test('it clears database and does not store tokens when storeToken is false', async function (assert) { const sqliteSpy = sinon.spy(sqliteHandler.sqlite); await store.query('target', {}, { storeToken: false }); // Targets are all deleted one time assert.ok(sqliteSpy.deleteResource.calledOnceWithExactly('target')); assert.ok(sqliteSpy.insertResource.withArgs('token').notCalled); assert.strictEqual( sqliteSpy.insertResource.withArgs('target').callCount, targets.length / testBatchLimit, ); }); }); module('option: pushToStore', function (hooks) { let mirageTargets; hooks.beforeEach(function () { mirageTargets = this.server.createList('target', 5, { scope }); }); test('it pushes fetched results to the store by default', async function (assert) { this.server.get( 'targets', createPaginatedResponseHandler.call(this, mirageTargets, { pageSize: 1, }), ); assert.strictEqual(mirageTargets.length, 5); assert.strictEqual(store.peekAll('target').length, 0); const results = await store.query('target', {}); assert.strictEqual(results.length, 5); assert.ok( results.every((result) => result instanceof TargetModel), 'results are ember data models', ); assert.strictEqual(store.peekAll('target').length, 5); }); test('it pushes fetched results to the store when pushToStore is true', async function (assert) { this.server.get( 'targets', createPaginatedResponseHandler.call(this, mirageTargets, { pageSize: 1, }), ); assert.strictEqual(mirageTargets.length, 5); assert.strictEqual(store.peekAll('target').length, 0); const results = await store.query('target', {}, { pushToStore: true }); assert.strictEqual(results.length, 5); assert.ok( results.every((result) => result instanceof TargetModel), 'results are ember data models', ); assert.strictEqual(store.peekAll('target').length, 5); }); test('it does not push fetched results to the store when pushToStore is false', async function (assert) { this.server.get( 'targets', createPaginatedResponseHandler.call(this, mirageTargets, { pageSize: 1, }), ); assert.strictEqual(mirageTargets.length, 5); assert.strictEqual(store.peekAll('target').length, 0); const results = await store.query('target', {}, { pushToStore: false }); assert.strictEqual(results.length, 5); assert.ok( results.every((result) => !(result instanceof TargetModel)), 'results are not ember data models', ); assert.strictEqual(store.peekAll('target').length, 0); }); }); module('sorting', function () { test('it sorts by ascending (default) `name` using string sorting', async function (assert) { const target001 = this.server.create('target', { name: 'Target 001', scope, }); const target002 = this.server.create('target', { name: 'Target 002', scope, }); const target003 = this.server.create('target', { name: 'Target 003', scope, }); const target004 = this.server.create('target', { name: 'Target 004', scope, }); const target005 = this.server.create('target', { name: 'Target 005', scope, }); const shuffledTargets = faker.helpers.shuffle([ target001, target002, target003, target004, target005, ]); this.server.get( 'targets', createPaginatedResponseHandler.call(this, shuffledTargets, { pageSize: 1, }), ); const results = await store.query('target', { query: { sort: { attributes: ['name'] } }, }); assert.deepEqual( results.map(({ name }) => name), ['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'], ); }); test('it sorts by descending, then ascending `name` using string sorting', async function (assert) { const target001 = this.server.create('target', { name: 'Target 001', scope, }); const target002 = this.server.create('target', { name: 'Target 002', scope, }); const target003 = this.server.create('target', { name: 'Target 003', scope, }); const target004 = this.server.create('target', { name: 'Target 004', scope, }); const shuffledTargets = faker.helpers.shuffle([ target001, target002, target003, target004, ]); this.server.get( 'targets', createPaginatedResponseHandler.call(this, shuffledTargets, { pageSize: 1, }), ); const resultsDesc = await store.query('target', { query: { sort: { attributes: ['name'], direction: 'desc' } }, }); const resultsAsc = await store.query('target', { query: { sort: { attributes: ['name'], direction: 'asc' } }, }); assert.deepEqual( resultsDesc.map(({ name }) => name), ['Target 004', 'Target 003', 'Target 002', 'Target 001'], ); assert.deepEqual( resultsAsc.map(({ name }) => name), ['Target 001', 'Target 002', 'Target 003', 'Target 004'], ); }); }); test('it supports searching', async function (assert) { this.server.create('target', { name: 'Magical Target', description: 'A target', scope, }); this.server.create('target', { name: 'Target 2', description: 'Target with magic', scope, }); this.server.createList('target', 4, { scope }); this.server.get( 'targets', createPaginatedResponseHandler.call( this, this.server.schema.targets.all().models, { pageSize: 1, }, ), ); const results = await store.query('target', { query: { search: 'magic' }, }); assert.deepEqual( results // search results aren't returning in a consistent order so // the sorting is used to be able to assert .sort((a, b) => (a.name > b.name ? 1 : -1)) .map(({ name, description }) => ({ name, description })), [ { description: 'A target', name: 'Magical Target', }, { description: 'Target with magic', name: 'Target 2', }, ], ); }); test('it supports filtering', async function (assert) { this.server.create('target', { name: 'specific-target', scope, }); this.server.createList('target', 5, { scope }); this.server.get( 'targets', createPaginatedResponseHandler.call( this, this.server.schema.targets.all().models, { pageSize: 1, }, ), ); const results = await store.query('target', { query: { filters: { name: [{ equals: 'specific-target' }] } }, }); assert.strictEqual(results[0].name, 'specific-target'); }); test('it retries query when invalid list token error occurs', async function (assert) { let targets = this.server.createList('target', 5, { scope }); const sqliteSpy = sinon.spy(sqliteHandler.sqlite); // Mock the first query to fail with invalid list token error but return data as normal otherwise sinon .stub(applicationAdapter, 'query') .callThrough() .onFirstCall() .throws(() => { const error = new Error('Invalid list token'); error.errors = [ { status: 400, code: 'invalid list token', }, ]; return error; }); this.server.get( 'targets', createPaginatedResponseHandler.call(this, targets, { pageSize: 5, }), ); // Insert tokens to simulate existing state await sqliteHandler.sqlite.insertResource('token', [ ['target-123', 'invalid key'], ['target-321', 'valid key'], ['session-123', 'session key'], ]); const results = await store.query('target', {}); assert.ok(sqliteHandlerSpy.retryQueryFailure.calledOnce); assert.ok(sqliteSpy.deleteResource.calledWithExactly('target')); assert.ok( sqliteSpy.deleteResource.calledWithExactly('token', [ 'target-123', 'target-321', ]), ); // Verify the query eventually succeeded assert.strictEqual(results.length, 5); // Verify query was called twice (initial fail + retry) assert.ok(applicationAdapter.query.calledTwice); }); });