Files
Zhihe Li e5657a8d4c Icu 18506 load resource IDs for autocomplete (#3247)
* feat: 🎸 Add id auto completion
* style: 💄 Fix code editor styling for dark mode
* feat: 🎸 Add optimization for caching sqlite data

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18506
2026-05-14 15:21:29 -04:00

583 lines
18 KiB
JavaScript

/**
* 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 reuses the global token for a more specific query when the global token exists', async function (assert) {
const targets = this.server.createList('target', 3, { scope });
let capturedListToken;
let responseToken = 'specific-list-token';
this.server.get('targets', (_schema, request) => {
capturedListToken = request.queryParams.list_token;
return {
items: targets.map((t) =>
this.server.serializerOrRegistry.serialize(t),
),
list_token: responseToken,
response_type: 'complete',
};
});
// Run the specific query first
await store.query('target', { scope_id: 'some-specific-scope' });
// Switch the response token and run the global query
responseToken = 'global-list-token';
await store.query('target', { scope_id: 'global', recursive: true });
capturedListToken = undefined;
// Re-run the specific query which has a cached token but should prefer the global token since it exists
await store.query('target', { scope_id: 'some-specific-scope' });
assert.strictEqual(capturedListToken, 'global-list-token');
});
test('it uses the specific token when no global token exists', async function (assert) {
const targets = this.server.createList('target', 3, { scope });
let capturedListToken;
this.server.get('targets', (_schema, request) => {
capturedListToken = request.queryParams.list_token;
return {
items: targets.map((t) =>
this.server.serializerOrRegistry.serialize(t),
),
list_token: 'specific-list-token',
response_type: 'complete',
};
});
await store.query('target', { scope_id: 'some-specific-scope' });
capturedListToken = undefined;
// Re-run the specific query which should just use the normal specific token since no global token exists
await store.query('target', { scope_id: 'some-specific-scope' });
assert.strictEqual(capturedListToken, 'specific-list-token');
});
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);
});
});