Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
01d42bc
feat: Integrate StorageService for large controller data
andrepimenta Nov 19, 2025
72939b0
refactor: Update mobile adapter for new StorageService interface
andrepimenta Nov 25, 2025
d2ab2d1
refactor(storage-service): update adapter to handle key building and …
andrepimenta Nov 25, 2025
e431a4f
Add preview package of storage-service
andrepimenta Nov 26, 2025
1f10b96
Merge branch 'main' into feature/storage-service
andrepimenta Nov 26, 2025
7294e96
Merge branch 'feature/storage-service' of https://github.com/MetaMask…
andrepimenta Nov 26, 2025
3e6f2ea
Use metamask-previews for now
andrepimenta Nov 26, 2025
f886613
fix(storage-service): add StorageServiceEvents to GlobalEvents
andrepimenta Nov 26, 2025
ba326e3
fix(storage-service): add StorageService to STATELESS_NON_CONTROLLER_…
andrepimenta Nov 26, 2025
23fc5b6
test(storage-service): achieve 100% coverage for storage-service-init
andrepimenta Nov 26, 2025
7d6da38
fix(storage-service): use undefined instead of null for FilesystemSto…
andrepimenta Nov 26, 2025
08c5d98
test(storage-service): rename test to avoid weasel words
andrepimenta Nov 26, 2025
58d0b47
fix(storage-service): throw errors consistently in all adapter methods
andrepimenta Nov 27, 2025
049a5ff
refactor(storage-service): use Json type and remove wrapper
andrepimenta Nov 27, 2025
ca1941d
Merge branch 'main' into feature/storage-service
andrepimenta Nov 28, 2025
a7de4a4
Merge branch 'main' into feature/storage-service
andrepimenta Dec 12, 2025
11b7710
chore: update @metamask-previews/storage-service with StorageGetResul…
andrepimenta Dec 12, 2025
52d20ab
refactor(storage-service): use @metamask/storage-service with Storage…
andrepimenta Dec 12, 2025
5cf519d
fix: prettier formatting in storage-service-init.test.ts
andrepimenta Dec 12, 2025
71d6519
Merge branch 'main' into feature/storage-service
andrepimenta Dec 12, 2025
6cdc688
Merge branch 'main' into feature/storage-service
andrepimenta Dec 15, 2025
650045d
Merge branch 'main' into feature/storage-service
andrepimenta Dec 15, 2025
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
2 changes: 2 additions & 0 deletions app/core/Engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import { rewardsDataServiceInit } from './controllers/rewards-data-service-init'
import { swapsControllerInit } from './controllers/swaps-controller-init';
import { remoteFeatureFlagControllerInit } from './controllers/remote-feature-flag-controller-init';
import { errorReportingServiceInit } from './controllers/error-reporting-service-init';
import { storageServiceInit } from './controllers/storage-service-init';
import { loggingControllerInit } from './controllers/logging-controller-init';
import { phishingControllerInit } from './controllers/phishing-controller-init';
import { addressBookControllerInit } from './controllers/address-book-controller-init';
Expand Down Expand Up @@ -284,6 +285,7 @@ export class Engine {
const { controllersByName } = initModularizedControllers({
controllerInitFunctions: {
ErrorReportingService: errorReportingServiceInit,
StorageService: storageServiceInit,
LoggingController: loggingControllerInit,
PreferencesController: preferencesControllerInit,
RemoteFeatureFlagController: remoteFeatureFlagControllerInit,
Expand Down
1 change: 1 addition & 0 deletions app/core/Engine/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [
'ExecutionService',
'NftDetectionController',
'RewardsDataService',
'StorageService',
'TokenDetectionController',
'WebSocketService',
'BackendWebSocketService',
Expand Down
364 changes: 364 additions & 0 deletions app/core/Engine/controllers/storage-service-init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
import { buildControllerInitRequestMock } from '../utils/test-utils';
import { ExtendedMessenger } from '../../ExtendedMessenger';
import { getStorageServiceMessenger } from '../messengers/storage-service-messenger';
import { ControllerInitRequest } from '../types';
import { storageServiceInit } from './storage-service-init';
import {
StorageService,
StorageServiceMessenger,
STORAGE_KEY_PREFIX,
} from '@metamask-previews/storage-service';
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
import FilesystemStorage from 'redux-persist-filesystem-storage';
import Device from '../../../util/device';
import Logger from '../../../util/Logger';

jest.mock('@metamask-previews/storage-service');
jest.mock('redux-persist-filesystem-storage');
jest.mock('../../../util/device');
jest.mock('../../../util/Logger');

const mockFilesystemStorage = jest.mocked(FilesystemStorage);
const mockDevice = jest.mocked(Device);
const mockLogger = jest.mocked(Logger);

function getInitRequestMock(): jest.Mocked<
ControllerInitRequest<StorageServiceMessenger>
> {
const baseMessenger = new ExtendedMessenger<MockAnyNamespace, never, never>({
namespace: MOCK_ANY_NAMESPACE,
});

const requestMock = {
...buildControllerInitRequestMock(baseMessenger),
controllerMessenger: getStorageServiceMessenger(baseMessenger),
initMessenger: undefined,
};

return requestMock;
}

describe('storageServiceInit', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('initializes the service', () => {
const { controller } = storageServiceInit(getInitRequestMock());

expect(controller).toBeInstanceOf(StorageService);
});

it('passes the proper arguments to the service', () => {
storageServiceInit(getInitRequestMock());

const serviceMock = jest.mocked(StorageService);

expect(serviceMock).toHaveBeenCalledWith({
messenger: expect.any(Object),
storage: expect.objectContaining({
getItem: expect.any(Function),
setItem: expect.any(Function),
removeItem: expect.any(Function),
getAllKeys: expect.any(Function),
clear: expect.any(Function),
}),
});
});

it('provides FilesystemStorage adapter with required methods', () => {
storageServiceInit(getInitRequestMock());

const serviceMock = jest.mocked(StorageService);
const callArguments = serviceMock.mock.calls[0][0];

expect(callArguments.storage).toBeDefined();
expect(callArguments.storage?.getItem).toBeInstanceOf(Function);
expect(callArguments.storage?.setItem).toBeInstanceOf(Function);
expect(callArguments.storage?.removeItem).toBeInstanceOf(Function);
expect(callArguments.storage?.getAllKeys).toBeInstanceOf(Function);
expect(callArguments.storage?.clear).toBeInstanceOf(Function);
});
});

describe('mobileStorageAdapter', () => {
beforeEach(() => {
jest.clearAllMocks();
});

/**
* Helper to get the storage adapter from the init call
*/
function getStorageAdapter() {
storageServiceInit(getInitRequestMock());
const serviceMock = jest.mocked(StorageService);
const storage = serviceMock.mock.calls[0][0].storage;
if (!storage) {
throw new Error('Storage adapter not provided');
}
return storage;
}

describe('getItem', () => {
it('returns unwrapped data when item exists', async () => {
const testData = { foo: 'bar' };
const wrapper = { timestamp: Date.now(), data: testData };
mockFilesystemStorage.getItem.mockResolvedValue(JSON.stringify(wrapper));

const adapter = getStorageAdapter();
const result = await adapter.getItem('TestController', 'testKey');

expect(result).toStrictEqual(testData);
expect(mockFilesystemStorage.getItem).toHaveBeenCalledWith(
`${STORAGE_KEY_PREFIX}TestController:testKey`,
);
});

it('returns null when item does not exist', async () => {
mockFilesystemStorage.getItem.mockResolvedValue(undefined);

const adapter = getStorageAdapter();
const result = await adapter.getItem('TestController', 'missingKey');

expect(result).toBeNull();
});

it('returns null and logs error when JSON parsing fails', async () => {
mockFilesystemStorage.getItem.mockResolvedValue('invalid json');

const adapter = getStorageAdapter();
const result = await adapter.getItem('TestController', 'badKey');

expect(result).toBeNull();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
message: 'StorageService: Failed to get item: TestController:badKey',
}),
);
});

it('returns null and logs error when FilesystemStorage throws', async () => {
mockFilesystemStorage.getItem.mockRejectedValue(
new Error('Storage error'),
);

const adapter = getStorageAdapter();
const result = await adapter.getItem('TestController', 'errorKey');

expect(result).toBeNull();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
message:
'StorageService: Failed to get item: TestController:errorKey',
}),
);
});
});

