Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce82034b9d | ||
| 4528da2934 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.21",
|
||||
"version": "0.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.21",
|
||||
"version": "0.7.22",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.7.21",
|
||||
"version": "0.7.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user