Files
moreminimore-chat/app/javascript/dashboard/api/specs/CacheEnabledApiClient.spec.js
2026-08-13 11:54:32 +05:30

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);
});
});