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
@@ -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