Skip to content

Commit 4122f73

Browse files
committed
fix: add storage + unit tests
1 parent 870d686 commit 4122f73

5 files changed

Lines changed: 355 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
async getItem(key: string): Promise<string | null> {
10+
if (!fs.existsSync(CONFIG_FILE)) {
11+
return null;
12+
}
13+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
14+
const config = JSON.parse(contents);
15+
return config[key] || null;
16+
}
17+
18+
async setItem(key: string, value: string): Promise<void> {
19+
if (!fs.existsSync(CONFIG_FILE)) {
20+
fs.writeFileSync(CONFIG_FILE, '{}');
21+
}
22+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
23+
const config = JSON.parse(contents);
24+
config[key] = value;
25+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
26+
}
27+
28+
async deleteItem(key: string): Promise<void> {
29+
if (!fs.existsSync(CONFIG_FILE)) {
30+
return;
31+
}
32+
const contents = fs.readFileSync(CONFIG_FILE, 'utf8');
33+
const config = JSON.parse(contents);
34+
delete config[key];
35+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
36+
}
37+
}
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: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import * as t from 'vitest'
2+
import fs from 'fs'
3+
import AsyncStorage from '@react-native-async-storage/async-storage';
4+
5+
6+
7+
import { Store } from './index';
8+
import { StoreAdapter, type ChannelConfig } from '../domain';
9+
import { StoreAdapterWeb } from './adapters/web';
10+
import { StoreAdapterRN } from './adapters/rn';
11+
import { StoreAdapterNode } from './adapters/node';
12+
13+
/**
14+
* Dummy mocked storage to keep track of data between tests
15+
*/
16+
const nativeStorageStub = {
17+
data: new Map<string, string>(),
18+
getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null),
19+
setItem: t.vi.fn((key: string, value: string) => {
20+
nativeStorageStub.data.set(key, value);
21+
}),
22+
removeItem: t.vi.fn((key: string) => {
23+
nativeStorageStub.data.delete(key);
24+
}),
25+
clear: t.vi.fn(() => {
26+
nativeStorageStub.data.clear();
27+
}),
28+
}
29+
30+
// Reusable test function that can be used with any adapter
31+
function createStoreTests(
32+
adapterName: string,
33+
createAdapter: () => StoreAdapter,
34+
setupMocks?: () => void,
35+
cleanupMocks?: () => void
36+
) {
37+
t.describe(`Store with ${adapterName}`, () => {
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} getChannelConfig`, () => {
125+
t.it('should return the channel config when it exists and is valid JSON', async () => {
126+
const channelConfig: ChannelConfig = {
127+
channelId: 'test-channel',
128+
validUntil: Date.now() + 3600000,
129+
otherKey: 'other-key',
130+
localKey: 'local-key',
131+
walletVersion: '1.0.0',
132+
deeplinkProtocolAvailable: true,
133+
relayPersistence: false,
134+
lastActive: Date.now()
135+
};
136+
137+
await adapter.setItem('channelConfig', JSON.stringify(channelConfig));
138+
const result = await store.getChannelConfig();
139+
140+
t.expect(result).toEqual(channelConfig);
141+
});
142+
143+
t.it('should return null when channel config does not exist', async () => {
144+
const result = await store.getChannelConfig();
145+
t.expect(result).toBeNull();
146+
});
147+
148+
t.it('should throw an error when stored JSON is invalid', async () => {
149+
await adapter.setItem('channelConfig', 'invalid-json');
150+
await t.expect(store.getChannelConfig()).rejects.toThrow();
151+
});
152+
});
153+
154+
t.describe(`${adapterName} setChannelConfig`, () => {
155+
t.it('should set the channel config successfully', async () => {
156+
const channelConfig: ChannelConfig = {
157+
channelId: 'test-channel',
158+
validUntil: Date.now() + 3600000,
159+
otherKey: 'other-key',
160+
localKey: 'local-key'
161+
};
162+
163+
await store.setChannelConfig(channelConfig);
164+
165+
const storedValue = await adapter.getItem('channelConfig');
166+
t.expect(JSON.parse(storedValue!)).toEqual(channelConfig);
167+
});
168+
169+
t.it('should handle minimal channel config', async () => {
170+
const channelConfig: ChannelConfig = {
171+
channelId: 'minimal-channel',
172+
validUntil: Date.now() + 3600000
173+
};
174+
175+
await store.setChannelConfig(channelConfig);
176+
177+
const storedValue = await adapter.getItem('channelConfig');
178+
t.expect(JSON.parse(storedValue!)).toEqual(channelConfig);
179+
});
180+
});
181+
182+
t.describe(`${adapterName} getDebug`, () => {
183+
t.it('should return the debug value when it exists', async () => {
184+
await adapter.setItem('DEBUG', 'metamask-sdk:*');
185+
const result = await store.getDebug();
186+
t.expect(result).toBe('metamask-sdk:*');
187+
});
188+
189+
t.it('should return null when debug value does not exist', async () => {
190+
const result = await store.getDebug();
191+
t.expect(result).toBeNull();
192+
});
193+
});
194+
});
195+
}
196+
197+
198+
199+
// Test with Node Adapter and mocked file system
200+
createStoreTests(
201+
'NodeAdapter',
202+
() => new StoreAdapterNode(),
203+
async () => {
204+
const memfs = new Map<string, any>()
205+
t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString()))
206+
t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data))
207+
t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString()))
208+
}
209+
);
210+
211+
//Test browser storage with mocked local storage
212+
createStoreTests(
213+
'WebAdapter',
214+
() => new StoreAdapterWeb(),
215+
() => {
216+
t.vi.stubGlobal('window', {
217+
localStorage: nativeStorageStub,
218+
});
219+
}
220+
);
221+
222+
// Test RN storage with mocked AsyncStorage
223+
createStoreTests(
224+
'RNAdapter',
225+
() => new StoreAdapterRN(),
226+
() => {
227+
t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key))
228+
t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value))
229+
t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.removeItem(key))
230+
}
231+
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ChannelConfig, 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 getChannelConfig(): Promise<ChannelConfig | null> {
20+
const channelConfig = await this.#adapter.getItem('channelConfig');
21+
if (!channelConfig) {
22+
return null;
23+
}
24+
return JSON.parse(channelConfig)
25+
}
26+
27+
async setChannelConfig(channelConfig: ChannelConfig): Promise<void> {
28+
return this.#adapter.setItem('channelConfig', JSON.stringify(channelConfig));
29+
}
30+
31+
async setAnonId(anonId: string): Promise<void> {
32+
return this.#adapter.setItem('anonId', anonId);
33+
}
34+
35+
async setExtensionId(extensionId: string): Promise<void> {
36+
return this.#adapter.setItem('extensionId', extensionId);
37+
}
38+
39+
async removeExtensionId(): Promise<void> {
40+
return this.#adapter.deleteItem('extensionId');
41+
}
42+
43+
async removeAnonId(): Promise<void> {
44+
return this.#adapter.deleteItem('anonId');
45+
}
46+
47+
async getDebug(): Promise<string | null> {
48+
return this.#adapter.getItem('DEBUG');
49+
}
50+
}

0 commit comments

Comments
 (0)