Files
flyer-crawler.projectium.com/src/services/aiService.server.test.ts
Torben Sorensen b456546feb
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m19s
lootsa tests fixes
2025-12-05 20:37:33 -08:00

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');
});
});
});