@@ -3,6 +3,15 @@ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
33import { useIdempotentAction } from '../useIdempotentAction' ;
44
55describe ( '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} ) ;
0 commit comments