// src/services/aiService.server.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MasterGroceryItem } from '../types'; // 1. Hoist the mocks so they can be referenced inside the mock factories const { mockGenerateContent, mockReadFile, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => { const generateContent = vi.fn(); const readFile = vi.fn(); const toBuffer = vi.fn(); const extract = vi.fn(() => ({ toBuffer })); const sharp = vi.fn(() => ({ extract })); return { mockGenerateContent: generateContent, mockReadFile: readFile, mockToBuffer: toBuffer, mockExtract: extract, mockSharp: sharp }; }); // 2. Mock @google/genai AND @google/generative-ai to cover both SDK versions const MockGoogleGenAIImplementation = class { constructor(public config: { apiKey: string }) {} get models() { return { generateContent: mockGenerateContent }; } getGenerativeModel() { return { generateContent: mockGenerateContent }; } }; vi.mock('@google/genai', () => ({ GoogleGenAI: MockGoogleGenAIImplementation, })); vi.mock('@google/generative-ai', () => ({ GoogleGenerativeAI: MockGoogleGenAIImplementation, })); // 3. Mock fs/promises vi.mock('fs/promises', () => ({ default: { readFile: mockReadFile, }, readFile: mockReadFile, })); // 4. Mock sharp vi.mock('sharp', () => ({ __esModule: true, default: mockSharp, })); // 5. Mock the logger vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), }, })); describe('AI Service (Server)', () => { beforeEach(() => { vi.clearAllMocks(); // Reset modules to ensure the service re-initializes with the mocks vi.resetModules(); // Default success response matching the shape expected by the implementation mockGenerateContent.mockResolvedValue({ text: '[]', candidates: [] }); }); describe('extractItemsFromReceiptImage', () => { it('should extract items from a valid AI response', async () => { const { extractItemsFromReceiptImage } = await import('./aiService.server'); const mockAiResponseText = `[ { "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 }, { "raw_item_description": "AVOCADO", "price_paid_cents": 299 } ]`; mockGenerateContent.mockResolvedValue({ text: mockAiResponseText }); mockReadFile.mockResolvedValue(Buffer.from('mock-image-data')); const result = await extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg'); expect(mockGenerateContent).toHaveBeenCalledTimes(1); expect(result).toEqual([ { raw_item_description: 'ORGANIC BANANAS', price_paid_cents: 129 }, { raw_item_description: 'AVOCADO', price_paid_cents: 299 }, ]); }); it('should throw an error if the AI response is not valid JSON', async () => { const { extractItemsFromReceiptImage } = await import('./aiService.server'); mockGenerateContent.mockResolvedValue({ text: 'This is not JSON.' }); mockReadFile.mockResolvedValue(Buffer.from('mock-image-data')); await expect(extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg')).rejects.toThrow( 'AI response did not contain a valid JSON array.' ); }); }); describe('extractCoreDataFromFlyerImage', () => { const mockMasterItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }]; it('should extract and post-process flyer data correctly', async () => { const { extractCoreDataFromFlyerImage } = await import('./aiService.server'); const mockAiResponse = { store_name: 'Test Store', valid_from: '2024-01-01', valid_to: '2024-01-07', items: [ { item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', category_name: 'Produce', master_item_id: 1 }, { item: 'Oranges', price_display: null, price_in_cents: null, quantity: undefined, category_name: null, master_item_id: null }, ], }; mockGenerateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse) }); mockReadFile.mockResolvedValue(Buffer.from('mock-image-data')); const result = await extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems); expect(mockGenerateContent).toHaveBeenCalledTimes(1); expect(result.store_name).toBe('Test Store'); expect(result.items).toHaveLength(2); expect(result.items[1].price_display).toBe(''); expect(result.items[1].quantity).toBe(''); expect(result.items[1].category_name).toBe('Other/Miscellaneous'); }); it('should throw an error if the AI response is not a valid JSON object', async () => { const { extractCoreDataFromFlyerImage } = await import('./aiService.server'); mockGenerateContent.mockResolvedValue({ text: 'not a json object' }); mockReadFile.mockResolvedValue(Buffer.from('mock-image-data')); await expect(extractCoreDataFromFlyerImage([], mockMasterItems)).rejects.toThrow( 'AI response did not contain a valid JSON object.' ); }); }); // describe('planTripWithMaps', () => { // it('should call generateContent and return the text and sources', async () => { // const { planTripWithMaps } = await import('./aiService.server'); // mockGenerateContent.mockResolvedValue({ // text: 'The nearest store is...', // candidates: [{ // groundingMetadata: { // groundingChunks: [ // { web: { uri: 'http://maps.google.com/1', title: 'Map to Store A' } }, // { web: { uri: 'http://maps.google.com/2', title: 'Map to Store B' } }, // ], // }, // }], // }); // const mockLocation = { latitude: 48.4284, longitude: -123.3656 } as GeolocationCoordinates; // const result = await planTripWithMaps([], undefined, mockLocation); // expect(mockGenerateContent).toHaveBeenCalledTimes(1); // const calledWith = mockGenerateContent.mock.calls[0][0] as any; // expect(calledWith.contents).toContain('latitude 48.4284'); // expect(result.text).toBe('The nearest store is...'); // expect(result.sources).toEqual([ // { uri: 'http://maps.google.com/1', title: 'Map to Store A' }, // { uri: 'http://maps.google.com/2', title: 'Map to Store B' }, // ]); // }); // }); describe('extractTextFromImageArea', () => { it('should call sharp to crop the image and call the AI with the correct prompt', async () => { const { extractTextFromImageArea } = await import('./aiService.server'); const imagePath = 'path/to/image.jpg'; const cropArea = { x: 10, y: 20, width: 100, height: 50 }; const extractionType = 'store_name'; // Mock sharp's output const mockCroppedBuffer = Buffer.from('cropped-image-data'); mockToBuffer.mockResolvedValue(mockCroppedBuffer); // Mock AI response mockGenerateContent.mockResolvedValue({ text: 'Super Store' }); const result = await extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType); // 1. Verify sharp was called correctly expect(mockSharp).toHaveBeenCalledWith(imagePath); expect(mockExtract).toHaveBeenCalledWith({ left: 10, top: 20, width: 100, height: 50, }); // 2. Verify the AI was called with the cropped image data and correct prompt expect(mockGenerateContent).toHaveBeenCalledTimes(1); // Define a specific type for the AI call arguments to avoid `as any`. interface AiCallArgs { contents: { parts: { text?: string; inlineData?: unknown; }[]; }[]; } const aiCallArgs = mockGenerateContent.mock.calls[0][0] as AiCallArgs; expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?'); expect(result.text).toBe('Super Store'); }); }); });