From 30d7ef701fd2d655b1c20091e1c19c74a2997c8c Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 04:18:59 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20cache=20JSON=20concorrente,=20valida?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20sync=20e=20timeout=20nas=20chamadas=20ao?= =?UTF-8?q?=20Open=20Food=20Facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JsonCacheManager: a leitura inicial concorrente carregava o arquivo duas vezes e a segunda descartava as alterações da primeira; gravações simultâneas escreviam no mesmo arquivo. Agora a leitura é compartilhada e a gravação é enfileirada, via arquivo temporário + rename. - POST /api/cache/sync: aceitava qualquer objeto e usava code como chave do cache (inclusive ausente ou não numérico); agora só grava produtos com code numérico e conta os demais em failed. JSON malformado responde 400 em vez de 500. - GET /api/products/[barcode]: resposta da API sem product virava 200 com corpo vazio; agora responde 404. - Chamadas ao Open Food Facts (busca e produto) passam a ter timeout de 15 s e o User-Agent vem de um único lugar (lib/offSearch). --- .gitignore | 1 + app/api/cache/sync/route.ts | 32 +++++-- app/api/products/[barcode]/route.ts | 8 +- lib/jsonCacheManager.ts | 50 +++++++---- lib/offSearch.ts | 3 + tests/cache-sync.test.ts | 80 +++++++++++++++++ tests/products-route.test.ts | 129 ++++++++++++++++++++++++++++ 7 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 tests/cache-sync.test.ts create mode 100644 tests/products-route.test.ts diff --git a/.gitignore b/.gitignore index c7f3f49..7cb90c0 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,5 @@ dist # Banco SQLite local (criado por `pnpm db:push`) e saída de ferramentas *.sqlite *.sqlite-journal +data/*.tmp graphify-out/ diff --git a/app/api/cache/sync/route.ts b/app/api/cache/sync/route.ts index d12311d..440c319 100644 --- a/app/api/cache/sync/route.ts +++ b/app/api/cache/sync/route.ts @@ -1,26 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; import { jsonCacheManager } from "@/lib/jsonCacheManager"; +import type { Product } from "@/lib/openFoodFactsApi"; + +const INVALID_PRODUCTS = { error: "Produtos inválidos" }; + +/** Aceita só objetos com `code` numérico (texto de dígitos): a chave do cache não pode ser arbitrária. */ +function isSyncableProduct(value: unknown): value is Product { + if (typeof value !== "object" || value === null) return false; + const code = (value as { code?: unknown }).code; + return typeof code === "string" && /^\d{1,32}$/.test(code); +} export async function POST(request: NextRequest) { + let body: { products?: unknown }; try { - const { products } = await request.json(); + body = await request.json(); + } catch { + return NextResponse.json(INVALID_PRODUCTS, { status: 400 }); + } - if (!products || !Array.isArray(products)) { - return NextResponse.json( - { error: "Produtos inválidos" }, - { status: 400 } - ); + try { + const products = body?.products; + if (!Array.isArray(products)) { + return NextResponse.json(INVALID_PRODUCTS, { status: 400 }); } - // Salvar produtos no cache - const result = await jsonCacheManager.saveProducts(products); + // Salvar produtos válidos no cache; os demais contam como falha + const valid = products.filter(isSyncableProduct); + const result = await jsonCacheManager.saveProducts(valid); console.log(`[Cache Sync] ${result.saved} novos produtos salvos`); return NextResponse.json({ success: true, saved: result.saved, - failed: result.failed, + failed: result.failed + (products.length - valid.length), }); } catch (error) { console.error("Erro ao sincronizar cache:", error); diff --git a/app/api/products/[barcode]/route.ts b/app/api/products/[barcode]/route.ts index 476d6f2..7a39ae5 100644 --- a/app/api/products/[barcode]/route.ts +++ b/app/api/products/[barcode]/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { jsonCacheManager } from "@/lib/jsonCacheManager"; - -const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; +import { OFF_TIMEOUT_MS, OFF_USER_AGENT } from "@/lib/offSearch"; export async function GET( request: NextRequest, @@ -55,9 +54,10 @@ export async function GET( `https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json`, { headers: { - "User-Agent": USER_AGENT, + "User-Agent": OFF_USER_AGENT, Accept: "application/json", }, + signal: AbortSignal.timeout(OFF_TIMEOUT_MS), } ); @@ -70,7 +70,7 @@ export async function GET( const data = await response.json(); - if (data.status === 0) { + if (data.status === 0 || !data.product) { return NextResponse.json( { error: "Produto não encontrado" }, { status: 404 } diff --git a/lib/jsonCacheManager.ts b/lib/jsonCacheManager.ts index b60353f..6bfe5c9 100644 --- a/lib/jsonCacheManager.ts +++ b/lib/jsonCacheManager.ts @@ -18,6 +18,10 @@ interface JsonCacheData { export class JsonCacheManager { private cacheFilePath: string; private cacheData: JsonCacheData | null = null; + /** Fila de gravações: evita que duas gravações simultâneas corrompam o arquivo. */ + private writeQueue: Promise = Promise.resolve(); + /** Leitura inicial em andamento, compartilhada por chamadas concorrentes. */ + private loadPromise: Promise | null = null; private static instance: JsonCacheManager; constructor() { @@ -53,7 +57,14 @@ export class JsonCacheManager { /** * Carrega dados do cache do arquivo JSON */ - private async loadCache(): Promise { + private loadCache(): Promise { + // Sem isto, duas chamadas simultâneas na primeira leitura carregam o arquivo + // duas vezes e a segunda descarta as alterações feitas pela primeira. + this.loadPromise ??= this.readCacheFile(); + return this.loadPromise; + } + + private async readCacheFile(): Promise { if (this.cacheData) return; try { @@ -86,23 +97,28 @@ export class JsonCacheManager { /** * Salva dados do cache no arquivo JSON */ - private async saveCache(): Promise { - if (!this.cacheData) return; + private saveCache(): Promise { + const write = async () => { + if (!this.cacheData) return; - try { - await fs.writeFile( - this.cacheFilePath, - JSON.stringify(this.cacheData, null, 2), - "utf-8" - ); - console.log( - `[JSON Cache] Cache salvo: ${ - Object.keys(this.cacheData.products).length - } produtos` - ); - } catch (error) { - console.error("[JSON Cache] Erro ao salvar cache:", error); - } + try { + // Grava em arquivo temporário e renomeia: o arquivo final nunca fica pela metade + const tempPath = `${this.cacheFilePath}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(this.cacheData, null, 2), "utf-8"); + await fs.rename(tempPath, this.cacheFilePath); + console.log( + `[JSON Cache] Cache salvo: ${ + Object.keys(this.cacheData.products).length + } produtos` + ); + } catch (error) { + console.error("[JSON Cache] Erro ao salvar cache:", error); + } + }; + + // write() nunca rejeita, então a fila continua mesmo após uma falha + this.writeQueue = this.writeQueue.then(write); + return this.writeQueue; } /** diff --git a/lib/offSearch.ts b/lib/offSearch.ts index fd76a48..951a5bf 100644 --- a/lib/offSearch.ts +++ b/lib/offSearch.ts @@ -11,6 +11,8 @@ export const OFF_USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; export const OFF_MAX_PAGE_SIZE = 1000; /** Pausa entre páginas consecutivas, para respeitar o limite de uso da API. */ export const OFF_RATE_LIMIT_MS = 200; +/** Tempo máximo de espera por página da API. */ +export const OFF_TIMEOUT_MS = 15000; const FIELDS = [ "code", @@ -138,6 +140,7 @@ async function fetchOffPage( const response = await deps.fetchFn(`${OFF_SEARCH_URL}?${pageParams.toString()}`, { headers: { "User-Agent": OFF_USER_AGENT, Accept: "application/json" }, + signal: AbortSignal.timeout(OFF_TIMEOUT_MS), }); if (!response.ok) { diff --git a/tests/cache-sync.test.ts b/tests/cache-sync.test.ts new file mode 100644 index 0000000..d02ff97 --- /dev/null +++ b/tests/cache-sync.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test, { before } from "node:test"; + +/** Rota POST /api/cache/sync e gravação concorrente no cache JSON, em pasta temporária. */ + +let POST: (req: import("next/server").NextRequest) => Promise; +let NextRequestCtor: typeof import("next/server").NextRequest; +let JsonCacheManager: typeof import("../lib/jsonCacheManager.ts").JsonCacheManager; +let cacheFile: string; + +before(async () => { + const dir = mkdtempSync(path.join(tmpdir(), "nutriscan-sync-")); + mkdirSync(path.join(dir, "data")); + cacheFile = path.join(dir, "data", "products-cache.json"); + process.chdir(dir); + + console.log = () => {}; + console.error = () => {}; + + ({ POST } = await import("../app/api/cache/sync/route.ts")); + ({ NextRequest: NextRequestCtor } = await import("next/server")); + ({ JsonCacheManager } = await import("../lib/jsonCacheManager.ts")); +}); + +async function post(body: string) { + const req = new NextRequestCtor("http://localhost/api/cache/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + const res = await POST(req); + return { status: res.status, body: (await res.json()) as Record }; +} + +const readCache = () => JSON.parse(readFileSync(cacheFile, "utf-8")) as { products: Record }; + +test("corpo que não é JSON, sem products ou com products fora de lista responde 400", async () => { + for (const body of ["não é json", "{}", "null", '{"products":"x"}', '{"products":{"a":1}}']) { + const { status, body: json } = await post(body); + assert.equal(status, 400, body); + assert.equal(json.error, "Produtos inválidos"); + } +}); + +test("lista vazia é aceita", async () => { + const { status, body } = await post('{"products":[]}'); + assert.equal(status, 200); + assert.deepEqual(body, { success: true, saved: 0, failed: 0 }); +}); + +test("só produtos com code numérico são gravados; os demais contam como falha", async () => { + const products = [ + { code: "7622210575975", product_name: "Válido" }, + { code: "__proto__", product_name: "Chave perigosa" }, + { code: "abc", product_name: "Não numérico" }, + { code: 123, product_name: "Número" }, + { product_name: "Sem code" }, + null, + "texto", + ]; + const { status, body } = await post(JSON.stringify({ products })); + assert.equal(status, 200); + assert.deepEqual(body, { success: true, saved: 1, failed: 6 }); + assert.deepEqual(Object.keys(readCache().products), ["7622210575975"]); +}); + +test("gravações simultâneas não corrompem o arquivo nem deixam temporário", async () => { + const manager = new JsonCacheManager(); + const batches = Array.from({ length: 25 }, (_, b) => + Array.from({ length: 40 }, (_, i) => ({ code: String(1000 + b * 40 + i), product_name: `Produto ${b}-${i}` })) + ); + await Promise.all(batches.map((batch) => manager.saveProducts(batch))); + + const saved = readCache(); + assert.equal(Object.keys(saved.products).length, 1000 + 1); + assert.equal(existsSync(`${cacheFile}.tmp`), false); +}); diff --git a/tests/products-route.test.ts b/tests/products-route.test.ts new file mode 100644 index 0000000..64e306a --- /dev/null +++ b/tests/products-route.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test, { before, beforeEach } from "node:test"; + +/** Rota GET /api/products/[barcode], com fetch mockado e cache JSON em pasta temporária. */ + +type Handler = (url: URL, init?: RequestInit) => { ok: boolean; body?: unknown }; +type Json = Record; // eslint-disable-line @typescript-eslint/no-explicit-any + +const calls: Array<{ url: URL; init?: RequestInit }> = []; +let handler: Handler = () => ({ ok: true, body: {} }); +let GET: (req: import("next/server").NextRequest, ctx: { params: Promise<{ barcode: string }> }) => Promise; +let NextRequestCtor: typeof import("next/server").NextRequest; +let cacheFile: string; + +before(async () => { + const dir = mkdtempSync(path.join(tmpdir(), "nutriscan-products-")); + mkdirSync(path.join(dir, "data")); + cacheFile = path.join(dir, "data", "products-cache.json"); + const meta = { firstSeen: 1, lastUpdated: 1, updateCount: 1 }; + const cached = { code: "7622210575975", product_name: "Bis", _metadata: meta }; + writeFileSync(cacheFile, JSON.stringify({ products: { [cached.code]: cached }, lastUpdated: 1, version: "1.0.0" })); + process.chdir(dir); + + console.log = () => {}; + console.error = () => {}; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + calls.push({ url, init }); + const result = handler(url, init); + return { ok: result.ok, status: result.ok ? 200 : 404, json: async () => result.body } as Response; + }) as typeof fetch; + + ({ GET } = await import("../app/api/products/[barcode]/route.ts")); + ({ NextRequest: NextRequestCtor } = await import("next/server")); +}); + +beforeEach(() => { + calls.length = 0; + handler = () => ({ ok: true, body: {} }); + delete process.env.NEXT_PUBLIC_CACHE_ONLY_MODE; +}); + +async function call(barcode: string, query = "") { + const req = new NextRequestCtor(`http://localhost/api/products/${barcode}${query}`); + const res = await GET(req, { params: Promise.resolve({ barcode }) }); + return { status: res.status, body: (await res.json()) as Json }; +} + +test("código de barras com menos de 8 ou mais de 13 dígitos responde 400", async () => { + for (const code of ["123", "12345678901234"]) { + const { status, body } = await call(code); + assert.equal(status, 400, code); + assert.equal(body.error, "Código de barras inválido"); + } + assert.equal(calls.length, 0); +}); + +test("produto no cache é devolvido sem chamar a API e sem metadados internos", async () => { + const { status, body } = await call("7622210575975"); + assert.equal(status, 200); + assert.equal(body.product_name, "Bis"); + assert.equal(body.fromCache, true); + assert.equal(body.fromAPI, false); + assert.equal(body._metadata, undefined); + assert.equal(calls.length, 0); +}); + +test("não-dígitos são removidos antes da validação", async () => { + const { status, body } = await call("7622-2105-75975"); + assert.equal(status, 200); + assert.equal(body.fromCache, true); +}); + +test("cache_only sem o produto no cache responde 404 sem chamar a API", async () => { + const { status } = await call("3017620422003", "?cache_only=true"); + assert.equal(status, 404); + assert.equal(calls.length, 0); + + process.env.NEXT_PUBLIC_CACHE_ONLY_MODE = "true"; + assert.equal((await call("3017620422003")).status, 404); + assert.equal(calls.length, 0); +}); + +test("produto fora do cache é buscado na API com User-Agent e gravado no cache", async () => { + handler = () => ({ ok: true, body: { status: 1, product: { code: "3017620422003", product_name: "Nutella" } } }); + const { status, body } = await call("3017620422003"); + assert.equal(status, 200); + assert.equal(body.product_name, "Nutella"); + assert.equal(body.fromAPI, true); + assert.equal(body.fromCache, false); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url.href, "https://world.openfoodfacts.org/api/v2/product/3017620422003.json"); + assert.match((calls[0].init?.headers as Record)["User-Agent"], /^NutriScan\//); + assert.ok(calls[0].init?.signal, "a requisição deve ter timeout"); + + const saved = JSON.parse(readFileSync(cacheFile, "utf-8")); + assert.equal(saved.products["3017620422003"].product_name, "Nutella"); + + calls.length = 0; + assert.equal((await call("3017620422003")).body.fromCache, true); + assert.equal(calls.length, 0); +}); + +test("API com erro HTTP, status 0 ou sem product responde 404", async () => { + handler = () => ({ ok: false }); + assert.equal((await call("1111111111111")).status, 404); + + handler = () => ({ ok: true, body: { status: 0 } }); + assert.equal((await call("2222222222222")).status, 404); + + handler = () => ({ ok: true, body: { status: 1 } }); + const { status, body } = await call("3333333333333"); + assert.equal(status, 404); + assert.equal(body.error, "Produto não encontrado"); +}); + +test("falha de rede na API responde 500", async () => { + handler = () => { + throw new Error("rede caiu"); + }; + const { status, body } = await call("4444444444444"); + assert.equal(status, 500); + assert.equal(body.error, "Erro interno do servidor"); +});