import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test, { before, beforeEach } from "node:test"; /** * Caracterização da rota GET /api/search. Nenhum teste chama o Open Food Facts: * `fetch` é substituído por um mock e o cache JSON do servidor aponta para uma * pasta temporária (o gerenciador resolve o caminho a partir de process.cwd()). */ type Handler = (url: URL) => { ok: boolean; status?: number; body?: unknown }; type Json = Record; // eslint-disable-line @typescript-eslint/no-explicit-any const calls: URL[] = []; let handler: Handler = () => ({ ok: true, body: { products: [] } }); let GET: (req: import("next/server").NextRequest) => Promise; let NextRequestCtor: typeof import("next/server").NextRequest; function makeProduct(code: string, name: string, extra: Record = {}) { return { code, product_name: name, brands: "Marca", categories: "Categoria", ...extra }; } function codes(products: { code: string }[]) { return products.map((p) => p.code); } function seedCache() { const now = Date.now(); const meta = { firstSeen: now, lastUpdated: now, updateCount: 1 }; const entries = [ makeProduct("1000000000001", "Chocolate", { countries_tags: ["en:brazil"], countries: "Brazil" }), makeProduct("1000000000002", "Chocolate ao leite", { countries_tags: ["en:france"], countries: "France" }), makeProduct("1000000000003", "Barra de chocolate"), makeProduct("1000000000004", "Biscoito", { brands: "Chocolate Co" }), makeProduct("1000000000005", "Suco de laranja"), ]; const products = Object.fromEntries(entries.map((p) => [p.code, { ...p, _metadata: meta }])); return JSON.stringify({ products, lastUpdated: now, version: "1.0.0" }); } before(async () => { const dir = mkdtempSync(path.join(tmpdir(), "nutriscan-search-")); mkdirSync(path.join(dir, "data")); writeFileSync(path.join(dir, "data", "products-cache.json"), seedCache()); process.chdir(dir); console.log = () => {}; console.error = () => {}; globalThis.fetch = (async (input: string | URL | Request) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); calls.push(url); const result = handler(url); return { ok: result.ok, status: result.status ?? (result.ok ? 200 : 503), statusText: result.ok ? "OK" : "Service Unavailable", json: async () => result.body, } as Response; }) as typeof fetch; ({ GET } = await import("../app/api/search/route.ts")); ({ NextRequest: NextRequestCtor } = await import("next/server")); }); beforeEach(() => { calls.length = 0; handler = () => ({ ok: true, body: { products: [] } }); delete process.env.NEXT_PUBLIC_CACHE_ONLY_MODE; }); async function call(query: string) { const res = await GET(new NextRequestCtor(`http://localhost/api/search?${query}`)); return { status: res.status, body: (await res.json()) as Json }; } test("sem q responde 400", async () => { const { status, body } = await call("page=1"); assert.equal(status, 400); assert.equal(body.error, "Query parameter is required"); assert.equal(calls.length, 0); }); test("page inválido responde 400; valor válido não", async () => { for (const page of ["0", "-1", "abc"]) { const { status, body } = await call(`q=x&page=${page}&cache_only=true`); assert.equal(status, 400, `page=${page}`); assert.equal(body.error, "Invalid page parameter"); } assert.equal((await call("q=x&page=2&cache_only=true")).status, 200); }); test("page_size inválido responde 400; valor válido não", async () => { for (const size of ["0", "-5", "abc"]) { const { status, body } = await call(`q=x&page_size=${size}&cache_only=true`); assert.equal(status, 400, `page_size=${size}`); assert.equal(body.error, "Invalid page_size parameter"); } assert.equal((await call("q=x&page_size=5&cache_only=true")).status, 200); }); test("cache_only=true responde só com o cache, ordenado por relevância, sem chamar a API", async () => { const { status, body } = await call("q=chocolate&cache_only=true&page_size=10"); assert.equal(status, 200); assert.deepEqual(codes(body.products), ["1000000000001", "1000000000002", "1000000000003", "1000000000004"]); assert.equal(body.count, 4); assert.equal(body.page, 1); assert.equal(body.page_size, 10); assert.equal(body.fromCache, true); assert.equal(body.fromAPI, false); assert.equal(body.cacheOnly, true); assert.equal(body.products[0]._metadata, undefined); assert.equal(calls.length, 0); }); test("NEXT_PUBLIC_CACHE_ONLY_MODE=true também evita a API; outro valor não", async () => { process.env.NEXT_PUBLIC_CACHE_ONLY_MODE = "true"; const only = await call("q=chocolate&page_size=10"); assert.equal(only.body.cacheOnly, true); assert.equal(calls.length, 0); process.env.NEXT_PUBLIC_CACHE_ONLY_MODE = "false"; const online = await call("q=chocolate&page_size=10"); assert.equal(online.body.cacheOnly, undefined); assert.equal(calls.length, 1); }); test("filtro de país no cache usa countries_tags ou o nome do país", async () => { const byTag = await call("q=chocolate&country=en:brazil&cache_only=true"); assert.deepEqual(codes(byTag.body.products), ["1000000000001"]); const byName = await call("q=chocolate&country=france&cache_only=true"); assert.deepEqual(codes(byName.body.products), ["1000000000002"]); }); test("cache com resultados suficientes não chama a API", async () => { const { body } = await call("q=chocolate&page_size=2"); assert.equal(calls.length, 0); assert.equal(body.fromAPI, false); assert.equal(body.fromCache, true); assert.equal(body.products.length, 2); assert.equal(body.count, 2); assert.equal(body.skip, 0); assert.deepEqual(body.cacheStats, { localProducts: 2, apiProducts: 0, newProducts: 0, totalReturned: 2 }); }); test("cache insuficiente consulta a API com os parâmetros básicos e a identificação do cliente", async () => { const { body } = await call("q=suco&page=3&page_size=5"); assert.equal(calls.length, 1); const url = calls[0]; assert.equal(url.origin + url.pathname, "https://world.openfoodfacts.org/cgi/search.pl"); assert.equal(url.searchParams.get("search_terms"), "suco"); assert.equal(url.searchParams.get("page"), "1"); assert.equal(url.searchParams.get("page_size"), "1000"); assert.equal(url.searchParams.get("json"), "true"); assert.match(url.searchParams.get("fields") ?? "", /^code,product_name,brands,/); assert.equal(url.searchParams.has("sort_by"), false); assert.equal(body.page, 3); assert.equal(body.skip, 10); assert.equal(body.fromAPI, false); }); test("filtros opcionais são repassados com os nomes do Open Food Facts; ausentes não são enviados", async () => { await call("q=filtros-vazios&page_size=5"); assert.deepEqual([...calls[0].searchParams.keys()].sort(), ["fields", "json", "page", "page_size", "search_terms"]); calls.length = 0; const nutrients = ["energy", "fat", "carbohydrates", "proteins", "sugars", "fiber", "sodium"]; const all = new URLSearchParams({ q: "filtros-cheios", page_size: "5", country: "brazil", nutrition_grades: "a", nova_groups: "1", categories: "snacks", brands: "lacta", allergens: "milk", exclude_allergens: "gluten", additives: "e330", exclude_additives: "e150", countries: "en:brazil", }); for (const f of nutrients) { all.set(`${f}_min`, "1"); all.set(`${f}_max`, "9"); } await call(all.toString()); const p = calls[0].searchParams; assert.equal(p.get("countries_tags_en"), "brazil"); assert.equal(p.get("nutrition_grades_tags"), "a"); assert.equal(p.get("nova_groups"), "1"); assert.equal(p.get("categories_tags"), "snacks"); assert.equal(p.get("brands"), "lacta"); assert.equal(p.get("allergens_tags"), "milk"); assert.equal(p.get("exclude_allergens_tags"), "gluten"); assert.equal(p.get("additives_tags"), "e330"); assert.equal(p.get("exclude_additives_tags"), "e150"); assert.equal(p.get("countries_tags"), "en:brazil"); for (const f of nutrients) { assert.equal(p.get(`${f}_min`), "1"); assert.equal(p.get(`${f}_max`), "9"); } }); test("no_additives só vale com 'true' e soma-se a additives_tags", async () => { await call("q=sem-aditivos&page_size=5&no_additives=true&additives=e330"); assert.deepEqual(calls[0].searchParams.getAll("additives_tags"), ["e330", "without-additives"]); calls.length = 0; await call("q=sem-aditivos-falso&page_size=5&no_additives=false"); assert.equal(calls[0].searchParams.has("additives_tags"), false); }); test("sort_by mapeia para o campo da API; desconhecido vira popularity; ordem padrão é asc", async () => { const expected: Record = { nutrition_grade: "nutrition_grades", name: "product_name", brand: "brands", energy: "nutriments.energy_100g", fat: "nutriments.fat_100g", sugars: "nutriments.sugars_100g", qualquer: "popularity", }; for (const [sortBy, field] of Object.entries(expected)) { calls.length = 0; await call(`q=ordem-${sortBy}&page_size=5&sort_by=${sortBy}`); assert.equal(calls[0].searchParams.get("sort_by"), field, sortBy); assert.equal(calls[0].searchParams.get("sort_order"), "asc", sortBy); } calls.length = 0; await call("q=ordem-desc&page_size=5&sort_by=name&sort_order=desc"); assert.equal(calls[0].searchParams.get("sort_order"), "desc"); }); test("produtos da API são ordenados por relevância, deduplicados contra o cache e gravados nele", async () => { handler = () => ({ ok: true, body: { products: [ makeProduct("2000000000001", "Pizza congelada"), makeProduct("2000000000002", "Pizza", { brands: "Outra" }), makeProduct("1000000000005", "Suco de laranja pizza"), ], }, }); const { status, body } = await call("q=pizza&page_size=10"); assert.equal(status, 200); assert.equal(body.fromAPI, true); assert.deepEqual(codes(body.products), ["2000000000002", "2000000000001", "1000000000005"]); assert.equal(body.cacheStats.apiProducts, 3); assert.equal(body.cacheStats.localProducts, 0); assert.equal(body.cacheStats.newProducts, 3); // Segunda busca: os produtos gravados agora vêm do cache e só os novos contam como "novos". calls.length = 0; handler = () => ({ ok: true, body: { products: [makeProduct("2000000000002", "Pizza")] } }); const again = await call("q=pizza&page_size=10"); assert.equal(again.body.cacheStats.localProducts, 3); assert.equal(again.body.cacheStats.newProducts, 0); assert.equal(again.body.count, 3); }); test("resposta da API sem a chave products não derruba a busca", async () => { handler = () => ({ ok: true, body: {} }); const { status, body } = await call("q=sem-chave&page_size=5"); assert.equal(status, 200); assert.equal(body.fromAPI, false); assert.deepEqual(body.products, []); }); test("erro HTTP da API encerra a busca com 200 e lista vazia", async () => { handler = () => ({ ok: false, status: 503 }); const { status, body } = await call("q=api-fora&page_size=5"); assert.equal(status, 200); assert.equal(calls.length, 1); assert.equal(body.fromAPI, false); assert.deepEqual(body.products, []); assert.equal(body.count, 0); }); test("página cheia (1000) pede a próxima; página curta encerra a paginação", async () => { const pageOf = (prefix: string, n: number) => Array.from({ length: n }, (_, i) => makeProduct(`${prefix}${String(i).padStart(6, "0")}`, `Item paginado ${prefix}${i}`) ); handler = (url) => ({ ok: true, body: { products: url.searchParams.get("page") === "1" ? pageOf("31", 1000) : pageOf("32", 10) }, }); const { body } = await call("q=paginado&page_size=1500"); assert.deepEqual(calls.map((u) => u.searchParams.get("page")), ["1", "2"]); assert.equal(calls[1].searchParams.get("page_size"), "1000"); assert.equal(body.cacheStats.apiProducts, 1010); assert.equal(body.products.length, 1010); }); test("página vazia da API encerra a paginação sem erro", async () => { handler = () => ({ ok: true, body: { products: [] } }); const { status, body } = await call("q=vazio-total&page_size=1500"); assert.equal(status, 200); assert.equal(calls.length, 1); assert.equal(body.fromAPI, false); }); test("quantidade suficiente na primeira página não pede outra", async () => { handler = () => ({ ok: true, body: { products: Array.from({ length: 1000 }, (_, i) => makeProduct(`4${String(i).padStart(12, "0")}`, `Lote cheio ${i}`) ), }, }); const { body } = await call("q=lote-cheio&page_size=800"); assert.equal(calls.length, 1); assert.equal(body.products.length, 800); assert.equal(body.cacheStats.apiProducts, 1000); }); test("exceção na busca responde 500 com corpo estruturado", async () => { handler = () => { throw new Error("rede caiu"); }; const { status, body } = await call("q=explode&page=2&page_size=5"); assert.equal(status, 500); assert.equal(body.error, "Erro interno do servidor"); assert.deepEqual(body.products, []); assert.equal(body.page, 2); assert.equal(body.skip, 5); assert.deepEqual(body.cacheStats, { localProducts: 0, apiProducts: 0, newProducts: 0, totalReturned: 0 }); });