Skip to content

Commit aa40e1f

Browse files
committed
test(frontend): cover idempotent action deduplication
1 parent 39a9065 commit aa40e1f

2 files changed

Lines changed: 116 additions & 7 deletions

File tree

Dechat/dex_with_fiat_frontend/src/hooks/__tests__/useIdempotentAction.test.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
33
import { useIdempotentAction } from '../useIdempotentAction';
44

55
describe('useIdempotentAction', () => {
6+
const deferred = <T,>() => {
7+
let resolve!: (value: T) => void;
8+
const promise = new Promise<T>((res) => {
9+
resolve = res;
10+
});
11+
12+
return { promise, resolve };
13+
};
14+
615
beforeEach(() => {
716
vi.clearAllMocks();
817
vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -66,6 +75,78 @@ describe('useIdempotentAction', () => {
6675
expect(mockAction).toHaveBeenCalledTimes(2);
6776
});
6877

78+
it("returns the first call's result for a second call with the same key", async () => {
79+
const { result } = renderHook(() =>
80+
useIdempotentAction({ cooldownMs: 0, logSuppressed: false }),
81+
);
82+
const pendingAction = deferred<string>();
83+
const mockAction = vi.fn(() => pendingAction.promise);
84+
85+
let firstExecution!: Promise<string | null>;
86+
let secondExecution!: Promise<string | null>;
87+
act(() => {
88+
firstExecution = result.current.execute(mockAction, 'shared_key');
89+
secondExecution = result.current.execute(mockAction, 'shared_key');
90+
});
91+
92+
await act(async () => {
93+
pendingAction.resolve('first-result');
94+
await expect(firstExecution).resolves.toBe('first-result');
95+
await expect(secondExecution).resolves.toBe('first-result');
96+
});
97+
98+
expect(mockAction).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('starts a fresh action for a third call after the first completes', async () => {
102+
const { result } = renderHook(() =>
103+
useIdempotentAction({ cooldownMs: 0, logSuppressed: false }),
104+
);
105+
const mockAction = vi
106+
.fn()
107+
.mockResolvedValueOnce('first-result')
108+
.mockResolvedValueOnce('fresh-result');
109+
110+
let firstResult!: string | null;
111+
await act(async () => {
112+
firstResult = await result.current.execute(mockAction, 'shared_key');
113+
});
114+
115+
let thirdResult!: string | null;
116+
await act(async () => {
117+
thirdResult = await result.current.execute(mockAction, 'shared_key');
118+
});
119+
120+
expect(firstResult).toBe('first-result');
121+
expect(thirdResult).toBe('fresh-result');
122+
expect(mockAction).toHaveBeenCalledTimes(2);
123+
});
124+
125+
it('does not let an error in the first call block a second call', async () => {
126+
const { result } = renderHook(() =>
127+
useIdempotentAction({ cooldownMs: 0, logSuppressed: false }),
128+
);
129+
const firstError = new Error('first failed');
130+
const mockAction = vi
131+
.fn()
132+
.mockRejectedValueOnce(firstError)
133+
.mockResolvedValueOnce('recovered');
134+
135+
await act(async () => {
136+
await expect(
137+
result.current.execute(mockAction, 'shared_key'),
138+
).rejects.toThrow('first failed');
139+
});
140+
141+
let secondResult!: string | null;
142+
await act(async () => {
143+
secondResult = await result.current.execute(mockAction, 'shared_key');
144+
});
145+
146+
expect(secondResult).toBe('recovered');
147+
expect(mockAction).toHaveBeenCalledTimes(2);
148+
});
149+
69150
it('should track isProcessing state correctly', async () => {
70151
const { result } = renderHook(() => useIdempotentAction());
71152
let resolveAction: (value: string) => void = () => {};
@@ -216,8 +297,9 @@ describe('useIdempotentAction', () => {
216297
);
217298
const mockAction = vi.fn().mockResolvedValue('success');
218299

300+
let results!: Array<string | null>;
219301
await act(async () => {
220-
await Promise.all([
302+
results = await Promise.all([
221303
result.current.execute(mockAction, 'button_click'),
222304
result.current.execute(mockAction, 'button_click'),
223305
result.current.execute(mockAction, 'button_click'),
@@ -227,9 +309,16 @@ describe('useIdempotentAction', () => {
227309
});
228310

229311
expect(mockAction).toHaveBeenCalledTimes(1);
312+
expect(results).toEqual([
313+
'success',
314+
'success',
315+
'success',
316+
'success',
317+
'success',
318+
]);
230319
});
231320

232-
it('should block submissions while processing', async () => {
321+
it('should dedupe submissions while processing', async () => {
233322
const { result } = renderHook(() => useIdempotentAction());
234323
let resolveAction: (value: string) => void = () => {};
235324
const mockAction = vi.fn(
@@ -248,17 +337,17 @@ describe('useIdempotentAction', () => {
248337
expect(result.current.isProcessing).toBe(true);
249338
});
250339

251-
let secondResult: string | null = null;
340+
let secondExecution!: Promise<string | null>;
252341
await act(async () => {
253-
secondResult = await result.current.execute(mockAction, 'test_action');
342+
secondExecution = result.current.execute(mockAction, 'test_action');
254343
});
255344

256345
await act(async () => {
257346
resolveAction('success');
258347
await firstExecution;
259348
});
260349

350+
await expect(secondExecution).resolves.toBe('success');
261351
expect(mockAction).toHaveBeenCalledTimes(1);
262-
expect(secondResult).toBeNull();
263352
});
264353
});

Dechat/dex_with_fiat_frontend/src/hooks/useIdempotentAction.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function useIdempotentAction(options: IdempotentActionOptions = {}) {
1919
// even when Date.now() returns 0 (e.g. with vi.useFakeTimers()).
2020
const lastExecutionTime = useRef(-(cooldownMs ?? 2000));
2121
const idempotencyKey = useRef<string>('');
22+
const inFlightActions = useRef(new Map<string, Promise<unknown>>());
2223
const isMountedRef = useRef(true);
2324

2425
useEffect(() => {
@@ -33,6 +34,22 @@ export function useIdempotentAction(options: IdempotentActionOptions = {}) {
3334
action: (idempotencyKey: string) => Promise<T>,
3435
actionName = 'action',
3536
): Promise<T | null> => {
37+
const inFlightAction = inFlightActions.current.get(actionName);
38+
if (inFlightAction) {
39+
if (logSuppressed) {
40+
console.warn(
41+
`[useIdempotentAction] Suppressed duplicate ${actionName} attempt`,
42+
{
43+
actionName,
44+
isProcessing: isProcessingRef.current,
45+
deduped: true,
46+
timestamp: new Date().toISOString(),
47+
},
48+
);
49+
}
50+
return inFlightAction as Promise<T>;
51+
}
52+
3653
const now = Date.now();
3754
const timeSinceLastExecution = now - lastExecutionTime.current;
3855

@@ -58,9 +75,11 @@ export function useIdempotentAction(options: IdempotentActionOptions = {}) {
5875
lastExecutionTime.current = now;
5976

6077
try {
61-
const result = await action(idempotencyKey.current);
62-
return result;
78+
const actionPromise = action(idempotencyKey.current);
79+
inFlightActions.current.set(actionName, actionPromise);
80+
return await actionPromise;
6381
} finally {
82+
inFlightActions.current.delete(actionName);
6483
isProcessingRef.current = false;
6584
if (isMountedRef.current) setIsProcessing(false);
6685
}
@@ -70,6 +89,7 @@ export function useIdempotentAction(options: IdempotentActionOptions = {}) {
7089

7190
const reset = useCallback(() => {
7291
isProcessingRef.current = false;
92+
inFlightActions.current.clear();
7393
if (isMountedRef.current) setIsProcessing(false);
7494
lastExecutionTime.current = -(cooldownMs ?? 2000);
7595
idempotencyKey.current = '';

0 commit comments

Comments
 (0)