diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 806d197..a359b21 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,384 +1,123 @@ import { NextRequest, NextResponse } from "next/server"; 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"; -const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; +type LocalResults = Awaited>; + +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 { + 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) { const searchParams = request.nextUrl.searchParams; const query = searchParams.get("q"); 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) { - return NextResponse.json( - { error: "Query parameter is required" }, - { status: 400 } - ); + return NextResponse.json({ error: "Query parameter is required" }, { status: 400 }); } - // Validar parâmetros - const pageNum = parseInt(page); - const pageSizeNum = parseInt(pageSize); - - 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 pagination = parsePagination(searchParams); + if ("error" in pagination) { + return NextResponse.json({ error: pagination.error }, { status: 400 }); } + const { page, pageSize, pageParam } = pagination; try { - console.log( - `[API] Iniciando busca: "${query}" (país: ${ - country || "todos" - }, page_size: ${pageSizeNum})` - ); + console.log(`[API] Iniciando busca: "${query}" (país: ${country || "todos"}, page_size: ${pageSize})`); - // Primeiro, buscar no cache JSON - console.log(`[API] Buscando "${query}" no cache JSON...`); - const localResults = await jsonCacheManager.searchProducts( - query, - country || undefined, - pageSizeNum - ); + const localResults = await jsonCacheManager.searchProducts(query, country || undefined, pageSize); - // Se modo cache-only estiver ativo, retornar apenas resultados locais - if (cacheOnly || process.env.NEXT_PUBLIC_CACHE_ONLY_MODE === "true") { - console.log( - `[Cache-Only Mode] Retornando apenas ${localResults.products.length} produtos do cache` - ); + if (isCacheOnly(searchParams)) { + console.log(`[Cache-Only Mode] Retornando apenas ${localResults.products.length} produtos do cache`); return NextResponse.json({ products: localResults.products, count: localResults.products.length, - page: pageNum, - page_size: pageSizeNum, + page, + page_size: pageSize, fromCache: true, fromAPI: false, cacheOnly: true, }); } - let fromAPI = false; - let apiProducts: Product[] = []; + const params = buildOffSearchParams(searchParams, query, country, pageParam); + const apiProducts = await loadApiProducts(query, params, localResults.products.length, pageSize); - // Se não encontrou resultados suficientes no cache JSON, buscar na API externa - 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); + return NextResponse.json(buildSearchResponse(query, localResults, apiProducts, page, pageSize)); } catch (error) { console.error("[API] Erro ao buscar produtos:", error); - - // 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 } - ); + return errorResponse(page, pageSize); } } diff --git a/lib/jsonCacheManager.ts b/lib/jsonCacheManager.ts index 72af1de..2fc02e2 100644 --- a/lib/jsonCacheManager.ts +++ b/lib/jsonCacheManager.ts @@ -6,8 +6,8 @@ import { promises as fs } from "fs"; import fsSync from "fs"; import path from "path"; -import { Product } from "./openFoodFactsApi"; -import { CachedProduct, StorageStats } from "./types"; +import type { Product } from "./openFoodFactsApi"; +import type { CachedProduct, StorageStats } from "./types"; interface JsonCacheData { products: { [key: string]: CachedProduct }; diff --git a/lib/offSearch.ts b/lib/offSearch.ts new file mode 100644 index 0000000..fd76a48 --- /dev/null +++ b/lib/offSearch.ts @@ -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 = [ + ["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 = 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; +} + +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 { + 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 { + 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; +} diff --git a/lib/openFoodFactsApi.ts b/lib/openFoodFactsApi.ts index 66787cb..9607122 100644 --- a/lib/openFoodFactsApi.ts +++ b/lib/openFoodFactsApi.ts @@ -56,9 +56,14 @@ const CONFIG = { * Sistema de Tratamento de Erros por Classe */ 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); this.name = "NutriScanError"; + this.status = status; + this.code = code; } } diff --git a/lib/searchUtils.ts b/lib/searchUtils.ts index 37013ef..62d4628 100644 --- a/lib/searchUtils.ts +++ b/lib/searchUtils.ts @@ -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 diff --git a/package.json b/package.json index 4f1be21..7b08721 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "test": "node --experimental-transform-types --test tests/*.test.ts", + "test": "node --import ./tests/helpers/register.mjs --test tests/*.test.ts", "dev": "next dev", "lint": "next lint", "postinstall": "prisma generate", diff --git a/tests/helpers/register.mjs b/tests/helpers/register.mjs new file mode 100644 index 0000000..d462f5c --- /dev/null +++ b/tests/helpers/register.mjs @@ -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; + } + }, +}); diff --git a/tests/off-search.test.ts b/tests/off-search.test.ts new file mode 100644 index 0000000..c32fb34 --- /dev/null +++ b/tests/off-search.test.ts @@ -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 | "erro">) { + const urls: URL[] = []; + const headers: Array = []; + 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)["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), []); +}); diff --git a/tests/search-route.test.ts b/tests/search-route.test.ts new file mode 100644 index 0000000..ca3cee6 --- /dev/null +++ b/tests/search-route.test.ts @@ -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; // 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; +let NextRequestCtor: typeof import("next/server").NextRequest; + +function makeProduct(code: string, name: string, extra: Record = {}) { + 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 = { + 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 }); +});