Skip to main content
Glama

Open Food Facts MCP Server

by caleb-conner
client.test.ts14 kB
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { OpenFoodFactsClient } from '../src/client.js'; import { OpenFoodFactsConfig } from '../src/types.js'; import { mockNutellaProductResponse, mockHealthyYogurtProductResponse, mockVeganProductResponse, mockProductNotFoundResponse, mockSearchResponse, mockEmptySearchResponse, mockBarcodes, } from './fixtures/products.js'; describe('OpenFoodFactsClient', () => { let client: OpenFoodFactsClient; let mockAxios: MockAdapter; let config: OpenFoodFactsConfig; beforeEach(() => { config = { baseUrl: 'https://test.openfoodfacts.net', userAgent: 'OpenFoodFactsMCP/1.0 (test)', rateLimits: { products: 100, search: 10, facets: 2, }, }; client = new OpenFoodFactsClient(config); mockAxios = new MockAdapter(axios); }); afterEach(() => { mockAxios.restore(); jest.clearAllMocks(); }); describe('constructor', () => { it('should create client with correct configuration', () => { expect(client).toBeInstanceOf(OpenFoodFactsClient); }); it('should set correct axios defaults', () => { const axiosInstance = (client as any).client; expect(axiosInstance.defaults.baseURL).toBe(config.baseUrl); expect(axiosInstance.defaults.headers['User-Agent']).toBe(config.userAgent); expect(axiosInstance.defaults.headers['Accept']).toBe('application/json'); expect(axiosInstance.defaults.timeout).toBe(30000); }); }); describe('getProduct', () => { it('should fetch product successfully', async () => { const barcode = mockBarcodes.nutella; mockAxios.onGet(`/api/v2/product/${barcode}`).reply(200, mockNutellaProductResponse); const result = await client.getProduct(barcode); expect(result).toBeValidProductResponse(); expect(result.status).toBe(1); expect(result.product).toBeDefined(); expect(result.product?.code).toBe(barcode); expect(result.product?.product_name).toBe('Nutella'); }); it('should handle product not found', async () => { const barcode = mockBarcodes.notFound; mockAxios.onGet(`/api/v2/product/${barcode}`).reply(200, mockProductNotFoundResponse); const result = await client.getProduct(barcode); expect(result).toBeValidProductResponse(); expect(result.status).toBe(0); expect(result.product).toBeUndefined(); }); it('should handle network errors', async () => { const barcode = mockBarcodes.nutella; mockAxios.onGet(`/api/v2/product/${barcode}`).networkError(); await expect(client.getProduct(barcode)).rejects.toThrow('Failed to fetch product'); }); it('should handle HTTP errors', async () => { const barcode = mockBarcodes.nutella; mockAxios.onGet(`/api/v2/product/${barcode}`).reply(500, 'Server Error'); await expect(client.getProduct(barcode)).rejects.toThrow('Failed to fetch product 3017620422003: 500'); }); it('should handle malformed response data', async () => { const barcode = mockBarcodes.nutella; mockAxios.onGet(`/api/v2/product/${barcode}`).reply(200, { invalid: 'response' }); await expect(client.getProduct(barcode)).rejects.toThrow(); }); }); describe('searchProducts', () => { it('should search products with default parameters', async () => { mockAxios.onGet(/\/cgi\/search\.pl/).reply(200, mockSearchResponse); const result = await client.searchProducts(); expect(result).toBeValidSearchResponse(); expect(result.count).toBe(150); expect(result.products).toHaveLength(3); expect(result.page).toBe(1); expect(result.page_size).toBe(20); }); it('should search products with all parameters', async () => { const params = { search: 'chocolate', categories: 'sweets', brands: 'ferrero', countries: 'france', nutrition_grades: 'a,b,c', nova_groups: '1,2', sort_by: 'popularity' as const, page: 2, page_size: 50, }; mockAxios.onGet(/\/cgi\/search\.pl/).reply((config) => { const url = new URL(config.url!, 'https://test.openfoodfacts.net'); const searchParams = url.searchParams; expect(searchParams.get('search_terms')).toBe('chocolate'); expect(searchParams.get('categories_tags')).toBe('sweets'); expect(searchParams.get('brands_tags')).toBe('ferrero'); expect(searchParams.get('countries_tags')).toBe('france'); expect(searchParams.get('nutrition_grades_tags')).toBe('a,b,c'); expect(searchParams.get('nova_groups_tags')).toBe('1,2'); expect(searchParams.get('sort_by')).toBe('popularity'); expect(searchParams.get('page')).toBe('2'); expect(searchParams.get('page_size')).toBe('50'); expect(searchParams.get('json')).toBe('1'); return [200, mockSearchResponse]; }); const result = await client.searchProducts(params); expect(result).toBeValidSearchResponse(); }); it('should limit page_size to maximum of 100', async () => { mockAxios.onGet(/\/cgi\/search\.pl/).reply((config) => { const url = new URL(config.url!, 'https://test.openfoodfacts.net'); const searchParams = url.searchParams; expect(searchParams.get('page_size')).toBe('100'); return [200, mockSearchResponse]; }); await client.searchProducts({ page_size: 200 }); }); it('should handle empty search results', async () => { mockAxios.onGet(/\/cgi\/search\.pl/).reply(200, mockEmptySearchResponse); const result = await client.searchProducts({ search: 'nonexistent' }); expect(result).toBeValidSearchResponse(); expect(result.count).toBe(0); expect(result.products).toHaveLength(0); }); it('should handle search API errors', async () => { mockAxios.onGet(/\/cgi\/search\.pl/).reply(429, 'Rate limit exceeded'); await expect(client.searchProducts()).rejects.toThrow('Search failed: 429'); }); }); describe('getProductsByBarcodes', () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('should fetch multiple products successfully', async () => { const barcodes = [mockBarcodes.nutella, mockBarcodes.yogurt]; mockAxios .onGet(`/api/v2/product/${mockBarcodes.nutella}`) .reply(200, mockNutellaProductResponse) .onGet(`/api/v2/product/${mockBarcodes.yogurt}`) .reply(200, mockHealthyYogurtProductResponse); const promise = client.getProductsByBarcodes(barcodes); // Fast-forward timers to handle delays jest.advanceTimersByTime(200); const results = await promise; expect(results).toHaveLength(2); expect(results[0]).toBeValidProductResponse(); expect(results[0].product?.code).toBe(mockBarcodes.nutella); expect(results[1]).toBeValidProductResponse(); expect(results[1].product?.code).toBe(mockBarcodes.yogurt); }); it('should handle mixed success and failure', async () => { const barcodes = [mockBarcodes.nutella, mockBarcodes.notFound]; mockAxios .onGet(`/api/v2/product/${mockBarcodes.nutella}`) .reply(200, mockNutellaProductResponse) .onGet(`/api/v2/product/${mockBarcodes.notFound}`) .reply(200, mockProductNotFoundResponse); const promise = client.getProductsByBarcodes(barcodes); jest.advanceTimersByTime(200); const results = await promise; expect(results).toHaveLength(2); expect(results[0].status).toBe(1); expect(results[1].status).toBe(0); }); it('should handle network errors gracefully', async () => { const barcodes = [mockBarcodes.nutella]; mockAxios.onGet(`/api/v2/product/${mockBarcodes.nutella}`).networkError(); const promise = client.getProductsByBarcodes(barcodes); jest.advanceTimersByTime(200); const results = await promise; expect(results).toHaveLength(1); expect(results[0].status).toBe(0); expect(results[0].status_verbose).toContain('Error fetching'); }); it('should add delays between requests', async () => { const barcodes = [mockBarcodes.nutella, mockBarcodes.yogurt]; mockAxios .onGet(`/api/v2/product/${mockBarcodes.nutella}`) .reply(200, mockNutellaProductResponse) .onGet(`/api/v2/product/${mockBarcodes.yogurt}`) .reply(200, mockHealthyYogurtProductResponse); const startTime = Date.now(); const promise = client.getProductsByBarcodes(barcodes); jest.advanceTimersByTime(200); await promise; // Verify that setTimeout was called for delays expect(setTimeout).toHaveBeenCalled(); }); }); describe('rate limiting', () => { beforeEach(() => { jest.useFakeTimers(); // Create client with stricter rate limits for testing config.rateLimits = { products: 2, search: 1, facets: 1 }; client = new OpenFoodFactsClient(config); }); afterEach(() => { jest.useRealTimers(); }); it('should enforce product rate limit', async () => { mockAxios.onGet().reply(200, mockNutellaProductResponse); // First two requests should succeed await client.getProduct(mockBarcodes.nutella); await client.getProduct(mockBarcodes.yogurt); // Third request should fail due to rate limit await expect(client.getProduct(mockBarcodes.oatDrink)) .rejects.toThrow('Rate limit exceeded for products'); }); it('should reset rate limit after time window', async () => { mockAxios.onGet().reply(200, mockNutellaProductResponse); // Exhaust rate limit await client.getProduct(mockBarcodes.nutella); await client.getProduct(mockBarcodes.yogurt); // Should fail await expect(client.getProduct(mockBarcodes.oatDrink)) .rejects.toThrow('Rate limit exceeded'); // Advance time to reset window jest.advanceTimersByTime(60001); // Should succeed after reset await expect(client.getProduct(mockBarcodes.oatDrink)) .resolves.toBeValidProductResponse(); }); it('should enforce search rate limit', async () => { mockAxios.onGet().reply(200, mockSearchResponse); // First search should succeed await client.searchProducts({ search: 'test' }); // Second search should fail due to rate limit await expect(client.searchProducts({ search: 'test2' })) .rejects.toThrow('Rate limit exceeded for search'); }); it('should track different endpoints separately', async () => { mockAxios.onGet(/\/api\/v2\/product/).reply(200, mockNutellaProductResponse); mockAxios.onGet(/\/cgi\/search\.pl/).reply(200, mockSearchResponse); // Use up product rate limit await client.getProduct(mockBarcodes.nutella); await client.getProduct(mockBarcodes.yogurt); // Search should still work (different rate limit) await expect(client.searchProducts({ search: 'test' })) .resolves.toBeValidSearchResponse(); // But products should be blocked await expect(client.getProduct(mockBarcodes.oatDrink)) .rejects.toThrow('Rate limit exceeded for products'); }); }); describe('request headers and configuration', () => { it('should include correct User-Agent header', async () => { mockAxios.onGet().reply((config) => { expect(config.headers?.['User-Agent']).toBe('OpenFoodFactsMCP/1.0 (test)'); return [200, mockNutellaProductResponse]; }); await client.getProduct(mockBarcodes.nutella); }); it('should include correct Accept header', async () => { mockAxios.onGet().reply((config) => { expect(config.headers?.['Accept']).toBe('application/json'); return [200, mockNutellaProductResponse]; }); await client.getProduct(mockBarcodes.nutella); }); it('should use correct base URL', async () => { mockAxios.onGet().reply((config) => { expect(config.baseURL).toBe('https://test.openfoodfacts.net'); return [200, mockNutellaProductResponse]; }); await client.getProduct(mockBarcodes.nutella); }); it('should have correct timeout', async () => { mockAxios.onGet().reply((config) => { expect(config.timeout).toBe(30000); return [200, mockNutellaProductResponse]; }); await client.getProduct(mockBarcodes.nutella); }); }); describe('data validation', () => { it('should validate product response schema', async () => { const invalidResponse = { status: 'invalid', product: { code: 123 }, // Invalid: code should be string }; mockAxios.onGet().reply(200, invalidResponse); await expect(client.getProduct(mockBarcodes.nutella)) .rejects.toThrow(); }); it('should validate search response schema', async () => { const invalidSearchResponse = { count: 'invalid', // Should be number products: 'not-array', // Should be array }; mockAxios.onGet().reply(200, invalidSearchResponse); await expect(client.searchProducts()) .rejects.toThrow(); }); it('should accept valid product response with minimal data', async () => { const minimalResponse = { status: 1, status_verbose: 'product found', product: { code: '123456789', }, }; mockAxios.onGet().reply(200, minimalResponse); const result = await client.getProduct('123456789'); expect(result).toBeValidProductResponse(); expect(result.product?.code).toBe('123456789'); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/caleb-conner/open-food-facts-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server