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

+91 -352
View File
@@ -1,384 +1,123 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { jsonCacheManager } from "@/lib/jsonCacheManager"; import { jsonCacheManager } from "@/lib/jsonCacheManager";
import { Product } from "@/lib/openFoodFactsApi"; import { buildOffSearchParams, fetchOffProducts, parsePagination } from "@/lib/offSearch";
import type { Product } from "@/lib/openFoodFactsApi";
import { sortByRelevance } from "@/lib/searchUtils"; import { sortByRelevance } from "@/lib/searchUtils";
const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; type LocalResults = Awaited<ReturnType<typeof jsonCacheManager.searchProducts>>;
function isCacheOnly(searchParams: URLSearchParams): boolean {
return searchParams.get("cache_only") === "true" || process.env.NEXT_PUBLIC_CACHE_ONLY_MODE === "true";
}
/** Complementa o cache com a API externa quando ele não alcança o tamanho da página. */
async function loadApiProducts(
query: string,
params: URLSearchParams,
localCount: number,
pageSize: number
): Promise<Product[]> {
if (localCount >= pageSize) return [];
console.log(`Cache JSON retornou ${localCount} produtos, buscando na API externa...`);
const fetched = await fetchOffProducts(params, pageSize - localCount);
if (fetched.length === 0) return [];
const apiProducts = sortByRelevance(fetched, query);
const saveResult = await jsonCacheManager.saveProducts(apiProducts);
console.log(`Cache JSON atualizado: ${saveResult.saved} salvos, ${saveResult.failed} falharam`);
return apiProducts;
}
function buildSearchResponse(
query: string,
localResults: LocalResults,
apiProducts: Product[],
page: number,
pageSize: number
) {
const localCodes = new Set(localResults.products.map((p) => p.code));
const newApiProducts = apiProducts.filter((p) => !localCodes.has(p.code));
const allProducts = [...localResults.products, ...newApiProducts];
const paginated = sortByRelevance(allProducts, query).slice(0, pageSize);
console.log(
`Busca finalizada: ${paginated.length} produtos (${localResults.products.length} do cache JSON, ${newApiProducts.length} novos da API)`
);
return {
products: paginated,
count: allProducts.length,
page,
page_size: pageSize,
skip: (page - 1) * pageSize,
fromCache: localResults.fromCache,
fromAPI: apiProducts.length > 0,
cacheStats: {
localProducts: localResults.products.length,
apiProducts: apiProducts.length,
newProducts: newApiProducts.length,
totalReturned: paginated.length,
},
};
}
function errorResponse(page: number, pageSize: number) {
return NextResponse.json(
{
products: [],
count: 0,
page,
page_size: pageSize,
skip: (page - 1) * pageSize,
fromCache: false,
fromAPI: false,
error: "Erro interno do servidor",
cacheStats: { localProducts: 0, apiProducts: 0, newProducts: 0, totalReturned: 0 },
},
{ status: 500 }
);
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const query = searchParams.get("q"); const query = searchParams.get("q");
const country = searchParams.get("country"); const country = searchParams.get("country");
const page = searchParams.get("page") || "1";
const pageSize = searchParams.get("page_size") || "20";
const cacheOnly = searchParams.get("cache_only") === "true";
if (!query) { if (!query) {
return NextResponse.json( return NextResponse.json({ error: "Query parameter is required" }, { status: 400 });
{ error: "Query parameter is required" },
{ status: 400 }
);
} }
// Validar parâmetros const pagination = parsePagination(searchParams);
const pageNum = parseInt(page); if ("error" in pagination) {
const pageSizeNum = parseInt(pageSize); return NextResponse.json({ error: pagination.error }, { status: 400 });
if (isNaN(pageNum) || pageNum < 1) {
return NextResponse.json(
{ error: "Invalid page parameter" },
{ status: 400 }
);
}
if (isNaN(pageSizeNum) || pageSizeNum < 1) {
return NextResponse.json(
{ error: "Invalid page_size parameter" },
{ status: 400 }
);
}
// Usar o máximo permitido pelo Open Food Facts (1000) para consultas na API
const apiPageSize = Math.max(pageSizeNum, 1000);
const params = new URLSearchParams({
search_terms: query,
page_size: apiPageSize.toString(),
page: page,
json: "true",
fields: [
"code",
"product_name",
"brands",
"categories",
"ingredients_text",
"nutriments",
"nutrition_grades",
"nova_group",
"image_url",
"image_small_url",
"image_front_url",
"image_front_small_url",
"countries",
"countries_tags",
"allergens_tags",
"additives_tags",
].join(","),
});
// Filtros básicos
if (country) {
params.append("countries_tags_en", country);
}
// Filtros de classificação nutricional
const nutritionGrades = searchParams.get("nutrition_grades");
if (nutritionGrades) {
params.append("nutrition_grades_tags", nutritionGrades);
}
const novaGroups = searchParams.get("nova_groups");
if (novaGroups) {
params.append("nova_groups", novaGroups);
}
// Filtros de categoria e marca
const categories = searchParams.get("categories");
if (categories) {
params.append("categories_tags", categories);
}
const brands = searchParams.get("brands");
if (brands) {
params.append("brands", brands);
}
// Filtros de alérgenos
const allergens = searchParams.get("allergens");
if (allergens) {
params.append("allergens_tags", allergens);
}
const excludeAllergens = searchParams.get("exclude_allergens");
if (excludeAllergens) {
params.append("exclude_allergens_tags", excludeAllergens);
}
// Filtros de aditivos
const additives = searchParams.get("additives");
if (additives) {
params.append("additives_tags", additives);
}
const excludeAdditives = searchParams.get("exclude_additives");
if (excludeAdditives) {
params.append("exclude_additives_tags", excludeAdditives);
}
const noAdditives = searchParams.get("no_additives");
if (noAdditives === "true") {
params.append("additives_tags", "without-additives");
}
// Filtros de países
const countries = searchParams.get("countries");
if (countries) {
params.append("countries_tags", countries);
}
// Filtros nutricionais (valores por 100g)
const nutritionFilters = [
"energy_min",
"energy_max",
"fat_min",
"fat_max",
"carbohydrates_min",
"carbohydrates_max",
"proteins_min",
"proteins_max",
"sugars_min",
"sugars_max",
"fiber_min",
"fiber_max",
"sodium_min",
"sodium_max",
];
nutritionFilters.forEach((filter) => {
const value = searchParams.get(filter);
if (value) {
params.append(filter, value);
}
});
// Ordenação
const sortBy = searchParams.get("sort_by");
const sortOrder = searchParams.get("sort_order") || "asc";
if (sortBy) {
let sortField = "";
switch (sortBy) {
case "nutrition_grade":
sortField = "nutrition_grades";
break;
case "name":
sortField = "product_name";
break;
case "brand":
sortField = "brands";
break;
case "energy":
sortField = "nutriments.energy_100g";
break;
case "fat":
sortField = "nutriments.fat_100g";
break;
case "sugars":
sortField = "nutriments.sugars_100g";
break;
default:
sortField = "popularity";
}
if (sortField) {
params.append("sort_by", sortField);
params.append("sort_order", sortOrder);
}
} }
const { page, pageSize, pageParam } = pagination;
try { try {
console.log( console.log(`[API] Iniciando busca: "${query}" (país: ${country || "todos"}, page_size: ${pageSize})`);
`[API] Iniciando busca: "${query}" (país: ${
country || "todos"
}, page_size: ${pageSizeNum})`
);
// Primeiro, buscar no cache JSON const localResults = await jsonCacheManager.searchProducts(query, country || undefined, pageSize);
console.log(`[API] Buscando "${query}" no cache JSON...`);
const localResults = await jsonCacheManager.searchProducts(
query,
country || undefined,
pageSizeNum
);
// Se modo cache-only estiver ativo, retornar apenas resultados locais if (isCacheOnly(searchParams)) {
if (cacheOnly || process.env.NEXT_PUBLIC_CACHE_ONLY_MODE === "true") { console.log(`[Cache-Only Mode] Retornando apenas ${localResults.products.length} produtos do cache`);
console.log(
`[Cache-Only Mode] Retornando apenas ${localResults.products.length} produtos do cache`
);
return NextResponse.json({ return NextResponse.json({
products: localResults.products, products: localResults.products,
count: localResults.products.length, count: localResults.products.length,
page: pageNum, page,
page_size: pageSizeNum, page_size: pageSize,
fromCache: true, fromCache: true,
fromAPI: false, fromAPI: false,
cacheOnly: true, cacheOnly: true,
}); });
} }
let fromAPI = false; const params = buildOffSearchParams(searchParams, query, country, pageParam);
let apiProducts: Product[] = []; const apiProducts = await loadApiProducts(query, params, localResults.products.length, pageSize);
// Se não encontrou resultados suficientes no cache JSON, buscar na API externa return NextResponse.json(buildSearchResponse(query, localResults, apiProducts, page, pageSize));
if (localResults.products.length < pageSizeNum) {
console.log(
`Cache JSON retornou ${localResults.products.length} produtos, buscando na API externa...`
);
// Implementar busca paginada automática para page_size > 1000
const allApiProducts: Product[] = [];
const maxPerPage = 1000;
let currentApiPage = 1;
const totalNeeded = pageSizeNum - localResults.products.length;
while (allApiProducts.length < totalNeeded) {
// Atualizar parâmetros para a página atual
const currentParams = new URLSearchParams(params);
currentParams.set("page", currentApiPage.toString());
currentParams.set("page_size", maxPerPage.toString());
console.log(
`Buscando página ${currentApiPage} da API (${allApiProducts.length}/${totalNeeded} produtos obtidos)...`
);
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${currentParams.toString()}`,
{
headers: {
"User-Agent": USER_AGENT,
Accept: "application/json",
},
}
);
if (response.ok) {
const apiData = await response.json();
const pageProducts = apiData.products || [];
if (pageProducts.length === 0) {
console.log("Não há mais produtos na API");
break; // Não há mais produtos
}
allApiProducts.push(...pageProducts);
fromAPI = true;
console.log(
`Página ${currentApiPage}: ${pageProducts.length} produtos (Total: ${allApiProducts.length})`
);
// Se retornou menos que o máximo, acabaram os produtos
if (pageProducts.length < maxPerPage) {
console.log("Última página de produtos obtida");
break;
}
// Se já temos o suficiente, parar
if (allApiProducts.length >= totalNeeded) {
console.log(
`Quantidade suficiente obtida: ${allApiProducts.length}/${totalNeeded}`
);
break;
}
// Incrementar página para próxima iteração
currentApiPage++;
// Rate limiting: aguardar 200ms entre requisições
if (allApiProducts.length < totalNeeded) {
console.log("Aguardando 200ms antes da próxima requisição...");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else {
console.error(
`Erro na página ${currentApiPage}: ${response.status} ${response.statusText}`
);
break;
}
}
apiProducts = allApiProducts;
// Ordenar produtos da API por relevância
if (apiProducts.length > 0) {
console.log(
`Ordenando ${apiProducts.length} produtos da API por relevância...`
);
apiProducts = sortByRelevance(apiProducts, query);
console.log(
`Salvando ${apiProducts.length} produtos da API no cache JSON...`
);
const saveResult = await jsonCacheManager.saveProducts(apiProducts);
console.log(
`Cache JSON atualizado: ${saveResult.saved} salvos, ${saveResult.failed} falharam`
);
}
}
// Combinar resultados JSON e da API
const allProducts: Product[] = [...localResults.products];
const localProductCodes = new Set(localResults.products.map((p) => p.code));
// Adicionar apenas produtos da API que não estão no cache JSON
const newApiProducts = apiProducts.filter(
(p) => !localProductCodes.has(p.code)
);
allProducts.push(...newApiProducts);
// Reordenar a lista combinada por relevância
console.log(
`Reordenando ${allProducts.length} produtos combinados por relevância...`
);
const sortedProducts = sortByRelevance(allProducts, query);
// Limitar ao tamanho da página solicitada
const paginatedProducts = sortedProducts.slice(0, pageSizeNum);
const responseData = {
products: paginatedProducts,
count: allProducts.length,
page: pageNum,
page_size: pageSizeNum,
skip: (pageNum - 1) * pageSizeNum,
fromCache: localResults.fromCache,
fromAPI: fromAPI,
cacheStats: {
localProducts: localResults.products.length,
apiProducts: apiProducts.length,
newProducts: newApiProducts.length,
totalReturned: paginatedProducts.length,
},
};
console.log(
`Busca finalizada: ${paginatedProducts.length} produtos (${localResults.products.length} do cache JSON, ${newApiProducts.length} novos da API)`
);
return NextResponse.json(responseData);
} catch (error) { } catch (error) {
console.error("[API] Erro ao buscar produtos:", error); console.error("[API] Erro ao buscar produtos:", error);
return errorResponse(page, pageSize);
// Retornar resposta de erro estruturada em vez de quebrar
return NextResponse.json(
{
products: [],
count: 0,
page: pageNum,
page_size: pageSizeNum,
skip: (pageNum - 1) * pageSizeNum,
fromCache: false,
fromAPI: false,
error: "Erro interno do servidor",
cacheStats: {
localProducts: 0,
apiProducts: 0,
newProducts: 0,
totalReturned: 0,
},
},
{ status: 500 }
);
} }
} }
+2 -2
View File
@@ -6,8 +6,8 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import fsSync from "fs"; import fsSync from "fs";
import path from "path"; import path from "path";
import { Product } from "./openFoodFactsApi"; import type { Product } from "./openFoodFactsApi";
import { CachedProduct, StorageStats } from "./types"; import type { CachedProduct, StorageStats } from "./types";
interface JsonCacheData { interface JsonCacheData {
products: { [key: string]: CachedProduct }; products: { [key: string]: CachedProduct };
+175
View File
@@ -0,0 +1,175 @@
/**
* Montagem das requisições de busca e paginação na API do Open Food Facts
* (endpoint legado cgi/search.pl). Sem dependências do Next.js, para ser testável.
*/
import type { Product } from "./openFoodFactsApi.ts";
export const OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl";
export const OFF_USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)";
/** Máximo de produtos por página aceito pelo Open Food Facts. */
export const OFF_MAX_PAGE_SIZE = 1000;
/** Pausa entre páginas consecutivas, para respeitar o limite de uso da API. */
export const OFF_RATE_LIMIT_MS = 200;
const FIELDS = [
"code",
"product_name",
"brands",
"categories",
"ingredients_text",
"nutriments",
"nutrition_grades",
"nova_group",
"image_url",
"image_small_url",
"image_front_url",
"image_front_small_url",
"countries",
"countries_tags",
"allergens_tags",
"additives_tags",
].join(",");
/** Parâmetro desta API -> parâmetro do Open Food Facts, repassados quando presentes. */
const PASSTHROUGH_FILTERS: ReadonlyArray<readonly [string, string]> = [
["nutrition_grades", "nutrition_grades_tags"],
["nova_groups", "nova_groups"],
["categories", "categories_tags"],
["brands", "brands"],
["allergens", "allergens_tags"],
["exclude_allergens", "exclude_allergens_tags"],
["additives", "additives_tags"],
["exclude_additives", "exclude_additives_tags"],
["countries", "countries_tags"],
];
/** Faixas nutricionais (por 100 g), repassadas com o mesmo nome. */
const NUTRITION_RANGE_FILTERS = ["energy", "fat", "carbohydrates", "proteins", "sugars", "fiber", "sodium"].flatMap(
(nutrient) => [`${nutrient}_min`, `${nutrient}_max`]
);
const SORT_FIELDS: ReadonlyMap<string, string> = new Map([
["nutrition_grade", "nutrition_grades"],
["name", "product_name"],
["brand", "brands"],
["energy", "nutriments.energy_100g"],
["fat", "nutriments.fat_100g"],
["sugars", "nutriments.sugars_100g"],
]);
const DEFAULT_SORT_FIELD = "popularity";
export interface Pagination {
page: number;
pageSize: number;
/** Valor textual de `page`, como recebido (padrão "1"). */
pageParam: string;
}
/** Lê e valida `page` e `page_size`. Devolve a mensagem de erro quando inválidos. */
export function parsePagination(searchParams: URLSearchParams): Pagination | { error: string } {
const pageParam = searchParams.get("page") || "1";
const page = parseInt(pageParam);
const pageSize = parseInt(searchParams.get("page_size") || "20");
if (isNaN(page) || page < 1) return { error: "Invalid page parameter" };
if (isNaN(pageSize) || pageSize < 1) return { error: "Invalid page_size parameter" };
return { page, pageSize, pageParam };
}
function appendSort(params: URLSearchParams, searchParams: URLSearchParams): void {
const sortBy = searchParams.get("sort_by");
if (!sortBy) return;
params.append("sort_by", SORT_FIELDS.get(sortBy) ?? DEFAULT_SORT_FIELD);
params.append("sort_order", searchParams.get("sort_order") || "asc");
}
/** Constrói os parâmetros da busca no Open Food Facts a partir da query string recebida. */
export function buildOffSearchParams(
searchParams: URLSearchParams,
query: string,
country: string | null,
pageParam: string
): URLSearchParams {
const params = new URLSearchParams({
search_terms: query,
page_size: OFF_MAX_PAGE_SIZE.toString(),
page: pageParam,
json: "true",
fields: FIELDS,
});
if (country) params.append("countries_tags_en", country);
for (const [from, to] of PASSTHROUGH_FILTERS) {
const value = searchParams.get(from);
if (value) params.append(to, value);
}
if (searchParams.get("no_additives") === "true") params.append("additives_tags", "without-additives");
for (const name of NUTRITION_RANGE_FILTERS) {
const value = searchParams.get(name);
if (value) params.append(name, value);
}
appendSort(params, searchParams);
return params;
}
export interface OffFetchDeps {
fetchFn: typeof fetch;
sleep: (ms: number) => Promise<void>;
}
const defaultDeps: OffFetchDeps = {
fetchFn: (...args) => fetch(...args),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
};
/** Busca uma página; devolve null quando a API responde com erro HTTP. */
async function fetchOffPage(
params: URLSearchParams,
apiPage: number,
deps: OffFetchDeps
): Promise<Product[] | null> {
const pageParams = new URLSearchParams(params);
pageParams.set("page", apiPage.toString());
pageParams.set("page_size", OFF_MAX_PAGE_SIZE.toString());
const response = await deps.fetchFn(`${OFF_SEARCH_URL}?${pageParams.toString()}`, {
headers: { "User-Agent": OFF_USER_AGENT, Accept: "application/json" },
});
if (!response.ok) {
console.error(`Erro na página ${apiPage}: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
return data.products || [];
}
/**
* Busca páginas de até 1000 produtos até reunir `totalNeeded`, esgotar os
* resultados ou receber erro da API (nesse caso devolve o que já foi obtido).
*/
export async function fetchOffProducts(
params: URLSearchParams,
totalNeeded: number,
deps: OffFetchDeps = defaultDeps
): Promise<Product[]> {
const products: Product[] = [];
let apiPage = 1;
while (products.length < totalNeeded) {
const pageProducts = await fetchOffPage(params, apiPage, deps);
if (!pageProducts || pageProducts.length === 0) break;
products.push(...pageProducts);
if (pageProducts.length < OFF_MAX_PAGE_SIZE || products.length >= totalNeeded) break;
apiPage++;
await deps.sleep(OFF_RATE_LIMIT_MS);
}
return products;
}
+6 -1
View File
@@ -56,9 +56,14 @@ const CONFIG = {
* Sistema de Tratamento de Erros por Classe * Sistema de Tratamento de Erros por Classe
*/ */
export class NutriScanError extends Error { export class NutriScanError extends Error {
constructor(public message: string, public status?: number, public code?: string) { status?: number;
code?: string;
constructor(message: string, status?: number, code?: string) {
super(message); super(message);
this.name = "NutriScanError"; this.name = "NutriScanError";
this.status = status;
this.code = code;
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { Product } from "./openFoodFactsApi"; import type { Product } from "./openFoodFactsApi";
/** /**
* Calcula score de relevância de um produto em relação à query * Calcula score de relevância de um produto em relação à query
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "node --experimental-transform-types --test tests/*.test.ts", "test": "node --import ./tests/helpers/register.mjs --test tests/*.test.ts",
"dev": "next dev", "dev": "next dev",
"lint": "next lint", "lint": "next lint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
+41
View File
@@ -0,0 +1,41 @@
// Resolve o alias "@/" e imports sem extensão do projeto, para que os testes
// importem módulos reais (inclusive Route Handlers do Next.js) com o type
// stripping nativo do Node. Requer Node >= 22.15 (module.registerHooks).
import { existsSync, statSync } from "node:fs";
import { registerHooks } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
const ROOT = new URL("../../", import.meta.url);
const SUFFIXES = ["", ".ts", ".tsx", ".js", "/index.ts", "/index.js"];
function findFile(base) {
for (const suffix of SUFFIXES) {
const candidate = base + suffix;
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith("@/")) {
const found = findFile(fileURLToPath(new URL(specifier.slice(2), ROOT)));
if (found) return nextResolve(pathToFileURL(found).href, context);
}
try {
return nextResolve(specifier, context);
} catch (error) {
const code = error?.code;
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw error;
if (specifier.startsWith(".") && context.parentURL) {
const found = findFile(fileURLToPath(new URL(specifier, context.parentURL)));
if (found) return nextResolve(pathToFileURL(found).href, context);
}
// Subcaminho de pacote CJS sem exports, como "next/server".
if (!specifier.startsWith(".") && !specifier.startsWith("node:")) {
return nextResolve(`${specifier}.js`, context);
}
throw error;
}
},
});
+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), []);
});
+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 });
});