Files
flyer-crawler.projectium.com/src/services/aiService.server.test.ts
Torben Sorensen 2564df1c64
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
get rid of localhost in tests - not a qualified URL - we'll see
2026-01-05 20:02:44 -08:00

1287 lines
46 KiB
TypeScript

// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { Job } from 'bullmq';
import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino';
import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import {
AIService,
aiService as aiServiceSingleton,
DuplicateFlyerError,
type RawFlyerItem,
} from './aiService.server';
import {
createMockMasterGroceryItem,
createMockFlyer,
createMockUserProfile,
} from '../tests/utils/mockFactories';
import { ValidationError } from './db/errors.db';
import { AiFlyerDataSchema } from '../types/ai';
// 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, mockAdminLogActivity } = vi.hoisted(() => {
const mockGenerateContent = vi.fn();
const mockToBuffer = vi.fn();
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
const mockAdminLogActivity = vi.fn();
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
});
// 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,
},
};
}),
};
});
// --- New Mocks for Database and Queue ---
vi.mock('./db/index.db', () => ({
flyerRepo: {
findFlyerByChecksum: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
withTransaction: vi.fn(),
}));
vi.mock('./queueService.server', () => ({
flyerQueue: {
add: vi.fn(),
},
}));
vi.mock('./db/flyer.db', () => ({
createFlyerAndItems: vi.fn(),
}));
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
processAndSaveImage: vi.fn(),
}));
vi.mock('./db/admin.db', () => ({
AdminRepository: vi.fn().mockImplementation(function () {
return { logActivity: mockAdminLogActivity };
}),
}));
// Import mocked modules to assert on them
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
interface MockFlyer {
flyer_id: number;
file_name: string;
image_url: string;
icon_url: string;
checksum: string;
store_name: string;
valid_from: string | null;
valid_to: string | null;
store_address: string | null;
item_count: number;
status: FlyerStatus;
uploaded_by: string | null | undefined;
created_at: string;
updated_at: string;
}
const baseUrl = 'https://example.com';
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();
mockGenerateContent.mockReset();
mockAdminLogActivity.mockClear();
// Reset modules to ensure the service re-initializes with the mocks
mockAiClient.generateContent.mockResolvedValue({
text: '[]',
candidates: [],
});
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
return callback({}); // Mock client
});
});
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);
// Null checks fail with a generic type error, which is acceptable.
});
});
describe('Constructor', () => {
beforeEach(() => {
// Reset process.env before each test in this block
vi.unstubAllEnvs();
vi.resetModules(); // Important to re-evaluate the service file
});
afterEach(() => {
// Restore original environment variables
vi.unstubAllEnvs();
});
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
// Simulate a non-test environment
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('GEMINI_API_KEY', '');
vi.stubEnv('VITEST_POOL_ID', '');
let error: Error | undefined;
// Dynamically import the class to re-evaluate the constructor logic
try {
const { AIService } = await import('./aiService.server');
new AIService(mockLoggerInstance);
} catch (e) {
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
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('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 () => {
vi.stubEnv('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 models = (service as any).models;
const request = { contents: [{ parts: [{ text: 'test' }] }] };
await adapter.generateContent(request);
expect(mockGenerateContent).toHaveBeenCalledWith({
model: models[0],
...request,
});
});
it('should throw error if adapter is called without content', async () => {
vi.stubEnv('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', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('GEMINI_API_KEY', 'test-key');
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
mockGenerateContent.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should use lite models when useLiteModels is true', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const models_lite = (serviceWithFallback as any).models_lite;
const successResponse = { text: 'Success from lite model', candidates: [] };
mockGenerateContent.mockResolvedValue(successResponse);
const request = {
contents: [{ parts: [{ text: 'test prompt' }] }],
useLiteModels: true,
};
// The adapter strips `useLiteModels` before calling the underlying client,
// so we prepare the expected request shape for our assertions.
const { useLiteModels, ...apiReq } = request;
// Act
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
// Assert
expect(result).toEqual(successResponse);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
// Check that the first model from the lite list was used
expect(mockGenerateContent).toHaveBeenCalledWith({
model: models_lite[0],
...apiReq,
});
});
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 models = (serviceWithFallback as any).models;
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, { // The first model in the list
model: models[0],
...request,
});
// Check second call
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list
model: models[1],
...request,
});
// Check that a warning was logged
expect(logger.warn).toHaveBeenCalledWith(
// The warning should be for the model that failed ('gemini-2.5-flash'), not the next one.
// The warning should be for the model that failed, not the next one.
expect.stringContaining(
`Model '${models[0]}' 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 models = (serviceWithFallback as any).models;
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 }, // The first model in the list is now 'gemini-2.5-flash'
`[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);
// Access private property for testing purposes to ensure test stays in sync with implementation
const models = (serviceWithFallback as any).models as string[];
// Use a quota error to trigger the fallback logic for each model
const errors = models.map((model, i) => new Error(`Quota error for model ${model} (${i})`));
const lastError = errors[errors.length - 1];
// Dynamically setup mocks
errors.forEach((err) => {
mockGenerateContent.mockRejectedValueOnce(err);
});
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
lastError,
);
expect(mockGenerateContent).toHaveBeenCalledTimes(models.length);
models.forEach((model, index) => {
expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
model: model,
...request,
});
});
expect(logger.error).toHaveBeenCalledWith(
{ lastError },
'[AIService Adapter] All AI models failed. Throwing last known error.',
);
});
it('should use lite models and throw the last error if all lite models fail', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
// We instantiate with the real logger to test the production fallback logic
const serviceWithFallback = new AIService(logger);
// Access private property for testing purposes
const modelsLite = (serviceWithFallback as any).models_lite as string[];
// Use a quota error to trigger the fallback logic for each model
const errors = modelsLite.map((model, i) => new Error(`Quota error for lite model ${model} (${i})`));
const lastError = errors[errors.length - 1];
// Dynamically setup mocks
errors.forEach((err) => {
mockGenerateContent.mockRejectedValueOnce(err);
});
const request = {
contents: [{ parts: [{ text: 'test prompt' }] }],
useLiteModels: true, // This is the key to trigger the lite model list
};
// The adapter strips `useLiteModels` before calling the underlying client,
// so we prepare the expected request shape for our assertions.
const { useLiteModels, ...apiReq } = request;
// Act & Assert
// Expect the entire operation to reject with the error from the very last model attempt.
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
lastError,
);
// Verify that all lite models were attempted in the correct order.
expect(mockGenerateContent).toHaveBeenCalledTimes(modelsLite.length);
modelsLite.forEach((model, index) => {
expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
model: model,
...apiReq,
});
});
});
it('should dynamically try the next model if the first one fails and succeed if the second one works', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
// Access private property for testing purposes
const models = (serviceWithFallback as any).models as string[];
// Ensure we have enough models to test fallback
expect(models.length).toBeGreaterThanOrEqual(2);
const error1 = new Error('Quota exceeded for model 1');
const successResponse = { text: 'Success', candidates: [] };
mockGenerateContent
.mockRejectedValueOnce(error1)
.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);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: models[0],
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: models[1],
...request,
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(`Model '${models[0]}' failed`),
);
});
it('should retry on a 429 error and succeed on the next model', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const models = (serviceWithFallback as any).models as string[];
const retriableError = new Error('429 Too Many Requests');
const successResponse = { text: 'Success from second model', candidates: [] };
mockGenerateContent
.mockRejectedValueOnce(retriableError)
.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);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { model: models[0], ...request });
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { model: models[1], ...request });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit.`));
});
it('should fail immediately on a 400 Bad Request error without retrying', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const models = (serviceWithFallback as any).models as string[];
const nonRetriableError = new Error('400 Bad Request: Invalid input');
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(nonRetriableError);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(mockGenerateContent).toHaveBeenCalledWith({ model: models[0], ...request });
expect(logger.error).toHaveBeenCalledWith(
{ error: nonRetriableError },
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
);
// Ensure it didn't log a warning about trying the next model
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Trying next model'));
});
});
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);
// With normalization removed from this service, the result should match the raw AI response.
expect(result).toEqual(mockAiResponse);
});
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.
// The code now fails earlier because it can't find the closing brace.
// We need to update the assertion to match the actual error log.
expect(localLogger.error).toHaveBeenCalledWith(
{ responseText }, // The log includes the full response text.
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
);
});
});
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('generateRecipeSuggestion', () => {
it('should call generateContent with useLiteModels set to true', async () => {
const ingredients = ['carrots', 'onions'];
const expectedPrompt = `Suggest a simple recipe using these ingredients: ${ingredients.join(
', ',
)}. Keep it brief.`;
mockAiClient.generateContent.mockResolvedValue({ text: 'Some recipe', candidates: [] });
await aiServiceInstance.generateRecipeSuggestion(ingredients, mockLoggerInstance);
expect(mockAiClient.generateContent).toHaveBeenCalledWith({
contents: [{ parts: [{ text: expectedPrompt }] }],
useLiteModels: true,
});
});
});
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('enqueueFlyerProcessing', () => {
const mockFile = {
path: '/tmp/test.pdf',
originalname: 'test.pdf',
} as Express.Multer.File;
const mockProfile = {
user: { user_id: 'user123' },
address: {
address_line_1: '123 St',
city: 'City',
country: 'Country', // This was a duplicate, fixed.
},
} as UserProfile;
it('should throw DuplicateFlyerError if flyer already exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({
flyer_id: 99,
checksum: 'checksum123',
file_name: 'test.pdf',
image_url: `${baseUrl}/flyer-images/test.pdf`,
icon_url: `${baseUrl}/flyer-images/icons/test.webp`,
store_id: 1,
status: 'processed',
item_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
await expect(
aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
mockProfile,
'127.0.0.1',
mockLoggerInstance,
),
).rejects.toThrow(DuplicateFlyerError);
});
it('should enqueue job with user address if profile exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as unknown as Job);
const result = await aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
mockProfile,
'127.0.0.1',
mockLoggerInstance,
);
expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', {
filePath: mockFile.path,
originalFileName: mockFile.originalname,
checksum: 'checksum123',
userId: 'user123',
submitterIp: '127.0.0.1',
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
baseUrl: 'https://example.com',
});
expect(result.id).toBe('job123');
});
it('should enqueue job without address if profile is missing', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as unknown as Job);
await aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
undefined, // No profile
'127.0.0.1',
mockLoggerInstance,
);
expect(flyerQueue.add).toHaveBeenCalledWith(
'process-flyer',
expect.objectContaining({
userId: undefined,
userProfileAddress: undefined,
baseUrl: 'https://example.com',
}),
);
});
});
describe('processLegacyFlyerUpload', () => {
const mockFile = {
path: '/tmp/upload.jpg',
filename: 'upload.jpg',
originalname: 'orig.jpg',
} as Express.Multer.File; // This was a duplicate, fixed.
const mockProfile = createMockUserProfile({ user: { user_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' } });
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: {
flyer_id: 100,
file_name: 'orig.jpg',
image_url: `${baseUrl}/flyer-images/upload.jpg`,
icon_url: `${baseUrl}/flyer-images/icons/icon.jpg`,
checksum: 'mock-checksum-123',
store_name: 'Mock Store',
valid_from: null,
valid_to: null,
store_address: null,
item_count: 0,
status: 'processed',
uploaded_by: mockProfile.user.user_id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as MockFlyer, // Use the more specific MockFlyer type
items: [],
});
});
it('should throw ValidationError if checksum is missing', async () => {
const body = { data: JSON.stringify({}) }; // No checksum
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(ValidationError);
});
it('should throw DuplicateFlyerError if checksum exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 55 }));
const body = { checksum: 'dup-sum' };
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(DuplicateFlyerError);
});
it('should parse "data" string property containing extractedData', async () => {
const payload = {
checksum: 'abc',
originalFileName: 'test.jpg',
extractedData: {
store_name: 'My Store',
items: [{ item: 'Milk', price_in_cents: 200 }],
},
};
const body = { data: JSON.stringify(payload) };
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'My Store',
checksum: 'abc',
}),
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
mockLoggerInstance,
expect.anything(),
);
});
it('should handle direct object body with extractedData', async () => {
const body = {
checksum: 'xyz',
extractedData: {
store_name: 'Direct Store',
valid_from: '2023-01-01',
},
};
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'Direct Store',
valid_from: '2023-01-01',
}),
[], // No items
mockLoggerInstance,
expect.anything(),
);
});
it('should fallback for missing store name and normalize items', async () => {
const body = {
checksum: 'fallback',
extractedData: {
// store_name missing
items: [{ item: 'Bread' }], // minimal item
},
};
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'Unknown Store (auto)',
}),
expect.arrayContaining([
expect.objectContaining({
item: 'Bread',
quantity: 1, // Default
view_count: 0,
}),
]),
mockLoggerInstance,
expect.anything(),
);
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
expect.stringContaining('extractedData.store_name missing'),
);
});
it('should log activity and return the new flyer', async () => {
const body = { checksum: 'act', extractedData: { store_name: 'Act Store' } };
const result = await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(result).toHaveProperty('flyer_id', 100);
expect(mockAdminLogActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'flyer_processed',
userId: mockProfile.user.user_id,
}),
mockLoggerInstance,
);
});
it('should catch JSON parsing errors in _parseLegacyPayload and log warning (errMsg coverage)', async () => {
// Sending a body where 'data' is a malformed JSON string to trigger the catch block in _parseLegacyPayload
const body = { data: '{ "malformed": json ' };
// This will eventually throw ValidationError because checksum won't be found
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(ValidationError);
// Verify that the error was caught and logged using errMsg logic
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(String) }),
'[AIService] Failed to parse nested "data" property string.',
);
});
it('should log and re-throw the original error if the database transaction fails', async () => {
const body = { checksum: 'legacy-fail-checksum', extractedData: { store_name: 'Fail Store' } };
const dbError = new Error('DB transaction failed');
// Mock withTransaction to fail
vi.mocked(withTransaction).mockRejectedValue(dbError);
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(dbError);
// Verify the service-level error logging
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: dbError, checksum: 'legacy-fail-checksum' },
'Legacy flyer upload database transaction failed.',
);
});
it('should handle body as a string', async () => {
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
const body = JSON.stringify(payload);
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({ checksum: 'str-body' }),
expect.anything(),
mockLoggerInstance,
expect.anything(),
);
});
});
describe('Singleton Export', () => {
it('should export a singleton instance of AIService', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService);
});
});
});