diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts index cab8fcc2..3e7610ef 100644 --- a/src/services/aiService.server.test.ts +++ b/src/services/aiService.server.test.ts @@ -320,41 +320,172 @@ describe('AI Service (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'); + // Access private property for testing purposes to ensure test stays in sync with implementation + const models = (serviceWithFallback as any).models as string[]; + const errors = models.map((model, i) => new Error(`Error for model ${model} (${i})`)); + const lastError = errors[errors.length - 1]; - mockGenerateContent - .mockRejectedValueOnce(quotaError1) - .mockRejectedValueOnce(quotaError2) - .mockRejectedValueOnce(quotaError3); + // 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( - quotaError3, + lastError, ); - expect(mockGenerateContent).toHaveBeenCalledTimes(3); - expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview' - model: 'gemini-3-flash-preview', - ...request, - }); - expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash' - model: 'gemini-2.5-flash', - ...request, - }); - expect(mockGenerateContent).toHaveBeenNthCalledWith(3, { // The third model in the list is 'gemini-2.5-flash-lite' - model: 'gemini-2.5-flash-lite', - ...request, + expect(mockGenerateContent).toHaveBeenCalledTimes(models.length); + + models.forEach((model, index) => { + expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, { + model: model, + ...request, + }); }); expect(logger.error).toHaveBeenCalledWith( - { lastError: quotaError3 }, + { 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[]; + const errors = modelsLite.map((model, i) => new Error(`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', () => { diff --git a/src/tests/integration/flyer-processing.integration.test.ts b/src/tests/integration/flyer-processing.integration.test.ts index c9247498..c714bf23 100644 --- a/src/tests/integration/flyer-processing.integration.test.ts +++ b/src/tests/integration/flyer-processing.integration.test.ts @@ -16,12 +16,6 @@ import piexif from 'piexifjs'; import exifParser from 'exif-parser'; import sharp from 'sharp'; -// Mock the AI service to prevent actual API calls during integration tests. -vi.mock('../../services/aiService.server', () => ({ - aiService: { - extractCoreDataFromFlyerImage: vi.fn(), - }, -})); /** * @vitest-environment node @@ -49,7 +43,7 @@ describe('Flyer Processing Background Job Integration Test', () => { category_name: 'Mock Category', }, ]; - vi.mocked(aiService.extractCoreDataFromFlyerImage).mockResolvedValue({ + vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({ store_name: 'Mock Store', valid_from: null, valid_to: null, diff --git a/src/tests/integration/gamification.integration.test.ts b/src/tests/integration/gamification.integration.test.ts index 65eb7d31..96337158 100644 --- a/src/tests/integration/gamification.integration.test.ts +++ b/src/tests/integration/gamification.integration.test.ts @@ -18,13 +18,6 @@ import type { } from '../../types'; import { cleanupFiles } from '../utils/cleanupFiles'; -// Mock the AI service to prevent actual API calls during integration tests. -vi.mock('../../services/aiService.server', () => ({ - aiService: { - extractCoreDataFromFlyerImage: vi.fn(), - }, -})); - /** * @vitest-environment node */ @@ -47,6 +40,28 @@ describe('Gamification Flow Integration Test', () => { fullName: 'Gamification Tester', request, })); + + // Mock the AI service's method to prevent actual API calls during integration tests. + // This is crucial for making the integration test reliable. We don't want to + // depend on the external Gemini API, which has quotas and can be slow. + // By mocking this, we test our application's internal flow: + // API -> Queue -> Worker -> DB -> Gamification Logic + const mockExtractedItems: ExtractedFlyerItem[] = [ + { + item: 'Integration Test Milk', + price_display: '$4.99', + price_in_cents: 499, + quantity: '2L', + category_name: 'Dairy', + }, + ]; + vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({ + store_name: 'Gamification Test Store', + valid_from: null, + valid_to: null, + store_address: null, + items: mockExtractedItems, + }); }); afterAll(async () => { @@ -60,28 +75,6 @@ describe('Gamification Flow Integration Test', () => { it( 'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer', async () => { - // --- Arrange: Mock AI Service Response --- - // This is crucial for making the integration test reliable. We don't want to - // depend on the external Gemini API, which has quotas and can be slow. - // By mocking this, we test our application's internal flow: - // API -> Queue -> Worker -> DB -> Gamification Logic - const mockExtractedItems: ExtractedFlyerItem[] = [ - { - item: 'Integration Test Milk', - price_display: '$4.99', - price_in_cents: 499, - quantity: '2L', - category_name: 'Dairy', - }, - ]; - vi.mocked(aiService.extractCoreDataFromFlyerImage).mockResolvedValue({ - store_name: 'Gamification Test Store', - valid_from: null, - valid_to: null, - store_address: null, - items: mockExtractedItems, - }); - // --- Arrange: Prepare a unique flyer file for upload --- const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); diff --git a/src/tests/integration/recipe.integration.test.ts b/src/tests/integration/recipe.integration.test.ts index 9a945dba..21a24db4 100644 --- a/src/tests/integration/recipe.integration.test.ts +++ b/src/tests/integration/recipe.integration.test.ts @@ -7,12 +7,6 @@ import { cleanupDb } from '../utils/cleanup'; import type { UserProfile, Recipe, RecipeComment } from '../../types'; import { getPool } from '../../services/db/connection.db'; -// Mock the AI service -vi.mock('../../services/aiService.server', () => ({ - aiService: { - generateRecipeSuggestion: vi.fn(), - }, -})); import { aiService } from '../../services/aiService.server'; /** @@ -39,6 +33,9 @@ describe('Recipe API Routes Integration Tests', () => { authToken = token; createdUserIds.push(user.user.user_id); + // Mock the AI service method using spyOn to preserve other exports like DuplicateFlyerError + vi.spyOn(aiService, 'generateRecipeSuggestion').mockResolvedValue('Default Mock Suggestion'); + // Create a recipe owned by the test user const recipeRes = await getPool().query( `INSERT INTO public.recipes (name, instructions, user_id, status, description)