diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index a8a2abf..16188a1 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,14 +1,21 @@ +Abaixo abre Abrir aceita adzuna Adzuna +ainda +além algumas alternativa ambiente andamento +aplica +asar ativas atuais +automático +benetesla blockdaemon brex Buscando @@ -16,34 +23,50 @@ canva captura chainalysis chainlink +chamada chave coleta +commitar compartilhado +conecta +conectado +conexao +conexão +configurado conhecidas contém +corretamente +correto +criação deduplica deduplicação deduplicar defval deleta +detecção diferenciar diferentes +disponível doordash duplicadas Elementos +emite encontra encontrada encontradas encontrados específica +esperada Estranha estranho executa existem expirou +exporta exportacao exportadas falhar +falhas falhou ferramentas filtra @@ -54,10 +77,12 @@ fluxo flyio fonte formato +garantindo hotjar huggingface ignora incompleto +indisponivel inexistente informada informado @@ -67,23 +92,36 @@ instacart interrompe Inválida isso +jobsglobalscraper KHTML klarna lança +lançam linhas Localização +lote +maior maiúsculas mantém +marca +memoria +memória mesma +mínimo minúsculas momentos montar Mostrando +muda múltiplas múltiplos Nenhum normaliza +nsis +nunca opcionais +operações +organização paginação palavra palavras @@ -105,20 +143,27 @@ processando processar Publicacao Quebrada +reais +reaproveita recente +reconecta recupera recuperado +refatorado rejeição rejeita +resiliência Resultado resultados retornadas reutiliza +reutilização robinhood salario salário scaleai segunda +simplificado sobrescrever solicitado supabase @@ -130,6 +175,7 @@ themuse titulo tiver trata +tratamento typeform unicas únicas @@ -137,10 +183,13 @@ unstub usam útil vaga +validar validos vazia vazio vazios +verdade vierem +visualizar wealthfront weightsbiases diff --git a/.gitignore b/.gitignore index 9574dc1..87c457e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ backend/coverage/ .nyc_output/ # Dist-electron -dist-electron/ \ No newline at end of file +dist-electron/.sfdx/ diff --git a/backend/.env.example b/backend/.env.example index b897a2d..361c297 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,7 +24,7 @@ DATABASE_URL=postgresql://app_user:change_me@127.0.0.1:5432/jobsglobalscraper REDIS_URL=redis://:change_me@127.0.0.1:6379/0 REDIS_KEY_PREFIX=vagas-full KEYWORDS_REDIS_KEY=vagas-full:keywords -CACHE_TTL_MS=600000 +CACHE_TTL_MS=21600000 # 6 horas # Scraping behavior WAIT_BETWEEN_SEARCHES_MS=5000 diff --git a/backend/src/cache/cache.js b/backend/src/cache/cache.js index 3b7c34a..8d3a35b 100644 --- a/backend/src/cache/cache.js +++ b/backend/src/cache/cache.js @@ -1,112 +1,272 @@ -let redisClientPromise = null; -let redisClientUrl = ""; -let redisWarningShown = false; - -const cacheStatus = { - provider: "memory", - configured: false, - connected: false, - lastError: null, -}; - -function readRedisUrl() { - return process.env.REDIS_URL?.trim() || ""; -} - -function syncCacheStatus() { - const redisUrl = readRedisUrl(); - cacheStatus.configured = Boolean(redisUrl); - cacheStatus.provider = redisUrl ? "redis" : "memory"; - - if (!redisUrl) { - cacheStatus.connected = false; - cacheStatus.lastError = null; - } -} - -function warnRedisFallback(message, error) { - cacheStatus.connected = false; - cacheStatus.lastError = error instanceof Error ? error.message : null; - - if (redisWarningShown) { - return; - } - - redisWarningShown = true; - - const errorMessage = error instanceof Error ? error.message : ""; - // eslint-disable-next-line no-console - console.warn(`${message}${errorMessage ? ` (${errorMessage})` : ""}`); -} - -export async function getRedisClient() { - const redisUrl = readRedisUrl(); - syncCacheStatus(); - - if (!redisUrl) { - return null; - } - - if (redisClientUrl !== redisUrl) { - redisClientPromise = null; - redisClientUrl = redisUrl; - } - - if (!redisClientPromise) { - redisClientPromise = import("redis") - .then(async ({ createClient }) => { - const client = createClient({ - url: redisUrl, - socket: { - connectTimeout: 3_000, - reconnectStrategy(retries) { - if (retries >= 2) { - return false; - } - - return Math.min((retries + 1) * 100, 500); - }, - }, - }); - - client.on("ready", () => { - cacheStatus.connected = true; - cacheStatus.lastError = null; - }); - - client.on("end", () => { - cacheStatus.connected = false; - }); - - client.on("error", (error) => { - warnRedisFallback("Redis indisponivel, usando cache em memoria.", error); - }); - - await client.connect(); - return client; - }) - .catch((error) => { - warnRedisFallback("Falha ao conectar no Redis, usando cache em memoria.", error); - redisClientPromise = null; - return null; - }); - } - - return redisClientPromise; -} - -export function getCacheStatus() { - syncCacheStatus(); - return { - ...cacheStatus, - type: cache.constructor.name, - }; -} +// let redisClientPromise = null; +// let redisClientUrl = ""; +// let redisWarningShown = false; + +// const cacheStatus = { +// provider: "memory", +// configured: false, +// connected: false, +// lastError: null, +// }; + +// function readRedisUrl() { +// return process.env.REDIS_URL?.trim() || ""; +// } + +// function syncCacheStatus() { +// const redisUrl = readRedisUrl(); +// cacheStatus.configured = Boolean(redisUrl); +// cacheStatus.provider = redisUrl ? "redis" : "memory"; + +// if (!redisUrl) { +// cacheStatus.connected = false; +// cacheStatus.lastError = null; +// } +// } + +// function warnRedisFallback(message, error) { +// cacheStatus.connected = false; +// cacheStatus.lastError = error instanceof Error ? error.message : null; + +// if (redisWarningShown) { +// return; +// } + +// redisWarningShown = true; + +// const errorMessage = error instanceof Error ? error.message : ""; +// // eslint-disable-next-line no-console +// console.warn(`${message}${errorMessage ? ` (${errorMessage})` : ""}`); +// } + +// export async function getRedisClient() { +// const redisUrl = readRedisUrl(); +// syncCacheStatus(); + +// if (!redisUrl) { +// return null; +// } + +// if (redisClientUrl !== redisUrl) { +// redisClientPromise = null; +// redisClientUrl = redisUrl; +// } + +// if (!redisClientPromise) { +// redisClientPromise = import("redis") +// .then(async ({ createClient }) => { +// const client = createClient({ +// url: redisUrl, +// socket: { +// connectTimeout: 3_000, +// reconnectStrategy(retries) { +// if (retries >= 2) { +// return false; +// } + +// return Math.min((retries + 1) * 100, 500); +// }, +// }, +// }); + +// client.on("ready", () => { +// cacheStatus.connected = true; +// cacheStatus.lastError = null; +// }); + +// client.on("end", () => { +// cacheStatus.connected = false; +// }); + +// client.on("error", (error) => { +// warnRedisFallback("Redis indisponivel, usando cache em memoria.", error); +// }); + +// await client.connect(); +// return client; +// }) +// .catch((error) => { +// warnRedisFallback("Falha ao conectar no Redis, usando cache em memoria.", error); +// redisClientPromise = null; +// return null; +// }); +// } + +// return redisClientPromise; +// } + +// export function getCacheStatus() { +// syncCacheStatus(); +// return { +// ...cacheStatus, +// type: cache.constructor.name, +// }; +// } + +// export async function warmupCache() { +// syncCacheStatus(); + +// if (!cacheStatus.configured) { +// return getCacheStatus(); +// } + +// const client = await getRedisClient(); +// if (!client) { +// return getCacheStatus(); +// } + +// try { +// const pong = await client.ping(); +// cacheStatus.connected = pong === "PONG"; +// cacheStatus.lastError = cacheStatus.connected ? null : "PING sem resposta esperada"; +// } catch (error) { +// warnRedisFallback("Falha ao validar conexao com Redis.", error); +// } + +// return getCacheStatus(); +// } + +// export class MemoryCache { +// constructor() { +// this.store = new Map(); +// } + +// get(key) { +// const entry = this.store.get(key); + +// if (!entry) { +// return null; +// } + +// if (Date.now() > entry.expiresAt) { +// this.store.delete(key); +// return null; +// } + +// return entry.value; +// } + +// set(key, value, ttlMs) { +// this.store.set(key, { +// value, +// expiresAt: Date.now() + ttlMs, +// }); +// } + +// delete(key) { +// this.store.delete(key); +// } + +// clear() { +// this.store.clear(); +// } + +// has(key) { +// return this.get(key) !== null; +// } +// } + +// export class RedisCache { +// constructor(options = {}) { +// this.prefix = options.prefix || process.env.REDIS_KEY_PREFIX?.trim() || "vagas-full"; +// this.memoryFallback = new MemoryCache(); +// } + +// buildKey(key) { +// return `${this.prefix}:${key}`; +// } + +// async get(key) { +// const client = await getRedisClient(); + +// if (!client) { +// return this.memoryFallback.get(key); +// } + +// try { +// const raw = await client.get(this.buildKey(key)); +// return raw ? JSON.parse(raw) : null; +// } catch (error) { +// warnRedisFallback("Erro ao ler do Redis, usando cache em memoria.", error); +// return this.memoryFallback.get(key); +// } +// } + +// async set(key, value, ttlMs) { +// this.memoryFallback.set(key, value, ttlMs); + +// const client = await getRedisClient(); +// if (!client) { +// return; +// } + +// try { +// await client.set(this.buildKey(key), JSON.stringify(value), { +// PX: Math.max(1, Number(ttlMs) || 1), +// }); +// } catch (error) { +// warnRedisFallback("Erro ao salvar no Redis, usando cache em memoria.", error); +// } +// } + +// async delete(key) { +// this.memoryFallback.delete(key); + +// const client = await getRedisClient(); +// if (!client) { +// return; +// } + +// try { +// await client.del(this.buildKey(key)); +// } catch (error) { +// warnRedisFallback("Erro ao remover chave do Redis.", error); +// } +// } + +// async clear() { +// this.memoryFallback.clear(); + +// const client = await getRedisClient(); +// if (!client) { +// return; +// } + +// try { +// const keys = []; +// for await (const key of client.scanIterator({ MATCH: `${this.prefix}:*` })) { +// keys.push(key); +// } + +// if (keys.length > 0) { +// await client.del(keys); +// } +// } catch (error) { +// warnRedisFallback("Erro ao limpar o Redis.", error); +// } +// } + +// async has(key) { +// return (await this.get(key)) !== null; +// } +// } + +// export const cache = process.env.REDIS_URL?.trim() ? new RedisCache() : new MemoryCache(); + +//Código acima refatorado para melhor organização e reutilização de código, além de melhorias na detecção e tratamento de falhas na conexão com o Redis. +//O cache em memória agora é usado como fallback automático em caso de falhas no Redis, garantindo maior resiliência do sistema. +//Abaixo está o facade simplificado para criação do cache, além de um módulo separado para o status do cache e outro para a conexão com o Redis. + +import { cache } from "./cacheFactory.js"; +import { getCacheStatus } from "./cacheStatus.js"; +import { getRedisClient } from "./redisConnection.js"; + +export { cache, getCacheStatus }; export async function warmupCache() { - syncCacheStatus(); + const status = getCacheStatus(); - if (!cacheStatus.configured) { + if (!status.configured) { return getCacheStatus(); } @@ -117,138 +277,12 @@ export async function warmupCache() { try { const pong = await client.ping(); - cacheStatus.connected = pong === "PONG"; - cacheStatus.lastError = cacheStatus.connected ? null : "PING sem resposta esperada"; + if (pong !== "PONG") { + console.warn("Redis PING sem resposta esperada."); + } } catch (error) { - warnRedisFallback("Falha ao validar conexao com Redis.", error); + console.warn("Falha ao validar conexão com Redis:", error.message); } return getCacheStatus(); } - -export class MemoryCache { - constructor() { - this.store = new Map(); - } - - get(key) { - const entry = this.store.get(key); - - if (!entry) { - return null; - } - - if (Date.now() > entry.expiresAt) { - this.store.delete(key); - return null; - } - - return entry.value; - } - - set(key, value, ttlMs) { - this.store.set(key, { - value, - expiresAt: Date.now() + ttlMs, - }); - } - - delete(key) { - this.store.delete(key); - } - - clear() { - this.store.clear(); - } - - has(key) { - return this.get(key) !== null; - } -} - -export class RedisCache { - constructor(options = {}) { - this.prefix = options.prefix || process.env.REDIS_KEY_PREFIX?.trim() || "vagas-full"; - this.memoryFallback = new MemoryCache(); - } - - buildKey(key) { - return `${this.prefix}:${key}`; - } - - async get(key) { - const client = await getRedisClient(); - - if (!client) { - return this.memoryFallback.get(key); - } - - try { - const raw = await client.get(this.buildKey(key)); - return raw ? JSON.parse(raw) : null; - } catch (error) { - warnRedisFallback("Erro ao ler do Redis, usando cache em memoria.", error); - return this.memoryFallback.get(key); - } - } - - async set(key, value, ttlMs) { - this.memoryFallback.set(key, value, ttlMs); - - const client = await getRedisClient(); - if (!client) { - return; - } - - try { - await client.set(this.buildKey(key), JSON.stringify(value), { - PX: Math.max(1, Number(ttlMs) || 1), - }); - } catch (error) { - warnRedisFallback("Erro ao salvar no Redis, usando cache em memoria.", error); - } - } - - async delete(key) { - this.memoryFallback.delete(key); - - const client = await getRedisClient(); - if (!client) { - return; - } - - try { - await client.del(this.buildKey(key)); - } catch (error) { - warnRedisFallback("Erro ao remover chave do Redis.", error); - } - } - - async clear() { - this.memoryFallback.clear(); - - const client = await getRedisClient(); - if (!client) { - return; - } - - try { - const keys = []; - for await (const key of client.scanIterator({ MATCH: `${this.prefix}:*` })) { - keys.push(key); - } - - if (keys.length > 0) { - await client.del(keys); - } - } catch (error) { - warnRedisFallback("Erro ao limpar o Redis.", error); - } - } - - async has(key) { - return (await this.get(key)) !== null; - } -} - -export const cache = process.env.REDIS_URL?.trim() ? new RedisCache() : new MemoryCache(); diff --git a/backend/src/cache/cacheFactory.js b/backend/src/cache/cacheFactory.js new file mode 100644 index 0000000..451ffa7 --- /dev/null +++ b/backend/src/cache/cacheFactory.js @@ -0,0 +1,14 @@ +import { MemoryCache } from "./memoryCache.js"; +import { RedisCache } from "./redisCache.js"; + +export function createCache() { + const hasRedis = Boolean(process.env.REDIS_URL?.trim()); + + if (hasRedis) { + return new RedisCache(); + } + + return new MemoryCache(); +} + +export const cache = createCache(); \ No newline at end of file diff --git a/backend/src/cache/cacheStatus.js b/backend/src/cache/cacheStatus.js new file mode 100644 index 0000000..58fab22 --- /dev/null +++ b/backend/src/cache/cacheStatus.js @@ -0,0 +1,11 @@ +import { isRedisConnected } from "./redisConnection.js"; + +export function getCacheStatus() { + const hasRedis = Boolean(process.env.REDIS_URL?.trim()); + + return { + provider: hasRedis ? "redis" : "memory", + configured: hasRedis, + connected: hasRedis ? isRedisConnected() : false, + }; +} \ No newline at end of file diff --git a/backend/src/cache/memoryCache.js b/backend/src/cache/memoryCache.js new file mode 100644 index 0000000..9d10d9e --- /dev/null +++ b/backend/src/cache/memoryCache.js @@ -0,0 +1,39 @@ +export class MemoryCache { + constructor() { + this.store = new Map(); + } + + get(key) { + const entry = this.store.get(key); + + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + + return entry.value; + } + + set(key, value, ttlMs) { + const ttl = Math.max(1000, Number(ttlMs) || 1000); + + this.store.set(key, { + value, + expiresAt: Date.now() + ttl, + }); + } + + delete(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } + + has(key) { + return this.get(key) !== null; + } +} \ No newline at end of file diff --git a/backend/src/cache/redisCache.js b/backend/src/cache/redisCache.js new file mode 100644 index 0000000..5b44671 --- /dev/null +++ b/backend/src/cache/redisCache.js @@ -0,0 +1,103 @@ +import { MemoryCache } from "./memoryCache.js"; +import { getRedisClient } from "./redisConnection.js"; + +export class RedisCache { + constructor(options = {}) { + this.prefix = + options.prefix || process.env.REDIS_KEY_PREFIX?.trim() || "app"; + + this.memory = new MemoryCache(); + } + + buildKey(key) { + return `${this.prefix}:${key}`; + } + + safeParse(value) { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } + } + + async get(key) { + const client = await getRedisClient(); + + if (!client) { + return this.memory.get(key); + } + + try { + const raw = await client.get(this.buildKey(key)); + return this.safeParse(raw); + } catch (error) { + console.warn( + "Erro ao ler do Redis, usando cache em memória:", + error.message, + ); + return this.memory.get(key); + } + } + + async set(key, value, ttlMs) { + const ttl = Math.max(1000, Number(ttlMs) || 1000); + const client = await getRedisClient(); + + if (!client) { + this.memory.set(key, value, ttl); + return; + } + + try { + await client.set(this.buildKey(key), JSON.stringify(value), { PX: ttl }); + } catch (error) { + console.warn( + "Erro ao salvar no Redis, usando cache em memória:", + error.message, + ); + this.memory.set(key, value, ttl); + } + } + + async delete(key) { + this.memory.delete(key); + + const client = await getRedisClient(); + if (!client) return; + + try { + await client.del(this.buildKey(key)); + } catch (error) { + console.warn("Erro ao deletar chave do Redis:", error.message); + } + } + + async clear() { + this.memory.clear(); + + const client = await getRedisClient(); + if (!client) return; + + try { + const keys = []; + for await (const key of client.scanIterator({ + MATCH: `${this.prefix}:*`, + COUNT: 100, + })) { + keys.push(key); + } + + if (keys.length > 0) { + await client.del(keys); + } + } catch (error) { + console.warn("Erro ao limpar o Redis:", error.message); + } + } + + async has(key) { + return (await this.get(key)) !== null; + } +} diff --git a/backend/src/cache/redisConnection.js b/backend/src/cache/redisConnection.js new file mode 100644 index 0000000..45813b4 --- /dev/null +++ b/backend/src/cache/redisConnection.js @@ -0,0 +1,59 @@ +let client = null; +let currentUrl = null; +let connected = false; + +export function isRedisConnected() { + return connected; +} + +export async function getRedisClient() { + const redisUrl = process.env.REDIS_URL?.trim(); + + if (!redisUrl) return null; + + if (client && currentUrl === redisUrl) { + return client; + } + + currentUrl = redisUrl; + + try { + const { createClient } = await import("redis"); + + const newClient = createClient({ + url: redisUrl, + socket: { + connectTimeout: 3000, + reconnectStrategy(retries) { + if (retries >= 2) return false; + return Math.min((retries + 1) * 100, 500); + }, + }, + }); + + newClient.on("ready", () => { + connected = true; + }); + + newClient.on("error", () => { + connected = false; + client = null; + currentUrl = null; + }); + + newClient.on("end", () => { + connected = false; + }); + + await newClient.connect(); + + client = newClient; + return client; + } catch (error) { + console.warn("Falha ao conectar no Redis:", error.message); + client = null; + currentUrl = null; + connected = false; + return null; + } +} diff --git a/backend/src/db/keywordsStore.js b/backend/src/db/keywordsStore.js index fa5e076..616e934 100644 --- a/backend/src/db/keywordsStore.js +++ b/backend/src/db/keywordsStore.js @@ -1,4 +1,4 @@ -import { getRedisClient } from "../cache/cache.js"; +import { getRedisClient } from "../cache/redisConnection.js"; function getKeywordsRedisKey() { const configuredKey = process.env.KEYWORDS_REDIS_KEY?.trim(); diff --git a/backend/tests/setup.js b/backend/tests/setup.js new file mode 100644 index 0000000..b9770fd --- /dev/null +++ b/backend/tests/setup.js @@ -0,0 +1,4 @@ +import { config } from "dotenv"; +import { resolve } from "path"; + +config({ path: resolve(process.cwd(), ".env.test") }); \ No newline at end of file diff --git a/backend/tests/unit/services/cache/cache.test.js b/backend/tests/unit/services/cache/cache.test.js index 6057b20..3ca693c 100644 --- a/backend/tests/unit/services/cache/cache.test.js +++ b/backend/tests/unit/services/cache/cache.test.js @@ -1,3 +1,273 @@ +// import { beforeEach, describe, expect, it, vi } from "vitest"; + +// const mocks = vi.hoisted(() => ({ +// createClient: vi.fn(), +// consoleWarn: vi.fn(), +// })); + +// vi.mock("redis", () => ({ +// createClient: mocks.createClient, +// })); + +// function buildRedisClientMock(options = {}) { +// const handlers = {}; +// const client = { +// on: vi.fn((event, handler) => { +// handlers[event] = handler; +// return client; +// }), +// connect: vi.fn(async () => { +// if (options.connectError) { +// throw options.connectError; +// } + +// if (options.triggerReady !== false) { +// handlers.ready?.(); +// } +// }), +// ping: vi.fn(async () => options.ping ?? "PONG"), +// get: vi.fn(async () => options.get ?? null), +// set: vi.fn(async () => "OK"), +// del: vi.fn(async () => 1), +// scanIterator: vi.fn(async function* () { +// if (options.scanError) { +// throw options.scanError; +// } + +// for (const key of options.scanKeys ?? []) { +// yield key; +// } +// }), +// }; + +// return { client, handlers }; +// } + +// async function importCacheModule() { +// return import("../../../../src/cache/cache.js"); +// } + +// describe("MemoryCache", () => { +// let memoryCache; + +// beforeEach(async () => { +// vi.resetModules(); +// vi.clearAllMocks(); +// vi.restoreAllMocks(); +// delete process.env.REDIS_URL; +// delete process.env.REDIS_KEY_PREFIX; + +// const { MemoryCache } = await importCacheModule(); +// memoryCache = new MemoryCache(); +// }); + +// it("retorna null quando a chave não existe", () => { +// expect(memoryCache.get("inexistente")).toBeNull(); +// }); + +// it("salva e recupera um valor dentro do TTL", () => { +// memoryCache.set("jobs:react", [{ titulo: "Dev React" }], 1000); + +// expect(memoryCache.get("jobs:react")).toEqual([ +// { titulo: "Dev React" }, +// ]); +// }); + +// it("remove e retorna null quando o item expirou", () => { +// const nowSpy = vi.spyOn(Date, "now"); + +// nowSpy.mockReturnValueOnce(1000); +// memoryCache.set("jobs:node", [{ titulo: "Dev Node" }], 500); + +// nowSpy.mockReturnValueOnce(1601); +// expect(memoryCache.get("jobs:node")).toBeNull(); + +// expect(memoryCache.store.has("jobs:node")).toBe(false); +// }); + +// it("deleta uma chave específica", () => { +// memoryCache.set("a", 123, 1000); +// memoryCache.set("b", 456, 1000); + +// memoryCache.delete("a"); + +// expect(memoryCache.get("a")).toBeNull(); +// expect(memoryCache.get("b")).toBe(456); +// }); + +// it("limpa todo o cache", () => { +// memoryCache.set("a", 123, 1000); +// memoryCache.set("b", 456, 1000); + +// memoryCache.clear(); + +// expect(memoryCache.get("a")).toBeNull(); +// expect(memoryCache.get("b")).toBeNull(); +// }); + +// it("has retorna true quando a chave existe e não expirou", () => { +// memoryCache.set("jobs", ["vaga 1"], 1000); + +// expect(memoryCache.has("jobs")).toBe(true); +// }); + +// it("has retorna false quando a chave não existe", () => { +// expect(memoryCache.has("jobs")).toBe(false); +// }); + +// it("has retorna false quando a chave expirou", () => { +// const nowSpy = vi.spyOn(Date, "now"); + +// nowSpy.mockReturnValueOnce(2000); +// memoryCache.set("jobs", ["vaga 1"], 100); + +// nowSpy.mockReturnValueOnce(2201); +// expect(memoryCache.has("jobs")).toBe(false); +// }); +// }); + +// describe("Redis cache helpers", () => { +// beforeEach(() => { +// vi.resetModules(); +// vi.clearAllMocks(); +// vi.restoreAllMocks(); +// delete process.env.REDIS_URL; +// delete process.env.REDIS_KEY_PREFIX; +// vi.spyOn(console, "warn").mockImplementation(mocks.consoleWarn); +// }); + +// it("retorna status de memoria quando REDIS_URL não está configurada", async () => { +// const { MemoryCache, cache, getCacheStatus, getRedisClient, warmupCache } = await importCacheModule(); + +// expect(await getRedisClient()).toBeNull(); +// expect(cache).toBeInstanceOf(MemoryCache); +// expect(getCacheStatus()).toMatchObject({ +// provider: "memory", +// configured: false, +// connected: false, +// type: "MemoryCache", +// }); +// expect(await warmupCache()).toMatchObject({ +// provider: "memory", +// configured: false, +// connected: false, +// }); +// expect(mocks.createClient).not.toHaveBeenCalled(); +// }); + +// it("conecta ao Redis, reaproveita o cliente e executa operações no RedisCache", async () => { +// process.env.REDIS_URL = "redis://127.0.0.1:6379"; +// process.env.REDIS_KEY_PREFIX = "jobs-test"; + +// const { client, handlers } = buildRedisClientMock({ +// scanKeys: ["jobs-test:a", "jobs-test:b"], +// }); +// mocks.createClient.mockReturnValue(client); + +// const { RedisCache, getCacheStatus, getRedisClient, warmupCache } = await importCacheModule(); + +// const firstClient = await getRedisClient(); +// const secondClient = await getRedisClient(); +// expect(firstClient).toBe(client); +// expect(secondClient).toBe(client); +// expect(mocks.createClient).toHaveBeenCalledTimes(1); + +// const socketConfig = mocks.createClient.mock.calls[0][0].socket; +// expect(socketConfig.reconnectStrategy(0)).toBe(100); +// expect(socketConfig.reconnectStrategy(3)).toBe(false); + +// const redisCache = new RedisCache({ prefix: "jobs-test" }); +// await redisCache.set("react", { ok: true }, 5000); +// client.get.mockResolvedValueOnce(JSON.stringify({ ok: true })); +// expect(await redisCache.get("react")).toEqual({ ok: true }); + +// await redisCache.delete("react"); +// await redisCache.clear(); + +// expect(client.set).toHaveBeenCalledWith("jobs-test:react", JSON.stringify({ ok: true }), { +// PX: 5000, +// }); +// expect(client.del).toHaveBeenCalledWith("jobs-test:react"); +// expect(client.del).toHaveBeenCalledWith(["jobs-test:a", "jobs-test:b"]); + +// expect(await warmupCache()).toMatchObject({ +// provider: "redis", +// configured: true, +// connected: true, +// type: "RedisCache", +// }); + +// handlers.end?.(); +// expect(getCacheStatus().connected).toBe(false); + +// process.env.REDIS_URL = "redis://127.0.0.1:6380"; +// await getRedisClient(); +// expect(mocks.createClient).toHaveBeenCalledTimes(2); +// }); + +// it("faz fallback quando a conexão com Redis falha", async () => { +// process.env.REDIS_URL = "redis://127.0.0.1:6379"; + +// const { client } = buildRedisClientMock({ +// connectError: new Error("connect failed"), +// triggerReady: false, +// }); +// mocks.createClient.mockReturnValue(client); + +// const { getCacheStatus, getRedisClient } = await importCacheModule(); + +// await expect(getRedisClient()).resolves.toBeNull(); +// expect(mocks.consoleWarn).toHaveBeenCalledTimes(1); +// expect(mocks.consoleWarn.mock.calls[0][0]).toContain("Falha ao conectar no Redis"); +// expect(getCacheStatus()).toMatchObject({ +// provider: "redis", +// configured: true, +// connected: false, +// lastError: "connect failed", +// }); +// }); + +// it("usa fallback em memória quando operações do Redis lançam erro", async () => { +// process.env.REDIS_URL = "redis://127.0.0.1:6379"; + +// const { client } = buildRedisClientMock(); +// client.get.mockResolvedValueOnce("{invalid-json"); +// client.set.mockRejectedValueOnce(new Error("set failed")); +// client.del.mockRejectedValueOnce(new Error("del failed")); +// client.scanIterator.mockImplementationOnce(async function* () { +// throw new Error("scan failed"); +// }); +// mocks.createClient.mockReturnValue(client); + +// const { RedisCache } = await importCacheModule(); + +// const redisCache = new RedisCache({ prefix: "jobs-test" }); +// redisCache.memoryFallback.set("node", { cached: true }, 1000); + +// await expect(redisCache.get("node")).resolves.toEqual({ cached: true }); +// await expect(redisCache.set("node", { cached: false }, 1000)).resolves.toBeUndefined(); +// await expect(redisCache.delete("node")).resolves.toBeUndefined(); +// await expect(redisCache.clear()).resolves.toBeUndefined(); + +// expect(mocks.consoleWarn).toHaveBeenCalledTimes(1); +// }); + +// it("marca o status como desconectado quando o ping não retorna PONG", async () => { +// process.env.REDIS_URL = "redis://127.0.0.1:6379"; + +// const { client } = buildRedisClientMock({ ping: "NOPE" }); +// mocks.createClient.mockReturnValue(client); + +// const { warmupCache } = await importCacheModule(); + +// await expect(warmupCache()).resolves.toMatchObject({ +// configured: true, +// connected: false, +// lastError: "PING sem resposta esperada", +// }); +// }); +// }); + import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -17,253 +287,80 @@ function buildRedisClientMock(options = {}) { return client; }), connect: vi.fn(async () => { - if (options.connectError) { - throw options.connectError; - } - - if (options.triggerReady !== false) { - handlers.ready?.(); - } + if (options.connectError) throw options.connectError; + if (options.triggerReady !== false) handlers.ready?.(); }), ping: vi.fn(async () => options.ping ?? "PONG"), - get: vi.fn(async () => options.get ?? null), + get: vi.fn(async () => null), set: vi.fn(async () => "OK"), del: vi.fn(async () => 1), - scanIterator: vi.fn(async function* () { - if (options.scanError) { - throw options.scanError; - } - - for (const key of options.scanKeys ?? []) { - yield key; - } - }), + scanIterator: vi.fn(async function* () {}), }; - return { client, handlers }; } -async function importCacheModule() { - return import("../../../../src/cache/cache.js"); -} - -describe("MemoryCache", () => { - let memoryCache; - - beforeEach(async () => { +describe("cache facade", () => { + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); vi.restoreAllMocks(); delete process.env.REDIS_URL; - delete process.env.REDIS_KEY_PREFIX; - - const { MemoryCache } = await importCacheModule(); - memoryCache = new MemoryCache(); - }); - - it("retorna null quando a chave não existe", () => { - expect(memoryCache.get("inexistente")).toBeNull(); - }); - - it("salva e recupera um valor dentro do TTL", () => { - memoryCache.set("jobs:react", [{ titulo: "Dev React" }], 1000); - - expect(memoryCache.get("jobs:react")).toEqual([ - { titulo: "Dev React" }, - ]); - }); - - it("remove e retorna null quando o item expirou", () => { - const nowSpy = vi.spyOn(Date, "now"); - - nowSpy.mockReturnValueOnce(1000); - memoryCache.set("jobs:node", [{ titulo: "Dev Node" }], 500); - - nowSpy.mockReturnValueOnce(1601); - expect(memoryCache.get("jobs:node")).toBeNull(); - - expect(memoryCache.store.has("jobs:node")).toBe(false); - }); - - it("deleta uma chave específica", () => { - memoryCache.set("a", 123, 1000); - memoryCache.set("b", 456, 1000); - - memoryCache.delete("a"); - - expect(memoryCache.get("a")).toBeNull(); - expect(memoryCache.get("b")).toBe(456); - }); - - it("limpa todo o cache", () => { - memoryCache.set("a", 123, 1000); - memoryCache.set("b", 456, 1000); - - memoryCache.clear(); - - expect(memoryCache.get("a")).toBeNull(); - expect(memoryCache.get("b")).toBeNull(); - }); - - it("has retorna true quando a chave existe e não expirou", () => { - memoryCache.set("jobs", ["vaga 1"], 1000); - - expect(memoryCache.has("jobs")).toBe(true); + vi.spyOn(console, "warn").mockImplementation(mocks.consoleWarn); }); - it("has retorna false quando a chave não existe", () => { - expect(memoryCache.has("jobs")).toBe(false); + it("exporta cache como MemoryCache quando REDIS_URL não está configurada", async () => { + const { cache } = await import("../../../../src/cache/cache.js"); + const { MemoryCache } = + await import("../../../../src/cache/memoryCache.js"); + expect(cache).toBeInstanceOf(MemoryCache); }); - it("has retorna false quando a chave expirou", () => { - const nowSpy = vi.spyOn(Date, "now"); - - nowSpy.mockReturnValueOnce(2000); - memoryCache.set("jobs", ["vaga 1"], 100); + it("exporta cache como RedisCache quando REDIS_URL está configurada", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); - nowSpy.mockReturnValueOnce(2201); - expect(memoryCache.has("jobs")).toBe(false); + const { cache } = await import("../../../../src/cache/cache.js"); + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + expect(cache).toBeInstanceOf(RedisCache); }); -}); -describe("Redis cache helpers", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - vi.restoreAllMocks(); - delete process.env.REDIS_URL; - delete process.env.REDIS_KEY_PREFIX; - vi.spyOn(console, "warn").mockImplementation(mocks.consoleWarn); - }); + it("warmupCache retorna status memory quando REDIS_URL não está configurada", async () => { + const { warmupCache } = await import("../../../../src/cache/cache.js"); - it("retorna status de memoria quando REDIS_URL não está configurada", async () => { - const { MemoryCache, cache, getCacheStatus, getRedisClient, warmupCache } = await importCacheModule(); - - expect(await getRedisClient()).toBeNull(); - expect(cache).toBeInstanceOf(MemoryCache); - expect(getCacheStatus()).toMatchObject({ - provider: "memory", - configured: false, - connected: false, - type: "MemoryCache", - }); expect(await warmupCache()).toMatchObject({ provider: "memory", configured: false, connected: false, }); - expect(mocks.createClient).not.toHaveBeenCalled(); }); - it("conecta ao Redis, reaproveita o cliente e executa operações no RedisCache", async () => { + it("warmupCache retorna connected true após ping bem-sucedido", async () => { process.env.REDIS_URL = "redis://127.0.0.1:6379"; - process.env.REDIS_KEY_PREFIX = "jobs-test"; - - const { client, handlers } = buildRedisClientMock({ - scanKeys: ["jobs-test:a", "jobs-test:b"], - }); + const { client } = buildRedisClientMock({ ping: "PONG" }); mocks.createClient.mockReturnValue(client); - const { RedisCache, getCacheStatus, getRedisClient, warmupCache } = await importCacheModule(); - - const firstClient = await getRedisClient(); - const secondClient = await getRedisClient(); - expect(firstClient).toBe(client); - expect(secondClient).toBe(client); - expect(mocks.createClient).toHaveBeenCalledTimes(1); - - const socketConfig = mocks.createClient.mock.calls[0][0].socket; - expect(socketConfig.reconnectStrategy(0)).toBe(100); - expect(socketConfig.reconnectStrategy(3)).toBe(false); - - const redisCache = new RedisCache({ prefix: "jobs-test" }); - await redisCache.set("react", { ok: true }, 5000); - client.get.mockResolvedValueOnce(JSON.stringify({ ok: true })); - expect(await redisCache.get("react")).toEqual({ ok: true }); - - await redisCache.delete("react"); - await redisCache.clear(); - - expect(client.set).toHaveBeenCalledWith("jobs-test:react", JSON.stringify({ ok: true }), { - PX: 5000, - }); - expect(client.del).toHaveBeenCalledWith("jobs-test:react"); - expect(client.del).toHaveBeenCalledWith(["jobs-test:a", "jobs-test:b"]); + const { warmupCache } = await import("../../../../src/cache/cache.js"); expect(await warmupCache()).toMatchObject({ provider: "redis", configured: true, connected: true, - type: "RedisCache", }); - - handlers.end?.(); - expect(getCacheStatus().connected).toBe(false); - - process.env.REDIS_URL = "redis://127.0.0.1:6380"; - await getRedisClient(); - expect(mocks.createClient).toHaveBeenCalledTimes(2); }); - it("faz fallback quando a conexão com Redis falha", async () => { + it("warmupCache emite warn quando ping falha", async () => { process.env.REDIS_URL = "redis://127.0.0.1:6379"; - - const { client } = buildRedisClientMock({ - connectError: new Error("connect failed"), - triggerReady: false, - }); - mocks.createClient.mockReturnValue(client); - - const { getCacheStatus, getRedisClient } = await importCacheModule(); - - await expect(getRedisClient()).resolves.toBeNull(); - expect(mocks.consoleWarn).toHaveBeenCalledTimes(1); - expect(mocks.consoleWarn.mock.calls[0][0]).toContain("Falha ao conectar no Redis"); - expect(getCacheStatus()).toMatchObject({ - provider: "redis", - configured: true, - connected: false, - lastError: "connect failed", - }); - }); - - it("usa fallback em memória quando operações do Redis lançam erro", async () => { - process.env.REDIS_URL = "redis://127.0.0.1:6379"; - const { client } = buildRedisClientMock(); - client.get.mockResolvedValueOnce("{invalid-json"); - client.set.mockRejectedValueOnce(new Error("set failed")); - client.del.mockRejectedValueOnce(new Error("del failed")); - client.scanIterator.mockImplementationOnce(async function* () { - throw new Error("scan failed"); - }); + client.ping.mockRejectedValueOnce(new Error("ping failed")); mocks.createClient.mockReturnValue(client); - const { RedisCache } = await importCacheModule(); - - const redisCache = new RedisCache({ prefix: "jobs-test" }); - redisCache.memoryFallback.set("node", { cached: true }, 1000); + const { warmupCache } = await import("../../../../src/cache/cache.js"); + await warmupCache(); - await expect(redisCache.get("node")).resolves.toEqual({ cached: true }); - await expect(redisCache.set("node", { cached: false }, 1000)).resolves.toBeUndefined(); - await expect(redisCache.delete("node")).resolves.toBeUndefined(); - await expect(redisCache.clear()).resolves.toBeUndefined(); - - expect(mocks.consoleWarn).toHaveBeenCalledTimes(1); + expect(mocks.consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("Falha ao validar conexão com Redis"), + expect.any(String), + ); }); - - it("marca o status como desconectado quando o ping não retorna PONG", async () => { - process.env.REDIS_URL = "redis://127.0.0.1:6379"; - - const { client } = buildRedisClientMock({ ping: "NOPE" }); - mocks.createClient.mockReturnValue(client); - - const { warmupCache } = await importCacheModule(); - - await expect(warmupCache()).resolves.toMatchObject({ - configured: true, - connected: false, - lastError: "PING sem resposta esperada", - }); - }); -}); \ No newline at end of file +}); diff --git a/backend/tests/unit/services/cache/cacheStatus.test.js b/backend/tests/unit/services/cache/cacheStatus.test.js new file mode 100644 index 0000000..eb98453 --- /dev/null +++ b/backend/tests/unit/services/cache/cacheStatus.test.js @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isRedisConnected: vi.fn(), +})); + +vi.mock("../../../../src/cache/redisConnection.js", () => ({ + isRedisConnected: mocks.isRedisConnected, + getRedisClient: vi.fn(), +})); + +describe("getCacheStatus", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + delete process.env.REDIS_URL; + }); + + it("retorna provider memory quando REDIS_URL não está configurada", async () => { + mocks.isRedisConnected.mockReturnValue(false); + const { getCacheStatus } = + await import("../../../../src/cache/cacheStatus.js"); + + expect(getCacheStatus()).toEqual({ + provider: "memory", + configured: false, + connected: false, + }); + }); + + it("retorna provider redis e connected true quando Redis está conectado", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + mocks.isRedisConnected.mockReturnValue(true); + const { getCacheStatus } = + await import("../../../../src/cache/cacheStatus.js"); + + expect(getCacheStatus()).toEqual({ + provider: "redis", + configured: true, + connected: true, + }); + }); + + it("retorna connected false quando Redis está configurado mas desconectado", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + mocks.isRedisConnected.mockReturnValue(false); + const { getCacheStatus } = + await import("../../../../src/cache/cacheStatus.js"); + + expect(getCacheStatus()).toEqual({ + provider: "redis", + configured: true, + connected: false, + }); + }); +}); diff --git a/backend/tests/unit/services/cache/memoryCache.test.js b/backend/tests/unit/services/cache/memoryCache.test.js new file mode 100644 index 0000000..b8ad652 --- /dev/null +++ b/backend/tests/unit/services/cache/memoryCache.test.js @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryCache } from "../../../../src/cache/memoryCache.js"; + +describe("MemoryCache", () => { + let memoryCache; + + beforeEach(() => { + memoryCache = new MemoryCache(); + }); + + it("retorna null quando a chave não existe", () => { + expect(memoryCache.get("inexistente")).toBeNull(); + }); + + it("salva e recupera um valor dentro do TTL", () => { + memoryCache.set("jobs:react", [{ titulo: "Dev React" }], 1000); + expect(memoryCache.get("jobs:react")).toEqual([{ titulo: "Dev React" }]); + }); + + it("remove e retorna null quando o item expirou", () => { + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValueOnce(1000); + memoryCache.set("jobs:node", [{ titulo: "Dev Node" }], 5000); + nowSpy.mockReturnValueOnce(7000); + expect(memoryCache.get("jobs:node")).toBeNull(); + expect(memoryCache.store.has("jobs:node")).toBe(false); + }); + + it("aplica TTL mínimo de 1000ms", () => { + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValueOnce(1000); + memoryCache.set("jobs:vue", [{ titulo: "Dev Vue" }], 0); + nowSpy.mockReturnValueOnce(1500); + expect(memoryCache.get("jobs:vue")).toEqual([{ titulo: "Dev Vue" }]); + }); + + it("deleta uma chave específica", () => { + memoryCache.set("a", 123, 1000); + memoryCache.set("b", 456, 1000); + memoryCache.delete("a"); + expect(memoryCache.get("a")).toBeNull(); + expect(memoryCache.get("b")).toBe(456); + }); + + it("limpa todo o cache", () => { + memoryCache.set("a", 123, 1000); + memoryCache.set("b", 456, 1000); + memoryCache.clear(); + expect(memoryCache.get("a")).toBeNull(); + expect(memoryCache.get("b")).toBeNull(); + }); + + it("has retorna true quando a chave existe e não expirou", () => { + memoryCache.set("jobs", ["vaga 1"], 1000); + expect(memoryCache.has("jobs")).toBe(true); + }); + + it("has retorna false quando a chave não existe", () => { + expect(memoryCache.has("jobs")).toBe(false); + }); + + it("has retorna false quando a chave expirou", () => { + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValueOnce(2000); + memoryCache.set("jobs", ["vaga 1"], 1000); + nowSpy.mockReturnValueOnce(4000); + expect(memoryCache.has("jobs")).toBe(false); + }); +}); diff --git a/backend/tests/unit/services/cache/redisCache.test.js b/backend/tests/unit/services/cache/redisCache.test.js new file mode 100644 index 0000000..425c078 --- /dev/null +++ b/backend/tests/unit/services/cache/redisCache.test.js @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + consoleWarn: vi.fn(), +})); + +vi.mock("redis", () => ({ + createClient: mocks.createClient, +})); + +function buildRedisClientMock(options = {}) { + const handlers = {}; + const client = { + on: vi.fn((event, handler) => { + handlers[event] = handler; + return client; + }), + connect: vi.fn(async () => { + if (options.connectError) throw options.connectError; + if (options.triggerReady !== false) handlers.ready?.(); + }), + get: vi.fn(async () => options.get ?? null), + set: vi.fn(async () => "OK"), + del: vi.fn(async () => 1), + scanIterator: vi.fn(async function* () { + for (const key of options.scanKeys ?? []) yield key; + }), + }; + return { client, handlers }; +} + +describe("RedisCache", () => { + const REDIS_URL = process.env.REDIS_URL; + const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX ?? "jobs-test"; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + process.env.REDIS_URL = REDIS_URL; + process.env.REDIS_KEY_PREFIX = REDIS_KEY_PREFIX; + vi.spyOn(console, "warn").mockImplementation(mocks.consoleWarn); + }); + + it("faz get e retorna valor do Redis", async () => { + const { client } = buildRedisClientMock(); + client.get.mockResolvedValueOnce(JSON.stringify({ titulo: "Dev React" })); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + expect(await cache.get("react")).toEqual({ titulo: "Dev React" }); + expect(client.get).toHaveBeenCalledWith(`${REDIS_KEY_PREFIX}:react`); + }); + + it("retorna null quando Redis retorna JSON inválido", async () => { + const { client } = buildRedisClientMock(); + client.get.mockResolvedValueOnce("{invalid-json"); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + expect(await cache.get("react")).toBeNull(); + }); + + it("faz fallback para memória quando Redis lança exceção no get", async () => { + const { client } = buildRedisClientMock(); + client.get.mockRejectedValueOnce(new Error("get failed")); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + cache.memory.set("react", { cached: true }, 5000); + + expect(await cache.get("react")).toEqual({ cached: true }); + }); + + it("faz set no Redis com TTL correto", async () => { + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.set("react", { ok: true }, 5000); + + expect(client.set).toHaveBeenCalledWith( + `${REDIS_KEY_PREFIX}:react`, + JSON.stringify({ ok: true }), + { PX: 5000 }, + ); + }); + + it("aplica TTL mínimo de 1000ms no set", async () => { + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.set("react", { ok: true }, 0); + + expect(client.set).toHaveBeenCalledWith( + `${REDIS_KEY_PREFIX}:react`, + JSON.stringify({ ok: true }), + { PX: 1000 }, + ); + }); + + it("faz delete no Redis e na memória", async () => { + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.delete("react"); + + expect(client.del).toHaveBeenCalledWith(`${REDIS_KEY_PREFIX}:react`); + }); + + it("faz clear em lote no Redis", async () => { + const scanKeys = [`${REDIS_KEY_PREFIX}:a`, `${REDIS_KEY_PREFIX}:b`]; + const { client } = buildRedisClientMock({ scanKeys }); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.clear(); + + expect(client.del).toHaveBeenCalledWith(scanKeys); + }); + + it("faz fallback para memória quando REDIS_URL não está configurada", async () => { + delete process.env.REDIS_URL; + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + cache.memory.set("react", { cached: true }, 5000); + expect(await cache.get("react")).toEqual({ cached: true }); + }); + + it("emite warn quando set falha e faz fallback para memória", async () => { + const { client } = buildRedisClientMock(); + client.set.mockRejectedValueOnce(new Error("set failed")); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.set("react", { ok: true }, 5000); + + expect(mocks.consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("Erro ao salvar no Redis"), + expect.any(String), + ); + }); + + it("emite warn quando delete falha", async () => { + const { client } = buildRedisClientMock(); + client.del.mockRejectedValueOnce(new Error("del failed")); + mocks.createClient.mockReturnValue(client); + + const { RedisCache } = await import("../../../../src/cache/redisCache.js"); + const cache = new RedisCache({ prefix: REDIS_KEY_PREFIX }); + + await cache.delete("react"); + + expect(mocks.consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("Erro ao deletar chave do Redis"), + expect.any(String), + ); + }); +}); diff --git a/backend/tests/unit/services/cache/redisConnection.test.js b/backend/tests/unit/services/cache/redisConnection.test.js new file mode 100644 index 0000000..3d47885 --- /dev/null +++ b/backend/tests/unit/services/cache/redisConnection.test.js @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + consoleWarn: vi.fn(), +})); + +vi.mock("redis", () => ({ + createClient: mocks.createClient, +})); + +function buildRedisClientMock(options = {}) { + const handlers = {}; + const client = { + on: vi.fn((event, handler) => { + handlers[event] = handler; + return client; + }), + connect: vi.fn(async () => { + if (options.connectError) throw options.connectError; + if (options.triggerReady !== false) handlers.ready?.(); + }), + ping: vi.fn(async () => options.ping ?? "PONG"), + get: vi.fn(async () => null), + set: vi.fn(async () => "OK"), + del: vi.fn(async () => 1), + scanIterator: vi.fn(async function* () {}), + }; + return { client, handlers }; +} + +describe("redisConnection", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + delete process.env.REDIS_URL; + vi.spyOn(console, "warn").mockImplementation(mocks.consoleWarn); + }); + + it("retorna null quando REDIS_URL não está configurada", async () => { + const { getRedisClient } = + await import("../../../../src/cache/redisConnection.js"); + expect(await getRedisClient()).toBeNull(); + expect(mocks.createClient).not.toHaveBeenCalled(); + }); + + it("conecta ao Redis e reutiliza o cliente na segunda chamada", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { getRedisClient } = + await import("../../../../src/cache/redisConnection.js"); + + const first = await getRedisClient(); + const second = await getRedisClient(); + + expect(first).toBe(client); + expect(second).toBe(client); + expect(mocks.createClient).toHaveBeenCalledTimes(1); + }); + + it("reconecta quando a REDIS_URL muda", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client: clientA } = buildRedisClientMock(); + const { client: clientB } = buildRedisClientMock(); + mocks.createClient + .mockReturnValueOnce(clientA) + .mockReturnValueOnce(clientB); + + const { getRedisClient } = + await import("../../../../src/cache/redisConnection.js"); + + await getRedisClient(); + process.env.REDIS_URL = "redis://127.0.0.1:6380"; + await getRedisClient(); + + expect(mocks.createClient).toHaveBeenCalledTimes(2); + }); + + it("configura reconnectStrategy corretamente", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { getRedisClient } = + await import("../../../../src/cache/redisConnection.js"); + await getRedisClient(); + + const socketConfig = mocks.createClient.mock.calls[0][0].socket; + expect(socketConfig.reconnectStrategy(0)).toBe(100); + expect(socketConfig.reconnectStrategy(1)).toBe(200); + expect(socketConfig.reconnectStrategy(3)).toBe(false); + }); + + it("retorna null e emite warn quando a conexão falha", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client } = buildRedisClientMock({ + connectError: new Error("connect failed"), + triggerReady: false, + }); + mocks.createClient.mockReturnValue(client); + + const { getRedisClient } = + await import("../../../../src/cache/redisConnection.js"); + + expect(await getRedisClient()).toBeNull(); + expect(mocks.consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("Falha ao conectar no Redis"), + expect.any(String), + ); + }); + + it("isRedisConnected retorna true após conexão e false após end", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client, handlers } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { getRedisClient, isRedisConnected } = + await import("../../../../src/cache/redisConnection.js"); + + await getRedisClient(); + expect(isRedisConnected()).toBe(true); + + handlers.end?.(); + expect(isRedisConnected()).toBe(false); + }); + + it("isRedisConnected retorna false após erro de conexão", async () => { + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + const { client, handlers } = buildRedisClientMock(); + mocks.createClient.mockReturnValue(client); + + const { getRedisClient, isRedisConnected } = + await import("../../../../src/cache/redisConnection.js"); + + await getRedisClient(); + handlers.error?.(new Error("redis error")); + expect(isRedisConnected()).toBe(false); + }); +}); diff --git a/backend/tests/unit/services/db/keywordsStore.test.js b/backend/tests/unit/services/db/keywordsStore.test.js index 95577e0..a6e9651 100644 --- a/backend/tests/unit/services/db/keywordsStore.test.js +++ b/backend/tests/unit/services/db/keywordsStore.test.js @@ -4,8 +4,9 @@ const mocks = vi.hoisted(() => ({ getRedisClient: vi.fn(), })); -vi.mock("../../../../src/cache/cache.js", () => ({ +vi.mock("../../../../src/cache/redisConnection.js", () => ({ getRedisClient: mocks.getRedisClient, + isRedisConnected: vi.fn().mockReturnValue(false), })); async function importKeywordsStore() { diff --git a/backend/vitest.config.js b/backend/vitest.config.js index 1633559..045fd61 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -1,18 +1,46 @@ +// import { defineConfig } from "vitest/config"; + +// export default defineConfig({ +// test: { +// globals: true, +// environment: "node", +// env: loadEnv(mode, process.cwd(), ""), +// setupFiles: ["./tests/setup.js"], +// coverage: { +// provider: "v8", +// reporter: ["text", "html"], +// thresholds: { +// lines: 80, +// functions: 80, +// branches: 80, +// statements: 80, +// }, +// }, +// }, +// }); + +import { loadEnv } from "vite"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - test: { - globals: true, - environment: "node", - coverage: { - provider: "v8", - reporter: ["text", "html"], - thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode ?? "test", process.cwd(), ""); + + return { + test: { + globals: true, + environment: "node", + setupFiles: ["./tests/setup.js"], + env, + coverage: { + provider: "v8", + reporter: ["text", "html"], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, }, }, - }, + }; }); diff --git a/package-lock.json b/package-lock.json index f4ad1ea..9692281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8178,6 +8178,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8199,6 +8200,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8220,6 +8222,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8241,6 +8244,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8262,6 +8266,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8283,6 +8288,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8304,6 +8310,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8325,6 +8332,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8346,6 +8354,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8367,6 +8376,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8388,6 +8398,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" },