- 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).
130 lines
5.2 KiB
TypeScript
130 lines
5.2 KiB
TypeScript
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");
|
|
});
|