client.test.ts•14 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');
});
});
});