feat/parapharmacy-baby-products #31
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user