describe('setItem', () => {
it('stores wrapped data with timestamp', async () => {
mockFilesystemStorage.setItem.mockResolvedValue(undefined);
mockDevice.isIos.mockReturnValue(true);
jest.spyOn(Date, 'now').mockReturnValue(1234567890);

const adapter = getStorageAdapter();
await adapter.setItem('TestController', 'testKey', { foo: 'bar' });

expect(mockFilesystemStorage.setItem).toHaveBeenCalledWith(
`${STORAGE_KEY_PREFIX}TestController:testKey`,
JSON.stringify({ timestamp: 1234567890, data: { foo: 'bar' } }),
true,
);
});

it('passes false for isIos on Android devices', async () => {
mockFilesystemStorage.setItem.mockResolvedValue(undefined);
mockDevice.isIos.mockReturnValue(false);
jest.spyOn(Date, 'now').mockReturnValue(1234567890);

const adapter = getStorageAdapter();
await adapter.setItem('TestController', 'testKey', 'value');

expect(mockFilesystemStorage.setItem).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
false,
);
});

it('throws and logs error when FilesystemStorage fails', async () => {
const storageError = new Error('Write failed');
mockFilesystemStorage.setItem.mockRejectedValue(storageError);
mockDevice.isIos.mockReturnValue(true);

const adapter = getStorageAdapter();

await expect(
adapter.setItem('TestController', 'testKey', 'value'),
).rejects.toThrow('Write failed');

expect(mockLogger.error).toHaveBeenCalledWith(
storageError,
expect.objectContaining({
message: 'StorageService: Failed to set item: TestController:testKey',
}),
);
});
});

