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
250 lines
7.4 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|