📄 src/web3/session/mutations.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4  getConnector: vi.fn(),
5  createWriteClient: vi.fn(),
6  syncAccount: vi.fn(),
7  emitWalletStateEvent: vi.fn(),
8  emitWalletError: vi.fn(),
9  serializePatches: vi.fn(),
10  getLevel: vi.fn(),
11  getNbPatches: vi.fn(),
12}));
13
14vi.mock('../wallet/registry', () => ({ getConnector: mocks.getConnector }));
15vi.mock('./writeClient', () => ({ createWriteClient: mocks.createWriteClient }));
16vi.mock('./sync', () => ({ syncAccount: mocks.syncAccount }));
17vi.mock('../events/wallet_events', () => ({
18  WalletEvents: {
19    emitWalletStateEvent: mocks.emitWalletStateEvent,
20    emitWalletError: mocks.emitWalletError,
21  },
22}));
23vi.mock('@the_library/public/models', () => ({
24  serializePatches: mocks.serializePatches,
25  getLevel: mocks.getLevel,
26}));
27vi.mock('../../models/indexdb/patch_storage', () => ({
28  getNbPatches: mocks.getNbPatches,
29}));
30
31import {
32  writeOperations,
33  saveMyLevel,
34  resetTransactionState,
35  handleWriteOperationErrors,
36} from './mutations';
37import { sessionStore } from './store';
38
39class FakeLocalStorage {
40  private map = new Map<string, string>();
41  get length() { return this.map.size; }
42  key(i: number): string | null { return Array.from(this.map.keys())[i] ?? null; }
43  getItem(k: string): string | null { return this.map.has(k) ? this.map.get(k)! : null; }
44  setItem(k: string, v: string): void { this.map.set(k, v); }
45  removeItem(k: string): void { this.map.delete(k); }
46  clear(): void { this.map.clear(); }
47}
48
49beforeEach(() => {
50  (globalThis as any).localStorage = new FakeLocalStorage();
51  sessionStore.clearAll();
52  Object.values(mocks).forEach((m) => m.mockReset());
53  resetTransactionState();
54  mocks.syncAccount.mockResolvedValue(undefined);
55  mocks.getConnector.mockReturnValue({ type: 'metamask' });
56});
57
58afterEach(() => {
59  resetTransactionState();
60  vi.useRealTimers();
61});
62
63function fakeWriteClient(overrides: any = {}) {
64  return {
65    factory: {
66      register: vi.fn().mockResolvedValue(undefined),
67      unregister: vi.fn().mockResolvedValue(undefined),
68      donateToProject: vi.fn().mockResolvedValue(undefined),
69      save: vi.fn().mockResolvedValue(undefined),
70      ...(overrides.factory ?? {}),
71    },
72    subscriptionManager: {
73      subscribeTo: vi.fn().mockResolvedValue(undefined),
74      unsubscribeFrom: vi.fn().mockResolvedValue(undefined),
75      ...(overrides.subscriptionManager ?? {}),
76    },
77  };
78}
79
80describe('writeOperations: happy path', () => {
81  it('retire() runs the full lifecycle: connector lookup, write, sync, and returns true', async () => {
82    const client = fakeWriteClient();
83    mocks.createWriteClient.mockResolvedValue(client);
84
85    const result = await writeOperations.retire('metamask', 1116, '0xACC');
86
87    expect(result).toBe(true);
88    expect(mocks.getConnector).toHaveBeenCalledWith('metamask');
89    expect(client.factory.unregister).toHaveBeenCalled();
90    expect(mocks.syncAccount).toHaveBeenCalledWith('0xACC', 1116, 'metamask', 'production', true);
91  });
92
93  it('save() advances the local lastPatchIndex counter by the number of new actions', async () => {
94    const client = fakeWriteClient();
95    mocks.createWriteClient.mockResolvedValue(client);
96    sessionStore.addKnown({
97      account: '0xACC', username: 'alice', hasAccount: true, userId: 1,
98      lastPatchIndex: 5, lastRestorationIndex: 0, chainId: 1116, wallet: 'metamask',
99    });
100
101    await writeOperations.save('metamask', 1116, '0xACC', [1n, 2n, 3n], [], [], 2);
102
103    expect(sessionStore.lastSyncCounters('0xACC', 1116).lastPatchIndex).toBe(8);
104  });
105
106  it('save() does not touch lastPatchIndex when skipCounterUpdate is set', async () => {
107    const client = fakeWriteClient();
108    mocks.createWriteClient.mockResolvedValue(client);
109    sessionStore.addKnown({
110      account: '0xACC', username: 'alice', hasAccount: true, userId: 1,
111      lastPatchIndex: 5, lastRestorationIndex: 0, chainId: 1116, wallet: 'metamask',
112    });
113
114    await writeOperations.save('metamask', 1116, '0xACC', [1n, 2n, 3n], [], [], 2, { skipCounterUpdate: true });
115
116    expect(sessionStore.lastSyncCounters('0xACC', 1116).lastPatchIndex).toBe(5);
117  });
118
119  it('throws a clear error when no connector is registered for the wallet type', async () => {
120    mocks.getConnector.mockReturnValue(undefined);
121    await expect(writeOperations.retire('metamask', 1116, '0xACC')).rejects.toThrow(
122      "No connector registered for wallet 'metamask'"
123    );
124  });
125});
126
127describe('writeOperations: concurrency guard', () => {
128  it('rejects a second write attempt started while the first is still in flight', async () => {
129    let resolveWrite!: () => void;
130    const client = fakeWriteClient({
131      factory: { unregister: vi.fn(() => new Promise<void>((r) => { resolveWrite = r; })) },
132    });
133    mocks.createWriteClient.mockResolvedValue(client);
134
135    const first = writeOperations.retire('metamask', 1116, '0xACC');
136    const second = writeOperations.retire('metamask', 1116, '0xACC');
137
138    await expect(second).resolves.toBe(false);
139    expect(mocks.createWriteClient).toHaveBeenCalledTimes(1);
140
141    resolveWrite();
142    await expect(first).resolves.toBe(true);
143  });
144
145  it('allows a new write immediately after the previous one completes', async () => {
146    const client = fakeWriteClient();
147    mocks.createWriteClient.mockResolvedValue(client);
148
149    await writeOperations.retire('metamask', 1116, '0xACC');
150    const second = await writeOperations.retire('metamask', 1116, '0xACC');
151
152    expect(second).toBe(true);
153    expect(mocks.createWriteClient).toHaveBeenCalledTimes(2);
154  });
155
156  it('releases the lock immediately when the write itself throws, instead of waiting out the timeout', async () => {
157    const client = fakeWriteClient({
158      factory: { unregister: vi.fn().mockRejectedValue(new Error('rpc rejected')) },
159    });
160    mocks.createWriteClient.mockResolvedValue(client);
161
162    await expect(writeOperations.retire('metamask', 1116, '0xACC')).rejects.toThrow('rpc rejected');
163
164    // Lock must already be free — a subsequent call should proceed, not be
165    // silently skipped as "already running".
166    mocks.createWriteClient.mockResolvedValue(fakeWriteClient());
167    const result = await writeOperations.retire('metamask', 1116, '0xACC');
168    expect(result).toBe(true);
169  });
170
171  it('bypasses a stuck lock once the 60s timeout has elapsed', async () => {
172    vi.useFakeTimers();
173    const neverResolves = new Promise<void>(() => {});
174    mocks.createWriteClient.mockResolvedValueOnce(fakeWriteClient({ factory: { unregister: vi.fn(() => neverResolves) } }));
175
176    void writeOperations.retire('metamask', 1116, '0xACC'); // never settles — simulates a hung transaction
177
178    await vi.advanceTimersByTimeAsync(1000 * 60 + 1);
179
180    mocks.createWriteClient.mockResolvedValueOnce(fakeWriteClient());
181    const secondPromise = writeOperations.retire('metamask', 1116, '0xACC');
182    await vi.advanceTimersByTimeAsync(0);
183
184    expect(mocks.createWriteClient).toHaveBeenCalledTimes(2);
185    await expect(secondPromise).resolves.toBe(true);
186  });
187
188  it('does not bypass the lock before the 60s timeout has elapsed', async () => {
189    vi.useFakeTimers();
190    const neverResolves = new Promise<void>(() => {});
191    mocks.createWriteClient.mockResolvedValueOnce(fakeWriteClient({ factory: { unregister: vi.fn(() => neverResolves) } }));
192
193    void writeOperations.retire('metamask', 1116, '0xACC');
194
195    await vi.advanceTimersByTimeAsync(1000 * 59);
196
197    const secondResult = await writeOperations.retire('metamask', 1116, '0xACC');
198    expect(secondResult).toBe(false);
199    expect(mocks.createWriteClient).toHaveBeenCalledTimes(1);
200  });
201});
202
203describe('handleWriteOperationErrors', () => {
204  it('classifies EIP-1193 code 4001 as a user rejection', () => {
205    handleWriteOperationErrors('metamask', 1116, { code: 4001 });
206    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
207  });
208
209  it('classifies ethers ACTION_REJECTED as a user rejection', () => {
210    handleWriteOperationErrors('metamask', 1116, { code: 'ACTION_REJECTED' });
211    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
212  });
213
214  it('classifies a lowercase "user rejected" message as a user rejection', () => {
215    handleWriteOperationErrors('metamask', 1116, { message: 'user rejected the transaction' });
216    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
217  });
218
219  it('classifies a real MetaMask-cased "User rejected" message as a user rejection (fixed)', () => {
220    // Regression test: MetaMask's actual rejection wording is capitalized
221    // ("User rejected the request."), with no code field guaranteed on every
222    // provider. The classifier must lowercase before matching.
223    handleWriteOperationErrors('metamask', 1116, { message: 'User rejected the request.' });
224    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
225  });
226
227  it('classifies "user denied" messages as a user rejection, matching normalizeError\'s coverage', () => {
228    handleWriteOperationErrors('metamask', 1116, { message: 'User denied transaction signature.' });
229    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
230  });
231
232  it('classifies "user cancelled" messages as a user rejection, matching normalizeError\'s coverage', () => {
233    handleWriteOperationErrors('metamask', 1116, { message: 'User Cancelled the request' });
234    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'USER_REJECTED' }));
235  });
236
237  it('does not misclassify an unrelated message that happens to contain neither rejection phrase', () => {
238    handleWriteOperationErrors('metamask', 1116, { message: 'Nonce too low' });
239    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'UNKNOWN_ERROR' }));
240  });
241
242  it('does not throw when the error has no message property at all', () => {
243    expect(() => handleWriteOperationErrors('metamask', 1116, { code: 'SOMETHING' })).not.toThrow();
244    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'SOMETHING' }));
245  });
246
247  it('resets the transaction lock so a subsequent write is not blocked', async () => {
248    handleWriteOperationErrors('metamask', 1116, new Error('boom'));
249
250    const client = fakeWriteClient();
251    mocks.createWriteClient.mockResolvedValue(client);
252    const result = await writeOperations.retire('metamask', 1116, '0xACC');
253
254    expect(result).toBe(true);
255  });
256
257  it('falls back to UNKNOWN_ERROR when the error has neither a rejection code nor message', () => {
258    handleWriteOperationErrors('metamask', 1116, {});
259    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'UNKNOWN_ERROR' }));
260  });
261
262  it('uses the error code as the reason when it is not a rejection code', () => {
263    handleWriteOperationErrors('metamask', 1116, { code: 'INSUFFICIENT_FUNDS' });
264    expect(mocks.emitWalletError).toHaveBeenCalledWith(expect.objectContaining({ reason: 'INSUFFICIENT_FUNDS' }));
265  });
266});
267
268describe('saveMyLevel', () => {
269  it('returns false without writing when there are no new local patches', async () => {
270    sessionStore.addKnown({
271      account: '0xACC', username: 'alice', hasAccount: true, userId: 1,
272      lastPatchIndex: 5, lastRestorationIndex: 0, chainId: 1116, wallet: 'metamask',
273    });
274    mocks.getNbPatches.mockResolvedValue(5);
275
276    const result = await saveMyLevel('metamask', 1116, '0xACC');
277
278    expect(result).toBe(false);
279    expect(mocks.createWriteClient).not.toHaveBeenCalled();
280  });
281
282  it('returns false when local patch count is behind the last saved index (should never happen, but must not underflow)', async () => {
283    sessionStore.addKnown({
284      account: '0xACC', username: 'alice', hasAccount: true, userId: 1,
285      lastPatchIndex: 10, lastRestorationIndex: 0, chainId: 1116, wallet: 'metamask',
286    });
287    mocks.getNbPatches.mockResolvedValue(3);
288
289    const result = await saveMyLevel('metamask', 1116, '0xACC');
290
291    expect(result).toBe(false);
292  });
293
294  it('serializes and pushes only the new patches when there is unsaved local state', async () => {
295    sessionStore.addKnown({
296      account: '0xACC', username: 'alice', hasAccount: true, userId: 1,
297      lastPatchIndex: 2, lastRestorationIndex: 0, chainId: 1116, wallet: 'metamask',
298    });
299    mocks.getNbPatches.mockResolvedValue(5);
300    mocks.serializePatches.mockResolvedValue({ patches: [9n], textIndex: [1], textData: ['x'] });
301    mocks.getLevel.mockReturnValue({ level: 3 });
302    const client = fakeWriteClient();
303    mocks.createWriteClient.mockResolvedValue(client);
304
305    const result = await saveMyLevel('metamask', 1116, '0xACC');
306
307    expect(mocks.serializePatches).toHaveBeenCalledWith(2, 5);
308    expect(client.factory.save).toHaveBeenCalledWith([9n], 3, [1], ['x'], undefined);
309    expect(result).toBe(true);
310  });
311});
312