Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
- 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.
286 lines
13 KiB
TypeScript
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.");
|
|
});
|
|
});
|
|
}); |