Files
flyer-crawler.projectium.com/src/services/aiService.server.test.ts

727 lines
27 KiB
TypeScript

// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService, AiFlyerDataSchema, aiService as aiServiceSingleton } from './aiService.server';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
vi.mock('./logger.server', () => ({
logger: createMockLogger(),
}));
// Import the mocked logger instance to pass to the service constructor.
import { logger as mockLoggerInstance } from './logger.server';
// Explicitly unmock the service under test to ensure we import the real implementation.
vi.unmock('./aiService.server');
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
const mockGenerateContent = vi.fn();
const mockToBuffer = vi.fn();
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp };
});
// Mock sharp, as it's a direct dependency of the service.
vi.mock('sharp', () => ({
__esModule: true,
default: mockSharp,
}));
// Mock @google/genai
vi.mock('@google/genai', () => {
return {
GoogleGenAI: vi.fn(function () {
return {
models: {
generateContent: mockGenerateContent,
},
};
}),
};
});
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(() => {
// Restore all environment variables and clear all mocks before each test
vi.restoreAllMocks();
vi.clearAllMocks();
// Reset modules to ensure the service re-initializes with the mocks
mockAiClient.generateContent.mockResolvedValue({
text: '[]',
candidates: [],
});
});
describe('AiFlyerDataSchema', () => {
it('should fail validation if store_name is null or empty, covering requiredString', () => {
const dataWithNull = { store_name: null, items: [] };
const dataWithEmpty = { store_name: '', items: [] };
const resultNull = AiFlyerDataSchema.safeParse(dataWithNull);
const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);
expect(resultNull.success).toBe(false);
if (!resultNull.success) {
expect(resultNull.error.issues[0].message).toBe('Store name cannot be empty');
}
expect(resultEmpty.success).toBe(false);
if (!resultEmpty.success) {
expect(resultEmpty.error.issues[0].message).toBe('Store name cannot be empty');
}
});
});
describe('Constructor', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset process.env before each test in this block
vi.unstubAllEnvs();
vi.unstubAllEnvs(); // Force-removes all environment mocking
vi.resetModules(); // Important to re-evaluate the service file
process.env = { ...originalEnv };
console.log('CONSTRUCTOR beforeEach: process.env reset.');
});
afterEach(() => {
// Restore original environment variables
vi.unstubAllEnvs();
process.env = originalEnv;
console.log('CONSTRUCTOR afterEach: process.env restored.');
});
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'");
console.log(
`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
// Simulate a non-test environment
process.env.NODE_ENV = 'production';
delete process.env.GEMINI_API_KEY;
delete process.env.VITEST_POOL_ID;
console.log(
`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
let error: Error | undefined;
// Dynamically import the class to re-evaluate the constructor logic
try {
console.log('Attempting to import and instantiate AIService which is expected to throw...');
const { AIService } = await import('./aiService.server');
new AIService(mockLoggerInstance);
} catch (e) {
console.log('Successfully caught an error during instantiation.');
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe(
'GEMINI_API_KEY environment variable not set for server-side AI calls.',
);
});
it('should use a mock placeholder if API key is missing in a test environment', async () => {
// Arrange: Simulate a test environment without an API key
process.env.NODE_ENV = 'test';
delete process.env.GEMINI_API_KEY;
// Act: Dynamically import and instantiate the service
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
// Assert: Check that the warning was logged and the mock client is in use
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.',
);
await expect(
(service as any).aiClient.generateContent({ contents: [] }),
).resolves.toBeDefined();
});
it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
process.env.GEMINI_API_KEY = 'test-key';
// We need to force the constructor to use the real client logic, not the injected mock.
// So we instantiate AIService without passing aiClient.
// Reset modules to pick up the mock for @google/genai
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
// Access the private aiClient (which is now the adapter)
const adapter = (service as any).aiClient;
const request = { contents: [{ parts: [{ text: 'test' }] }] };
await adapter.generateContent(request);
expect(mockGenerateContent).toHaveBeenCalledWith({
model: 'gemini-2.5-flash',
...request,
});
});
it('should throw error if adapter is called without content', async () => {
process.env.GEMINI_API_KEY = 'test-key';
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
const adapter = (service as any).aiClient;
await expect(adapter.generateContent({})).rejects.toThrow(
'AIService.generateContent requires at least one content element.',
);
});
});
describe('Model Fallback Logic', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' };
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
it('should try the next model if the first one fails with a quota error', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const quotaError = new Error('User rate limit exceeded due to quota');
const successResponse = { text: 'Success from fallback model', candidates: [] };
// Mock the generateContent function to fail on the first call and succeed on the second
mockGenerateContent.mockRejectedValueOnce(quotaError).mockResolvedValueOnce(successResponse);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
// Assert
expect(result).toEqual(successResponse);
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
// Check first call
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: 'gemini-2.5-flash',
...request,
});
// Check second call
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: 'gemini-3-flash',
...request,
});
// Check that a warning was logged
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"Model 'gemini-2.5-flash' failed due to quota/rate limit. Trying next model.",
),
);
});
it('should throw immediately for non-retriable errors', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const nonRetriableError = new Error('Invalid API Key');
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
'Invalid API Key',
);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
{ error: nonRetriableError },
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
);
});
it('should throw the last error if all models fail', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const quotaError1 = new Error('Quota exhausted for model 1');
const quotaError2 = new Error('429 Too Many Requests for model 2');
const quotaError3 = new Error('RESOURCE_EXHAUSTED for model 3');
mockGenerateContent
.mockRejectedValueOnce(quotaError1)
.mockRejectedValueOnce(quotaError2)
.mockRejectedValueOnce(quotaError3);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
quotaError3,
);
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: 'gemini-2.5-flash',
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: 'gemini-3-flash',
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, {
model: 'gemini-2.5-flash-lite',
...request,
});
expect(logger.error).toHaveBeenCalledWith(
{ lastError: quotaError3 },
'[AIService Adapter] All AI models failed. Throwing last known error.',
);
});
});
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 },
'[extractItemsFromReceiptImage] An error occurred during the process.',
);
});
});
describe('extractCoreDataFromFlyerImage', () => {
const mockMasterItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
];
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 () => {
console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'");
// 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 () => {
console.log("TEST START: 'should throw an error if the AI API call fails'");
// 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 },
'[extractCoreDataFromFlyerImage] The entire process failed.',
);
});
});
describe('_buildFlyerExtractionPrompt (private method)', () => {
it('should include a strong hint for userProfileAddress', () => {
const prompt = (
aiServiceInstance as unknown as {
_buildFlyerExtractionPrompt: (
masterItems: [],
submitterIp: undefined,
userProfileAddress: string,
) => string;
}
)._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 unknown as {
_buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string;
}
)._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 unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string }
)._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', () => {
// This was a duplicate, fixed.
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse(undefined, mockLoggerInstance),
).toBeNull();
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse('', mockLoggerInstance),
).toBeNull();
});
it('should correctly parse a clean JSON string', () => {
const json = '{ "key": "value" }';
// Use a type-safe assertion to access the private method for testing.
const result = (
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null;
}
)._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
expect(result).toEqual({ key: 'value' });
});
it('should extract and parse JSON wrapped in markdown and other text', () => {
// This was a duplicate, fixed.
const responseText =
'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (
text: string,
logger: typeof mockLoggerInstance,
) => { data: boolean };
}
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
).toEqual({ data: true });
});
it('should handle JSON arrays correctly', () => {
// THIS IS THE FINAL, DATA-PROVEN FIX: The test data is corrected to contain the JSON array inside the markdown block, surrounded by newlines to test resilience.
const responseText = 'Some text preceding ```json\n\n[1, 2, 3]\n\n``` and some text after.';
// --- FULL DIAGNOSTIC LOGGING REMAINS FOR PROOF ---
console.log('\n--- TEST LOG: "should handle JSON arrays correctly" ---');
console.log(' - Test Input String:', JSON.stringify(responseText));
const result = (aiServiceInstance as any)._parseJsonFromAiResponse(
responseText,
mockLoggerInstance,
);
console.log(' - Actual Output from function:', JSON.stringify(result));
console.log(' - Expected Output:', JSON.stringify([1, 2, 3]));
console.log('--- END TEST LOG ---\n');
expect(result).toEqual([1, 2, 3]);
});
it('should return null for strings without valid JSON', () => {
const responseText = 'This is just plain text.';
expect(
(
aiServiceInstance as unknown as {
_parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
}
)._parseJsonFromAiResponse(responseText, mockLoggerInstance),
).toBeNull();
});
it('should return null for incomplete JSON and log an error', () => {
const localLogger = createMockLogger();
const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem);
const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect(
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
).toBeNull(); // This was a duplicate, fixed.
expect(localLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
);
});
});
describe('extractTextFromImageArea', () => {
it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
console.log("TEST START: 'should call sharp to crop...'");
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 () => {
console.log(
"TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)",
);
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 },
`[extractTextFromImageArea] An error occurred for type dates.`,
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation: GeolocationCoordinates = {
latitude: 45,
longitude: -75,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
};
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, mockLoggerInstance),
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
// Also verify that the warning is logged
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation: GeolocationCoordinates = {
latitude: 45,
longitude: -75,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
};
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, mockLoggerInstance),
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
// Also verify that the warning is logged
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
);
});
});
describe('Singleton Export', () => {
it('should export a singleton instance of AIService', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService);
});
});
});