Files
flyer-crawler.projectium.com/src/services/upcService.server.test.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

675 lines
20 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);
});
});
});