diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js new file mode 100644 index 0000000..c5d7840 --- /dev/null +++ b/apps/backend/__tests__/off-service.test.js @@ -0,0 +1,92 @@ +import { transformOFFProduct, searchBabyProducts, getBabyProductDetails } from '../off-service.js'; + +describe('transformOFFProduct', () => { + test('transforms a valid OFF product to unified model', () => { + const offProduct = { + _id: '12345', + product_name: 'Baby Rice', + brands: 'Nestlé', + image_url: 'https://example.com/image.jpg', + nutriscore_grade: 'a', + ingredients_text: 'Rice, Iron, Vitamins', + nova_group: 1, + ecoscore_grade: 'b', + categories_tags: ['en:baby-foods', 'en:baby-rice'], + }; + + const result = transformOFFProduct(offProduct); + + expect(result).toEqual({ + id: '12345', + source: 'openfoodfacts', + name: 'Baby Rice', + brand: 'Nestlé', + category: 'baby_food', + image_url: 'https://example.com/image.jpg', + nutriscore: 'a', + ingredients: 'Rice, Iron, Vitamins', + nova_group: 1, + eco_score: 'b', + }); + }); + + test('returns null for null/undefined input', () => { + expect(transformOFFProduct(null)).toBeNull(); + expect(transformOFFProduct(undefined)).toBeNull(); + }); + + test('returns null when missing required fields', () => { + expect(transformOFFProduct({ _id: '123' })).toBeNull(); + expect(transformOFFProduct({ product_name: 'Test' })).toBeNull(); + }); + + test('sets default brand to empty string when missing', () => { + const result = transformOFFProduct({ + _id: '999', + product_name: 'Plain Product', + }); + expect(result.brand).toBe(''); + expect(result.image_url).toBeNull(); + expect(result.nutriscore).toBeNull(); + expect(result.ingredients).toBeNull(); + expect(result.nova_group).toBeNull(); + expect(result.eco_score).toBeNull(); + }); + + test('detects baby_milk category from tags', () => { + const result = transformOFFProduct({ + _id: '200', + product_name: 'Infant Formula', + categories_tags: ['en:infant-milk'], + }); + expect(result.category).toBe('baby_milk'); + }); + + test('detects baby_cereal category from tags', () => { + const result = transformOFFProduct({ + _id: '300', + product_name: 'Baby Cereal', + categories_tags: ['en:baby-cereals'], + }); + expect(result.category).toBe('baby_cereal'); + }); +}); + +describe('searchBabyProducts', () => { + test('returns empty array for short query', async () => { + const result = await searchBabyProducts('a'); + expect(result).toEqual([]); + }); + + test('returns empty array for null/empty query', async () => { + expect(await searchBabyProducts(null)).toEqual([]); + expect(await searchBabyProducts('')).toEqual([]); + }); +}); + +describe('getBabyProductDetails', () => { + test('returns null for null barcode', async () => { + const result = await getBabyProductDetails(null); + expect(result).toBeNull(); + }); +}); diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js new file mode 100644 index 0000000..58ad5ff --- /dev/null +++ b/apps/backend/off-service.js @@ -0,0 +1,139 @@ +import axios from 'axios'; +import redisClient from './redis-client.js'; + +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; +const CACHE_TTL = 3600; + +const BABY_CATEGORIES = { + 'baby food': 'baby_food', + 'baby milk': 'baby_milk', + 'baby cereal': 'baby_cereal', +}; + +export function transformOFFProduct(offProduct) { + if (!offProduct || !offProduct._id || !offProduct.product_name) { + return null; + } + + const categoriesTags = offProduct.categories_tags || []; + let category = 'baby_food'; + for (const tag of categoriesTags) { + const lower = tag.toLowerCase(); + if (lower.includes('baby-milk') || lower.includes('infant-milk')) { + category = 'baby_milk'; + break; + } + if (lower.includes('baby-cereal') || lower.includes('infant-cereal')) { + category = 'baby_cereal'; + break; + } + } + + return { + id: offProduct._id, + source: 'openfoodfacts', + name: offProduct.product_name, + brand: offProduct.brands || '', + category, + image_url: offProduct.image_url || null, + nutriscore: offProduct.nutriscore_grade || null, + ingredients: offProduct.ingredients_text || null, + nova_group: offProduct.nova_group || null, + eco_score: offProduct.ecoscore_grade || null, + }; +} + +export async function searchBabyProducts(query) { + if (!query || query.trim().length < 2) { + return []; + } + + const searchTerm = query.trim().toLowerCase(); + const cacheKey = `off:baby:${searchTerm}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF search: ${searchTerm}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching from OFF API: ${searchTerm}`); + const response = await axios.get(`${OFF_API_BASE}/search`, { + params: { + categories_tags: 'baby-food', + search_terms: searchTerm, + json: true, + fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + page_size: 20, + }, + timeout: 5000, + }); + + if (response.data && response.data.products) { + const products = response.data.products + .map(transformOFFProduct) + .filter(Boolean); + + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`✅ Cached ${products.length} OFF products for: ${searchTerm}`); + return products; + } + + return []; + } catch (error) { + console.error('Error searching baby products from OFF:', error.message); + return []; + } +} + +export async function getBabyProductDetails(barcode) { + if (!barcode) { + return null; + } + + const cacheKey = `off:product:${barcode}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF product: ${barcode}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching OFF product details: ${barcode}`); + const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { + timeout: 5000, + }); + + if (response.data && response.data.product) { + const product = transformOFFProduct(response.data.product); + + if (product) { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + console.log(`✅ Cached OFF product: ${barcode}`); + return product; + } + } + + return null; + } catch (error) { + console.error(`Error fetching OFF product ${barcode}:`, error.message); + return null; + } +} + +export async function clearCache(pattern = 'off:*') { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(keys); + console.log(`🗑️ Cleared ${keys.length} OFF cache entries`); + return keys.length; + } + return 0; + } catch (error) { + console.error('Error clearing OFF cache:', error); + return 0; + } +}