Skip to content

Commit 9f2e53c

Browse files
committed
Add BaseControllerV2 state metadata
State metadata has been added to the new BaseController constructor as a required constructor parameter. The metadata describes how to derive the state that should be persisted, and how to derive an 'anonymized' representation of the controller state. The metadata describes top-level properties only, but it allows you to define a function to derive the anonymized or persistent state for each property. The only requirement of this derivation function is that the output is also valid JSON. This is part of the controller redesign (#337).
1 parent 18467ee commit 9f2e53c

File tree

2 files changed

+406
-15
lines changed

2 files changed

+406
-15
lines changed

src/BaseControllerV2.test.ts

Lines changed: 297 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { Draft } from 'immer';
22
import * as sinon from 'sinon';
33

4-
import { BaseController } from './BaseControllerV2';
4+
import { BaseController, getAnonymizedState, getPersistentState } from './BaseControllerV2';
55

66
type MockControllerState = {
77
count: number;
88
};
99

10+
const mockControllerStateMetadata = {
11+
count: {
12+
persist: true,
13+
anonymous: true,
14+
},
15+
};
16+
1017
class MockController extends BaseController<MockControllerState> {
1118
update(callback: (state: Draft<MockControllerState>) => void | MockControllerState) {
1219
super.update(callback);
@@ -19,21 +26,27 @@ class MockController extends BaseController<MockControllerState> {
1926

2027
describe('BaseController', () => {
2128
it('should set initial state', () => {
22-
const controller = new MockController({ count: 0 });
29+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
2330

2431
expect(controller.state).toEqual({ count: 0 });
2532
});
2633

34+
it('should set initial schema', () => {
35+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
36+
37+
expect(controller.metadata).toEqual(mockControllerStateMetadata);
38+
});
39+
2740
it('should not allow mutating state directly', () => {
28-
const controller = new MockController({ count: 0 });
41+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
2942

3043
expect(() => {
3144
controller.state = { count: 1 };
3245
}).toThrow();
3346
});
3447

3548
it('should allow updating state by modifying draft', () => {
36-
const controller = new MockController({ count: 0 });
49+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
3750

3851
controller.update((draft) => {
3952
draft.count += 1;
@@ -43,7 +56,7 @@ describe('BaseController', () => {
4356
});
4457

4558
it('should allow updating state by return a value', () => {
46-
const controller = new MockController({ count: 0 });
59+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
4760

4861
controller.update(() => {
4962
return { count: 1 };
@@ -53,7 +66,7 @@ describe('BaseController', () => {
5366
});
5467

5568
it('should throw an error if update callback modifies draft and returns value', () => {
56-
const controller = new MockController({ count: 0 });
69+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
5770

5871
expect(() => {
5972
controller.update((draft) => {
@@ -64,7 +77,7 @@ describe('BaseController', () => {
6477
});
6578

6679
it('should inform subscribers of state changes', () => {
67-
const controller = new MockController({ count: 0 });
80+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
6881
const listener1 = sinon.stub();
6982
const listener2 = sinon.stub();
7083

@@ -81,7 +94,7 @@ describe('BaseController', () => {
8194
});
8295

8396
it('should inform a subscriber of each state change once even after multiple subscriptions', () => {
84-
const controller = new MockController({ count: 0 });
97+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
8598
const listener1 = sinon.stub();
8699

87100
controller.subscribe(listener1);
@@ -95,7 +108,7 @@ describe('BaseController', () => {
95108
});
96109

97110
it('should no longer inform a subscriber about state changes after unsubscribing', () => {
98-
const controller = new MockController({ count: 0 });
111+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
99112
const listener1 = sinon.stub();
100113

101114
controller.subscribe(listener1);
@@ -108,7 +121,7 @@ describe('BaseController', () => {
108121
});
109122

110123
it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => {
111-
const controller = new MockController({ count: 0 });
124+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
112125
const listener1 = sinon.stub();
113126

114127
controller.subscribe(listener1);
@@ -122,7 +135,7 @@ describe('BaseController', () => {
122135
});
123136

124137
it('should allow unsubscribing listeners who were never subscribed', () => {
125-
const controller = new MockController({ count: 0 });
138+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
126139
const listener1 = sinon.stub();
127140

128141
expect(() => {
@@ -131,7 +144,7 @@ describe('BaseController', () => {
131144
});
132145

133146
it('should no longer update subscribers after being destroyed', () => {
134-
const controller = new MockController({ count: 0 });
147+
const controller = new MockController({ count: 0 }, mockControllerStateMetadata);
135148
const listener1 = sinon.stub();
136149
const listener2 = sinon.stub();
137150

@@ -146,3 +159,275 @@ describe('BaseController', () => {
146159
expect(listener2.callCount).toEqual(0);
147160
});
148161
});
162+
163+
describe('getAnonymizedState', () => {
164+
it('should return empty state', () => {
165+
expect(getAnonymizedState({}, {})).toEqual({});
166+
});
167+
168+
it('should return empty state when no properties are anonymized', () => {
169+
const anonymizedState = getAnonymizedState({ count: 1 }, { count: { anonymous: false, persist: false } });
170+
expect(anonymizedState).toEqual({});
171+
});
172+
173+
it('should return state that is already anonymized', () => {
174+
const anonymizedState = getAnonymizedState(
175+
{
176+
password: 'secret password',
177+
privateKey: '123',
178+
network: 'mainnet',
179+
tokens: ['DAI', 'USDC'],
180+
},
181+
{
182+
password: {
183+
anonymous: false,
184+
persist: false,
185+
},
186+
privateKey: {
187+
anonymous: false,
188+
persist: false,
189+
},
190+
network: {
191+
anonymous: true,
192+
persist: false,
193+
},
194+
tokens: {
195+
anonymous: true,
196+
persist: false,
197+
},
198+
},
199+
);
200+
expect(anonymizedState).toEqual({ network: 'mainnet', tokens: ['DAI', 'USDC'] });
201+
});
202+
203+
it('should use anonymizing function to anonymize state', () => {
204+
const anonymizeTransactionHash = (hash: string) => {
205+
return hash.split('').reverse().join('');
206+
};
207+
208+
const anonymizedState = getAnonymizedState(
209+
{
210+
transactionHash: '0x1234',
211+
},
212+
{
213+
transactionHash: {
214+
anonymous: anonymizeTransactionHash,
215+
persist: false,
216+
},
217+
},
218+
);
219+
220+
expect(anonymizedState).toEqual({ transactionHash: '4321x0' });
221+
});
222+
223+
it('should allow returning a partial object from an anonymizing function', () => {
224+
const anonymizeTxMeta = (txMeta: { hash: string; value: number }) => {
225+
return { value: txMeta.value };
226+
};
227+
228+
const anonymizedState = getAnonymizedState(
229+
{
230+
txMeta: {
231+
hash: '0x123',
232+
value: 10,
233+
},
234+
},
235+
{
236+
txMeta: {
237+
anonymous: anonymizeTxMeta,
238+
persist: false,
239+
},
240+
},
241+
);
242+
243+
expect(anonymizedState).toEqual({ txMeta: { value: 10 } });
244+
});
245+
246+
it('should allow returning a nested partial object from an anonymizing function', () => {
247+
const anonymizeTxMeta = (txMeta: { hash: string; value: number; history: { hash: string; value: number }[] }) => {
248+
return {
249+
history: txMeta.history.map((entry) => {
250+
return { value: entry.value };
251+
}),
252+
value: txMeta.value,
253+
};
254+
};
255+
256+
const anonymizedState = getAnonymizedState(
257+
{
258+
txMeta: {
259+
hash: '0x123',
260+
history: [
261+
{
262+
hash: '0x123',
263+
value: 9,
264+
},
265+
],
266+
value: 10,
267+
},
268+
},
269+
{
270+
txMeta: {
271+
anonymous: anonymizeTxMeta,
272+
persist: false,
273+
},
274+
},
275+
);
276+
277+
expect(anonymizedState).toEqual({ txMeta: { history: [{ value: 9 }], value: 10 } });
278+
});
279+
280+
it('should allow transforming types in an anonymizing function', () => {
281+
const anonymizedState = getAnonymizedState(
282+
{
283+
count: '1',
284+
},
285+
{
286+
count: {
287+
anonymous: (count) => Number(count),
288+
persist: false,
289+
},
290+
},
291+
);
292+
293+
expect(anonymizedState).toEqual({ count: 1 });
294+
});
295+
});
296+
297+
describe('getPersistentState', () => {
298+
it('should return empty state', () => {
299+
expect(getPersistentState({}, {})).toEqual({});
300+
});
301+
302+
it('should return empty state when no properties are persistent', () => {
303+
const persistentState = getPersistentState({ count: 1 }, { count: { anonymous: false, persist: false } });
304+
expect(persistentState).toEqual({});
305+
});
306+
307+
it('should return persistent state', () => {
308+
const persistentState = getPersistentState(
309+
{
310+
password: 'secret password',
311+
privateKey: '123',
312+
network: 'mainnet',
313+
tokens: ['DAI', 'USDC'],
314+
},
315+
{
316+
password: {
317+
anonymous: false,
318+
persist: true,
319+
},
320+
privateKey: {
321+
anonymous: false,
322+
persist: true,
323+
},
324+
network: {
325+
anonymous: false,
326+
persist: false,
327+
},
328+
tokens: {
329+
anonymous: false,
330+
persist: false,
331+
},
332+
},
333+
);
334+
expect(persistentState).toEqual({ password: 'secret password', privateKey: '123' });
335+
});
336+
337+
it('should use function to derive persistent state', () => {
338+
const normalizeTransacitonHash = (hash: string) => {
339+
return hash.toLowerCase();
340+
};
341+
342+
const persistentState = getPersistentState(
343+
{
344+
transactionHash: '0X1234',
345+
},
346+
{
347+
transactionHash: {
348+
anonymous: false,
349+
persist: normalizeTransacitonHash,
350+
},
351+
},
352+
);
353+
354+
expect(persistentState).toEqual({ transactionHash: '0x1234' });
355+
});
356+
357+
it('should allow returning a partial object from a persist function', () => {
358+
const getPersistentTxMeta = (txMeta: { hash: string; value: number }) => {
359+
return { value: txMeta.value };
360+
};
361+
362+
const persistentState = getPersistentState(
363+
{
364+
txMeta: {
365+
hash: '0x123',
366+
value: 10,
367+
},
368+
},
369+
{
370+
txMeta: {
371+
anonymous: false,
372+
persist: getPersistentTxMeta,
373+
},
374+
},
375+
);
376+
377+
expect(persistentState).toEqual({ txMeta: { value: 10 } });
378+
});
379+
380+
it('should allow returning a nested partial object from a persist function', () => {
381+
const getPersistentTxMeta = (txMeta: {
382+
hash: string;
383+
value: number;
384+
history: { hash: string; value: number }[];
385+
}) => {
386+
return {
387+
history: txMeta.history.map((entry) => {
388+
return { value: entry.value };
389+
}),
390+
value: txMeta.value,
391+
};
392+
};
393+
394+
const persistentState = getPersistentState(
395+
{
396+
txMeta: {
397+
hash: '0x123',
398+
history: [
399+
{
400+
hash: '0x123',
401+
value: 9,
402+
},
403+
],
404+
value: 10,
405+
},
406+
},
407+
{
408+
txMeta: {
409+
anonymous: false,
410+
persist: getPersistentTxMeta,
411+
},
412+
},
413+
);
414+
415+
expect(persistentState).toEqual({ txMeta: { history: [{ value: 9 }], value: 10 } });
416+
});
417+
418+
it('should allow transforming types in a persist function', () => {
419+
const persistentState = getPersistentState(
420+
{
421+
count: '1',
422+
},
423+
{
424+
count: {
425+
anonymous: false,
426+
persist: (count) => Number(count),
427+
},
428+
},
429+
);
430+
431+
expect(persistentState).toEqual({ count: 1 });
432+
});
433+
});

0 commit comments

Comments
 (0)