📄 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