All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m34s
1202 lines
36 KiB
TypeScript
1202 lines
36 KiB
TypeScript
// 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> = {},
|
|
): 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> = {}): 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);
|
|
});
|
|
});
|
|
|
|
describe('lookupExternalUpc - additional coverage', () => {
|
|
it('should use image_front_url as fallback when image_url is missing', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
status: 1,
|
|
product: {
|
|
product_name: 'Test Product',
|
|
brands: 'Test Brand',
|
|
image_url: null,
|
|
image_front_url: 'https://example.com/front.jpg',
|
|
},
|
|
}),
|
|
});
|
|
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result?.image_url).toBe('https://example.com/front.jpg');
|
|
});
|
|
|
|
it('should return Unknown Product when both product_name and generic_name are missing', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
status: 1,
|
|
product: {
|
|
brands: 'Test Brand',
|
|
// No product_name or generic_name
|
|
},
|
|
}),
|
|
});
|
|
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result?.name).toBe('Unknown Product');
|
|
});
|
|
|
|
it('should handle category without en: prefix', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
status: 1,
|
|
product: {
|
|
product_name: 'Test Product',
|
|
categories_tags: ['snacks'], // No en: prefix
|
|
},
|
|
}),
|
|
});
|
|
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result?.category).toBe('snacks');
|
|
});
|
|
|
|
it('should handle non-Error thrown in catch block', async () => {
|
|
mockFetch.mockRejectedValueOnce('String error');
|
|
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('scanUpc - additional coverage', () => {
|
|
it('should not set external_lookup when cached lookup was unsuccessful', 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(),
|
|
});
|
|
vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({
|
|
scan_id: 5,
|
|
user_id: 'user-1',
|
|
upc_code: '012345678905',
|
|
product_id: null,
|
|
scan_source: 'manual_entry',
|
|
scan_confidence: 1.0,
|
|
raw_image_path: null,
|
|
lookup_successful: false,
|
|
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).toBeNull();
|
|
expect(result.lookup_successful).toBe(false);
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should cache unsuccessful external lookup result', 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: 6,
|
|
user_id: 'user-1',
|
|
upc_code: '012345678905',
|
|
product_id: null,
|
|
scan_source: 'manual_entry',
|
|
scan_confidence: 1.0,
|
|
raw_image_path: null,
|
|
lookup_successful: false,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
// External lookup returns nothing
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
});
|
|
|
|
const result = await scanUpc(
|
|
'user-1',
|
|
{ upc_code: '012345678905', scan_source: 'manual_entry' },
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.external_lookup).toBeNull();
|
|
expect(upcRepo.upsertExternalLookup).toHaveBeenCalledWith(
|
|
'012345678905',
|
|
'unknown',
|
|
false,
|
|
expect.anything(),
|
|
{},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('lookupUpc - additional coverage', () => {
|
|
it('should cache unsuccessful external lookup and return found=false', async () => {
|
|
vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null);
|
|
vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce(null);
|
|
vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce(
|
|
createMockExternalLookupRecord(),
|
|
);
|
|
|
|
// External lookup returns nothing
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
});
|
|
|
|
const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger);
|
|
|
|
expect(result.found).toBe(false);
|
|
expect(result.from_cache).toBe(false);
|
|
expect(result.external_lookup).toBeNull();
|
|
});
|
|
|
|
it('should use custom max_cache_age_hours', 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, product: null }),
|
|
});
|
|
|
|
await lookupUpc({ upc_code: '012345678905', max_cache_age_hours: 24 }, mockLogger);
|
|
|
|
expect(upcRepo.findExternalLookup).toHaveBeenCalledWith(
|
|
'012345678905',
|
|
24,
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Tests for UPC Item DB and Barcode Lookup APIs when configured.
|
|
* These require separate describe blocks to re-mock the config module.
|
|
*/
|
|
describe('upcService.server - with API keys configured', () => {
|
|
let mockLogger: Logger;
|
|
const mockFetch = vi.fn();
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
global.fetch = mockFetch;
|
|
mockFetch.mockReset();
|
|
|
|
// Re-mock with API keys configured
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
upc: {
|
|
upcItemDbApiKey: 'test-upcitemdb-key',
|
|
barcodeLookupApiKey: 'test-barcodelookup-key',
|
|
},
|
|
},
|
|
isUpcItemDbConfigured: true,
|
|
isBarcodeLookupConfigured: true,
|
|
}));
|
|
|
|
vi.doMock('./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(),
|
|
},
|
|
}));
|
|
|
|
mockLogger = createMockLogger();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('lookupExternalUpc with UPC Item DB', () => {
|
|
it('should return product from UPC Item DB when Open Food Facts has no result', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns product
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
code: 'OK',
|
|
items: [
|
|
{
|
|
title: 'UPC Item DB Product',
|
|
brand: 'UPC Brand',
|
|
category: 'Electronics',
|
|
description: 'A test product',
|
|
images: ['https://example.com/upcitemdb.jpg'],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.name).toBe('UPC Item DB Product');
|
|
expect(result?.brand).toBe('UPC Brand');
|
|
expect(result?.source).toBe('upcitemdb');
|
|
});
|
|
|
|
it('should handle UPC Item DB rate limit (429)', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB rate limit
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
})
|
|
// Barcode Lookup also returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
{ upcCode: '012345678905' },
|
|
'UPC Item DB rate limit exceeded',
|
|
);
|
|
});
|
|
|
|
it('should handle UPC Item DB network error', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB network error
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
// Barcode Lookup also errors
|
|
.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle UPC Item DB empty items array', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns empty items
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup also returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return Unknown Product when UPC Item DB item has no title', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns item without title
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
code: 'OK',
|
|
items: [{ brand: 'Some Brand' }],
|
|
}),
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result?.name).toBe('Unknown Product');
|
|
expect(result?.source).toBe('upcitemdb');
|
|
});
|
|
});
|
|
|
|
describe('lookupExternalUpc with Barcode Lookup', () => {
|
|
it('should return product from Barcode Lookup when other APIs have no result', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup returns product
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
products: [
|
|
{
|
|
title: 'Barcode Lookup Product',
|
|
brand: 'BL Brand',
|
|
category: 'Food',
|
|
description: 'A barcode lookup product',
|
|
images: ['https://example.com/barcodelookup.jpg'],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.name).toBe('Barcode Lookup Product');
|
|
expect(result?.source).toBe('barcodelookup');
|
|
});
|
|
|
|
it('should handle Barcode Lookup rate limit (429)', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup rate limit
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
{ upcCode: '012345678905' },
|
|
'Barcode Lookup rate limit exceeded',
|
|
);
|
|
});
|
|
|
|
it('should handle Barcode Lookup 404 response', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup 404
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should use product_name fallback when title is missing in Barcode Lookup', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup with product_name instead of title
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
products: [
|
|
{
|
|
product_name: 'Product Name Fallback',
|
|
brand: 'BL Brand',
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result?.name).toBe('Product Name Fallback');
|
|
});
|
|
|
|
it('should handle Barcode Lookup network error', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup network error
|
|
.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle non-Error thrown in Barcode Lookup', async () => {
|
|
// Open Food Facts returns nothing
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ status: 0, product: null }),
|
|
})
|
|
// UPC Item DB returns nothing
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ code: 'OK', items: [] }),
|
|
})
|
|
// Barcode Lookup throws non-Error
|
|
.mockRejectedValueOnce('String error thrown');
|
|
|
|
const { lookupExternalUpc } = await import('./upcService.server');
|
|
const result = await lookupExternalUpc('012345678905', mockLogger);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|