1+ import { flushMicrotasks } from '@sim/testing/helpers/async'
12import { dbChainMockFns , resetDbChainMock } from '@sim/testing/mocks/database.mock'
23import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
34import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
45
5- const { redisDelMock, redisEvalMock } = vi . hoisted ( ( ) => ( {
6+ const { redisDelMock, redisEvalMock, redisGetMock } = vi . hoisted ( ( ) => ( {
67 redisDelMock : vi . fn ( ) ,
78 redisEvalMock : vi . fn ( ) ,
9+ redisGetMock : vi . fn ( ) ,
810} ) )
911
1012vi . mock ( '@/lib/core/storage' , ( ) => ( {
@@ -18,11 +20,16 @@ vi.mock('@/lib/core/storage', () => ({
1820import { RetryableSetupError } from '@/lib/core/errors/retryable-infrastructure'
1921import {
2022 IdempotencyService ,
23+ pollingIdempotency ,
2124 WEBHOOK_IN_PROGRESS_LEASE_SECONDS ,
2225 webhookIdempotency ,
2326} from '@/lib/core/idempotency/service'
2427
25- redisConfigMockFns . mockGetRedisClient . mockReturnValue ( { del : redisDelMock , eval : redisEvalMock } )
28+ redisConfigMockFns . mockGetRedisClient . mockReturnValue ( {
29+ del : redisDelMock ,
30+ eval : redisEvalMock ,
31+ get : redisGetMock ,
32+ } )
2633
2734const SEVEN_DAYS_SECONDS = 60 * 60 * 24 * 7
2835
@@ -32,6 +39,8 @@ afterEach(() => {
3239
3340beforeEach ( ( ) => {
3441 resetDbChainMock ( )
42+ redisEvalMock . mockReset ( )
43+ redisGetMock . mockReset ( )
3544} )
3645
3746describe ( 'IdempotencyService.createWebhookIdempotencyKey' , ( ) => {
@@ -223,26 +232,41 @@ describe('IdempotencyService in-progress deadlines', () => {
223232 expect ( WEBHOOK_IN_PROGRESS_LEASE_SECONDS ) . toBeLessThan ( SEVEN_DAYS_SECONDS )
224233 } )
225234
226- it ( 'leases an untimed webhook claim for the bounded lease, not the seven-day dedupe window' , async ( ) => {
227- vi . useFakeTimers ( )
228- vi . setSystemTime ( new Date ( '2026-08-03T12:00:00.000Z' ) )
229- redisEvalMock . mockResolvedValue ( [ 1 , '' ] )
230-
231- await webhookIdempotency . atomicallyClaim ( 'gmail' , 'wh_1:untimed-delivery' )
232-
233- const [ , , redisKey , serialized , ttlSeconds ] = redisEvalMock . mock . calls [ 0 ] as [
234- string ,
235- number ,
236- string ,
237- string ,
238- number ,
239- ]
240- expect ( redisKey ) . toBe ( 'idempotency:webhook:gmail:wh_1:untimed-delivery' )
241- expect ( ttlSeconds ) . toBe ( WEBHOOK_IN_PROGRESS_LEASE_SECONDS )
242- expect ( JSON . parse ( serialized ) . inProgressExpiresAt ) . toBe (
243- Date . now ( ) + WEBHOOK_IN_PROGRESS_LEASE_SECONDS * 1000
244- )
245- } )
235+ it . each ( [
236+ {
237+ name : 'webhook' ,
238+ service : webhookIdempotency ,
239+ leaseSeconds : WEBHOOK_IN_PROGRESS_LEASE_SECONDS ,
240+ dedupeSeconds : SEVEN_DAYS_SECONDS ,
241+ } ,
242+ {
243+ name : 'polling' ,
244+ service : pollingIdempotency ,
245+ leaseSeconds : 5 * 60 ,
246+ dedupeSeconds : 60 * 60 * 24 * 3 ,
247+ } ,
248+ ] ) (
249+ 'leases an untimed $name claim for its bounded lease so a crashed holder frees the key, not the dedupe window' ,
250+ async ( { name, service, leaseSeconds, dedupeSeconds } ) => {
251+ vi . useFakeTimers ( )
252+ vi . setSystemTime ( new Date ( '2026-08-03T12:00:00.000Z' ) )
253+ redisEvalMock . mockResolvedValue ( [ 1 , '' ] )
254+
255+ await service . atomicallyClaim ( 'gmail' , 'wh_1:untimed-delivery' )
256+
257+ const [ , , redisKey , serialized , ttlSeconds ] = redisEvalMock . mock . calls [ 0 ] as [
258+ string ,
259+ number ,
260+ string ,
261+ string ,
262+ number ,
263+ ]
264+ expect ( redisKey ) . toBe ( `idempotency:${ name } :gmail:wh_1:untimed-delivery` )
265+ expect ( ttlSeconds ) . toBe ( leaseSeconds )
266+ expect ( ttlSeconds ) . toBeLessThan ( dedupeSeconds )
267+ expect ( JSON . parse ( serialized ) . inProgressExpiresAt ) . toBe ( Date . now ( ) + leaseSeconds * 1000 )
268+ }
269+ )
246270
247271 it ( 'keeps the seven-day dedupe window on a completed webhook result' , async ( ) => {
248272 redisEvalMock . mockResolvedValueOnce ( [ 1 , '' ] ) . mockResolvedValueOnce ( 1 )
@@ -305,6 +329,78 @@ describe('IdempotencyService in-progress deadlines', () => {
305329 } )
306330} )
307331
332+ describe ( 'IdempotencyService duplicate of an in-progress operation' , ( ) => {
333+ const liveClaim = ( ) =>
334+ JSON . stringify ( {
335+ success : false ,
336+ status : 'in-progress' ,
337+ startedAt : Date . now ( ) ,
338+ inProgressExpiresAt : Date . now ( ) + WEBHOOK_IN_PROGRESS_LEASE_SECONDS * 1000 ,
339+ claimToken : 'other-holder' ,
340+ } )
341+
342+ it ( 'returns in-progress at once instead of polling the live holder when skipping' , async ( ) => {
343+ vi . useFakeTimers ( )
344+ vi . setSystemTime ( new Date ( '2026-08-03T12:00:00.000Z' ) )
345+ const holder = liveClaim ( )
346+ redisEvalMock . mockResolvedValueOnce ( [ 0 , holder ] )
347+ redisGetMock . mockResolvedValue ( holder )
348+ const operation = vi . fn ( )
349+ const settled = vi . fn ( )
350+
351+ void webhookIdempotency
352+ . executeOrSkipInProgress ( 'gmail' , 'wh_1:running-delivery' , operation )
353+ . then ( settled , settled )
354+ await flushMicrotasks ( 10 )
355+
356+ expect ( settled ) . toHaveBeenCalledExactlyOnceWith ( { outcome : 'in-progress' } )
357+ expect ( operation ) . not . toHaveBeenCalled ( )
358+ expect ( redisGetMock ) . not . toHaveBeenCalled ( )
359+ } )
360+
361+ it ( 'still replays a completed result and rethrows a failed one when skipping' , async ( ) => {
362+ const service = new IdempotencyService ( { forceStorage : 'redis' } )
363+ redisEvalMock
364+ . mockResolvedValueOnce ( [
365+ 0 ,
366+ JSON . stringify ( { success : true , status : 'completed' , result : 'first-run' } ) ,
367+ ] )
368+ . mockResolvedValueOnce ( [
369+ 0 ,
370+ JSON . stringify ( { success : false , status : 'failed' , error : 'first run failed' } ) ,
371+ ] )
372+
373+ await expect ( service . executeOrSkipInProgress ( 'provider' , 'done' , vi . fn ( ) ) ) . resolves . toEqual ( {
374+ outcome : 'resolved' ,
375+ result : 'first-run' ,
376+ } )
377+ await expect ( service . executeOrSkipInProgress ( 'provider' , 'failed' , vi . fn ( ) ) ) . rejects . toThrow (
378+ 'first run failed'
379+ )
380+ } )
381+
382+ it ( 'keeps waiting for the live holder by default, so a Stripe-style caller never acknowledges early' , async ( ) => {
383+ vi . useFakeTimers ( )
384+ vi . setSystemTime ( new Date ( '2026-08-03T12:00:00.000Z' ) )
385+ const service = new IdempotencyService ( { forceStorage : 'redis' } )
386+ const holder = liveClaim ( )
387+ redisEvalMock . mockResolvedValueOnce ( [ 0 , holder ] )
388+ redisGetMock
389+ . mockResolvedValueOnce ( holder )
390+ . mockResolvedValueOnce (
391+ JSON . stringify ( { success : true , status : 'completed' , result : 'holder-result' } )
392+ )
393+ const settled = vi . fn ( )
394+
395+ void service . executeWithIdempotency ( 'stripe' , 'evt_1' , vi . fn ( ) ) . then ( settled , settled )
396+ await flushMicrotasks ( 10 )
397+ expect ( settled ) . not . toHaveBeenCalled ( )
398+
399+ await vi . advanceTimersByTimeAsync ( 1_000 )
400+ expect ( settled ) . toHaveBeenCalledExactlyOnceWith ( 'holder-result' )
401+ } )
402+ } )
403+
308404describe ( 'IdempotencyService retryable setup failures' , ( ) => {
309405 it ( 'releases the claim instead of memoizing when the operation throws a RetryableSetupError' , async ( ) => {
310406 redisEvalMock . mockResolvedValueOnce ( [ 1 , '' ] ) . mockResolvedValueOnce ( 1 )
0 commit comments