📄 src/web3/session/store.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, beforeEach } from 'vitest';
2import { createSessionStore } from './store';
3import type { Web3SessionAccount } from './types';
4
5class FakeLocalStorage {
6 private map = new Map<string, string>();
7 get length() { return this.map.size; }
8 key(i: number): string | null { return Array.from(this.map.keys())[i] ?? null; }
9 getItem(k: string): string | null { return this.map.has(k) ? this.map.get(k)! : null; }
10 setItem(k: string, v: string): void { this.map.set(k, v); }
11 removeItem(k: string): void { this.map.delete(k); }
12 clear(): void { this.map.clear(); }
13}
14
15function account(overrides: Partial<Web3SessionAccount> = {}): Web3SessionAccount {
16 return {
17 account: '0xAAA0000000000000000000000000000000000A',
18 username: 'alice',
19 hasAccount: true,
20 userId: 1,
21 lastPatchIndex: 0,
22 lastRestorationIndex: 0,
23 chainId: 1116,
24 wallet: 'metamask',
25 ...overrides,
26 };
27}
28
29beforeEach(() => {
30 (globalThis as any).localStorage = new FakeLocalStorage();
31});
32
33describe('createSessionStore: account lifecycle', () => {
34 it('setActive adds a new account to known and persists it', () => {
35 const store = createSessionStore();
36 const a = account();
37 store.setActive(a);
38
39 expect(store.getState().active).toBe(a);
40 expect(store.getState().known).toContain(a);
41 });
42
43 it('setActive(null) clears the active account without touching known', () => {
44 const store = createSessionStore();
45 const a = account();
46 store.setActive(a);
47 store.setActive(null);
48
49 expect(store.getState().active).toBeNull();
50 expect(store.getState().known).toEqual([a]);
51 });
52
53 it('setActive does not duplicate an account already in known (case-insensitive address match)', () => {
54 const store = createSessionStore();
55 const a = account({ account: '0xabc' });
56 store.addKnown(a);
57 store.setActive(account({ account: '0xABC' }));
58
59 expect(store.getState().known).toHaveLength(1);
60 });
61
62 it('addKnown inserts a new account', () => {
63 const store = createSessionStore();
64 store.addKnown(account());
65 expect(store.getState().known).toHaveLength(1);
66 });
67
68 it('addKnown replaces an existing account for the same address+chainId instead of duplicating', () => {
69 const store = createSessionStore();
70 store.addKnown(account({ username: 'old' }));
71 store.addKnown(account({ username: 'new' }));
72
73 expect(store.getState().known).toHaveLength(1);
74 expect(store.getState().known[0].username).toBe('new');
75 });
76
77 it('addKnown treats the same address on a different chainId as a distinct account', () => {
78 const store = createSessionStore();
79 store.addKnown(account({ chainId: 1116 }));
80 store.addKnown(account({ chainId: 1114 }));
81
82 expect(store.getState().known).toHaveLength(2);
83 });
84
85 it('updateAccount patches a known account in place', () => {
86 const store = createSessionStore();
87 const a = account();
88 store.addKnown(a);
89 store.updateAccount(a.account, a.chainId, { username: 'renamed' });
90
91 expect(store.getState().known[0].username).toBe('renamed');
92 });
93
94 it('updateAccount also patches the active account when it matches', () => {
95 const store = createSessionStore();
96 const a = account();
97 store.setActive(a);
98 store.updateAccount(a.account, a.chainId, { username: 'renamed' });
99
100 expect(store.getState().active?.username).toBe('renamed');
101 });
102
103 it('updateAccount is a no-op for an unknown address', () => {
104 const store = createSessionStore();
105 expect(() => store.updateAccount('0xdoesnotexist', 1116, { username: 'x' })).not.toThrow();
106 });
107
108 it('removeAccount removes it from known and clears active if it was active', () => {
109 const store = createSessionStore();
110 const a = account();
111 store.setActive(a);
112 store.removeAccount(a.account, a.chainId);
113
114 expect(store.getState().known).toHaveLength(0);
115 expect(store.getState().active).toBeNull();
116 });
117
118 it('removeAccount leaves active untouched when removing a different account', () => {
119 const store = createSessionStore();
120 const active = account({ account: '0x1', username: 'active-one' });
121 const other = account({ account: '0x2', username: 'other' });
122 store.setActive(active);
123 store.addKnown(other);
124
125 store.removeAccount(other.account, other.chainId);
126
127 expect(store.getState().active).toBe(active);
128 expect(store.getState().known).toEqual([active]);
129 });
130
131 it('clearAll wipes known, active, and persisted storage', () => {
132 const store = createSessionStore();
133 store.setActive(account());
134 store.clearAll();
135
136 expect(store.getState().known).toEqual([]);
137 expect(store.getState().active).toBeNull();
138 expect((globalThis as any).localStorage.length).toBe(0);
139 });
140});
141
142describe('createSessionStore: counters', () => {
143 it('lastSyncCounters defaults to zero for an unknown account', () => {
144 const store = createSessionStore();
145 expect(store.lastSyncCounters('0xnope', 1116)).toEqual({ lastPatchIndex: 0, lastRestorationIndex: 0 });
146 });
147
148 it('setLastSavedPatchIndex updates only lastPatchIndex', () => {
149 const store = createSessionStore();
150 const a = account();
151 store.addKnown(a);
152 store.setLastSavedPatchIndex(a.account, a.chainId, 42);
153
154 expect(store.lastSyncCounters(a.account, a.chainId)).toEqual({ lastPatchIndex: 42, lastRestorationIndex: 0 });
155 });
156
157 it('setLastRestorationIndex updates only lastRestorationIndex', () => {
158 const store = createSessionStore();
159 const a = account();
160 store.addKnown(a);
161 store.setLastRestorationIndex(a.account, a.chainId, 7);
162
163 expect(store.lastSyncCounters(a.account, a.chainId)).toEqual({ lastPatchIndex: 0, lastRestorationIndex: 7 });
164 });
165});
166
167describe('createSessionStore: updateProjectVotes', () => {
168 it('accumulates votes at the correct (1-indexed) project slot', () => {
169 const store = createSessionStore();
170 const a = account();
171 store.addKnown(a);
172
173 store.updateProjectVotes(a.account, a.chainId, 1, 10);
174 store.updateProjectVotes(a.account, a.chainId, 1, 5);
175
176 expect(store.getState().known[0].donationsPerProject).toEqual([15]);
177 });
178
179 it('pads intermediate project slots with zero', () => {
180 const store = createSessionStore();
181 const a = account();
182 store.addKnown(a);
183
184 store.updateProjectVotes(a.account, a.chainId, 3, 20);
185
186 expect(store.getState().known[0].donationsPerProject).toEqual([0, 0, 20]);
187 });
188
189 it('ignores projectId 0 or negative (guards against a negative array index)', () => {
190 const store = createSessionStore();
191 const a = account();
192 store.addKnown(a);
193
194 store.updateProjectVotes(a.account, a.chainId, 0, 10);
195 store.updateProjectVotes(a.account, a.chainId, -1, 10);
196
197 expect(store.getState().known[0].donationsPerProject).toBeUndefined();
198 });
199
200 it('is a no-op for an account that is not known', () => {
201 const store = createSessionStore();
202 expect(() => store.updateProjectVotes('0xnope', 1116, 1, 10)).not.toThrow();
203 });
204});
205
206describe('createSessionStore: persist/load round trip', () => {
207 it('load() restores known accounts and re-selects the persisted active one', () => {
208 const store = createSessionStore();
209 const a = account({ account: '0x1' });
210 store.setActive(a);
211
212 const reloaded = createSessionStore();
213 reloaded.load();
214
215 expect(reloaded.getState().known).toHaveLength(1);
216 expect(reloaded.getState().active?.account.toLowerCase()).toBe(a.account.toLowerCase());
217 });
218
219 it('load() does not crash on corrupt JSON for a persisted account', () => {
220 (globalThis as any).localStorage.setItem('@the_library/web3-session:infos_web3_1116_0xbad', '{not json');
221 const store = createSessionStore();
222 expect(() => store.load()).not.toThrow();
223 expect(store.getState().known).toEqual([]);
224 });
225
226 it('load() migrates legacy infos_evm_ keys to the infos_web3_ segment', () => {
227 const legacyAccount = account({ account: '0x1' });
228 (globalThis as any).localStorage.setItem(
229 '@the_library/web3-session:infos_evm_1116_0x1',
230 JSON.stringify(legacyAccount)
231 );
232
233 const store = createSessionStore();
234 store.load();
235
236 expect(store.getState().known).toHaveLength(1);
237 expect(store.getState().known[0].account).toBe('0x1');
238 expect((globalThis as any).localStorage.getItem('@the_library/web3-session:infos_evm_1116_0x1')).toBeNull();
239 expect((globalThis as any).localStorage.getItem('@the_library/web3-session:infos_web3_1116_0x1')).not.toBeNull();
240 });
241
242 it('load() migrates multiple consecutive legacy keys without skipping any', () => {
243 const ls = (globalThis as any).localStorage as FakeLocalStorage;
244 for (const addr of ['0x1', '0x2', '0x3', '0x4']) {
245 ls.setItem(
246 `@the_library/web3-session:infos_evm_1116_${addr}`,
247 JSON.stringify(account({ account: addr }))
248 );
249 }
250
251 const store = createSessionStore();
252 store.load();
253
254 expect(store.getState().known.map((a) => a.account).sort()).toEqual(['0x1', '0x2', '0x3', '0x4']);
255 });
256});
257
258describe('createSessionStore: subscribe', () => {
259 it('notifies subscribers on mutation', () => {
260 const store = createSessionStore();
261 let calls = 0;
262 store.subscribe(() => { calls++; });
263
264 store.setActive(account());
265
266 expect(calls).toBe(1);
267 });
268
269 it('unsubscribe stops further notifications', () => {
270 const store = createSessionStore();
271 let calls = 0;
272 const unsubscribe = store.subscribe(() => { calls++; });
273 unsubscribe();
274
275 store.setActive(account());
276
277 expect(calls).toBe(0);
278 });
279
280 it('supports multiple independent subscribers', () => {
281 const store = createSessionStore();
282 let a = 0, b = 0;
283 store.subscribe(() => { a++; });
284 store.subscribe(() => { b++; });
285
286 store.setActive(account());
287
288 expect(a).toBe(1);
289 expect(b).toBe(1);
290 });
291});
292