validation.test.ts•19.4 kB
import { describe, it, expect } from '@jest/globals';
import {
ProductSchema,
ProductResponseSchema,
SearchResponseSchema,
OpenFoodFactsConfig
} from '../src/types.js';
describe('Schema Validation', () => {
describe('ProductSchema', () => {
it('should validate a complete product', () => {
const validProduct = {
code: '3017620422003',
product_name: 'Nutella',
brands: 'Ferrero',
categories: 'Sweet spreads, Chocolate spreads',
ingredients_text: 'Sugar, palm oil, hazelnuts',
nutriments: {
'energy-kcal_100g': 539,
'fat_100g': 30.9,
'proteins_100g': 6.3,
},
nutriscore_grade: 'e',
nova_group: 4,
ecoscore_grade: 'd',
image_url: 'https://example.com/image.jpg',
image_front_url: 'https://example.com/front.jpg',
quantity: '400g',
packaging: 'Plastic jar',
labels: 'No palm oil',
countries: 'France',
manufacturing_places: 'France',
stores: 'Carrefour',
created_datetime: '2012-08-24T14:56:20Z',
last_modified_datetime: 1699185045,
};
const result = ProductSchema.safeParse(validProduct);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBe('3017620422003');
expect(result.data.product_name).toBe('Nutella');
}
});
it('should validate a minimal product with only code', () => {
const minimalProduct = {
code: '123456789',
};
const result = ProductSchema.safeParse(minimalProduct);
expect(result.success).toBe(true);
});
it('should reject product without code', () => {
const invalidProduct = {
product_name: 'Test Product',
brands: 'Test Brand',
};
const result = ProductSchema.safeParse(invalidProduct);
expect(result.success).toBe(false);
});
it('should reject product with invalid code type', () => {
const invalidProduct = {
code: 123456789, // Should be string
product_name: 'Test Product',
};
const result = ProductSchema.safeParse(invalidProduct);
expect(result.success).toBe(false);
});
it('should handle numeric nova_group', () => {
const productWithNumericNova = {
code: '123456789',
nova_group: 4,
};
const result = ProductSchema.safeParse(productWithNumericNova);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nova_group).toBe(4);
}
});
it('should handle string nova_group', () => {
const productWithStringNova = {
code: '123456789',
nova_group: '4',
};
const result = ProductSchema.safeParse(productWithStringNova);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nova_group).toBe('4');
}
});
it('should handle numeric datetime values', () => {
const productWithNumericDates = {
code: '123456789',
created_datetime: 1692882980,
last_modified_datetime: 1699185045,
};
const result = ProductSchema.safeParse(productWithNumericDates);
expect(result.success).toBe(true);
});
it('should handle string datetime values', () => {
const productWithStringDates = {
code: '123456789',
created_datetime: '2012-08-24T14:56:20Z',
last_modified_datetime: '2023-11-15T10:30:45Z',
};
const result = ProductSchema.safeParse(productWithStringDates);
expect(result.success).toBe(true);
});
it('should validate nutriments with mixed types', () => {
const productWithMixedNutriments = {
code: '123456789',
nutriments: {
'energy-kcal_100g': 539, // number
'fat_100g': '30.9', // string
'proteins_100g': 6.3, // number
'salt_100g': '0.107', // string
},
};
const result = ProductSchema.safeParse(productWithMixedNutriments);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nutriments?.['energy-kcal_100g']).toBe(539);
expect(result.data.nutriments?.['fat_100g']).toBe('30.9');
}
});
it('should reject invalid nutriment values', () => {
const productWithInvalidNutriments = {
code: '123456789',
nutriments: {
'energy-kcal_100g': true, // Invalid type
'fat_100g': 30.9,
},
};
const result = ProductSchema.safeParse(productWithInvalidNutriments);
expect(result.success).toBe(false);
});
});
describe('ProductResponseSchema', () => {
it('should validate successful product response', () => {
const validResponse = {
status: 1,
status_verbose: 'product found',
product: {
code: '3017620422003',
product_name: 'Nutella',
},
};
const result = ProductResponseSchema.safeParse(validResponse);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe(1);
expect(result.data.product?.code).toBe('3017620422003');
}
});
it('should validate product not found response', () => {
const notFoundResponse = {
status: 0,
status_verbose: 'product not found',
};
const result = ProductResponseSchema.safeParse(notFoundResponse);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe(0);
expect(result.data.product).toBeUndefined();
}
});
it('should reject response without status', () => {
const invalidResponse = {
status_verbose: 'product found',
product: { code: '123456789' },
};
const result = ProductResponseSchema.safeParse(invalidResponse);
expect(result.success).toBe(false);
});
it('should reject response without status_verbose', () => {
const invalidResponse = {
status: 1,
product: { code: '123456789' },
};
const result = ProductResponseSchema.safeParse(invalidResponse);
expect(result.success).toBe(false);
});
it('should reject response with invalid status type', () => {
const invalidResponse = {
status: '1', // Should be number
status_verbose: 'product found',
product: { code: '123456789' },
};
const result = ProductResponseSchema.safeParse(invalidResponse);
expect(result.success).toBe(false);
});
it('should reject response with invalid product', () => {
const invalidResponse = {
status: 1,
status_verbose: 'product found',
product: {
// Missing required 'code' field
product_name: 'Test Product',
},
};
const result = ProductResponseSchema.safeParse(invalidResponse);
expect(result.success).toBe(false);
});
});
describe('SearchResponseSchema', () => {
it('should validate complete search response', () => {
const validSearchResponse = {
count: 150,
page: 1,
page_count: 8,
page_size: 20,
products: [
{
code: '3017620422003',
product_name: 'Nutella',
},
{
code: '3229820787015',
product_name: 'Greek Yogurt',
},
],
};
const result = SearchResponseSchema.safeParse(validSearchResponse);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.count).toBe(150);
expect(result.data.products).toHaveLength(2);
}
});
it('should validate empty search response', () => {
const emptySearchResponse = {
count: 0,
page: 1,
page_count: 0,
page_size: 20,
products: [],
};
const result = SearchResponseSchema.safeParse(emptySearchResponse);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.count).toBe(0);
expect(result.data.products).toHaveLength(0);
}
});
it('should reject search response with missing required fields', () => {
const invalidSearchResponse = {
count: 150,
// Missing page, page_count, page_size
products: [],
};
const result = SearchResponseSchema.safeParse(invalidSearchResponse);
expect(result.success).toBe(false);
});
it('should reject search response with invalid field types', () => {
const invalidSearchResponse = {
count: '150', // Should be number
page: 1,
page_count: 8,
page_size: 20,
products: [],
};
const result = SearchResponseSchema.safeParse(invalidSearchResponse);
expect(result.success).toBe(false);
});
it('should reject search response with non-array products', () => {
const invalidSearchResponse = {
count: 150,
page: 1,
page_count: 8,
page_size: 20,
products: 'not-an-array',
};
const result = SearchResponseSchema.safeParse(invalidSearchResponse);
expect(result.success).toBe(false);
});
it('should reject search response with invalid products', () => {
const invalidSearchResponse = {
count: 1,
page: 1,
page_count: 1,
page_size: 20,
products: [
{
// Missing required 'code' field
product_name: 'Invalid Product',
},
],
};
const result = SearchResponseSchema.safeParse(invalidSearchResponse);
expect(result.success).toBe(false);
});
it('should validate search response with products containing optional fields', () => {
const searchResponseWithOptionals = {
count: 1,
page: 1,
page_count: 1,
page_size: 20,
products: [
{
code: '3017620422003',
product_name: 'Nutella',
brands: 'Ferrero',
nutriscore_grade: 'e',
nova_group: 4,
},
],
};
const result = SearchResponseSchema.safeParse(searchResponseWithOptionals);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.products[0].nutriscore_grade).toBe('e');
expect(result.data.products[0].nova_group).toBe(4);
}
});
});
describe('Edge Cases and Boundary Values', () => {
it('should handle extremely long strings', () => {
const productWithLongStrings = {
code: '123456789',
product_name: 'A'.repeat(1000),
ingredients_text: 'B'.repeat(10000),
categories: 'C'.repeat(500),
};
const result = ProductSchema.safeParse(productWithLongStrings);
expect(result.success).toBe(true);
});
it('should handle empty strings', () => {
const productWithEmptyStrings = {
code: '123456789',
product_name: '',
brands: '',
categories: '',
};
const result = ProductSchema.safeParse(productWithEmptyStrings);
expect(result.success).toBe(true);
});
it('should handle very large numbers in nutriments', () => {
const productWithLargeNutriments = {
code: '123456789',
nutriments: {
'energy-kcal_100g': 999999,
'fat_100g': 100.999,
'proteins_100g': Number.MAX_SAFE_INTEGER,
},
};
const result = ProductSchema.safeParse(productWithLargeNutriments);
expect(result.success).toBe(true);
});
it('should handle zero and negative numbers in nutriments', () => {
const productWithZeroNutriments = {
code: '123456789',
nutriments: {
'energy-kcal_100g': 0,
'fat_100g': -1, // Negative values might exist in real data
'fiber_100g': 0,
},
};
const result = ProductSchema.safeParse(productWithZeroNutriments);
expect(result.success).toBe(true);
});
it('should handle floating point precision issues', () => {
const productWithPrecisionNumbers = {
code: '123456789',
nutriments: {
'fat_100g': 0.1 + 0.2, // 0.30000000000000004
'proteins_100g': 1.23456789,
'salt_100g': 0.000001,
},
};
const result = ProductSchema.safeParse(productWithPrecisionNumbers);
expect(result.success).toBe(true);
});
it('should handle special characters and Unicode', () => {
const productWithUnicode = {
code: '123456789',
product_name: 'Café au lait 🥛 ñoño',
brands: 'Société Générale®',
ingredients_text: 'Ingrédients: café, lait, sucre β-carotène',
countries: '中国, 日本, Россия',
};
const result = ProductSchema.safeParse(productWithUnicode);
expect(result.success).toBe(true);
});
});
describe('Configuration Validation', () => {
it('should validate valid OpenFoodFactsConfig', () => {
const validConfig: OpenFoodFactsConfig = {
baseUrl: 'https://world.openfoodfacts.net',
userAgent: 'OpenFoodFactsMCP/1.0 (test)',
rateLimits: {
products: 100,
search: 10,
facets: 2,
},
};
// Since there's no explicit schema for config, we test the structure
expect(validConfig.baseUrl).toMatch(/^https?:\/\//);
expect(validConfig.userAgent).toContain('OpenFoodFactsMCP');
expect(validConfig.rateLimits.products).toBeGreaterThan(0);
expect(validConfig.rateLimits.search).toBeGreaterThan(0);
expect(validConfig.rateLimits.facets).toBeGreaterThan(0);
});
it('should identify invalid URLs in config', () => {
const configWithInvalidUrl = {
baseUrl: 'not-a-url',
userAgent: 'OpenFoodFactsMCP/1.0 (test)',
rateLimits: { products: 100, search: 10, facets: 2 },
};
expect(configWithInvalidUrl.baseUrl).not.toMatch(/^https?:\/\//);
});
it('should identify invalid rate limits', () => {
const configWithInvalidRates = {
baseUrl: 'https://world.openfoodfacts.net',
userAgent: 'OpenFoodFactsMCP/1.0 (test)',
rateLimits: {
products: 0, // Invalid: should be positive
search: -5, // Invalid: should be positive
facets: 2,
},
};
expect(configWithInvalidRates.rateLimits.products).not.toBeGreaterThan(0);
expect(configWithInvalidRates.rateLimits.search).not.toBeGreaterThan(0);
});
});
describe('Type Coercion and Transformation', () => {
it('should handle numeric strings that could be coerced', () => {
const productWithNumericStrings = {
code: '123456789',
nova_group: '4', // String that represents number
created_datetime: '1692882980', // String timestamp
nutriments: {
'energy-kcal_100g': '539', // String number
},
};
const result = ProductSchema.safeParse(productWithNumericStrings);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nova_group).toBe('4'); // Should remain string
expect(result.data.nutriments?.['energy-kcal_100g']).toBe('539'); // Should remain string
}
});
it('should not coerce invalid string types', () => {
const productWithInvalidTypes = {
code: 123456789, // Number instead of string
product_name: true, // Boolean instead of string
};
const result = ProductSchema.safeParse(productWithInvalidTypes);
expect(result.success).toBe(false);
});
});
describe('Real-world API Response Simulation', () => {
it('should validate typical OFF API response structure', () => {
// Simulate a real Open Food Facts API response
const realWorldResponse = {
status: 1,
status_verbose: 'product found',
product: {
code: '3017620422003',
url: 'https://world.openfoodfacts.org/product/3017620422003/nutella-ferrero',
creator: 'openfoodfacts-contributors',
created_t: 1345821380,
created_datetime: '2012-08-24T14:56:20Z',
last_modified_t: 1699185045,
last_modified_datetime: 1699185045,
product_name: 'Nutella',
generic_name: 'Pâte à tartiner aux noisettes et au cacao',
quantity: '400 g',
packaging: 'Plastique, Pot, Couvercle, Carton',
brands: 'Ferrero',
categories: 'Aliments et boissons à base de végétaux, Aliments d\'origine végétale',
origins: 'France',
manufacturing_places: 'France',
labels: '',
emb_codes: '',
link: '',
purchase_places: 'France',
stores: 'Magasins U, Carrefour, Leclerc',
countries: 'France',
ingredients_text: 'Sucre, huile de palme, NOISETTES 13%, lait écrémé en poudre 8,7%',
allergens: 'Lait, Fruits à coque',
traces: 'Gluten',
serving_size: '',
no_nutriments: '0',
additives_n: 3,
additives_tags: ['en:e322', 'en:e476', 'en:vanillin'],
nutriscore_score: 26,
nutriscore_grade: 'e',
nova_group: 4,
ecoscore_score: 27,
ecoscore_grade: 'd',
nutriments: {
'energy-kcal_100g': 539,
'energy_100g': 2255,
'fat_100g': 30.9,
'saturated-fat_100g': 10.6,
'carbohydrates_100g': 57.5,
'sugars_100g': 56.3,
'fiber_100g': 0,
'proteins_100g': 6.3,
'salt_100g': 0.107,
'sodium_100g': 0.043,
'calcium_100g': 0.16,
'iron_100g': 4.2,
},
image_url: 'https://images.openfoodfacts.org/images/products/301/762/042/2003/front_fr.4.400.jpg',
image_front_url: 'https://images.openfoodfacts.org/images/products/301/762/042/2003/front_fr.4.400.jpg',
image_ingredients_url: 'https://images.openfoodfacts.org/images/products/301/762/042/2003/ingredients_fr.7.400.jpg',
image_nutrition_url: 'https://images.openfoodfacts.org/images/products/301/762/042/2003/nutrition_fr.8.400.jpg',
},
};
const result = ProductResponseSchema.safeParse(realWorldResponse);
expect(result.success).toBe(true);
});
it('should validate typical search API response', () => {
const realWorldSearchResponse = {
count: 1642,
page: 1,
page_count: 83,
page_size: 20,
skip: 0,
products: [
{
code: '3017620422003',
product_name: 'Nutella',
brands: 'Ferrero',
categories: 'Sweet spreads',
nutriscore_grade: 'e',
nova_group: '4',
image_url: 'https://images.openfoodfacts.org/images/products/301/762/042/2003/front_fr.4.400.jpg',
},
{
code: '8000500037676',
product_name: 'Nutella B-ready',
brands: 'Ferrero',
categories: 'Snacks',
nutriscore_grade: 'e',
nova_group: '4',
},
],
};
const result = SearchResponseSchema.safeParse(realWorldSearchResponse);
expect(result.success).toBe(true);
});
});
});