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

+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildOffSearchParams,
fetchOffProducts,
OFF_RATE_LIMIT_MS,
OFF_USER_AGENT,
parsePagination,
type OffFetchDeps,
} from "../lib/offSearch.ts";
const qs = (query: string) => new URLSearchParams(query);
test("parsePagination aplica padrões e rejeita valores inválidos", () => {
assert.deepEqual(parsePagination(qs("")), { page: 1, pageSize: 20, pageParam: "1" });
assert.deepEqual(parsePagination(qs("page=3&page_size=50")), { page: 3, pageSize: 50, pageParam: "3" });
assert.deepEqual(parsePagination(qs("page=0")), { error: "Invalid page parameter" });
assert.deepEqual(parsePagination(qs("page=x")), { error: "Invalid page parameter" });
assert.deepEqual(parsePagination(qs("page_size=0")), { error: "Invalid page_size parameter" });
assert.deepEqual(parsePagination(qs("page_size=-1")), { error: "Invalid page_size parameter" });
});
test("buildOffSearchParams monta os parâmetros básicos e ignora filtros vazios", () => {
const params = buildOffSearchParams(qs("brands=&sort_by="), "leite", null, "1");
assert.deepEqual([...params.keys()].sort(), ["fields", "json", "page", "page_size", "search_terms"]);
assert.equal(params.get("search_terms"), "leite");
assert.equal(params.get("page_size"), "1000");
});
test("buildOffSearchParams ordena com sort_order padrão e campo popularity para valores desconhecidos", () => {
const known = buildOffSearchParams(qs("sort_by=fat&sort_order=desc"), "x", null, "1");
assert.equal(known.get("sort_by"), "nutriments.fat_100g");
assert.equal(known.get("sort_order"), "desc");
const unknown = buildOffSearchParams(qs("sort_by=constructor"), "x", null, "1");
assert.equal(unknown.get("sort_by"), "popularity");
assert.equal(unknown.get("sort_order"), "asc");
});
test("buildOffSearchParams repassa país e filtros com os nomes da API", () => {
const params = buildOffSearchParams(qs("allergens=milk&fat_max=5&no_additives=true"), "x", "brazil", "1");
assert.equal(params.get("countries_tags_en"), "brazil");
assert.equal(params.get("allergens_tags"), "milk");
assert.equal(params.get("fat_max"), "5");
assert.equal(params.get("additives_tags"), "without-additives");
});
function makeDeps(pages: Array<Array<{ code: string }> | "erro">) {
const urls: URL[] = [];
const headers: Array<HeadersInit | undefined> = [];
const sleeps: number[] = [];
const deps: OffFetchDeps = {
fetchFn: (async (input: string | URL | Request, init?: RequestInit) => {
urls.push(new URL(String(input)));
headers.push(init?.headers);
const page = pages[urls.length - 1];
return page === "erro" || page === undefined
? ({ ok: false, status: 503, statusText: "Service Unavailable" } as Response)
: ({ ok: true, json: async () => ({ products: page }) } as Response);
}) as typeof fetch,
sleep: async (ms) => {
sleeps.push(ms);
},
};
return { deps, urls, headers, sleeps };
}
const batch = (n: number, prefix = "p") => Array.from({ length: n }, (_, i) => ({ code: `${prefix}${i}` }));
test("fetchOffProducts envia User-Agent e pausa entre páginas cheias", async () => {
const { deps, urls, headers, sleeps } = makeDeps([batch(1000, "a"), batch(1000, "b"), batch(5, "c")]);
const products = await fetchOffProducts(new URLSearchParams({ search_terms: "x" }), 2500, deps);
assert.equal(products.length, 2005);
assert.deepEqual(urls.map((u) => u.searchParams.get("page")), ["1", "2", "3"]);
assert.equal(urls[0].searchParams.get("search_terms"), "x");
assert.deepEqual(sleeps, [OFF_RATE_LIMIT_MS, OFF_RATE_LIMIT_MS]);
assert.equal((headers[0] as Record<string, string>)["User-Agent"], OFF_USER_AGENT);
});
test("fetchOffProducts para quando já reuniu o necessário, sem pausa", async () => {
const { deps, urls, sleeps } = makeDeps([batch(1000)]);
const products = await fetchOffProducts(new URLSearchParams(), 500, deps);
assert.equal(products.length, 1000);
assert.equal(urls.length, 1);
assert.deepEqual(sleeps, []);
});
test("fetchOffProducts devolve o que já obteve quando uma página falha", async () => {
const { deps, urls } = makeDeps([batch(1000), "erro"]);
const products = await fetchOffProducts(new URLSearchParams(), 3000, deps);
assert.equal(products.length, 1000);
assert.equal(urls.length, 2);
});
test("fetchOffProducts trata página vazia e resposta sem products", async () => {
const empty = makeDeps([[]]);
assert.deepEqual(await fetchOffProducts(new URLSearchParams(), 10, empty.deps), []);
const noKey: OffFetchDeps = {
fetchFn: (async () => ({ ok: true, json: async () => ({}) }) as Response) as typeof fetch,
sleep: async () => {},
};
assert.deepEqual(await fetchOffProducts(new URLSearchParams(), 10, noKey), []);
});