diff --git a/src/services/db/admin.db.test.ts b/src/services/db/admin.db.test.ts index 2b79f42e..369b4402 100644 --- a/src/services/db/admin.db.test.ts +++ b/src/services/db/admin.db.test.ts @@ -668,12 +668,17 @@ describe('Admin DB Service', () => { const mockUsers: AdminUserView[] = [ createMockAdminUserView({ user_id: '1', email: 'test@test.com' }), ]; - mockDb.query.mockResolvedValue({ rows: mockUsers }); + // Mock count query + mockDb.query.mockResolvedValueOnce({ rows: [{ count: '1' }] }); + // Mock users query + mockDb.query.mockResolvedValueOnce({ rows: mockUsers }); + const result = await adminRepo.getAllUsers(mockLogger); expect(mockDb.query).toHaveBeenCalledWith( expect.stringContaining('FROM public.users u JOIN public.profiles p'), + undefined, ); - expect(result).toEqual(mockUsers); + expect(result).toEqual({ users: mockUsers, total: 1 }); }); it('should throw an error if the database query fails', async () => { diff --git a/src/services/db/personalization.db.test.ts b/src/services/db/personalization.db.test.ts index 11aabb5b..6221f836 100644 --- a/src/services/db/personalization.db.test.ts +++ b/src/services/db/personalization.db.test.ts @@ -5,7 +5,10 @@ import type { Pool, PoolClient } from 'pg'; import { withTransaction } from './connection.db'; import { PersonalizationRepository } from './personalization.db'; import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance } from '../../types'; -import { createMockMasterGroceryItem, createMockUserAppliance } from '../../tests/utils/mockFactories'; +import { + createMockMasterGroceryItem, + createMockUserAppliance, +} from '../../tests/utils/mockFactories'; // Un-mock the module we are testing to ensure we use the real implementation. vi.unmock('./personalization.db'); @@ -50,7 +53,10 @@ describe('Personalization DB Service', () => { const mockItems: MasterGroceryItem[] = [ createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }), ]; - mockQuery.mockResolvedValue({ rows: mockItems }); + // Mock count query + mockQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] }); + // Mock items query + mockQuery.mockResolvedValueOnce({ rows: mockItems }); const result = await personalizationRepo.getAllMasterItems(mockLogger); @@ -64,14 +70,17 @@ describe('Personalization DB Service', () => { // The query string in the implementation has a lot of whitespace from the template literal. // This updated expectation matches the new query exactly. - expect(mockQuery).toHaveBeenCalledWith(expectedQuery); - expect(result).toEqual(mockItems); + expect(mockQuery).toHaveBeenCalledWith(expectedQuery, undefined); + expect(result).toEqual({ items: mockItems, total: 1 }); }); it('should return an empty array if no master items exist', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + // Mock count query + mockQuery.mockResolvedValueOnce({ rows: [{ count: '0' }] }); + // Mock items query + mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await personalizationRepo.getAllMasterItems(mockLogger); - expect(result).toEqual([]); + expect(result).toEqual({ items: [], total: 0 }); }); it('should throw an error if the database query fails', async () => { diff --git a/src/services/flyerAiProcessor.server.test.ts b/src/services/flyerAiProcessor.server.test.ts index d9ae6d7c..20f5b693 100644 --- a/src/services/flyerAiProcessor.server.test.ts +++ b/src/services/flyerAiProcessor.server.test.ts @@ -37,7 +37,7 @@ describe('FlyerAiProcessor', () => { extractCoreDataFromFlyerImage: vi.fn(), } as unknown as AIService; mockPersonalizationRepo = { - getAllMasterItems: vi.fn().mockResolvedValue([]), + getAllMasterItems: vi.fn().mockResolvedValue({ items: [], total: 0 }), } as unknown as PersonalizationRepository; service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo); @@ -86,9 +86,9 @@ describe('FlyerAiProcessor', () => { const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }]; // Act & Assert - await expect( - service.extractAndValidateData(imagePaths, jobData, logger), - ).rejects.toThrow(dbError); + await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow( + dbError, + ); // Verify that the process stops before calling the AI service expect(mockAiService.extractCoreDataFromFlyerImage).not.toHaveBeenCalled(); @@ -103,8 +103,20 @@ describe('FlyerAiProcessor', () => { valid_to: '2024-01-07', store_address: '123 Good St', items: [ - { item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }, - { item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' }, + { + item: 'Priced Item 1', + price_in_cents: 199, + price_display: '$1.99', + quantity: '1', + category_name: 'A', + }, + { + item: 'Priced Item 2', + price_in_cents: 299, + price_display: '$2.99', + quantity: '1', + category_name: 'B', + }, ], }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); @@ -128,7 +140,9 @@ describe('FlyerAiProcessor', () => { valid_to: null, store_address: null, }; - vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any); + vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue( + invalidResponse as any, + ); const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }]; await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow( @@ -140,7 +154,15 @@ describe('FlyerAiProcessor', () => { const jobData = createMockJobData({}); const mockAiResponse = { store_name: null, // Missing store name - items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }], + items: [ + { + item: 'Test Item', + price_display: '$1.99', + price_in_cents: 199, + quantity: 'each', + category_name: 'Grocery', + }, + ], valid_from: '2024-01-01', valid_to: '2024-01-07', store_address: null, @@ -187,9 +209,27 @@ describe('FlyerAiProcessor', () => { valid_to: '2024-01-07', store_address: '123 Test St', items: [ - { item: 'Priced Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }, - { item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'B' }, - { item: 'Unpriced Item 2', price_in_cents: null, price_display: 'FREE', quantity: '1', category_name: 'C' }, + { + item: 'Priced Item', + price_in_cents: 199, + price_display: '$1.99', + quantity: '1', + category_name: 'A', + }, + { + item: 'Unpriced Item 1', + price_in_cents: null, + price_display: 'See store', + quantity: '1', + category_name: 'B', + }, + { + item: 'Unpriced Item 2', + price_in_cents: null, + price_display: 'FREE', + quantity: '1', + category_name: 'C', + }, ], // 1/3 = 33% have price, which is < 50% }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); @@ -200,7 +240,9 @@ describe('FlyerAiProcessor', () => { expect(result.needsReview).toBe(true); expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ qualityIssues: ['Low price quality (33% of items have a price)'] }), + expect.objectContaining({ + qualityIssues: ['Low price quality (33% of items have a price)'], + }), expect.stringContaining('AI response has quality issues.'), ); }); @@ -216,10 +258,34 @@ describe('FlyerAiProcessor', () => { valid_to: '2024-01-07', store_address: '123 Test St', items: [ - { item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }, - { item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' }, - { item: 'Priced Item 3', price_in_cents: 399, price_display: '$3.99', quantity: '1', category_name: 'C' }, - { item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'D' }, + { + item: 'Priced Item 1', + price_in_cents: 199, + price_display: '$1.99', + quantity: '1', + category_name: 'A', + }, + { + item: 'Priced Item 2', + price_in_cents: 299, + price_display: '$2.99', + quantity: '1', + category_name: 'B', + }, + { + item: 'Priced Item 3', + price_in_cents: 399, + price_display: '$3.99', + quantity: '1', + category_name: 'C', + }, + { + item: 'Unpriced Item 1', + price_in_cents: null, + price_display: 'See store', + quantity: '1', + category_name: 'D', + }, ], // 3/4 = 75% have price. This is > 50% (default) but < 80% (custom). }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); @@ -233,7 +299,9 @@ describe('FlyerAiProcessor', () => { // Because 75% < 80%, it should be flagged for review. expect(result.needsReview).toBe(true); expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ qualityIssues: ['Low price quality (75% of items have a price)'] }), + expect.objectContaining({ + qualityIssues: ['Low price quality (75% of items have a price)'], + }), expect.stringContaining('AI response has quality issues.'), ); }); @@ -243,9 +311,17 @@ describe('FlyerAiProcessor', () => { const mockAiResponse = { store_name: 'Test Store', valid_from: null, // Missing date - valid_to: null, // Missing date + valid_to: null, // Missing date store_address: '123 Test St', - items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }], + items: [ + { + item: 'Test Item', + price_in_cents: 199, + price_display: '$1.99', + quantity: '1', + category_name: 'A', + }, + ], }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); const { logger } = await import('./logger.server'); @@ -264,7 +340,7 @@ describe('FlyerAiProcessor', () => { const jobData = createMockJobData({}); const mockAiResponse = { store_name: null, // Issue 1 - items: [], // Issue 2 + items: [], // Issue 2 valid_from: null, // Issue 3 valid_to: null, store_address: null, @@ -277,7 +353,14 @@ describe('FlyerAiProcessor', () => { expect(result.needsReview).toBe(true); expect(logger.warn).toHaveBeenCalledWith( - { rawData: mockAiResponse, qualityIssues: ['Missing store name', 'No items were extracted', 'Missing both valid_from and valid_to dates'] }, + { + rawData: mockAiResponse, + qualityIssues: [ + 'Missing store name', + 'No items were extracted', + 'Missing both valid_from and valid_to dates', + ], + }, 'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing both valid_from and valid_to dates', ); }); @@ -291,7 +374,15 @@ describe('FlyerAiProcessor', () => { valid_from: '2024-01-01', valid_to: '2024-01-07', store_address: '123 Test St', - items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }], + items: [ + { + item: 'Test Item', + price_in_cents: 199, + price_display: '$1.99', + quantity: '1', + category_name: 'A', + }, + ], }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); @@ -300,7 +391,11 @@ describe('FlyerAiProcessor', () => { // Assert expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith( - imagePaths, [], undefined, '456 Fallback Ave', logger + imagePaths, + [], + undefined, + '456 Fallback Ave', + logger, ); }); @@ -323,8 +418,22 @@ describe('FlyerAiProcessor', () => { valid_to: '2025-01-07', store_address: '123 Batch St', items: [ - { item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 }, - { item: 'Item B', price_display: '$2', price_in_cents: 200, quantity: '1', category_name: 'Cat B', master_item_id: 2 }, + { + item: 'Item A', + price_display: '$1', + price_in_cents: 100, + quantity: '1', + category_name: 'Cat A', + master_item_id: 1, + }, + { + item: 'Item B', + price_display: '$2', + price_in_cents: 200, + quantity: '1', + category_name: 'Cat B', + master_item_id: 2, + }, ], }; @@ -334,7 +443,14 @@ describe('FlyerAiProcessor', () => { valid_to: null, store_address: null, items: [ - { item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 }, + { + item: 'Item C', + price_display: '$3', + price_in_cents: 300, + quantity: '1', + category_name: 'Cat C', + master_item_id: 3, + }, ], }; @@ -351,8 +467,22 @@ describe('FlyerAiProcessor', () => { expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(2); // 2. Check the arguments for each call - expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(1, imagePaths.slice(0, 4), [], undefined, undefined, logger); - expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(2, imagePaths.slice(4, 5), [], undefined, undefined, logger); + expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith( + 1, + imagePaths.slice(0, 4), + [], + undefined, + undefined, + logger, + ); + expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith( + 2, + imagePaths.slice(4, 5), + [], + undefined, + undefined, + logger, + ); // 3. Check the merged data expect(result.data.store_name).toBe('Batch 1 Store'); // Metadata from the first batch @@ -362,11 +492,13 @@ describe('FlyerAiProcessor', () => { // 4. Check that items from both batches are merged expect(result.data.items).toHaveLength(3); - expect(result.data.items).toEqual(expect.arrayContaining([ - expect.objectContaining({ item: 'Item A' }), - expect.objectContaining({ item: 'Item B' }), - expect.objectContaining({ item: 'Item C' }), - ])); + expect(result.data.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ item: 'Item A' }), + expect.objectContaining({ item: 'Item B' }), + expect.objectContaining({ item: 'Item C' }), + ]), + ); // 5. Check that the job is not flagged for review expect(result.needsReview).toBe(false); @@ -376,7 +508,11 @@ describe('FlyerAiProcessor', () => { // Arrange const jobData = createMockJobData({}); const imagePaths = [ - { path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' }, + { path: 'page1.jpg', mimetype: 'image/jpeg' }, + { path: 'page2.jpg', mimetype: 'image/jpeg' }, + { path: 'page3.jpg', mimetype: 'image/jpeg' }, + { path: 'page4.jpg', mimetype: 'image/jpeg' }, + { path: 'page5.jpg', mimetype: 'image/jpeg' }, ]; const mockAiResponseBatch1 = { @@ -385,7 +521,14 @@ describe('FlyerAiProcessor', () => { valid_to: '2025-01-07', store_address: '123 Good St', items: [ - { item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 }, + { + item: 'Item A', + price_display: '$1', + price_in_cents: 100, + quantity: '1', + category_name: 'Cat A', + master_item_id: 1, + }, ], }; @@ -416,11 +559,45 @@ describe('FlyerAiProcessor', () => { // Arrange const jobData = createMockJobData({}); const imagePaths = [ - { path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' }, + { path: 'page1.jpg', mimetype: 'image/jpeg' }, + { path: 'page2.jpg', mimetype: 'image/jpeg' }, + { path: 'page3.jpg', mimetype: 'image/jpeg' }, + { path: 'page4.jpg', mimetype: 'image/jpeg' }, + { path: 'page5.jpg', mimetype: 'image/jpeg' }, ]; - const mockAiResponseBatch1 = { store_name: null, valid_from: '2025-01-01', valid_to: '2025-01-07', store_address: null, items: [{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 }] }; - const mockAiResponseBatch2 = { store_name: 'Batch 2 Store', valid_from: '2025-01-02', valid_to: null, store_address: '456 Subsequent St', items: [{ item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 }] }; + const mockAiResponseBatch1 = { + store_name: null, + valid_from: '2025-01-01', + valid_to: '2025-01-07', + store_address: null, + items: [ + { + item: 'Item A', + price_display: '$1', + price_in_cents: 100, + quantity: '1', + category_name: 'Cat A', + master_item_id: 1, + }, + ], + }; + const mockAiResponseBatch2 = { + store_name: 'Batch 2 Store', + valid_from: '2025-01-02', + valid_to: null, + store_address: '456 Subsequent St', + items: [ + { + item: 'Item C', + price_display: '$3', + price_in_cents: 300, + quantity: '1', + category_name: 'Cat C', + master_item_id: 3, + }, + ], + }; vi.mocked(mockAiService.extractCoreDataFromFlyerImage) .mockResolvedValueOnce(mockAiResponseBatch1) @@ -453,7 +630,14 @@ describe('FlyerAiProcessor', () => { valid_to: '2025-02-07', store_address: '789 Single St', items: [ - { item: 'Item X', price_display: '$10', price_in_cents: 1000, quantity: '1', category_name: 'Cat X', master_item_id: 10 }, + { + item: 'Item X', + price_display: '$10', + price_in_cents: 1000, + quantity: '1', + category_name: 'Cat X', + master_item_id: 10, + }, ], }; @@ -468,9 +652,15 @@ describe('FlyerAiProcessor', () => { expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1); // 2. Check the arguments for the single call. - expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(imagePaths, [], undefined, undefined, logger); + expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith( + imagePaths, + [], + undefined, + undefined, + logger, + ); // 3. Check that the final data matches the single batch's data. expect(result.data).toEqual(mockAiResponse); }); -}); \ No newline at end of file +});