11import { createMockRedis } from '@sim/testing'
22import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
33
4- const { mockEnv, MockRedisConstructor } = vi . hoisted ( ( ) => ( {
4+ const { mockEnv, MockRedisConstructor, mockLogger } = vi . hoisted ( ( ) => ( {
55 mockEnv : {
66 REDIS_URL : 'redis://localhost:6379' as string | undefined ,
77 REDIS_TLS_SERVERNAME : undefined as string | undefined ,
88 } ,
99 MockRedisConstructor : vi . fn ( ) ,
10+ mockLogger : {
11+ info : vi . fn ( ) ,
12+ warn : vi . fn ( ) ,
13+ error : vi . fn ( ) ,
14+ debug : vi . fn ( ) ,
15+ trace : vi . fn ( ) ,
16+ fatal : vi . fn ( ) ,
17+ } ,
1018} ) )
1119
1220const mockRedisInstance = createMockRedis ( )
@@ -20,6 +28,15 @@ MockRedisConstructor.mockImplementation(
2028
2129vi . unmock ( '@/lib/core/config/redis' )
2230vi . mock ( '@/lib/core/config/env' , ( ) => ( { env : mockEnv } ) )
31+ /** Overrides the global mock, whose `createLogger` returns a fresh spy per call,
32+ * so assertions can reach the instance this module captured at import. */
33+ vi . mock ( '@sim/logger' , ( ) => ( {
34+ createLogger : ( ) => mockLogger ,
35+ logger : mockLogger ,
36+ runWithRequestContext : < T > ( _ctx : unknown , fn : ( ) => T ) : T => fn ( ) ,
37+ getRequestContext : ( ) => undefined ,
38+ setRequestTraceId : ( ) => { } ,
39+ } ) )
2340vi . mock ( 'ioredis' , ( ) => ( {
2441 default : MockRedisConstructor ,
2542} ) )
@@ -383,6 +400,54 @@ describe('redis config', () => {
383400 expect ( await acquireLock ( lockKey , value , ttlSeconds ) ) . toBe ( true )
384401 expect ( mockRedisInstance . set ) . not . toHaveBeenCalled ( )
385402 } )
403+
404+ it ( 'pairs the failure with connection state so the cause is not left to timing' , async ( ) => {
405+ // The bare rejection carries only ioredis timer frames, so without this
406+ // there is nothing to separate a handshake still in flight from a socket
407+ // that died silently.
408+ mockRedisInstance . status = 'connecting'
409+ mockRedisInstance . set . mockRejectedValueOnce ( new Error ( 'Command timed out' ) )
410+
411+ await expect ( acquireLock ( lockKey , value , ttlSeconds ) ) . rejects . toThrow ( 'Command timed out' )
412+ expect ( mockLogger . error ) . toHaveBeenCalledWith (
413+ 'Redis lock acquire failed' ,
414+ expect . objectContaining ( {
415+ lockKey,
416+ error : 'Command timed out' ,
417+ redis : expect . objectContaining ( { status : 'connecting' } ) ,
418+ } )
419+ )
420+ } )
421+
422+ it ( 'reads connection state before the reclaim, which resolves against a live socket' , async ( ) => {
423+ // The reclaim awaits, so a connection that completes inside that window
424+ // would leave a diagnostic read after it reporting `ready` — hiding the
425+ // very handshake that failed.
426+ mockRedisInstance . status = 'connecting'
427+ mockRedisInstance . set . mockRejectedValueOnce ( new Error ( 'Command timed out' ) )
428+ // Mutates the constructed client, not the shared instance it was copied from.
429+ mockRedisInstance . eval . mockImplementationOnce ( async ( ) => {
430+ Object . assign ( getRedisClient ( ) ?? { } , { status : 'ready' } )
431+ return 1
432+ } )
433+
434+ await expect (
435+ acquireLock ( lockKey , value , ttlSeconds , { reclaimOnFailure : true } )
436+ ) . rejects . toThrow ( 'Command timed out' )
437+ expect ( mockLogger . error ) . toHaveBeenCalledWith (
438+ 'Redis lock acquire failed' ,
439+ expect . objectContaining ( { redis : expect . objectContaining ( { status : 'connecting' } ) } )
440+ )
441+ } )
442+
443+ it ( 'stays quiet on the taken and contended paths, which poll routes run constantly' , async ( ) => {
444+ mockRedisInstance . set . mockResolvedValueOnce ( 'OK' )
445+ await acquireLock ( lockKey , value , ttlSeconds )
446+ mockRedisInstance . set . mockResolvedValueOnce ( null )
447+ await acquireLock ( lockKey , value , ttlSeconds )
448+
449+ expect ( mockLogger . error ) . not . toHaveBeenCalled ( )
450+ } )
386451 } )
387452
388453 describe ( 'capability validation' , ( ) => {
0 commit comments