Skip to content

Commit 449a82a

Browse files
authored
fix: multichain core - stores adapters and client implementation (#1314)
* fix: add storage + unit tests * fix: improve test coverage for nodejs and browser adapters * fix: remove channelConfig, no longer needed in here * fix: better manage StoreAdapterNode
1 parent 94e09da commit 449a82a

File tree

6 files changed

+317
-23
lines changed

6 files changed

+317
-23
lines changed
Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,11 @@
11
/* c8 ignore start */
2-
export interface ChannelConfig {
3-
channelId: string;
4-
validUntil: number;
5-
otherKey?: string;
6-
localKey?: string;
7-
walletVersion?: string;
8-
deeplinkProtocolAvailable?: boolean;
9-
relayPersistence?: boolean; // Set if the session has full relay persistence (can exchange message without the other side connected)
10-
/**
11-
* lastActive: ms value of the last time connection was ready CLIENTS_READY event.
12-
* */
13-
lastActive?: number;
14-
}
152

163
export abstract class StoreClient {
174
abstract getAnonId(): Promise<string | null>;
18-
195
abstract getExtensionId(): Promise<string | null>;
20-
21-
abstract getChannelConfig(): Promise<ChannelConfig | null>;
22-
236
abstract setExtensionId(extensionId: string): Promise<void>;
24-
257
abstract setAnonId(anonId: string): Promise<void>;
26-
27-
abstract setChannelConfig(channelConfig: ChannelConfig): Promise<void>;
28-
298
abstract removeExtensionId(): Promise<void>;
30-
319
abstract removeAnonId(): Promise<void>;
32-
3310
abstract getDebug(): Promise<string | null>;
3411
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { StoreAdapter } from "../../domain";
2+
import fs from 'fs'
3+
import path from 'path';
4+
5+
const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json');
6+
7+
export class StoreAdapterNode extends StoreAdapter {
8+
9+
10+
private safeParse(contents: string): Record<string, string> {
11+
try {
12+
return JSON.parse(contents);
13+
} catch (e) {
14+
return {};
15+
}
16+
}
17+
18+
async getItem(key: string): Promise<string | null> {
19+
if (!fs.existsSync(CONFIG_FILE)) {
20+
return null;
21+
}
22+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
23+
const config = this.safeParse(contents);
24+
if (config[key] !== undefined) {
25+
return config[key];
26+
}
27+
return null;
28+
}
29+
30+
async setItem(key: string, value: string): Promise<void> {
31+
if (!fs.existsSync(CONFIG_FILE)) {
32+
fs.writeFileSync(CONFIG_FILE, '{}');
33+
}
34+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
35+
const config = this.safeParse(contents);
36+
config[key] = value;
37+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
38+
}
39+
40+
async deleteItem(key: string): Promise<void> {
41+
if (!fs.existsSync(CONFIG_FILE)) {
42+
return;
43+
}
44+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
45+
const config = this.safeParse(contents);
46+
delete config[key];
47+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
48+
}
49+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import { StoreAdapter } from "../../domain";
3+
4+
export class StoreAdapterRN extends StoreAdapter {
5+
async getItem(key: string): Promise<string | null> {
6+
return AsyncStorage.getItem(key);
7+
}
8+
9+
async setItem(key: string, value: string): Promise<void> {
10+
return AsyncStorage.setItem(key, value);
11+
}
12+
13+
async deleteItem(key: string): Promise<void> {
14+
return AsyncStorage.removeItem(key);
15+
}
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { StoreAdapter } from "../../domain";
2+
3+
export class StoreAdapterWeb extends StoreAdapter {
4+
private get internal() {
5+
if (typeof window === 'undefined' || !window.localStorage) {
6+
throw new Error('localStorage is not available in this environment');
7+
}
8+
return window.localStorage;
9+
}
10+
async getItem(key: string): Promise<string | null> {
11+
return this.internal.getItem(key);
12+
}
13+
14+
async setItem(key: string, value: string): Promise<void> {
15+
this.internal.setItem(key, value);
16+
}
17+
18+
async deleteItem(key: string): Promise<void> {
19+
this.internal.removeItem(key);
20+
}
21+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as t from 'vitest'
2+
import fs from 'fs'
3+
import path from 'path';
4+
import AsyncStorage from '@react-native-async-storage/async-storage';
5+
6+
7+
8+
import { Store } from './index';
9+
import { StoreAdapter } from '../domain';
10+
import { StoreAdapterWeb } from './adapters/web';
11+
import { StoreAdapterRN } from './adapters/rn';
12+
import { StoreAdapterNode } from './adapters/node';
13+
14+
/**
15+
* Dummy mocked storage to keep track of data between tests
16+
*/
17+
const nativeStorageStub = {
18+
data: new Map<string, string>(),
19+
getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null),
20+
setItem: t.vi.fn((key: string, value: string) => {
21+
nativeStorageStub.data.set(key, value);
22+
}),
23+
removeItem: t.vi.fn((key: string) => {
24+
nativeStorageStub.data.delete(key);
25+
}),
26+
clear: t.vi.fn(() => {
27+
nativeStorageStub.data.clear();
28+
}),
29+
}
30+
31+
// Reusable test function that can be used with any adapter
32+
function createStoreTests(
33+
adapterName: string,
34+
createAdapter: () => StoreAdapter,
35+
setupMocks?: () => void,
36+
cleanupMocks?: () => void
37+
) {
38+
let store: Store;
39+
let adapter: StoreAdapter;
40+
41+
t.beforeEach(async () => {
42+
setupMocks?.();
43+
adapter = createAdapter();
44+
store = new Store(adapter);
45+
});
46+
47+
t.afterEach(async () => {
48+
nativeStorageStub.data.clear()
49+
cleanupMocks?.();
50+
});
51+
52+
t.describe(`${adapterName} constructor`, () => {
53+
t.it('should create a Store instance with the provided adapter', () => {
54+
t.expect(store).toBeInstanceOf(Store);
55+
});
56+
});
57+
58+
t.describe(`${adapterName} getAnonId`, () => {
59+
t.it('should return the anonymous ID when it exists', async () => {
60+
await adapter.setItem('anonId', 'test-anon-id');
61+
const result = await store.getAnonId();
62+
t.expect(result).toBe('test-anon-id');
63+
});
64+
65+
t.it('should return null when anonymous ID does not exist', async () => {
66+
const result = await store.getAnonId();
67+
t.expect(result).toBeNull();
68+
});
69+
});
70+
71+
t.describe(`${adapterName} setAnonId`, () => {
72+
t.it('should set the anonymous ID successfully', async () => {
73+
await store.setAnonId('new-anon-id');
74+
const result = await adapter.getItem('anonId');
75+
t.expect(result).toBe('new-anon-id');
76+
});
77+
});
78+
79+
t.describe(`${adapterName} removeAnonId`, () => {
80+
t.it('should remove the anonymous ID successfully', async () => {
81+
await adapter.setItem('anonId', 'test-anon-id');
82+
const beforeRemove = await adapter.getItem('anonId');
83+
t.expect(beforeRemove).toBe('test-anon-id');
84+
85+
await store.removeAnonId();
86+
const afterRemove = await adapter.getItem('anonId');
87+
t.expect(afterRemove).toBeNull();
88+
});
89+
});
90+
91+
t.describe(`${adapterName} getExtensionId`, () => {
92+
t.it('should return the extension ID when it exists', async () => {
93+
await adapter.setItem('extensionId', 'test-extension-id');
94+
const result = await store.getExtensionId();
95+
t.expect(result).toBe('test-extension-id');
96+
});
97+
98+
t.it('should return null when extension ID does not exist', async () => {
99+
const result = await store.getExtensionId();
100+
t.expect(result).toBeNull();
101+
});
102+
});
103+
104+
t.describe(`${adapterName} setExtensionId`, () => {
105+
t.it('should set the extension ID successfully', async () => {
106+
await store.setExtensionId('new-extension-id');
107+
const result = await adapter.getItem('extensionId');
108+
t.expect(result).toBe('new-extension-id');
109+
});
110+
});
111+
112+
t.describe(`${adapterName} removeExtensionId`, () => {
113+
t.it('should remove the extension ID successfully', async () => {
114+
await adapter.setItem('extensionId', 'test-extension-id');
115+
const beforeRemove = await adapter.getItem('extensionId');
116+
t.expect(beforeRemove).toBe('test-extension-id');
117+
118+
await store.removeExtensionId();
119+
const afterRemove = await adapter.getItem('extensionId');
120+
t.expect(afterRemove).toBeNull();
121+
});
122+
});
123+
124+
t.describe(`${adapterName} getDebug`, () => {
125+
t.it('should return the debug value when it exists', async () => {
126+
await adapter.setItem('DEBUG', 'metamask-sdk:*');
127+
const result = await store.getDebug();
128+
t.expect(result).toBe('metamask-sdk:*');
129+
});
130+
131+
t.it('should return null when debug value does not exist', async () => {
132+
const result = await store.getDebug();
133+
t.expect(result).toBeNull();
134+
});
135+
});
136+
}
137+
138+
139+
t.describe(`Store with NodeAdapter`, () => {
140+
// Test with Node Adapter and mocked file system
141+
createStoreTests(
142+
'NodeAdapter',
143+
() => new StoreAdapterNode(),
144+
async () => {
145+
const memfs = new Map<string, any>()
146+
t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString()))
147+
t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data))
148+
t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString()))
149+
}
150+
);
151+
152+
t.it("Should gracefully manage deleteItem even if the config file does not exist", async () => {
153+
const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json');
154+
t.vi.spyOn(fs, 'existsSync').mockImplementation(() => false)
155+
const store = new Store(new StoreAdapterNode())
156+
await store.removeExtensionId()
157+
t.expect(fs.existsSync).toHaveBeenCalledWith(CONFIG_FILE)
158+
})
159+
});
160+
161+
t.describe(`Store with WebAdapter`, () => {
162+
//Test browser storage with mocked local storage
163+
createStoreTests(
164+
'WebAdapter',
165+
() => new StoreAdapterWeb(),
166+
() => {
167+
t.vi.stubGlobal('window', {
168+
localStorage: nativeStorageStub,
169+
});
170+
}
171+
);
172+
173+
t.it("Should throw an exception if we try using the store with a browser that doesn't support localStorage", async () => {
174+
t.vi.stubGlobal('window', {
175+
localStorage: null,
176+
});
177+
const store = new Store(new StoreAdapterWeb());
178+
await t.expect(() => store.getAnonId()).rejects.toThrow();
179+
});
180+
});
181+
182+
t.describe(`Store with RNAdapter`, () => {
183+
// Test RN storage with mocked AsyncStorage
184+
createStoreTests(
185+
'RNAdapter',
186+
() => new StoreAdapterRN(),
187+
() => {
188+
t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key))
189+
t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value))
190+
t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.removeItem(key))
191+
}
192+
);
193+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {StoreClient, StoreAdapter } from "../domain";
2+
3+
4+
export class Store implements StoreClient {
5+
readonly #adapter: StoreAdapter;
6+
7+
constructor(adapter: StoreAdapter) {
8+
this.#adapter = adapter;
9+
}
10+
11+
async getAnonId(): Promise<string | null> {
12+
return this.#adapter.getItem('anonId');
13+
}
14+
15+
async getExtensionId(): Promise<string | null> {
16+
return this.#adapter.getItem('extensionId');
17+
}
18+
19+
async setAnonId(anonId: string): Promise<void> {
20+
return this.#adapter.setItem('anonId', anonId);
21+
}
22+
23+
async setExtensionId(extensionId: string): Promise<void> {
24+
return this.#adapter.setItem('extensionId', extensionId);
25+
}
26+
27+
async removeExtensionId(): Promise<void> {
28+
return this.#adapter.deleteItem('extensionId');
29+
}
30+
31+
async removeAnonId(): Promise<void> {
32+
return this.#adapter.deleteItem('anonId');
33+
}
34+
35+
async getDebug(): Promise<string | null> {
36+
return this.#adapter.getItem('DEBUG');
37+
}
38+
}

0 commit comments

Comments
 (0)