Files
simstudioai--sim/apps/sim/lib/core/config/redis.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

250 lines
7.4 KiB
TypeScript

import { createEnvMock, createMockRedis } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { MockRedisConstructor } = vi.hoisted(() => ({
MockRedisConstructor: vi.fn(),
}))
const mockRedisInstance = createMockRedis()
MockRedisConstructor.mockImplementation(
class {
constructor() {
Object.assign(this, mockRedisInstance)
}
}
)
vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' }))
vi.mock('ioredis', () => ({
default: MockRedisConstructor,
}))
import {
closeRedisConnection,
extendLock,
getRedisClient,
onRedisReconnect,
resetForTesting,
} from '@/lib/core/config/redis'
describe('redis config', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
resetForTesting()
MockRedisConstructor.mockImplementation(
class {
constructor() {
Object.assign(this, mockRedisInstance)
}
}
)
})
afterEach(() => {
vi.useRealTimers()
})
describe('onRedisReconnect', () => {
it('should register and invoke reconnect listeners', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).toHaveBeenCalledTimes(1)
})
it('should not invoke listeners when PINGs succeed', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockResolvedValue('PONG')
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).not.toHaveBeenCalled()
})
it('should reset failure count on successful PING', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
mockRedisInstance.ping.mockResolvedValueOnce('PONG')
await vi.advanceTimersByTimeAsync(15_000)
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).not.toHaveBeenCalled()
})
it('should call disconnect(true) after 2 consecutive PING failures', async () => {
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledWith(true)
})
it('should drop the cached client so the next getRedisClient() builds a fresh one', async () => {
getRedisClient()
const callsBefore = MockRedisConstructor.mock.calls.length
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledWith(true)
getRedisClient()
expect(MockRedisConstructor.mock.calls.length).toBe(callsBefore + 1)
})
it('should restart the PING health check against the new client', async () => {
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledTimes(1)
getRedisClient()
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledTimes(2)
})
it('should handle listener errors gracefully without breaking health check', async () => {
const badListener = vi.fn(() => {
throw new Error('listener crashed')
})
const goodListener = vi.fn()
onRedisReconnect(badListener)
onRedisReconnect(goodListener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(badListener).toHaveBeenCalledTimes(1)
expect(goodListener).toHaveBeenCalledTimes(1)
})
})
describe('closeRedisConnection', () => {
it('should clear the PING interval', async () => {
getRedisClient()
mockRedisInstance.quit.mockResolvedValue('OK')
await closeRedisConnection()
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000 * 5)
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
})
})
describe('extendLock', () => {
const lockKey = 'copilot:chat-stream-lock:chat-1'
const value = 'stream-abc'
const ttlSeconds = 60
it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(1)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
expect.stringContaining('expire'),
1,
lockKey,
value,
ttlSeconds
)
})
it('returns false when the value does not match (lock owned by another)', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(0)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(false)
})
it('returns true as a no-op when Redis is unavailable', async () => {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () =>
createEnvMock({ REDIS_URL: undefined as unknown as string })
)
const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis')
const extended = await extendLockNoRedis(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
vi.doUnmock('@/lib/core/config/env')
})
})
describe('retryStrategy', () => {
function captureRetryStrategy(): (times: number) => number {
let capturedConfig: Record<string, unknown> = {}
MockRedisConstructor.mockImplementation(
class {
constructor(_url: string, config: Record<string, unknown>) {
capturedConfig = config
Object.assign(this, { ping: vi.fn(), on: vi.fn() })
}
}
)
getRedisClient()
return capturedConfig.retryStrategy as (times: number) => number
}
it('should use exponential backoff with jitter', () => {
const retryStrategy = captureRetryStrategy()
expect(retryStrategy).toBeDefined()
const delay1 = retryStrategy(1)
expect(delay1).toBeGreaterThanOrEqual(1000)
expect(delay1).toBeLessThanOrEqual(1300)
const delay3 = retryStrategy(3)
expect(delay3).toBeGreaterThanOrEqual(4000)
expect(delay3).toBeLessThanOrEqual(5200)
const delay5 = retryStrategy(5)
expect(delay5).toBeGreaterThanOrEqual(10000)
expect(delay5).toBeLessThanOrEqual(13000)
})
it('should cap at 30s for attempts beyond 10', () => {
const retryStrategy = captureRetryStrategy()
expect(retryStrategy(11)).toBe(30000)
expect(retryStrategy(100)).toBe(30000)
})
})
})