Files
flyer-crawler.projectium.com/src/services/aiService.server.test.ts
Torben Sorensen 424cbaf0d4
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
feat: Implement deals repository and routes for fetching best watched item prices
- Added a new DealsRepository class to interact with the database for fetching the best sale prices of watched items.
- Created a new route `/api/users/deals/best-watched-prices` to handle requests for the best prices of items the authenticated user is watching.
- Enhanced logging in the FlyerDataTransformer and FlyerProcessingService for better traceability.
- Updated tests to ensure proper logging and functionality in the FlyerProcessingService.
- Refactored logger client to support structured logging for better consistency across the application.
2025-12-13 20:02:18 -08:00

286 lines
13 KiB
TypeScript

// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger as mockLoggerInstance } from './logger.server';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server';
// Explicitly unmock the service under test to ensure we import the real implementation.
vi.unmock('./aiService.server');
// Mock sharp, as it's a direct dependency of the service.
const mockToBuffer = vi.fn();
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
vi.mock('sharp', () => ({
__esModule: true,
default: mockSharp,
}));
describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
const mockAiClient = { generateContent: vi.fn() };
const mockFileSystem = { readFile: vi.fn() };
// Instantiate the service with our mock dependencies
const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem);
beforeEach(() => {
vi.clearAllMocks();
// Reset modules to ensure the service re-initializes with the mocks
mockAiClient.generateContent.mockResolvedValue({
text: '[]',
candidates: []
});
});
describe('Constructor', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset process.env before each test in this block
vi.resetModules(); // Important to re-evaluate the service file
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original environment variables
process.env = originalEnv;
});
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
// Simulate a non-test environment
process.env.NODE_ENV = 'production';
delete process.env.GEMINI_API_KEY;
// Dynamically import the class to re-evaluate the constructor logic
const { AIService } = await import('./aiService.server');
expect(() => new AIService(mockLoggerInstance)).toThrow('GEMINI_API_KEY environment variable not set for server-side AI calls.');
});
});
describe('extractItemsFromReceiptImage', () => {
it('should extract items from a valid AI response', async () => {
const mockAiResponseText = `[
{ "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 },
{ "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
]`;
mockAiClient.generateContent.mockResolvedValue({ text: mockAiResponseText, candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance);
expect(mockAiClient.generateContent).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 () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'This is not JSON.', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON array.'
);
});
it('should throw an error if the AI API call fails', async () => {
const apiError = new Error('API limit reached');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance))
.rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractItemsFromReceiptImage"
);
});
});
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 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 },
],
};
mockAiClient.generateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse), candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await aiServiceInstance.extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems, undefined, undefined, mockLoggerInstance);
expect(mockAiClient.generateContent).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 () => {
mockAiClient.generateContent.mockResolvedValue({ text: 'not a json object', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(
'AI response did not contain a valid JSON object.'
);
});
it('should throw an error if the AI response contains malformed JSON', async () => {
// Arrange: AI returns a string that looks like JSON but is invalid
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance))
.rejects.toThrow('AI response did not contain a valid JSON object.');
});
it('should throw an error if the AI API call fails', async () => {
// Arrange: AI client's method rejects
const apiError = new Error('API call failed');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage"
);
});
});
describe('_buildFlyerExtractionPrompt (private method)', () => {
it('should include a strong hint for userProfileAddress', () => {
const prompt = (aiServiceInstance as any)._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
expect(prompt).toContain('The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.');
});
it('should include a general hint for submitterIp when no address is present', () => {
const prompt = (aiServiceInstance as any)._buildFlyerExtractionPrompt([], '123.45.67.89');
expect(prompt).toContain('The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store\'s region.');
});
it('should not include any location hint if no IP or address is provided', () => {
const prompt = (aiServiceInstance as any)._buildFlyerExtractionPrompt([]);
expect(prompt).not.toContain('Use this as a strong hint');
expect(prompt).not.toContain('Use this as a general hint');
});
});
describe('_parseJsonFromAiResponse (private method)', () => {
it('should return null for undefined or empty input', () => {
expect((aiServiceInstance as any)._parseJsonFromAiResponse(undefined, mockLoggerInstance)).toBeNull();
expect((aiServiceInstance as any)._parseJsonFromAiResponse('', mockLoggerInstance)).toBeNull();
});
it('should correctly parse a clean JSON string', () => {
const json = '{ "key": "value" }';
expect((aiServiceInstance as any)._parseJsonFromAiResponse(json, mockLoggerInstance)).toEqual({ key: 'value' });
});
it('should extract and parse JSON wrapped in markdown and other text', () => {
const responseText = 'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual({ data: true });
});
it('should handle JSON arrays correctly', () => {
const responseText = '```json\n\n```'; // This test seems incorrect, but I will fix the signature.
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual([1, 2, 3]);
});
it('should return null for strings without valid JSON', () => {
const responseText = 'This is just plain text.';
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull();
});
it('should return null for incomplete JSON and log an error', async () => {
const { logger } = await import('./logger.server');
const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, logger)).toBeNull();
expect(logger.error).toHaveBeenCalledWith({ jsonString: '{ "key": "value"', error: expect.any(SyntaxError) }, 'Failed to parse JSON from AI response slice');
});
});
describe('_normalizeExtractedItems (private method)', () => {
it('should replace null or undefined fields with default values', () => {
const rawItems = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }];
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems, mockLoggerInstance);
expect(normalized.price_display).toBe('');
expect(normalized.quantity).toBe('');
expect(normalized.category_name).toBe('Other/Miscellaneous');
expect(normalized.master_item_id).toBeUndefined();
});
});
describe('extractTextFromImageArea', () => {
it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
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
mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] });
const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType, mockLoggerInstance);
expect(mockSharp).toHaveBeenCalledWith(imagePath);
expect(mockExtract).toHaveBeenCalledWith({
left: 10,
top: 20,
width: 100,
height: 50,
});
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
interface AiCallArgs {
contents: {
parts: {
text?: string;
inlineData?: unknown;
}[];
}[];
}
const aiCallArgs = mockAiClient.generateContent.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');
});
it('should throw an error if the AI API call fails', async () => {
const apiError = new Error('API Error');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data'));
await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates', mockLoggerInstance))
.rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractTextFromImageArea for type dates"
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null };
const mockStore = { name: 'Test Store' };
it('should throw a "feature disabled" error', async () => {
// This test verifies the current implementation which has the feature disabled.
await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation as any, mockLoggerInstance))
.rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
});
});
});