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.
237 lines
7.9 KiB
TypeScript
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(() => {});
|
|
});
|
|
});
|
|
});
|