224 lines
6.8 KiB
JavaScript
224 lines
6.8 KiB
JavaScript
// The subclasses below are test doubles standing in for the two response shapes the
|
|
// cached clients have to support
|
|
/* eslint-disable max-classes-per-file */
|
|
import axios from 'axios';
|
|
import { deleteDB } from 'idb';
|
|
import CacheEnabledApiClient from '../CacheEnabledApiClient';
|
|
|
|
global.axios = axios;
|
|
vi.mock('axios');
|
|
|
|
const ACCOUNT_ID = '7';
|
|
|
|
// Most cached resources answer with a `payload` wrapper
|
|
class WrappedClient extends CacheEnabledApiClient {
|
|
constructor() {
|
|
super('inboxes', { accountScoped: true });
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
get cacheModelName() {
|
|
return 'inbox';
|
|
}
|
|
}
|
|
|
|
// Canned responses answer with a bare array, so they override both hooks. The cached read
|
|
// has to hand back the same shape as the network read or callers break on a cache hit.
|
|
class BareArrayClient extends CacheEnabledApiClient {
|
|
constructor() {
|
|
super('canned_responses', { accountScoped: true });
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
get cacheModelName() {
|
|
return 'canned_response';
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
extractDataFromResponse(response) {
|
|
return response.data;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
marshallData(dataToParse) {
|
|
return { data: dataToParse };
|
|
}
|
|
}
|
|
|
|
describe('CacheEnabledApiClient', () => {
|
|
const inboxes = [
|
|
{ id: 1, name: 'inbox-1' },
|
|
{ id: 2, name: 'inbox-2' },
|
|
];
|
|
const cannedResponses = [{ id: 1, short_code: 'hello', content: 'Hi there' }];
|
|
|
|
let openClients = [];
|
|
|
|
const stubEndpoints = ({ cacheKeys, payload }) => {
|
|
axios.get.mockImplementation(url =>
|
|
url.includes('cache_keys')
|
|
? Promise.resolve({ data: { cache_keys: cacheKeys } })
|
|
: Promise.resolve({ data: payload })
|
|
);
|
|
};
|
|
|
|
const listRequests = () =>
|
|
axios.get.mock.calls.filter(([url]) => !url.includes('cache_keys'));
|
|
|
|
const buildClient = Klass => {
|
|
const client = new Klass();
|
|
openClients.push(client);
|
|
return client;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
window.history.pushState({}, '', `/app/accounts/${ACCOUNT_ID}/dashboard`);
|
|
await deleteDB(`cw-store-${ACCOUNT_ID}`);
|
|
openClients = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
openClients.forEach(client => client.dataManager.db?.close());
|
|
});
|
|
|
|
it('throws when a subclass does not declare a cache model', () => {
|
|
class Incomplete extends CacheEnabledApiClient {}
|
|
|
|
expect(() => buildClient(Incomplete).cacheModelName).toThrow();
|
|
});
|
|
|
|
it('does not serve one account its data to another account', async () => {
|
|
// The bundle boots at /app and the router redirects into an account without
|
|
// re-evaluating it, so the singleton client outlives the account in the route
|
|
window.history.pushState({}, '', '/app');
|
|
const client = buildClient(BareArrayClient);
|
|
await deleteDB('cw-store-');
|
|
await deleteDB('cw-store-9');
|
|
|
|
// A never-written cache key is the same '0000000000' for every account
|
|
const sharedKey = '0000000000';
|
|
stubEndpoints({
|
|
cacheKeys: { canned_response: sharedKey },
|
|
payload: cannedResponses,
|
|
});
|
|
window.history.pushState({}, '', `/app/accounts/${ACCOUNT_ID}/dashboard`);
|
|
await client.get(true);
|
|
client.dataManager.db?.close();
|
|
|
|
const otherAccountResponses = [
|
|
{ id: 99, short_code: 'other-account', content: 'Should stay private' },
|
|
];
|
|
window.history.pushState({}, '', '/app/accounts/9/dashboard');
|
|
axios.get.mockClear();
|
|
stubEndpoints({
|
|
cacheKeys: { canned_response: sharedKey },
|
|
payload: otherAccountResponses,
|
|
});
|
|
const response = await client.get(true);
|
|
|
|
expect(response.data).toEqual(otherAccountResponses);
|
|
expect(listRequests()).toHaveLength(1);
|
|
});
|
|
|
|
it('skips the cache entirely when asked for a network read', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-1' },
|
|
payload: { payload: inboxes },
|
|
});
|
|
const client = buildClient(WrappedClient);
|
|
|
|
const response = await client.get(false);
|
|
|
|
expect(response.data.payload).toEqual(inboxes);
|
|
// No cache key lookup either, so the request count is exactly one
|
|
expect(axios.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fetches from the network and stores locally when the cache is cold', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-1' },
|
|
payload: { payload: inboxes },
|
|
});
|
|
const client = buildClient(WrappedClient);
|
|
|
|
const response = await client.get(true);
|
|
|
|
expect(response.data.payload).toEqual(inboxes);
|
|
expect(listRequests()).toHaveLength(1);
|
|
expect(await client.dataManager.get({ modelName: 'inbox' })).toEqual(
|
|
inboxes
|
|
);
|
|
expect(await client.dataManager.getCacheKey('inbox')).toBe('key-1');
|
|
});
|
|
|
|
it('serves local data without a list request while the cache key is unchanged', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-1' },
|
|
payload: { payload: inboxes },
|
|
});
|
|
const client = buildClient(WrappedClient);
|
|
await client.get(true);
|
|
|
|
axios.get.mockClear();
|
|
const response = await client.get(true);
|
|
|
|
expect(response.data.payload).toEqual(inboxes);
|
|
expect(listRequests()).toHaveLength(0);
|
|
});
|
|
|
|
it('refetches and overwrites local data once the cache key moves', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-1' },
|
|
payload: { payload: inboxes },
|
|
});
|
|
const client = buildClient(WrappedClient);
|
|
await client.get(true);
|
|
|
|
const renamed = [{ id: 1, name: 'inbox-1-renamed' }];
|
|
axios.get.mockClear();
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-2' },
|
|
payload: { payload: renamed },
|
|
});
|
|
const response = await client.get(true);
|
|
|
|
expect(response.data.payload).toEqual(renamed);
|
|
expect(listRequests()).toHaveLength(1);
|
|
expect(await client.dataManager.get({ modelName: 'inbox' })).toEqual(
|
|
renamed
|
|
);
|
|
expect(await client.dataManager.getCacheKey('inbox')).toBe('key-2');
|
|
});
|
|
|
|
it('returns the same shape from the cache as from the network for a bare array resource', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { canned_response: 'key-1' },
|
|
payload: cannedResponses,
|
|
});
|
|
const client = buildClient(BareArrayClient);
|
|
|
|
const fromNetwork = await client.get(true);
|
|
axios.get.mockClear();
|
|
const fromCache = await client.get(true);
|
|
|
|
expect(fromNetwork.data).toEqual(cannedResponses);
|
|
expect(fromCache.data).toEqual(cannedResponses);
|
|
expect(listRequests()).toHaveLength(0);
|
|
});
|
|
|
|
it('falls back to the network when IndexedDB is unavailable', async () => {
|
|
stubEndpoints({
|
|
cacheKeys: { inbox: 'key-1' },
|
|
payload: { payload: inboxes },
|
|
});
|
|
const client = buildClient(WrappedClient);
|
|
vi.spyOn(client.dataManager, 'initDb').mockRejectedValue(
|
|
new Error('IndexedDB is disabled in private mode')
|
|
);
|
|
|
|
const response = await client.get(true);
|
|
|
|
expect(response.data.payload).toEqual(inboxes);
|
|
});
|
|
});
|