/** * Tests integration RedisCache contre un vrai Redis 7 (testcontainers). * * Choix : testcontainers Redis 7-alpine est leger (~3-5s) et l'API ioredis * est complexe a mocker fidelement (multi/scan/zadd...) donc on prefere un vrai * container plutot qu'un mock. Coverage attendue >= 70% lines+branches. */ import IORedis from 'ioredis'; import pino from 'pino'; import { GenericContainer, type StartedTestContainer } from 'testcontainers'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { RedisCache } from '../../src/adapters/redis-cache.js'; const silentLogger = () => pino({ level: 'silent' }); describe('RedisCache integration', () => { let container: StartedTestContainer; let cache: RedisCache; let url: string; beforeAll(async () => { container = await new GenericContainer('redis:7-alpine').withExposedPorts(6379).start(); const host = container.getHost(); const port = container.getMappedPort(6379); url = `redis://${host}:${port}`; cache = new RedisCache({ url, logger: silentLogger() }); // Attendre l'event 'ready' avant de lancer les tests — sinon le 1er test peut // hit le client avant que la handshake soit terminee (enableOfflineQueue: false). await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('redis ready timeout')), 10_000); // biome-ignore lint/suspicious/noExplicitAny: acces interne au client pour test const client = (cache as unknown as { client: any }).client; if (client.status === 'ready') { clearTimeout(timer); resolve(); } else { client.once('ready', () => { clearTimeout(timer); resolve(); }); } }); }, 60_000); afterAll(async () => { await cache?.close(); await container?.stop(); }, 30_000); // Nettoyage entre tests pour eviter les pollutions afterEach(async () => { const flushClient = new IORedis(url); await flushClient.flushdb(); await flushClient.quit(); }); describe('get/set roundtrip', () => { it('set + get retourne la valeur JSON typee', async () => { const key = `test:obj:${crypto.randomUUID()}`; const value = { foo: 'bar', n: 42, list: [1, 2, 3] }; await cache.set(key, value, 60); const got = await cache.get(key); expect(got).toEqual(value); }); it('get retourne null si la cle est absente', async () => { const got = await cache.get('non-existent-key'); expect(got).toBeNull(); }); it('respecte le TTL par defaut (300s) quand non fourni', async () => { const flushClient = new IORedis(url); const key = `test:ttl:${crypto.randomUUID()}`; await cache.set(key, { x: 1 }); const ttl = await flushClient.ttl(key); await flushClient.quit(); // TTL doit etre proche de 300s (peut varier de quelques secondes) expect(ttl).toBeGreaterThan(290); expect(ttl).toBeLessThanOrEqual(300); }); it('respecte un TTL custom', async () => { const flushClient = new IORedis(url); const key = `test:ttl-custom:${crypto.randomUUID()}`; await cache.set(key, 'x', 42); const ttl = await flushClient.ttl(key); await flushClient.quit(); expect(ttl).toBeGreaterThan(35); expect(ttl).toBeLessThanOrEqual(42); }); it('get supprime la cle si la valeur stockee n est pas du JSON valide', async () => { const flushClient = new IORedis(url); const key = `test:bad-json:${crypto.randomUUID()}`; await flushClient.set(key, 'pas-du-json{{{'); const got = await cache.get(key); expect(got).toBeNull(); const after = await flushClient.get(key); await flushClient.quit(); expect(after).toBeNull(); }); }); describe('del', () => { it('supprime une cle unique', async () => { const key = `test:del:${crypto.randomUUID()}`; await cache.set(key, 'v', 60); await cache.del(key); expect(await cache.get(key)).toBeNull(); }); it('supprime un tableau de cles', async () => { const k1 = `test:del:${crypto.randomUUID()}`; const k2 = `test:del:${crypto.randomUUID()}`; await cache.set(k1, 'a', 60); await cache.set(k2, 'b', 60); await cache.del([k1, k2]); expect(await cache.get(k1)).toBeNull(); expect(await cache.get(k2)).toBeNull(); }); it('no-op sur tableau vide (pas d erreur)', async () => { await expect(cache.del([])).resolves.toBeUndefined(); }); }); describe('invalidatePattern', () => { it('supprime toutes les cles matchant le pattern et retourne le count', async () => { const prefix = `test:invalidate:${crypto.randomUUID()}`; await cache.set(`${prefix}:a`, 1, 60); await cache.set(`${prefix}:b`, 2, 60); await cache.set(`${prefix}:c`, 3, 60); // Cle qui ne matche pas — doit survivre. const survivor = `test:other:${crypto.randomUUID()}`; await cache.set(survivor, 99, 60); const count = await cache.invalidatePattern(`${prefix}:*`); expect(count).toBe(3); expect(await cache.get(`${prefix}:a`)).toBeNull(); expect(await cache.get(survivor)).toBe(99); }); it('retourne 0 quand aucun match', async () => { const count = await cache.invalidatePattern(`test:nomatch:${crypto.randomUUID()}:*`); expect(count).toBe(0); }); }); describe('checkAndStoreEventId (idempotence webhooks)', () => { it('retourne false sur premier call, true sur duplicate', async () => { const eventId = crypto.randomUUID(); const firstSeen = await cache.checkAndStoreEventId(eventId, 60); const secondSeen = await cache.checkAndStoreEventId(eventId, 60); expect(firstSeen).toBe(false); expect(secondSeen).toBe(true); }); it('TTL applique sur la cle event', async () => { const flushClient = new IORedis(url); const eventId = crypto.randomUUID(); await cache.checkAndStoreEventId(eventId, 30); const ttl = await flushClient.ttl(`bridge:webhook:event:${eventId}`); await flushClient.quit(); expect(ttl).toBeGreaterThan(20); expect(ttl).toBeLessThanOrEqual(30); }); }); describe('checkRateLimit', () => { it('autorise les requetes sous la limite', async () => { const key = `rl:${crypto.randomUUID()}`; const r1 = await cache.checkRateLimit(key, 5, 10); await new Promise((r) => setTimeout(r, 2)); const r2 = await cache.checkRateLimit(key, 5, 10); await new Promise((r) => setTimeout(r, 2)); const r3 = await cache.checkRateLimit(key, 5, 10); expect(r1).toBe(true); expect(r2).toBe(true); expect(r3).toBe(true); }); it('refuse les requetes au-dela de la limite', async () => { const key = `rl-deny:${crypto.randomUUID()}`; // Note: les appels DOIVENT etre espaces de >= 1ms car le ZSET utilise // `${Date.now()}` comme membre — sinon collision sur meme ms (valeur unique). // Saturer la fenetre. for (let i = 0; i < 3; i++) { await cache.checkRateLimit(key, 3, 10); await new Promise((r) => setTimeout(r, 2)); } const overLimit = await cache.checkRateLimit(key, 3, 10); expect(overLimit).toBe(false); }); }); describe('healthCheck', () => { it('retourne true quand Redis repond', async () => { const ok = await cache.healthCheck(); expect(ok).toBe(true); }); it('retourne false quand le client echoue (URL invalide)', async () => { const broken = new RedisCache({ url: 'redis://127.0.0.1:1', // port qui ne repond pas logger: silentLogger(), }); const ok = await broken.healthCheck(); expect(ok).toBe(false); // Cleanup — on ne crash pas sur close meme si non connecte. await broken.close().catch(() => {}); }); }); });