Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
ce82034b9d ci: Bump version to 0.7.22 [skip ci] 2026-01-02 07:30:53 +05:00
4528da2934 integration test fixes + added new ai models and recipeSuggestion
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m36s
2026-01-01 18:30:03 -08:00
6 changed files with 181 additions and 66 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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\"",

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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)