// src/services/upcService.server.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Logger } from 'pino'; import { createMockLogger } from '../tests/utils/mockLogger'; import type { UpcScanSource, UpcExternalLookupRecord, UpcExternalSource } from '../types/upc'; // Mock dependencies vi.mock('./db/index.db', () => ({ upcRepo: { recordScan: vi.fn(), findProductByUpc: vi.fn(), findExternalLookup: vi.fn(), upsertExternalLookup: vi.fn(), linkUpcToProduct: vi.fn(), getScanHistory: vi.fn(), getUserScanStats: vi.fn(), getScanById: vi.fn(), }, })); vi.mock('../config/env', () => ({ config: { upc: { upcItemDbApiKey: undefined, barcodeLookupApiKey: undefined, }, }, isUpcItemDbConfigured: false, isBarcodeLookupConfigured: false, })); vi.mock('./logger.server', () => ({ logger: { child: vi.fn().mockReturnThis(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); // Mock global fetch const mockFetch = vi.fn(); global.fetch = mockFetch; // Import after mocks are set up import { isValidUpcCode, normalizeUpcCode, detectBarcodeFromImage, lookupExternalUpc, scanUpc, lookupUpc, linkUpcToProduct, getScanHistory, getScanStats, getScanById, } from './upcService.server'; import { upcRepo } from './db/index.db'; // Helper to create mock UpcExternalLookupRecord function createMockExternalLookupRecord( overrides: Partial = {}, ): UpcExternalLookupRecord { return { lookup_id: 1, upc_code: '012345678905', product_name: null, brand_name: null, category: null, description: null, image_url: null, external_source: 'openfoodfacts' as UpcExternalSource, lookup_data: null, lookup_successful: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), ...overrides, }; } // Helper to create mock ProductRow (from db layer - matches upc.db.ts) interface ProductRow { product_id: number; name: string; brand_id: number | null; category_id: number | null; description: string | null; size: string | null; upc_code: string | null; master_item_id: number | null; created_at: string; updated_at: string; } function createMockProductRow(overrides: Partial = {}): ProductRow { return { product_id: 1, name: 'Test Product', brand_id: null, category_id: null, description: null, size: null, upc_code: '012345678905', master_item_id: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), ...overrides, }; } describe('upcService.server', () => { let mockLogger: Logger; beforeEach(() => { vi.clearAllMocks(); mockLogger = createMockLogger(); mockFetch.mockReset(); }); afterEach(() => { vi.resetAllMocks(); }); describe('isValidUpcCode', () => { it('should return true for valid 12-digit UPC-A', () => { expect(isValidUpcCode('012345678905')).toBe(true); }); it('should return true for valid 8-digit UPC-E', () => { expect(isValidUpcCode('01234567')).toBe(true); }); it('should return true for valid 13-digit EAN-13', () => { expect(isValidUpcCode('5901234123457')).toBe(true); }); it('should return true for valid 8-digit EAN-8', () => { expect(isValidUpcCode('96385074')).toBe(true); }); it('should return true for valid 14-digit GTIN-14', () => { expect(isValidUpcCode('00012345678905')).toBe(true); }); it('should return false for code with less than 8 digits', () => { expect(isValidUpcCode('1234567')).toBe(false); }); it('should return false for code with more than 14 digits', () => { expect(isValidUpcCode('123456789012345')).toBe(false); }); it('should return false for code with non-numeric characters', () => { expect(isValidUpcCode('01234567890A')).toBe(false); }); it('should return false for empty string', () => { expect(isValidUpcCode('')).toBe(false); }); }); describe('normalizeUpcCode', () => { it('should remove spaces from UPC code', () => { expect(normalizeUpcCode('012 345 678 905')).toBe('012345678905'); }); it('should remove dashes from UPC code', () => { expect(normalizeUpcCode('012-345-678-905')).toBe('012345678905'); }); it('should remove mixed spaces and dashes', () => { expect(normalizeUpcCode('012-345 678-905')).toBe('012345678905'); }); it('should return unchanged if no spaces or dashes', () => { expect(normalizeUpcCode('012345678905')).toBe('012345678905'); }); }); describe('detectBarcodeFromImage', () => { it('should return not implemented error', async () => { const result = await detectBarcodeFromImage('base64imagedata', mockLogger); expect(result.detected).toBe(false); expect(result.upc_code).toBeNull(); expect(result.error).toBe( 'Barcode detection from images is not yet implemented. Please use manual entry.', ); }); }); describe('lookupExternalUpc', () => { it('should return product info from Open Food Facts on success', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 1, product: { product_name: 'Test Product', brands: 'Test Brand', categories_tags: ['en:snacks'], ingredients_text: 'Test ingredients', image_url: 'https://example.com/image.jpg', }, }), }); const result = await lookupExternalUpc('012345678905', mockLogger); expect(result).not.toBeNull(); expect(result?.name).toBe('Test Product'); expect(result?.brand).toBe('Test Brand'); expect(result?.source).toBe('openfoodfacts'); }); it('should return null when Open Food Facts returns status 0', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 0, product: null, }), }); const result = await lookupExternalUpc('012345678905', mockLogger); expect(result).toBeNull(); }); it('should return null when Open Food Facts request fails', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const result = await lookupExternalUpc('012345678905', mockLogger); expect(result).toBeNull(); }); it('should return null on network error', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); const result = await lookupExternalUpc('012345678905', mockLogger); expect(result).toBeNull(); }); it('should use generic_name when product_name is missing', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 1, product: { generic_name: 'Generic Product Name', brands: null, }, }), }); const result = await lookupExternalUpc('012345678905', mockLogger); expect(result?.name).toBe('Generic Product Name'); }); }); describe('scanUpc', () => { it('should scan with manual entry and return product from database', async () => { const mockProduct = { product_id: 1, name: 'Test Product', brand: 'Test Brand', category: 'Snacks', description: null, size: '100g', upc_code: '012345678905', image_url: null, master_item_id: null, }; vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(mockProduct); vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ scan_id: 1, user_id: 'user-1', upc_code: '012345678905', product_id: 1, scan_source: 'manual_entry', scan_confidence: 1.0, raw_image_path: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); const result = await scanUpc( 'user-1', { upc_code: '012345678905', scan_source: 'manual_entry' }, mockLogger, ); expect(result.upc_code).toBe('012345678905'); expect(result.product).toEqual(mockProduct); expect(result.lookup_successful).toBe(true); expect(result.is_new_product).toBe(false); expect(result.confidence).toBe(1.0); }); it('should scan with manual entry and perform external lookup when not in database', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce(null); vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( createMockExternalLookupRecord(), ); vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ scan_id: 2, user_id: 'user-1', upc_code: '012345678905', product_id: null, scan_source: 'manual_entry', scan_confidence: 1.0, raw_image_path: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 1, product: { product_name: 'External Product', brands: 'External Brand', }, }), }); const result = await scanUpc( 'user-1', { upc_code: '012345678905', scan_source: 'manual_entry' }, mockLogger, ); expect(result.product).toBeNull(); expect(result.external_lookup).not.toBeNull(); expect(result.external_lookup?.name).toBe('External Product'); expect(result.is_new_product).toBe(true); }); it('should use cached external lookup when available', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ lookup_id: 1, upc_code: '012345678905', product_name: 'Cached Product', brand_name: 'Cached Brand', category: 'Cached Category', description: null, image_url: null, external_source: 'openfoodfacts', lookup_data: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ scan_id: 3, user_id: 'user-1', upc_code: '012345678905', product_id: null, scan_source: 'manual_entry', scan_confidence: 1.0, raw_image_path: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); const result = await scanUpc( 'user-1', { upc_code: '012345678905', scan_source: 'manual_entry' }, mockLogger, ); expect(result.external_lookup?.name).toBe('Cached Product'); expect(mockFetch).not.toHaveBeenCalled(); }); it('should throw error for invalid UPC code format', async () => { await expect( scanUpc('user-1', { upc_code: 'invalid', scan_source: 'manual_entry' }, mockLogger), ).rejects.toThrow('Invalid UPC code format. UPC codes must be 8-14 digits.'); }); it('should throw error when neither upc_code nor image_base64 provided', async () => { await expect( scanUpc('user-1', { scan_source: 'manual_entry' } as any, mockLogger), ).rejects.toThrow('Either upc_code or image_base64 must be provided.'); }); it('should record failed scan when image detection fails', async () => { vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ scan_id: 4, user_id: 'user-1', upc_code: 'DETECTION_FAILED', product_id: null, scan_source: 'image_upload', scan_confidence: 0, raw_image_path: null, lookup_successful: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); const result = await scanUpc( 'user-1', { image_base64: 'base64data', scan_source: 'image_upload' }, mockLogger, ); expect(result.lookup_successful).toBe(false); expect(result.confidence).toBe(0); }); }); describe('lookupUpc', () => { it('should return product from database when found', async () => { const mockProduct = { product_id: 1, name: 'Test Product', brand: 'Test Brand', category: 'Snacks', description: null, size: '100g', upc_code: '012345678905', image_url: null, master_item_id: null, }; vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(mockProduct); const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); expect(result.found).toBe(true); expect(result.product).toEqual(mockProduct); expect(result.from_cache).toBe(false); }); it('should return cached external lookup when available', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ lookup_id: 1, upc_code: '012345678905', product_name: 'Cached Product', brand_name: null, category: null, description: null, image_url: null, external_source: 'openfoodfacts', lookup_data: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); expect(result.found).toBe(true); expect(result.from_cache).toBe(true); expect(result.external_lookup?.name).toBe('Cached Product'); }); it('should return cached unsuccessful lookup', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ lookup_id: 1, upc_code: '012345678905', product_name: null, brand_name: null, category: null, description: null, image_url: null, external_source: 'unknown', lookup_data: null, lookup_successful: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); expect(result.found).toBe(false); expect(result.from_cache).toBe(true); }); it('should perform fresh external lookup when force_refresh is true', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( createMockExternalLookupRecord(), ); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 1, product: { product_name: 'Fresh External Product', brands: 'Fresh Brand', }, }), }); const result = await lookupUpc({ upc_code: '012345678905', force_refresh: true }, mockLogger); expect(result.from_cache).toBe(false); expect(result.external_lookup?.name).toBe('Fresh External Product'); expect(upcRepo.findExternalLookup).not.toHaveBeenCalled(); }); it('should throw error for invalid UPC code', async () => { await expect(lookupUpc({ upc_code: 'invalid' }, mockLogger)).rejects.toThrow( 'Invalid UPC code format. UPC codes must be 8-14 digits.', ); }); it('should normalize UPC code before lookup', async () => { vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce(null); vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( createMockExternalLookupRecord(), ); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 0 }), }); const result = await lookupUpc({ upc_code: '012-345-678-905' }, mockLogger); expect(result.upc_code).toBe('012345678905'); }); }); describe('linkUpcToProduct', () => { it('should link UPC code to product successfully', async () => { vi.mocked(upcRepo.linkUpcToProduct).mockResolvedValueOnce(createMockProductRow()); await linkUpcToProduct(1, '012345678905', mockLogger); expect(upcRepo.linkUpcToProduct).toHaveBeenCalledWith(1, '012345678905', mockLogger); }); it('should throw error for invalid UPC code', async () => { await expect(linkUpcToProduct(1, 'invalid', mockLogger)).rejects.toThrow( 'Invalid UPC code format. UPC codes must be 8-14 digits.', ); }); it('should normalize UPC code before linking', async () => { vi.mocked(upcRepo.linkUpcToProduct).mockResolvedValueOnce(createMockProductRow()); await linkUpcToProduct(1, '012-345-678-905', mockLogger); expect(upcRepo.linkUpcToProduct).toHaveBeenCalledWith(1, '012345678905', mockLogger); }); }); describe('getScanHistory', () => { it('should return paginated scan history', async () => { const mockHistory = { scans: [ { scan_id: 1, user_id: 'user-1', upc_code: '012345678905', product_id: 1, scan_source: 'manual_entry' as UpcScanSource, scan_confidence: 1.0, raw_image_path: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, ], total: 1, }; vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce(mockHistory); const result = await getScanHistory({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger); expect(result.scans).toHaveLength(1); expect(result.total).toBe(1); }); it('should filter by scan source', async () => { vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce({ scans: [], total: 0 }); await getScanHistory({ user_id: 'user-1', scan_source: 'image_upload' }, mockLogger); expect(upcRepo.getScanHistory).toHaveBeenCalledWith( { user_id: 'user-1', scan_source: 'image_upload' }, mockLogger, ); }); it('should filter by date range', async () => { vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce({ scans: [], total: 0 }); await getScanHistory( { user_id: 'user-1', from_date: '2024-01-01', to_date: '2024-01-31', }, mockLogger, ); expect(upcRepo.getScanHistory).toHaveBeenCalledWith( { user_id: 'user-1', from_date: '2024-01-01', to_date: '2024-01-31', }, mockLogger, ); }); }); describe('getScanStats', () => { it('should return user scan statistics', async () => { const mockStats = { total_scans: 100, successful_lookups: 80, unique_products: 50, scans_today: 5, scans_this_week: 20, }; vi.mocked(upcRepo.getUserScanStats).mockResolvedValueOnce(mockStats); const result = await getScanStats('user-1', mockLogger); expect(result).toEqual(mockStats); expect(upcRepo.getUserScanStats).toHaveBeenCalledWith('user-1', mockLogger); }); }); describe('getScanById', () => { it('should return scan record by ID', async () => { const mockScan = { scan_id: 1, user_id: 'user-1', upc_code: '012345678905', product_id: 1, scan_source: 'manual_entry' as UpcScanSource, scan_confidence: 1.0, raw_image_path: null, lookup_successful: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; vi.mocked(upcRepo.getScanById).mockResolvedValueOnce(mockScan); const result = await getScanById(1, 'user-1', mockLogger); expect(result).toEqual(mockScan); expect(upcRepo.getScanById).toHaveBeenCalledWith(1, 'user-1', mockLogger); }); }); });