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:
1 parent
fc7243e5f8
commit
bf688257ef
9 files changed
+752
-357
No files matched your search
+91
-352
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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), []);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user