refactor(api): extrair montagem e paginação da busca para lib/offSearch

GET /api/search passa de CC 41 para <= 10. Testes de caracterização
(sem rede: fetch e cache JSON temporário) cobrem validação, filtros,
ordenação, paginação, modo cache-only e erros.

Também: parameter property de NutriScanError virou campo explícito e
imports só de tipo usam import type, para o type stripping nativo do
Node; o script de teste não usa mais --experimental-transform-types
(removida no Node 26).
This commit is contained in:
evandro committed 2026-10-10 04:14:48 -03:00
1 parent fc7243e5f8
commit bf688257ef
9 files changed
+752 -357

No files matched your search

+332
View File
@@ -0,0 +1,332 @@
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<string, any>; // 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<Response>;
let NextRequestCtor: typeof import("next/server").NextRequest;
function makeProduct(code: string, name: string, extra: Record<string, unknown> = {}) {
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<string, string> = {
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 });
});