📄 src/web3/session/sync.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4  getReadClient: vi.fn(),
5  getSubscriptionClient: vi.fn(),
6  cursorStoreGet: vi.fn(),
7  cursorStoreSet: vi.fn(),
8  getItem: vi.fn(),
9  setItem: vi.fn(),
10  emitWalletStateEvent: vi.fn(),
11  emitSubscriptionPatchesSynced: vi.fn(),
12  emitSaveRestored: vi.fn(),
13  deserializePatches: vi.fn(),
14  applyRestoredPatches: vi.fn(),
15  getAllPatchesByUsername: vi.fn(),
16  getNbPatches: vi.fn(),
17}));
18
19vi.mock('../client/registry', () => ({ getReadClient: mocks.getReadClient }));
20vi.mock('../subscription/registry', () => ({ getSubscriptionClient: mocks.getSubscriptionClient }));
21vi.mock('../subscription/sync-cursor', () => ({
22  cursorStore: { get: mocks.cursorStoreGet, set: mocks.cursorStoreSet },
23}));
24vi.mock('./storage', () => ({ getItem: mocks.getItem, setItem: mocks.setItem }));
25vi.mock('../events/wallet_events', () => ({
26  WalletEvents: {
27    emitWalletStateEvent: mocks.emitWalletStateEvent,
28    emitSubscriptionPatchesSynced: mocks.emitSubscriptionPatchesSynced,
29    emitSaveRestored: mocks.emitSaveRestored,
30  },
31}));
32vi.mock('@the_library/public/models', () => ({
33  deserializePatches: mocks.deserializePatches,
34  applyRestoredPatches: mocks.applyRestoredPatches,
35  CURRENT_VERSION: 1,
36}));
37vi.mock('../../models/indexdb/patch_storage', () => ({
38  getAllPatchesByUsername: mocks.getAllPatchesByUsername,
39  getNbPatches: mocks.getNbPatches,
40}));
41
42import { syncAccount, restoreAccountSave, hasUnsavedPatches, loadNetworkInfos } from './sync';
43import { sessionStore } from './store';
44
45class FakeLocalStorage {
46  private map = new Map<string, string>();
47  get length() { return this.map.size; }
48  key(i: number): string | null { return Array.from(this.map.keys())[i] ?? null; }
49  getItem(k: string): string | null { return this.map.has(k) ? this.map.get(k)! : null; }
50  setItem(k: string, v: string): void { this.map.set(k, v); }
51  removeItem(k: string): void { this.map.delete(k); }
52  clear(): void { this.map.clear(); }
53}
54
55let nextChainId = 100000;
56function freshChainId() { return nextChainId++; }
57
58beforeEach(() => {
59  (globalThis as any).localStorage = new FakeLocalStorage();
60  sessionStore.clearAll();
61  Object.values(mocks).forEach((m) => m.mockReset());
62  mocks.cursorStoreGet.mockResolvedValue(undefined);
63  mocks.getSubscriptionClient.mockReturnValue({
64    sync: vi.fn().mockResolvedValue({ patches: [], cursor: { rootUserId: 1, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() }, truncated: false }),
65    syncSince: vi.fn().mockResolvedValue({ patches: [], cursor: { rootUserId: 1, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() }, truncated: false }),
66  });
67});
68
69function fakeClient(overrides: any = {}) {
70  return {
71    ready: Promise.resolve(),
72    bouncerStorage: {
73      hasAccount: vi.fn().mockResolvedValue(true),
74      userInfos: vi.fn().mockResolvedValue(['alice', 'AU', 'en', 42]),
75      userIdToUsername: vi.fn().mockResolvedValue('alice'),
76      ...(overrides.bouncerStorage ?? {}),
77    },
78    projectManagerStorage: {
79      balanceOfAddress: vi.fn().mockResolvedValue({ total: 10, donations: 3, unspent: 7 }),
80      addressDonationsPerProject: vi.fn().mockResolvedValue([1, 2]),
81      ...(overrides.projectManagerStorage ?? {}),
82    },
83    archivistStorage: {
84      userLevel: vi.fn().mockResolvedValue(5),
85      ...(overrides.archivistStorage ?? {}),
86    },
87    factory: {
88      nbWritesByUsername: vi.fn().mockResolvedValue(0),
89      loadUsernameSince: vi.fn().mockResolvedValue([[], [], []]),
90      ...(overrides.factory ?? {}),
91    },
92  };
93}
94
95describe('syncAccount', () => {
96  it('hydrates a new account and stores it in sessionStore', async () => {
97    const chainId = freshChainId();
98    const client = fakeClient();
99    mocks.getReadClient.mockReturnValue(client);
100
101    const account = await syncAccount('0xABC', chainId, 'metamask');
102
103    expect(account.hasAccount).toBe(true);
104    expect(account.username).toBe('alice');
105    expect(account.userId).toBe(42);
106    expect(account.projectBalance).toEqual({ contributions: 10, allocated: 3, remaining: 7 });
107    expect(account.level).toBe(5);
108    expect(account.donationsPerProject).toEqual([1, 2]);
109    expect(account.lastUpdated).toBeTypeOf('number');
110    expect(sessionStore.getState().known).toHaveLength(1);
111  });
112
113  it('returns the cached account without re-fetching when already hydrated and forceRefresh is false', async () => {
114    const chainId = freshChainId();
115    const client = fakeClient();
116    mocks.getReadClient.mockReturnValue(client);
117
118    await syncAccount('0xABC', chainId, 'metamask');
119    client.bouncerStorage.hasAccount.mockClear();
120
121    const account = await syncAccount('0xABC', chainId, 'metamask');
122
123    expect(client.bouncerStorage.hasAccount).not.toHaveBeenCalled();
124    expect(account.username).toBe('alice');
125  });
126
127  it('re-fetches when forceRefresh is true even if already cached', async () => {
128    const chainId = freshChainId();
129    const client = fakeClient();
130    mocks.getReadClient.mockReturnValue(client);
131
132    await syncAccount('0xABC', chainId, 'metamask');
133    client.bouncerStorage.hasAccount.mockClear();
134
135    await syncAccount('0xABC', chainId, 'metamask', 'production', true);
136
137    expect(client.bouncerStorage.hasAccount).toHaveBeenCalledTimes(1);
138  });
139
140  it('defaults level to 1 when archivistStorage.userLevel throws', async () => {
141    const chainId = freshChainId();
142    const client = fakeClient({ archivistStorage: { userLevel: vi.fn().mockRejectedValue(new Error('rpc fail')) } });
143    mocks.getReadClient.mockReturnValue(client);
144
145    const account = await syncAccount('0xABC', chainId, 'metamask');
146
147    expect(account.level).toBe(1);
148  });
149
150  it('defaults donationsPerProject to [] when the call throws', async () => {
151    const chainId = freshChainId();
152    const client = fakeClient({
153      projectManagerStorage: {
154        balanceOfAddress: vi.fn().mockResolvedValue({ total: 0, donations: 0, unspent: 0 }),
155        addressDonationsPerProject: vi.fn().mockRejectedValue(new Error('rpc fail')),
156      },
157    });
158    mocks.getReadClient.mockReturnValue(client);
159
160    const account = await syncAccount('0xABC', chainId, 'metamask');
161
162    expect(account.donationsPerProject).toEqual([]);
163  });
164
165  it('does not fetch account details when the address has no on-chain account', async () => {
166    const chainId = freshChainId();
167    const client = fakeClient({ bouncerStorage: { hasAccount: vi.fn().mockResolvedValue(false) } });
168    mocks.getReadClient.mockReturnValue(client);
169
170    const account = await syncAccount('0xABC', chainId, 'metamask');
171
172    expect(account.hasAccount).toBe(false);
173    expect(account.username).toBe('');
174    expect(client.projectManagerStorage.balanceOfAddress).not.toHaveBeenCalled();
175    expect(mocks.getSubscriptionClient).not.toHaveBeenCalled();
176  });
177
178  it('applies subscription patches and reports appliedCount from the index delta', async () => {
179    const chainId = freshChainId();
180    const client = fakeClient();
181    mocks.getReadClient.mockReturnValue(client);
182    mocks.getSubscriptionClient.mockReturnValue({
183      sync: vi.fn().mockResolvedValue({
184        patches: [{ userId: 7, fromIndex: 2, toIndex: 5, raw: { compressedActions: [1n, 2n, 3n], indexes: [], stringIndexes: [] } }],
185        cursor: { rootUserId: 42, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() },
186        truncated: false,
187      }),
188    });
189    mocks.getAllPatchesByUsername.mockResolvedValue([]);
190
191    await syncAccount('0xABC', chainId, 'metamask');
192
193    expect(mocks.deserializePatches).toHaveBeenCalledWith('alice', expect.objectContaining({ patches: [1n, 2n, 3n] }));
194    expect(mocks.applyRestoredPatches).toHaveBeenCalled();
195    expect(mocks.cursorStoreSet).toHaveBeenCalledWith(42, expect.any(Object));
196    expect(mocks.emitSubscriptionPatchesSynced).toHaveBeenCalledWith(
197      expect.objectContaining({ rootUserId: 42, appliedCount: 3 })
198    );
199  });
200
201  it('skips a subscription patch whose userId has no resolvable username', async () => {
202    const chainId = freshChainId();
203    const client = fakeClient({ bouncerStorage: { userIdToUsername: vi.fn().mockResolvedValue('') } });
204    mocks.getReadClient.mockReturnValue(client);
205    mocks.getSubscriptionClient.mockReturnValue({
206      sync: vi.fn().mockResolvedValue({
207        patches: [{ userId: 7, fromIndex: 0, toIndex: 3, raw: {} }],
208        cursor: { rootUserId: 42, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() },
209        truncated: false,
210      }),
211    });
212
213    await syncAccount('0xABC', chainId, 'metamask');
214
215    expect(mocks.deserializePatches).not.toHaveBeenCalled();
216    expect(mocks.emitSubscriptionPatchesSynced).toHaveBeenCalledWith(expect.objectContaining({ appliedCount: 0 }));
217  });
218
219  it('one failing patch does not prevent other patches in the same batch from being applied', async () => {
220    const chainId = freshChainId();
221    const client = fakeClient({
222      bouncerStorage: {
223        hasAccount: vi.fn().mockResolvedValue(true),
224        userInfos: vi.fn().mockResolvedValue(['alice', 'AU', 'en', 42]),
225        userIdToUsername: vi.fn()
226          .mockResolvedValueOnce('bob')
227          .mockResolvedValueOnce('carol'),
228      },
229    });
230    mocks.getReadClient.mockReturnValue(client);
231    mocks.deserializePatches.mockRejectedValueOnce(new Error('bad patch')).mockResolvedValue(undefined);
232    mocks.getSubscriptionClient.mockReturnValue({
233      sync: vi.fn().mockResolvedValue({
234        patches: [
235          { userId: 1, fromIndex: 0, toIndex: 1, raw: { compressedActions: [1n], indexes: [], stringIndexes: [] } },
236          { userId: 2, fromIndex: 0, toIndex: 1, raw: { compressedActions: [2n], indexes: [], stringIndexes: [] } },
237        ],
238        cursor: { rootUserId: 42, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() },
239        truncated: false,
240      }),
241    });
242    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
243
244    await syncAccount('0xABC', chainId, 'metamask');
245
246    expect(mocks.deserializePatches).toHaveBeenCalledTimes(2);
247    expect(mocks.cursorStoreSet).toHaveBeenCalled();
248    warnSpy.mockRestore();
249  });
250
251  it('uses syncSince with the persisted cursor instead of a full sync when a cursor already exists', async () => {
252    const chainId = freshChainId();
253    const client = fakeClient();
254    mocks.getReadClient.mockReturnValue(client);
255    const existingCursor = { rootUserId: 42, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() };
256    mocks.cursorStoreGet.mockResolvedValue(existingCursor);
257    const sync = vi.fn().mockResolvedValue({ patches: [], cursor: existingCursor, truncated: false });
258    const syncSince = vi.fn().mockResolvedValue({ patches: [], cursor: existingCursor, truncated: false });
259    mocks.getSubscriptionClient.mockReturnValue({ sync, syncSince });
260
261    await syncAccount('0xABC', chainId, 'metamask');
262
263    expect(syncSince).toHaveBeenCalledWith(existingCursor);
264    expect(sync).not.toHaveBeenCalled();
265  });
266
267  it('dedupes concurrent subscription syncs for the same userId+chainId to a single call', async () => {
268    const chainId = freshChainId();
269    const client = fakeClient();
270    mocks.getReadClient.mockReturnValue(client);
271    const sync = vi.fn().mockResolvedValue({ patches: [], cursor: { rootUserId: 42, knownNodes: new Map(), countSnapshot: new Map(), patchIndex: new Map() }, truncated: false });
272    mocks.getSubscriptionClient.mockReturnValue({ sync });
273
274    await Promise.all([
275      syncAccount('0xABC', chainId, 'metamask', 'production', true),
276      syncAccount('0xABC', chainId, 'metamask', 'production', true),
277    ]);
278
279    expect(sync).toHaveBeenCalledTimes(1);
280  });
281});
282
283describe('restoreAccountSave', () => {
284  it('returns false for an address not known to the session store', async () => {
285    const result = await restoreAccountSave('0xUNKNOWN', freshChainId());
286    expect(result).toBe(false);
287  });
288
289  it('returns false for a known account that has no on-chain registration', async () => {
290    const chainId = freshChainId();
291    sessionStore.addKnown({
292      account: '0xABC', username: '', hasAccount: false, userId: 0,
293      lastPatchIndex: 0, lastRestorationIndex: 0, chainId, wallet: 'metamask',
294    });
295
296    const result = await restoreAccountSave('0xABC', chainId);
297    expect(result).toBe(false);
298  });
299
300  it('returns true without applying anything when already up to date', async () => {
301    const chainId = freshChainId();
302    sessionStore.addKnown({
303      account: '0xABC', username: 'alice', hasAccount: true, userId: 42,
304      lastPatchIndex: 0, lastRestorationIndex: 5, chainId, wallet: 'metamask',
305    });
306    const client = fakeClient({ factory: { nbWritesByUsername: vi.fn().mockResolvedValue(5) } });
307    mocks.getReadClient.mockReturnValue(client);
308
309    const result = await restoreAccountSave('0xABC', chainId);
310
311    expect(result).toBe(true);
312    expect(mocks.emitSaveRestored).not.toHaveBeenCalled();
313  });
314
315  it('applies new writes and advances lastRestorationIndex when the chain is ahead', async () => {
316    const chainId = freshChainId();
317    sessionStore.addKnown({
318      account: '0xABC', username: 'alice', hasAccount: true, userId: 42,
319      lastPatchIndex: 0, lastRestorationIndex: 2, chainId, wallet: 'metamask',
320    });
321    const client = fakeClient({
322      factory: {
323        nbWritesByUsername: vi.fn().mockResolvedValue(5),
324        loadUsernameSince: vi.fn().mockResolvedValue([[1n, 2n, 3n], [1n, 2n, 3n], []]),
325      },
326    });
327    mocks.getReadClient.mockReturnValue(client);
328    mocks.getAllPatchesByUsername.mockResolvedValue([]);
329
330    const result = await restoreAccountSave('0xABC', chainId);
331
332    expect(result).toBe(true);
333    expect(mocks.deserializePatches).toHaveBeenCalledWith('mine', expect.objectContaining({ patches: [1n, 2n, 3n] }));
334    expect(sessionStore.lastSyncCounters('0xABC', chainId).lastRestorationIndex).toBe(5);
335    expect(mocks.emitSaveRestored).toHaveBeenCalledWith(expect.objectContaining({ newRestorationIndex: 5, appliedCount: 3 }));
336  });
337
338  it('returns false when the on-chain read throws', async () => {
339    const chainId = freshChainId();
340    sessionStore.addKnown({
341      account: '0xABC', username: 'alice', hasAccount: true, userId: 42,
342      lastPatchIndex: 0, lastRestorationIndex: 2, chainId, wallet: 'metamask',
343    });
344    const client = fakeClient({ factory: { nbWritesByUsername: vi.fn().mockRejectedValue(new Error('rpc down')) } });
345    mocks.getReadClient.mockReturnValue(client);
346    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
347
348    const result = await restoreAccountSave('0xABC', chainId);
349
350    expect(result).toBe(false);
351    errorSpy.mockRestore();
352  });
353});
354
355describe('hasUnsavedPatches', () => {
356  it('is true when local patch count exceeds the last saved index', async () => {
357    const chainId = freshChainId();
358    sessionStore.addKnown({
359      account: '0xABC', username: 'alice', hasAccount: true, userId: 42,
360      lastPatchIndex: 3, lastRestorationIndex: 0, chainId, wallet: 'metamask',
361    });
362    mocks.getNbPatches.mockResolvedValue(5);
363
364    expect(await hasUnsavedPatches('0xABC', chainId)).toBe(true);
365  });
366
367  it('is false when local patch count matches the last saved index', async () => {
368    const chainId = freshChainId();
369    sessionStore.addKnown({
370      account: '0xABC', username: 'alice', hasAccount: true, userId: 42,
371      lastPatchIndex: 5, lastRestorationIndex: 0, chainId, wallet: 'metamask',
372    });
373    mocks.getNbPatches.mockResolvedValue(5);
374
375    expect(await hasUnsavedPatches('0xABC', chainId)).toBe(false);
376  });
377});
378
379describe('loadNetworkInfos', () => {
380  it('returns the cached value without hitting the chain when present', async () => {
381    const chainId = freshChainId();
382    mocks.getItem.mockReturnValue({ nbUsers: 1, nbLocations: 1, nbProjects: 1, locationStats: [] });
383
384    const result = await loadNetworkInfos(chainId);
385
386    expect(result).toEqual({ nbUsers: 1, nbLocations: 1, nbProjects: 1, locationStats: [] });
387    expect(mocks.getReadClient).not.toHaveBeenCalled();
388  });
389
390  it('fetches from chain and caches the result on a cache miss', async () => {
391    const chainId = freshChainId();
392    mocks.getItem.mockReturnValue(null);
393    const client = fakeClient({
394      bouncerStorage: {
395        getAllLocationCounts: vi.fn().mockResolvedValue([1, 2, 3]),
396        nbAccounts: vi.fn().mockResolvedValue(9),
397      },
398      projectManagerStorage: { nbProjects: vi.fn().mockResolvedValue(4) },
399    });
400    mocks.getReadClient.mockReturnValue(client);
401
402    const result = await loadNetworkInfos(chainId);
403
404    expect(result).toEqual({ nbUsers: 9, nbLocations: 3, nbProjects: 4, locationStats: [1, 2, 3] });
405    expect(mocks.setItem).toHaveBeenCalledWith(`networkInfos_${chainId}`, result);
406  });
407
408  it('bypasses the cache when forceRefresh is true even if a cached value exists', async () => {
409    const chainId = freshChainId();
410    mocks.getItem.mockReturnValue({ nbUsers: 999, nbLocations: 0, nbProjects: 0, locationStats: [] });
411    const client = fakeClient({
412      bouncerStorage: { getAllLocationCounts: vi.fn().mockResolvedValue([]), nbAccounts: vi.fn().mockResolvedValue(1) },
413      projectManagerStorage: { nbProjects: vi.fn().mockResolvedValue(0) },
414    });
415    mocks.getReadClient.mockReturnValue(client);
416
417    const result = await loadNetworkInfos(chainId, 'production', true);
418
419    expect(result.nbUsers).toBe(1);
420  });
421
422  it('uses a distinct cache key for staging vs production on the same chainId', async () => {
423    const chainId = freshChainId();
424    mocks.getItem.mockReturnValue(null);
425    const client = fakeClient({
426      bouncerStorage: { getAllLocationCounts: vi.fn().mockResolvedValue([]), nbAccounts: vi.fn().mockResolvedValue(0) },
427      projectManagerStorage: { nbProjects: vi.fn().mockResolvedValue(0) },
428    });
429    mocks.getReadClient.mockReturnValue(client);
430
431    await loadNetworkInfos(chainId, 'staging');
432
433    expect(mocks.getItem).toHaveBeenCalledWith(`networkInfos_${chainId}_staging`);
434    expect(mocks.setItem).toHaveBeenCalledWith(`networkInfos_${chainId}_staging`, expect.any(Object));
435  });
436});
437