Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ linkStyle default opacity:0.5
authenticated_user_storage --> messenger;
base_controller --> messenger;
base_data_service --> messenger;
base_data_service --> storage_service;
bitcoin_regtest_up --> local_node_utils;
bridge_controller --> accounts_controller;
bridge_controller --> assets_controller;
Expand Down
5 changes: 4 additions & 1 deletion packages/base-data-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@
},
"dependencies": {
"@metamask/messenger": "^2.0.0",
"@metamask/storage-service": "^1.0.2",
"@metamask/utils": "^11.11.0",
"@tanstack/query-core": "^4.43.0",
"cockatiel": "^3.1.2",
"fast-deep-equal": "^3.1.3"
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"devDependencies": {
"@metamask/auto-changelog": "^6.1.0",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.14.191",
"deepmerge": "^4.2.2",
"jest": "^29.7.0",
"nock": "^13.3.1",
Expand Down
300 changes: 298 additions & 2 deletions packages/base-data-service/src/BaseDataService.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Messenger } from '@metamask/messenger';
import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger';
import { hashQueryKey } from '@tanstack/query-core';
import { BrokenCircuitError } from 'cockatiel';
import { cleanAll } from 'nock';
Expand All @@ -22,6 +22,14 @@ const MOCK_ASSETS = [
];

describe('BaseDataService', () => {
beforeAll(() => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
});

afterAll(() => {
jest.useRealTimers();
});

beforeEach(() => {
mockAssets();
mockTransactionsPage1();
Expand Down Expand Up @@ -170,7 +178,7 @@ describe('BaseDataService', () => {
await service.getAssets(MOCK_ASSETS);

// Wait for GC
await new Promise((resolve) => setTimeout(resolve, 0));
jest.runAllTimers();

const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS];

Expand Down Expand Up @@ -214,6 +222,14 @@ describe('BaseDataService', () => {
});

describe('service policy', () => {
beforeAll(() => {
jest.useRealTimers();
});

afterAll(() => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
});

beforeEach(() => {
cleanAll();
});
Expand Down Expand Up @@ -248,6 +264,8 @@ describe('BaseDataService', () => {
symbol: 'ETH',
},
]);

service.destroy();
});

it('throws after exhausting service policy retries', async () => {
Expand All @@ -261,6 +279,8 @@ describe('BaseDataService', () => {
await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow(
'Query failed with status code: 500.',
);

service.destroy();
});

it('breaks the circuit after consecutive failures', async () => {
Expand All @@ -278,6 +298,282 @@ describe('BaseDataService', () => {
await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow(
BrokenCircuitError,
);

service.destroy();
});
});

describe('persistence', () => {
it('persists the cache using the StorageService', async () => {
const rootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
captureException: console.error,
});

const setItem = jest.fn();
rootMessenger.registerActionHandler('StorageService:setItem', setItem);

const messenger = rootMessenger.buildChild({
namespace: serviceName,
actions: ['StorageService:getItem', 'StorageService:setItem'],
});
const service = new ExampleDataService(messenger);

mockAssets();

await service.getAssets(MOCK_ASSETS);

jest.runAllTimers();

expect(setItem).toHaveBeenCalledWith(serviceName, 'cache', {
state: {
queries: [
{
queryHash:
'["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]',
queryKey: [
'ExampleDataService:getAssets',
[
'eip155:1/slip44:60',
'bip122:000000000019d6689c085ae165831e93/slip44:0',
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
],
],
state: {
data: [
{
assetId:
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
decimals: 18,
name: 'Dai Stablecoin',
symbol: 'DAI',
},
{
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
decimals: 8,
name: 'Bitcoin',
symbol: 'BTC',
},
{
assetId: 'eip155:1/slip44:60',
decimals: 18,
name: 'Ethereum',
symbol: 'ETH',
},
],
dataUpdateCount: 1,
dataUpdatedAt: expect.any(Number),
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchMeta: null,
fetchStatus: 'idle',
isInvalidated: false,
status: 'success',
},
},
],
mutations: [],
},
timestamp: expect.any(Number),
});
});

it('rehydrates the cache using the StorageService', async () => {
const rootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
captureException: console.error,
});

rootMessenger.registerActionHandler('StorageService:setItem', jest.fn());
rootMessenger.registerActionHandler('StorageService:getItem', () => {
return {
result: {
state: {
queries: [
{
queryHash:
'["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]',
queryKey: [
'ExampleDataService:getAssets',
[
'eip155:1/slip44:60',
'bip122:000000000019d6689c085ae165831e93/slip44:0',
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
],
],
state: {
data: [
{
assetId:
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
decimals: 18,
name: 'Dai Stablecoin',
symbol: 'DAI',
},
{
assetId:
'bip122:000000000019d6689c085ae165831e93/slip44:0',
decimals: 8,
name: 'Bitcoin',
symbol: 'BTC',
},
{
assetId: 'eip155:1/slip44:60',
decimals: 18,
name: 'Ethereum',
symbol: 'ETH',
},
],
dataUpdateCount: 1,
dataUpdatedAt: Date.now(),
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchMeta: null,
fetchStatus: 'idle',
isInvalidated: false,
status: 'success',
},
},
],
mutations: [],
},
timestamp: Date.now(),
},
};
});

const messenger = rootMessenger.buildChild({
namespace: serviceName,
actions: ['StorageService:getItem', 'StorageService:setItem'],
});
const spy = jest.spyOn(messenger, 'call');
const service = new ExampleDataService(messenger);
service.init();

await rootMessenger.waitUntil('ExampleDataService:cacheUpdated');

mockAssets({ status: 500 });

const result = await service.getAssets(MOCK_ASSETS);

expect(result).toHaveLength(3);

expect(spy).toHaveBeenCalledWith(
'StorageService:getItem',
serviceName,
'cache',
);
});

it('discards the cache if it has expired', async () => {
const rootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
captureException: console.error,
});

rootMessenger.registerActionHandler('StorageService:setItem', jest.fn());
rootMessenger.registerActionHandler('StorageService:getItem', () => {
return {
result: {
state: {
queries: [],
mutations: [],
},
timestamp: 1783516587702,
},
};
});

const messenger = rootMessenger.buildChild({
namespace: serviceName,
actions: ['StorageService:getItem', 'StorageService:setItem'],
});

const callSpy = jest.spyOn(messenger, 'call');
const publishSpy = jest.spyOn(messenger, 'publish');

const service = new ExampleDataService(messenger);
service.init();

expect(callSpy).toHaveBeenCalledWith(
'StorageService:getItem',
serviceName,
'cache',
);

expect(publishSpy).not.toHaveBeenCalled();
});

it('skips persisting cache if persistConfig is not set', async () => {
const messenger = new Messenger({ namespace: serviceName });
const callSpy = jest.spyOn(messenger, 'call');
const service = new ExampleDataService(messenger, {});

mockAssets();

await service.getAssets(MOCK_ASSETS);

jest.runAllTimers();

expect(callSpy).not.toHaveBeenCalledWith(
'StorageService:setItem',
expect.anything(),
expect.anything(),
expect.anything(),
);
});

it('skips rehydrating cache if persistConfig is not set', async () => {
const messenger = new Messenger({ namespace: serviceName });
const callSpy = jest.spyOn(messenger, 'call');
const service = new ExampleDataService(messenger, {});

service.init();

expect(callSpy).not.toHaveBeenCalledWith(
'StorageService:getItem',
expect.anything(),
expect.anything(),
);
});

it('ignores rehydration if the StorageService fails', async () => {
const rootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
captureException: console.error,
});

rootMessenger.registerActionHandler('StorageService:setItem', jest.fn());
rootMessenger.registerActionHandler('StorageService:getItem', () => {
return {
error: new Error('Failed to retrieve item.'),
};
});

const messenger = rootMessenger.buildChild({
namespace: serviceName,
actions: ['StorageService:getItem', 'StorageService:setItem'],
});

const callSpy = jest.spyOn(messenger, 'call');
const publishSpy = jest.spyOn(messenger, 'publish');

const service = new ExampleDataService(messenger);
service.init();

expect(callSpy).toHaveBeenCalledWith(
'StorageService:getItem',
serviceName,
'cache',
);

expect(publishSpy).not.toHaveBeenCalled();
});
});
});
Loading
Loading