📄 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