qwen_2.5_model / src /common /cache /cache.service.spec.ts
Muhammad Noman
Configure managed services for Hugging Face Space
b58ffca
Raw
History Blame Contribute Delete
8.54 kB
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { CacheService, CACHE_QUIT_TIMEOUT_MS } from './cache.service';
// Auto-mock ioredis so no real socket is opened. The onModuleDestroy suite below injects its own fake
// `redis` and never constructs one, so the mock is inert there; the resilience suite drives it.
jest.mock('ioredis');
describe('CacheService.onModuleDestroy (bounded shutdown)', () => {
const makeService = (): CacheService => {
const configService = { get: jest.fn().mockReturnValue(false) } as unknown as ConfigService;
return new CacheService(configService);
};
const withRedis = (service: CacheService, redis: unknown): void => {
(service as unknown as { redis: unknown }).redis = redis;
};
it('returns immediately when there is no redis client', async () => {
await expect(makeService().onModuleDestroy()).resolves.toBeUndefined();
});
it('prefers a graceful quit() but always releases the socket afterward', async () => {
const service = makeService();
const redis = { quit: jest.fn().mockResolvedValue('OK'), disconnect: jest.fn() };
withRedis(service, redis);
await service.onModuleDestroy();
// A clean quit() is used, and disconnect() still runs as the guaranteed final release — a
// never-ready client (down Redis + never-give-up retryStrategy) would otherwise leak a live
// reconnect timer past teardown. disconnect() is idempotent, so this is safe after a clean quit.
expect(redis.quit).toHaveBeenCalledTimes(1);
expect(redis.disconnect).toHaveBeenCalledTimes(1);
});
it('force-disconnects when quit() hangs past the deadline (shutdown still completes)', async () => {
jest.useFakeTimers();
try {
const service = makeService();
const redis = { quit: jest.fn(() => new Promise<string>(() => {})), disconnect: jest.fn() }; // never resolves
withRedis(service, redis);
const done = service.onModuleDestroy();
await jest.advanceTimersByTimeAsync(CACHE_QUIT_TIMEOUT_MS);
await done;
expect(redis.disconnect).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
it('releases a never-ready client whose quit() rejects fast (no leaked reconnect timer)', async () => {
const service = makeService();
// A down/reconnecting client with enableOfflineQueue:false rejects quit() immediately WITHOUT
// closing the socket; onModuleDestroy must still disconnect() it so ioredis's forever-retry timer
// does not outlive teardown.
const redis = { quit: jest.fn().mockRejectedValue(new Error("Stream isn't writeable")), disconnect: jest.fn() };
withRedis(service, redis);
await expect(service.onModuleDestroy()).resolves.toBeUndefined();
expect(redis.disconnect).toHaveBeenCalledTimes(1);
});
});
// Redis-outage recovery. The old cache gave up reconnecting after a fixed number of failures and never
// cleared a dead client, so a restart (or a Redis down at boot) left the cache dead until the app
// restarted. The client mock here always resolves connect(), so these first tests pin the NEW contract
// — one client for the process lifetime, isAvailable() tracking live ping state and never latching off —
// rather than the old-vs-new difference itself. That difference lives entirely in the ioredis options
// (retryStrategy never null + enableOfflineQueue:false), which the 'configures ioredis…' test below
// asserts directly; it is the one test that fails against the pre-fix code.
describe('CacheService Redis-outage resilience', () => {
const RedisMock = Redis as unknown as jest.Mock;
interface FakeClient {
connect: jest.Mock;
ping: jest.Mock;
on: jest.Mock;
quit: jest.Mock;
disconnect: jest.Mock;
}
const fakeClient = (ping: jest.Mock): FakeClient => ({
connect: jest.fn().mockResolvedValue(undefined),
ping,
on: jest.fn(),
quit: jest.fn().mockResolvedValue('OK'),
disconnect: jest.fn(),
});
const useClient = (ping: jest.Mock): void => {
RedisMock.mockImplementation(() => fakeClient(ping));
};
// enabled=true purely from config, so the suite is independent of the ambient REDIS_ENABLED env.
const enabledConfig = (): ConfigService =>
({ get: (key: string, def?: unknown) => (key === 'cache.enabled' ? true : def) }) as unknown as ConfigService;
let savedEnabled: string | undefined;
beforeEach(() => {
savedEnabled = process.env.REDIS_ENABLED;
delete process.env.REDIS_ENABLED;
RedisMock.mockReset();
});
afterEach(() => {
if (savedEnabled === undefined) delete process.env.REDIS_ENABLED;
else process.env.REDIS_ENABLED = savedEnabled;
});
it('self-heals across a Redis restart without recreating the client', async () => {
const ping = jest
.fn()
.mockResolvedValueOnce('PONG') // connected
.mockRejectedValueOnce(new Error('connection lost')) // Redis down
.mockResolvedValueOnce('PONG'); // ioredis reconnected
useClient(ping);
const service = new CacheService(enabledConfig());
expect(await service.isAvailable()).toBe(true);
expect(await service.isAvailable()).toBe(false); // during the outage
expect(await service.isAvailable()).toBe(true); // healed
// One client for the whole lifetime — it is never torn down and recreated on the outage.
expect(RedisMock).toHaveBeenCalledTimes(1);
});
it('never latches off: keeps reflecting live state and recovers after a long run of failures', async () => {
const ping = jest.fn();
// Many consecutive unavailable polls (more than the old code's 5-attempt give-up cap) must not make
// isAvailable() stick at false — the single client keeps being re-pinged, never abandoned.
for (let i = 0; i < 8; i++) ping.mockRejectedValueOnce(new Error('down'));
ping.mockResolvedValue('PONG'); // Redis finally reachable
useClient(ping);
const service = new CacheService(enabledConfig());
for (let i = 0; i < 8; i++) expect(await service.isAvailable()).toBe(false);
expect(await service.isAvailable()).toBe(true);
expect(RedisMock).toHaveBeenCalledTimes(1);
});
it('configures ioredis to reconnect forever and fail fast while disconnected', async () => {
useClient(jest.fn().mockResolvedValue('PONG'));
const service = new CacheService(enabledConfig());
await service.isAvailable();
const opts = (
RedisMock.mock.calls as Array<[{ retryStrategy: (t: number) => number | null; enableOfflineQueue: boolean }]>
)[0][0];
// Never returns null → ioredis keeps reconnecting for any attempt count, small or large.
expect(opts.retryStrategy(1)).not.toBeNull();
expect(typeof opts.retryStrategy(1000)).toBe('number');
// Commands fail fast instead of queueing until reconnect.
expect(opts.enableOfflineQueue).toBe(false);
});
it('uses the configured managed Redis connection, including TLS', async () => {
const originalEnv = process.env;
process.env = { ...originalEnv };
delete process.env.REDIS_HOST;
delete process.env.REDIS_PORT;
delete process.env.REDIS_USERNAME;
delete process.env.REDIS_PASSWORD;
try {
useClient(jest.fn().mockResolvedValue('PONG'));
const configService = {
get: (key: string, defaultValue?: unknown) =>
(
({
'cache.enabled': true,
'redis.host': 'redis.example.test',
'redis.port': 6379,
'redis.username': 'default',
'redis.password': 'redis-password',
'redis.tls': {},
'redis.connectTimeoutMs': 1234,
}) as Record<string, unknown>
)[key] ?? defaultValue,
} as unknown as ConfigService;
const service = new CacheService(configService);
await service.isAvailable();
expect(RedisMock).toHaveBeenCalledWith(
expect.objectContaining({
host: 'redis.example.test',
port: 6379,
username: 'default',
password: 'redis-password',
connectTimeout: 1234,
tls: {},
}),
);
} finally {
process.env = originalEnv;
}
});
it('does not construct a client when the cache is disabled', async () => {
const disabled = { get: (_key: string, def?: unknown) => def } as unknown as ConfigService;
const service = new CacheService(disabled);
expect(await service.isAvailable()).toBe(false);
expect(RedisMock).not.toHaveBeenCalled();
});
});