handlers.test.ts•20 kB
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { ToolHandlers } from '../src/handlers.js';
import { OpenFoodFactsClient } from '../src/client.js';
import {
mockNutellaProduct,
mockHealthyYogurtProduct,
mockVeganProduct,
mockNutellaProductResponse,
mockHealthyYogurtProductResponse,
mockVeganProductResponse,
mockProductNotFoundResponse,
mockSearchResponse,
mockEmptySearchResponse,
mockBarcodes,
} from './fixtures/products.js';
// Mock the client
jest.mock('../src/client.js');
describe('ToolHandlers', () => {
let handlers: ToolHandlers;
let mockClient: jest.Mocked<OpenFoodFactsClient>;
beforeEach(() => {
mockClient = {
getProduct: jest.fn(),
searchProducts: jest.fn(),
getProductsByBarcodes: jest.fn(),
} as any;
handlers = new ToolHandlers(mockClient);
});
describe('handleGetProduct', () => {
it('should format product successfully', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleGetProduct(mockBarcodes.nutella);
expect(mockClient.getProduct).toHaveBeenCalledWith(mockBarcodes.nutella);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const text = result.content[0].text;
expect(text).toContain('**Nutella**');
expect(text).toContain('Brand: Ferrero');
expect(text).toContain('Nutri-Score: E');
expect(text).toContain('NOVA Group: 4');
expect(text).toContain('Eco-Score: D');
expect(text).toContain('Energy: 539 kcal');
expect(text).toContain('Fat: 30.9g');
expect(text).toContain('Sugar, palm oil, hazelnuts');
});
it('should handle product not found', async () => {
mockClient.getProduct.mockResolvedValue(mockProductNotFoundResponse);
const result = await handlers.handleGetProduct(mockBarcodes.notFound);
expect(result.content[0].text).toContain('Product not found for barcode');
expect(result.content[0].text).toContain(mockBarcodes.notFound);
});
it('should handle product with minimal data', async () => {
const minimalResponse = {
status: 1,
status_verbose: 'product found',
product: {
code: '123456789',
product_name: 'Test Product',
},
};
mockClient.getProduct.mockResolvedValue(minimalResponse as any);
const result = await handlers.handleGetProduct('123456789');
expect(result.content[0].text).toContain('**Test Product**');
expect(result.content[0].text).not.toContain('**Scores:**');
expect(result.content[0].text).not.toContain('**Nutrition**');
});
it('should handle missing product name', async () => {
const responseWithoutName = {
...mockNutellaProductResponse,
product: {
...mockNutellaProduct,
product_name: undefined,
},
};
mockClient.getProduct.mockResolvedValue(responseWithoutName as any);
const result = await handlers.handleGetProduct(mockBarcodes.nutella);
expect(result.content[0].text).toContain('**Unknown Product**');
});
it('should format all nutritional information correctly', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleGetProduct(mockBarcodes.nutella);
const text = result.content[0].text;
// Check all key nutrients are present
expect(text).toContain('Energy: 539 kcal');
expect(text).toContain('Fat: 30.9g');
expect(text).toContain('Carbs: 57.5g');
expect(text).toContain('Sugars: 56.3g');
expect(text).toContain('Protein: 6.3g');
expect(text).toContain('Salt: 0.107g');
expect(text).not.toContain('Fiber: 0g'); // Should not show zero fiber
});
it('should format additional info correctly', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleGetProduct(mockBarcodes.nutella);
const text = result.content[0].text;
expect(text).toContain('**Additional Info:**');
expect(text).toContain('Packaging: Plastic jar');
expect(text).toContain('Labels: No palm oil');
expect(text).toContain('Countries: France');
});
});
describe('handleSearchProducts', () => {
it('should format search results successfully', async () => {
mockClient.searchProducts.mockResolvedValue(mockSearchResponse);
const result = await handlers.handleSearchProducts({ search: 'chocolate' });
expect(mockClient.searchProducts).toHaveBeenCalledWith({ search: 'chocolate' });
expect(result.content[0].text).toContain('Found 150 products');
expect(result.content[0].text).toContain('showing page 1 of 8');
expect(result.content[0].text).toContain('1. **Nutella**');
expect(result.content[0].text).toContain('[Nutri-Score: E]');
expect(result.content[0].text).toContain('Barcode: 3017620422003');
});
it('should handle empty search results', async () => {
mockClient.searchProducts.mockResolvedValue(mockEmptySearchResponse);
const result = await handlers.handleSearchProducts({ search: 'nonexistent' });
expect(result.content[0].text).toBe('No products found matching your search criteria.');
});
it('should format product summaries correctly', async () => {
mockClient.searchProducts.mockResolvedValue(mockSearchResponse);
const result = await handlers.handleSearchProducts({});
const text = result.content[0].text;
// Check all three products are formatted
expect(text).toContain('**Nutella** (Ferrero) [Nutri-Score: E]');
expect(text).toContain('**Greek Yogurt Natural** (Danone) [Nutri-Score: B]');
expect(text).toContain('**Oat Drink Original** (Alpro) [Nutri-Score: B]');
});
it('should handle products without brands or nutriscore', async () => {
const searchWithMinimalData = {
...mockSearchResponse,
products: [{
code: '123456789',
product_name: 'Test Product',
}],
};
mockClient.searchProducts.mockResolvedValue(searchWithMinimalData as any);
const result = await handlers.handleSearchProducts({});
const text = result.content[0].text;
expect(text).toContain('**Test Product**');
expect(text).not.toContain('('); // No brand parentheses
expect(text).not.toContain('[Nutri-Score:'); // No nutriscore
});
});
describe('handleAnalyzeProduct', () => {
it('should provide comprehensive nutritional analysis', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleAnalyzeProduct(mockBarcodes.nutella);
const text = result.content[0].text;
expect(text).toContain('**Nutritional Analysis: Nutella**');
expect(text).toContain('• Nutri-Score E: Very poor nutritional quality');
expect(text).toContain('• NOVA Group 4: Ultra-processed foods');
expect(text).toContain('• Eco-Score D: High environmental impact');
expect(text).toContain('**Nutritional Breakdown:**');
expect(text).toContain('• Energy: 539 kcal (high)');
expect(text).toContain('• Fat: 30.9g (high)');
expect(text).toContain('• Sugars: 56.3g (high)');
expect(text).toContain('• Salt: 0.107g (low)');
});
it('should handle healthy product analysis', async () => {
mockClient.getProduct.mockResolvedValue(mockHealthyYogurtProductResponse);
const result = await handlers.handleAnalyzeProduct(mockBarcodes.yogurt);
const text = result.content[0].text;
expect(text).toContain('• Nutri-Score B: Good nutritional quality');
expect(text).toContain('• NOVA Group 1: Unprocessed or minimally processed foods');
expect(text).toContain('• Energy: 97 kcal (low)');
expect(text).toContain('• Fat: 5g (moderate)');
expect(text).toContain('• Sugars: 4g (low)');
});
it('should handle product not found for analysis', async () => {
mockClient.getProduct.mockResolvedValue(mockProductNotFoundResponse);
const result = await handlers.handleAnalyzeProduct(mockBarcodes.notFound);
expect(result.content[0].text).toContain('Product not found for barcode');
});
it('should handle product without scores', async () => {
const productWithoutScores = {
status: 1,
status_verbose: 'product found',
product: {
code: '123456789',
product_name: 'Test Product',
nutriments: {
'energy-kcal_100g': 200,
'fat_100g': 10,
},
},
};
mockClient.getProduct.mockResolvedValue(productWithoutScores as any);
const result = await handlers.handleAnalyzeProduct('123456789');
const text = result.content[0].text;
expect(text).toContain('**Nutritional Analysis: Test Product**');
expect(text).not.toContain('**Scores:**');
expect(text).toContain('**Nutritional Breakdown:**');
});
it('should categorize nutrient levels correctly', async () => {
const testProduct = {
status: 1,
status_verbose: 'product found',
product: {
code: '123456789',
product_name: 'Test Product',
nutriments: {
'energy-kcal_100g': 100, // low
'fat_100g': 25, // high
'sugars_100g': 10, // moderate
'salt_100g': 0.1, // low
},
},
};
mockClient.getProduct.mockResolvedValue(testProduct as any);
const result = await handlers.handleAnalyzeProduct('123456789');
const text = result.content[0].text;
expect(text).toContain('• Energy: 100 kcal (low)');
expect(text).toContain('• Fat: 25g (high)');
expect(text).toContain('• Sugars: 10g (moderate)');
expect(text).toContain('• Salt: 0.1g (low)');
});
});
describe('handleCompareProducts', () => {
it('should compare products with nutrition focus', async () => {
const responses = [
mockNutellaProductResponse,
mockHealthyYogurtProductResponse,
];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.yogurt],
'nutrition'
);
const text = result.content[0].text;
expect(text).toContain('**Product Comparison (nutrition)**');
expect(text).toContain('**1. Nutella**');
expect(text).toContain('Nutri-Score: E');
expect(text).toContain('**2. Greek Yogurt Natural**');
expect(text).toContain('Nutri-Score: B');
expect(text).toContain('Energy: 539 kcal');
expect(text).toContain('Energy: 97 kcal');
});
it('should compare products with environmental focus', async () => {
const responses = [mockNutellaProductResponse, mockVeganProductResponse];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.oatDrink],
'environmental'
);
const text = result.content[0].text;
expect(text).toContain('**Product Comparison (environmental)**');
expect(text).toContain('Eco-Score: D');
expect(text).toContain('Eco-Score: B');
expect(text).toContain('Packaging: Plastic jar');
expect(text).toContain('Packaging: Tetra pak');
});
it('should compare products with processing focus', async () => {
const responses = [mockNutellaProductResponse, mockHealthyYogurtProductResponse];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.yogurt],
'processing'
);
const text = result.content[0].text;
expect(text).toContain('**Product Comparison (processing)**');
expect(text).toContain('NOVA Group: 4 (Ultra-processed foods)');
expect(text).toContain('NOVA Group: 1 (Unprocessed or minimally processed foods)');
});
it('should compare products with ingredients focus', async () => {
const responses = [mockNutellaProductResponse, mockVeganProductResponse];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.oatDrink],
'ingredients'
);
const text = result.content[0].text;
expect(text).toContain('**Product Comparison (ingredients)**');
expect(text).toContain('Sugar, palm oil, hazelnuts');
expect(text).toContain('Oat base (water, oats 10%)');
});
it('should handle insufficient valid products', async () => {
const responses = [
mockNutellaProductResponse,
mockProductNotFoundResponse,
];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.notFound]
);
expect(result.content[0].text).toBe('Need at least 2 valid products to compare.');
});
it('should handle long ingredients text with truncation', async () => {
const longIngredientsProduct = {
...mockNutellaProduct,
ingredients_text: 'A'.repeat(300) + ' and more ingredients',
};
const response = {
...mockNutellaProductResponse,
product: longIngredientsProduct,
};
const responses = [response, mockHealthyYogurtProductResponse];
mockClient.getProductsByBarcodes.mockResolvedValue(responses);
const result = await handlers.handleCompareProducts(
[mockBarcodes.nutella, mockBarcodes.yogurt],
'ingredients'
);
const text = result.content[0].text;
expect(text).toContain('...');
expect(text.length).toBeLessThan(1000); // Ensure truncation occurred
});
});
describe('handleGetProductSuggestions', () => {
it('should provide product suggestions with dietary preferences', async () => {
const veganSearchResponse = {
...mockSearchResponse,
products: [mockVeganProduct],
};
mockClient.searchProducts.mockResolvedValue(veganSearchResponse);
const result = await handlers.handleGetProductSuggestions({
category: 'beverages',
dietary_preferences: ['vegan'],
max_results: 5,
});
expect(mockClient.searchProducts).toHaveBeenCalledWith({
categories: 'beverages',
page_size: 10, // max_results * 2, but minimum
sort_by: 'popularity',
});
const text = result.content[0].text;
expect(text).toContain('Product suggestions in beverages:');
expect(text).toContain('1. **Oat Drink Original**');
expect(text).toContain('Processing: NOVA 3');
});
it('should filter by minimum nutriscore', async () => {
mockClient.searchProducts.mockResolvedValue(mockSearchResponse);
await handlers.handleGetProductSuggestions({
category: 'snacks',
min_nutriscore: 'b',
});
expect(mockClient.searchProducts).toHaveBeenCalledWith({
categories: 'snacks',
page_size: 20,
sort_by: 'popularity',
nutrition_grades: 'a,b',
});
});
it('should handle multiple dietary preferences', async () => {
const organicVeganResponse = {
...mockSearchResponse,
products: [mockVeganProduct],
};
mockClient.searchProducts.mockResolvedValue(organicVeganResponse);
const result = await handlers.handleGetProductSuggestions({
category: 'plant-based-foods',
dietary_preferences: ['vegan', 'organic', 'gluten-free'],
max_results: 3,
});
const text = result.content[0].text;
expect(text).toContain('Product suggestions in plant-based-foods:');
});
it('should limit results to max_results', async () => {
const manyProductsResponse = {
...mockSearchResponse,
products: Array(10).fill(mockVeganProduct),
};
mockClient.searchProducts.mockResolvedValue(manyProductsResponse);
const result = await handlers.handleGetProductSuggestions({
category: 'beverages',
max_results: 3,
});
const text = result.content[0].text;
const suggestionCount = (text.match(/^\d+\./gm) || []).length;
expect(suggestionCount).toBe(3);
});
it('should handle dietary preference filtering correctly', async () => {
// Test internal filtering method by providing products that should/shouldn't match
const mixedProducts = [
{
...mockVeganProduct,
labels: 'Vegan, Organic',
},
{
...mockNutellaProduct,
labels: 'Chocolate, Sweet',
},
];
const mixedResponse = {
...mockSearchResponse,
products: mixedProducts,
};
mockClient.searchProducts.mockResolvedValue(mixedResponse as any);
const result = await handlers.handleGetProductSuggestions({
category: 'foods',
dietary_preferences: ['vegan'],
});
const text = result.content[0].text;
// Should only include the vegan product
expect(text).toContain('Oat Drink Original');
expect(text).not.toContain('Nutella');
});
it('should format product suggestions with scores', async () => {
mockClient.searchProducts.mockResolvedValue(mockSearchResponse);
const result = await handlers.handleGetProductSuggestions({
category: 'beverages',
});
const text = result.content[0].text;
expect(text).toContain('Scores: Nutri-Score: E | Processing: NOVA 4');
expect(text).toContain('Scores: Nutri-Score: B | Processing: NOVA 1');
expect(text).toContain('Scores: Nutri-Score: B | Processing: NOVA 3 | Eco: B');
});
});
describe('private helper methods', () => {
it('should extract key nutrients correctly', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleGetProduct(mockBarcodes.nutella);
const text = result.content[0].text;
// Verify the nutrient extraction logic
expect(text).toContain('Energy: 539 kcal');
expect(text).toContain('Fat: 30.9g');
expect(text).toContain('Carbs: 57.5g');
expect(text).toContain('Sugars: 56.3g');
expect(text).toContain('Protein: 6.3g');
expect(text).toContain('Salt: 0.107g');
expect(text).not.toContain('Fiber: 0g'); // Should not show zero fiber
});
it('should get score descriptions correctly', async () => {
mockClient.getProduct.mockResolvedValue(mockNutellaProductResponse);
const result = await handlers.handleAnalyzeProduct(mockBarcodes.nutella);
const text = result.content[0].text;
expect(text).toContain('Very poor nutritional quality');
expect(text).toContain('Ultra-processed foods');
expect(text).toContain('High environmental impact');
});
it('should handle unknown scores gracefully', async () => {
const unknownScoresProduct = {
status: 1,
status_verbose: 'product found',
product: {
code: '123456789',
product_name: 'Test Product',
nutriscore_grade: 'x',
nova_group: 99,
ecoscore_grade: 'z',
},
};
mockClient.getProduct.mockResolvedValue(unknownScoresProduct as any);
const result = await handlers.handleAnalyzeProduct('123456789');
const text = result.content[0].text;
expect(text).toContain('Unknown quality');
expect(text).toContain('Unknown processing level');
expect(text).toContain('Unknown environmental impact');
});
});
});