Wiki/bridge/tests/integration/redis-cache.test.ts
Corentin JOGUET 1cdb1b6ca4 fix(redis-cache): membre ZSET unique pour eviter collision sub-ms dans checkRateLimit
Date.now() seul collisionne sur appels concurrents dans la meme milliseconde,
ce qui faisait compter 1 entry au lieu de N dans la fenetre glissante.
Suffixe randomUUID pour garantir l'unicite du membre. Ajoute test burst 10x
qui prouve les 5 allowed + 5 denied attendus.
2026-05-07 20:38:07 +02:00

237 lines
7.9 KiB
TypeScript

/**
* 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<void>((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<typeof value>(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);
const r2 = await cache.checkRateLimit(key, 5, 10);
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()}`;
for (let i = 0; i < 3; i++) {
await cache.checkRateLimit(key, 3, 10);
}
const overLimit = await cache.checkRateLimit(key, 3, 10);
expect(overLimit).toBe(false);
});
it('compte chaque appel meme en burst sub-ms (membre ZSET unique)', async () => {
const key = `rl-burst:${crypto.randomUUID()}`;
const results = await Promise.all(
Array.from({ length: 10 }, () => cache.checkRateLimit(key, 5, 10)),
);
const allowed = results.filter((r) => r === true).length;
const denied = results.filter((r) => r === false).length;
expect(allowed).toBe(5);
expect(denied).toBe(5);
});
});
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(() => {});
});
});
});