fix: cache JSON concorrente, validação do sync e timeout nas chamadas ao Open Food Facts

- 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).
This commit is contained in:
evandro committed 2026-10-10 04:18:59 -03:00
1 parent 5ee5dcd893
commit 30d7ef701f
7 files changed
+273 -30

No files matched your search

+1
View File
@@ -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/
+23 -9
View File
@@ -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);
+4 -4
View File
@@ -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 }
+33 -17
View File
@@ -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<void> = Promise.resolve();
/** Leitura inicial em andamento, compartilhada por chamadas concorrentes. */
private loadPromise: Promise<void> | 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<void> {
private loadCache(): Promise<void> {
// 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<void> {
if (this.cacheData) return;
try {
@@ -86,23 +97,28 @@ export class JsonCacheManager {
/**
* Salva dados do cache no arquivo JSON
*/
private async saveCache(): Promise<void> {
if (!this.cacheData) return;
private saveCache(): Promise<void> {
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;
}
/**
+3
View File
@@ -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) {
+80
View File
@@ -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<Response>;
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<string, unknown> };
}
const readCache = () => JSON.parse(readFileSync(cacheFile, "utf-8")) as { products: Record<string, { product_name: string }> };
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);
});
+129
View File
@@ -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<string, any>; // 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<Response>;
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<string, string>)["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");
});