727 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|