All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m19s
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
// 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');
|
|
});
|
|
});
|
|
}); |