📄 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