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).
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
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 });
|
|
});
|