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:
1 parent
5ee5dcd893
commit
30d7ef701f
7 files changed
+273
-30
No files matched your search
@@ -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/
|
||||
Vendored
+23
-9
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user