describe('removeItem', () => {
it('removes item from storage', async () => {
mockFilesystemStorage.removeItem.mockResolvedValue(undefined);

const adapter = getStorageAdapter();
await adapter.removeItem('TestController', 'testKey');

expect(mockFilesystemStorage.removeItem).toHaveBeenCalledWith(
`${STORAGE_KEY_PREFIX}TestController:testKey`,
);
});

it('throws and logs error when FilesystemStorage fails', async () => {
const storageError = new Error('Remove failed');
mockFilesystemStorage.removeItem.mockRejectedValue(storageError);

const adapter = getStorageAdapter();

await expect(
adapter.removeItem('TestController', 'testKey'),
).rejects.toThrow('Remove failed');

expect(mockLogger.error).toHaveBeenCalledWith(
storageError,
expect.objectContaining({
message:
'StorageService: Failed to remove item: TestController:testKey',
}),
);
});
});

describe('getAllKeys', () => {
it('returns keys matching namespace prefix without prefix', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue([
`${STORAGE_KEY_PREFIX}TestController:key1`,
`${STORAGE_KEY_PREFIX}TestController:key2`,
`${STORAGE_KEY_PREFIX}OtherController:key3`,
]);

const adapter = getStorageAdapter();
const result = await adapter.getAllKeys('TestController');

expect(result).toStrictEqual(['key1', 'key2']);
});

it('returns empty array when no keys exist', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue([]);

const adapter = getStorageAdapter();
const result = await adapter.getAllKeys('TestController');

expect(result).toStrictEqual([]);
});

it('returns empty array when getAllKeys returns null', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue(
null as unknown as string[],
);

const adapter = getStorageAdapter();
const result = await adapter.getAllKeys('TestController');

expect(result).toStrictEqual([]);
});

it('returns empty array and logs error when FilesystemStorage fails', async () => {
mockFilesystemStorage.getAllKeys.mockRejectedValue(
new Error('Keys error'),
);

const adapter = getStorageAdapter();
const result = await adapter.getAllKeys('TestController');

expect(result).toStrictEqual([]);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
message: 'StorageService: Failed to get keys for TestController',
}),
);
});
});

describe('clear', () => {
it('removes all keys matching namespace prefix', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue([
`${STORAGE_KEY_PREFIX}TestController:key1`,
`${STORAGE_KEY_PREFIX}TestController:key2`,
`${STORAGE_KEY_PREFIX}OtherController:key3`,
]);
mockFilesystemStorage.removeItem.mockResolvedValue(undefined);

const adapter = getStorageAdapter();
await adapter.clear('TestController');

expect(mockFilesystemStorage.removeItem).toHaveBeenCalledTimes(2);
expect(mockFilesystemStorage.removeItem).toHaveBeenCalledWith(
`${STORAGE_KEY_PREFIX}TestController:key1`,
);
expect(mockFilesystemStorage.removeItem).toHaveBeenCalledWith(
`${STORAGE_KEY_PREFIX}TestController:key2`,
);
expect(mockLogger.log).toHaveBeenCalledWith(
'StorageService: Cleared 2 keys for TestController',
);
});

it('returns early when getAllKeys returns null', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue(
null as unknown as string[],
);

const adapter = getStorageAdapter();
await adapter.clear('TestController');

expect(mockFilesystemStorage.removeItem).not.toHaveBeenCalled();
});

it('handles empty namespace gracefully', async () => {
mockFilesystemStorage.getAllKeys.mockResolvedValue([
`${STORAGE_KEY_PREFIX}OtherController:key1`,
]);
mockFilesystemStorage.removeItem.mockResolvedValue(undefined);

const adapter = getStorageAdapter();
await adapter.clear('TestController');

expect(mockFilesystemStorage.removeItem).not.toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalledWith(
'StorageService: Cleared 0 keys for TestController',
);
});

it('throws and logs error when FilesystemStorage fails', async () => {
mockFilesystemStorage.getAllKeys.mockRejectedValue(
new Error('Clear failed'),
);

const adapter = getStorageAdapter();

await expect(adapter.clear('TestController')).rejects.toThrow(
'Clear failed',
);

expect(mockLogger.error).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
message: 'StorageService: Failed to clear namespace TestController',
}),
);
});
});
});
Loading
Loading