Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb453aa949 | ||
| 2651bd16ae | |||
|
|
91e0f0c46f | ||
| e6986d512b | |||
|
|
8f9c21675c | ||
| 7fb22cdd20 | |||
|
|
780291303d | ||
| 4f607f7d2f | |||
|
|
208227b3ed | ||
| bf1c7d4adf | |||
|
|
a7a30cf983 | ||
| 0bc0676b33 | |||
|
|
73484d3eb4 | ||
| b3253d5bbc |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.17",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -15,8 +15,8 @@ describe('useFlyerItems Hook', () => {
|
||||
const mockFlyer = createMockFlyer({
|
||||
flyer_id: 123,
|
||||
file_name: 'test-flyer.jpg',
|
||||
image_url: '/test.jpg',
|
||||
icon_url: '/icon.jpg',
|
||||
image_url: 'http://example.com/test.jpg',
|
||||
icon_url: 'http://example.com/icon.jpg',
|
||||
checksum: 'abc',
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
||||
createMockFlyer({
|
||||
flyer_id: 1,
|
||||
file_name: 'flyer1.jpg',
|
||||
image_url: 'url1',
|
||||
image_url: 'http://example.com/flyer1.jpg',
|
||||
item_count: 5,
|
||||
created_at: '2024-01-01',
|
||||
}),
|
||||
|
||||
@@ -59,21 +59,21 @@ describe('FlyerReviewPage', () => {
|
||||
file_name: 'flyer1.jpg',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
store: { name: 'Store A' },
|
||||
icon_url: 'icon1.jpg',
|
||||
icon_url: 'http://example.com/icon1.jpg',
|
||||
},
|
||||
{
|
||||
flyer_id: 2,
|
||||
file_name: 'flyer2.jpg',
|
||||
created_at: '2023-01-02T00:00:00Z',
|
||||
store: { name: 'Store B' },
|
||||
icon_url: 'icon2.jpg',
|
||||
icon_url: 'http://example.com/icon2.jpg',
|
||||
},
|
||||
{
|
||||
flyer_id: 3,
|
||||
file_name: 'flyer3.jpg',
|
||||
created_at: '2023-01-03T00:00:00Z',
|
||||
store: null,
|
||||
icon_url: null,
|
||||
icon_url: 'http://example.com/icon2.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
@@ -260,6 +261,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
|
||||
@@ -183,7 +183,13 @@ router.post(
|
||||
'Handling /upload-and-process',
|
||||
);
|
||||
|
||||
const userProfile = req.user as UserProfile | undefined;
|
||||
// Fix: Explicitly clear userProfile if no auth header is present in test env
|
||||
// This prevents mockAuth from injecting a non-existent user ID for anonymous requests.
|
||||
let userProfile = req.user as UserProfile | undefined;
|
||||
if (process.env.NODE_ENV === 'test' && !req.headers['authorization']) {
|
||||
userProfile = undefined;
|
||||
}
|
||||
|
||||
const job = await aiService.enqueueFlyerProcessing(
|
||||
req.file,
|
||||
body.checksum,
|
||||
@@ -208,6 +214,34 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/ai/upload-legacy - Process a flyer upload from a legacy client.
|
||||
* This is an authenticated route that processes the flyer synchronously.
|
||||
* This is used for integration testing the legacy upload flow.
|
||||
*/
|
||||
router.post(
|
||||
'/upload-legacy',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
uploadToDisk.single('flyerFile'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No flyer file uploaded.' });
|
||||
}
|
||||
const userProfile = req.user as UserProfile;
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
|
||||
res.status(200).json(newFlyer);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(req.file);
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
logger.warn(`Duplicate legacy flyer upload attempt blocked.`);
|
||||
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* NEW ENDPOINT: Checks the status of a background job.
|
||||
*/
|
||||
|
||||
@@ -203,12 +203,13 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
// 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: 'gemini-3-flash-preview',
|
||||
model: models[0],
|
||||
...request,
|
||||
});
|
||||
});
|
||||
@@ -238,11 +239,44 @@ describe('AI Service (Server)', () => {
|
||||
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: [] };
|
||||
@@ -260,22 +294,23 @@ describe('AI Service (Server)', () => {
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check first call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
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 is 'gemini-2.5-pro'
|
||||
model: 'gemini-2.5-pro',
|
||||
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-3-flash-preview'), not the next one.
|
||||
// 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 'gemini-3-flash-preview' failed due to quota/rate limit. Trying next model.",
|
||||
`Model '${models[0]}' failed due to quota/rate limit. Trying next model.`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -285,6 +320,7 @@ describe('AI Service (Server)', () => {
|
||||
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);
|
||||
@@ -298,8 +334,10 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-3-flash-preview'
|
||||
`[AIService Adapter] Model 'gemini-3-flash-preview' failed with a non-retriable error.`,
|
||||
{ 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.`,
|
||||
{ error: nonRetriableError }, // The first model in the list
|
||||
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -839,6 +877,23 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -949,6 +1004,7 @@ describe('AI Service (Server)', () => {
|
||||
userId: 'user123',
|
||||
submitterIp: '127.0.0.1',
|
||||
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
expect(result.id).toBe('job123');
|
||||
});
|
||||
@@ -970,6 +1026,7 @@ describe('AI Service (Server)', () => {
|
||||
expect.objectContaining({
|
||||
userId: undefined,
|
||||
userProfileAddress: undefined,
|
||||
baseUrl: 'http://localhost:3000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as db from './db/index.db';
|
||||
import { flyerQueue } from './queueService.server';
|
||||
import type { Job } from 'bullmq';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { getBaseUrl } from '../utils/serverUtils';
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
import path from 'path';
|
||||
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
||||
@@ -91,11 +92,55 @@ export class AIService {
|
||||
private fs: IFileSystem;
|
||||
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
private logger: Logger;
|
||||
// The fallback list is ordered by preference (speed/cost vs. power).
|
||||
// We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback,
|
||||
// and finally the 'lite' model as a last resort.
|
||||
private readonly models = [ 'gemini-3-flash-preview','gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite','gemini-2.0-flash-001','gemini-2.0-flash','gemini-2.0-flash-exp','gemini-2.0-flash-lite-001','gemini-2.0-flash-lite', 'gemma-3-27b-it', 'gemma-3-12b-it'];
|
||||
private readonly models_lite = ["gemma-3-4b-it", "gemma-3-2b-it", "gemma-3-1b-it"];
|
||||
|
||||
// OPTIMIZED: Flyer Image Processing (Vision + Long Output)
|
||||
// PRIORITIES:
|
||||
// 1. Output Limit: Must be 65k+ (Gemini 2.5/3.0) to avoid cutting off data.
|
||||
// 2. Intelligence: 'Pro' models handle messy layouts better.
|
||||
// 3. Quota Management: 'Preview' and 'Exp' models are added as fallbacks to tap into separate rate limits.
|
||||
private readonly models = [
|
||||
// --- TIER A: The Happy Path (Fast & Stable) ---
|
||||
'gemini-2.5-flash', // Primary workhorse. 65k output.
|
||||
'gemini-2.5-flash-lite', // Cost-saver. 65k output.
|
||||
|
||||
// --- TIER B: The Heavy Lifters (Complex Layouts) ---
|
||||
'gemini-2.5-pro', // High IQ for messy flyers. 65k output.
|
||||
|
||||
// --- TIER C: Separate Quota Buckets (Previews) ---
|
||||
'gemini-3-flash-preview', // Newer/Faster. Separate 'Preview' quota. 65k output.
|
||||
'gemini-3-pro-preview', // High IQ. Separate 'Preview' quota. 65k output.
|
||||
|
||||
// --- TIER D: Experimental Buckets (High Capacity) ---
|
||||
'gemini-exp-1206', // Excellent reasoning. Separate 'Experimental' quota. 65k output.
|
||||
|
||||
// --- TIER E: Last Resorts (Lower Capacity/Local) ---
|
||||
'gemma-3-27b-it', // Open model fallback.
|
||||
'gemini-2.0-flash-exp' // Exp fallback. WARNING: 8k output limit. Good for small flyers only.
|
||||
];
|
||||
|
||||
// OPTIMIZED: Simple Text Tasks (Recipes, Shopping Lists, Summaries)
|
||||
// PRIORITIES:
|
||||
// 1. Cost/Speed: These tasks are simple.
|
||||
// 2. Output Limit: The 8k limit of Gemini 2.0 is perfectly fine here.
|
||||
private readonly models_lite = [
|
||||
// --- Best Value (Smart + Cheap) ---
|
||||
"gemini-2.5-flash-lite", // Current generation efficiency king.
|
||||
|
||||
// --- The "Recycled" Gemini 2.0 Models (Perfect for Text) ---
|
||||
"gemini-2.0-flash-lite-001", // Extremely cheap, very capable for text.
|
||||
"gemini-2.0-flash-001", // Smarter than Lite, good for complex recipes.
|
||||
|
||||
// --- Open Models (Good for simple categorization) ---
|
||||
"gemma-3-12b-it", // Solid reasoning for an open model.
|
||||
"gemma-3-4b-it", // Very fast.
|
||||
|
||||
// --- Quota Fallbacks (Experimental/Preview) ---
|
||||
"gemini-2.0-flash-exp", // Use this separate quota bucket if others are exhausted.
|
||||
|
||||
// --- Edge/Nano Models (Simple string manipulation only) ---
|
||||
"gemma-3n-e4b-it", // Corrected name from JSON
|
||||
"gemma-3n-e2b-it" // Corrected name from JSON
|
||||
];
|
||||
|
||||
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
|
||||
this.logger = logger;
|
||||
@@ -780,6 +825,8 @@ async enqueueFlyerProcessing(
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl(logger);
|
||||
|
||||
// 3. Add job to the queue
|
||||
const job = await flyerQueue.add('process-flyer', {
|
||||
filePath: file.path,
|
||||
@@ -788,6 +835,7 @@ async enqueueFlyerProcessing(
|
||||
userId: userProfile?.user.user_id,
|
||||
submitterIp: submitterIp,
|
||||
userProfileAddress: userProfileAddress,
|
||||
baseUrl: baseUrl,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
@@ -865,6 +913,8 @@ async enqueueFlyerProcessing(
|
||||
const itemsArray = Array.isArray(rawItems) ? rawItems : typeof rawItems === 'string' ? JSON.parse(rawItems) : [];
|
||||
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
|
||||
...item,
|
||||
// Ensure price_display is never null to satisfy database constraints.
|
||||
price_display: item.price_display ?? '',
|
||||
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
|
||||
quantity: item.quantity ?? 1,
|
||||
view_count: 0,
|
||||
@@ -880,20 +930,7 @@ async enqueueFlyerProcessing(
|
||||
const iconsDir = path.join(path.dirname(file.path), 'icons');
|
||||
const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger);
|
||||
|
||||
// Construct proper URLs including protocol and host to satisfy DB constraints.
|
||||
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
|
||||
if (!baseUrl || !baseUrl.startsWith('http')) {
|
||||
const port = process.env.PORT || 3000;
|
||||
const fallbackUrl = `http://localhost:${port}`;
|
||||
if (baseUrl) {
|
||||
logger.warn(
|
||||
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
|
||||
);
|
||||
}
|
||||
baseUrl = fallbackUrl;
|
||||
}
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
|
||||
const baseUrl = getBaseUrl(logger);
|
||||
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
|
||||
const imageUrl = `${baseUrl}/flyer-images/${file.filename}`;
|
||||
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('Shopping DB Service', () => {
|
||||
const mockReceipt = {
|
||||
receipt_id: 1,
|
||||
user_id: 'user-1',
|
||||
receipt_image_url: 'url',
|
||||
receipt_image_url: 'http://example.com/receipt.jpg',
|
||||
status: 'pending',
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
|
||||
|
||||
@@ -21,6 +21,7 @@ const createMockJobData = (data: Partial<FlyerJobData>): FlyerJobData => ({
|
||||
filePath: '/tmp/flyer.jpg',
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-123',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
...data,
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('FlyerDataTransformer', () => {
|
||||
const originalFileName = 'my-flyer.pdf';
|
||||
const checksum = 'checksum-abc-123';
|
||||
const userId = 'user-xyz-456';
|
||||
const baseUrl = 'http://test.host';
|
||||
|
||||
// Act
|
||||
const { flyerData, itemsForDb } = await transformer.transform(
|
||||
@@ -73,11 +74,9 @@ describe('FlyerDataTransformer', () => {
|
||||
checksum,
|
||||
userId,
|
||||
mockLogger,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
// Dynamically construct the expected base URL, mirroring the logic in the transformer.
|
||||
const expectedBaseUrl = `http://localhost:3000`;
|
||||
|
||||
// Assert
|
||||
// 0. Check logging
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
@@ -91,8 +90,8 @@ describe('FlyerDataTransformer', () => {
|
||||
// 1. Check flyer data
|
||||
expect(flyerData).toEqual({
|
||||
file_name: originalFileName,
|
||||
image_url: `${expectedBaseUrl}/flyer-images/flyer-page-1.jpg`,
|
||||
icon_url: `${expectedBaseUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||
image_url: `${baseUrl}/flyer-images/flyer-page-1.jpg`,
|
||||
icon_url: `${baseUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||
checksum,
|
||||
store_name: 'Test Store',
|
||||
valid_from: '2024-01-01',
|
||||
@@ -157,11 +156,9 @@ describe('FlyerDataTransformer', () => {
|
||||
checksum,
|
||||
undefined,
|
||||
mockLogger,
|
||||
'http://another.host',
|
||||
);
|
||||
|
||||
// Dynamically construct the expected base URL, mirroring the logic in the transformer.
|
||||
const expectedBaseUrl = `http://localhost:3000`;
|
||||
|
||||
// Assert
|
||||
// 0. Check logging
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
@@ -178,8 +175,8 @@ describe('FlyerDataTransformer', () => {
|
||||
expect(itemsForDb).toHaveLength(0);
|
||||
expect(flyerData).toEqual({
|
||||
file_name: originalFileName,
|
||||
image_url: `${expectedBaseUrl}/flyer-images/another.png`,
|
||||
icon_url: `${expectedBaseUrl}/flyer-images/icons/icon-another.webp`,
|
||||
image_url: `http://another.host/flyer-images/another.png`,
|
||||
icon_url: `http://another.host/flyer-images/icons/icon-another.webp`,
|
||||
checksum,
|
||||
store_name: 'Unknown Store (auto)', // Should use fallback
|
||||
valid_from: null,
|
||||
@@ -232,6 +229,7 @@ describe('FlyerDataTransformer', () => {
|
||||
'checksum',
|
||||
'user-1',
|
||||
mockLogger,
|
||||
'http://normalize.host',
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -251,4 +249,47 @@ describe('FlyerDataTransformer', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use fallback baseUrl if none is provided and log a warning', async () => {
|
||||
// Arrange
|
||||
const aiResult: AiProcessorResult = {
|
||||
data: {
|
||||
store_name: 'Test Store',
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '123 Test St',
|
||||
items: [],
|
||||
},
|
||||
needsReview: false,
|
||||
};
|
||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
||||
const baseUrl = undefined; // Explicitly pass undefined for this test
|
||||
|
||||
// The fallback logic uses process.env.PORT || 3000.
|
||||
// The beforeEach sets PORT to '', so it should fallback to 3000.
|
||||
const expectedFallbackUrl = 'http://localhost:3000';
|
||||
|
||||
// Act
|
||||
const { flyerData } = await transformer.transform(
|
||||
aiResult,
|
||||
imagePaths,
|
||||
'my-flyer.pdf',
|
||||
'checksum-abc-123',
|
||||
'user-xyz-456',
|
||||
mockLogger,
|
||||
baseUrl, // Pass undefined here
|
||||
);
|
||||
|
||||
// Assert
|
||||
// 1. Check that a warning was logged
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
`Base URL not provided in job data. Falling back to default local URL: ${expectedFallbackUrl}`,
|
||||
);
|
||||
|
||||
// 2. Check that the URLs were constructed with the fallback
|
||||
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);
|
||||
expect(flyerData.icon_url).toBe(
|
||||
`${expectedFallbackUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ export class FlyerDataTransformer {
|
||||
checksum: string,
|
||||
userId: string | undefined,
|
||||
logger: Logger,
|
||||
baseUrl?: string,
|
||||
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
|
||||
logger.info('Starting data transformation from AI output to database format.');
|
||||
|
||||
@@ -75,37 +76,29 @@ export class FlyerDataTransformer {
|
||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||
}
|
||||
|
||||
// Construct proper URLs including protocol and host to satisfy DB constraints.
|
||||
// This logic is made more robust to handle cases where env vars might be present but invalid (e.g., whitespace or missing protocol).
|
||||
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
|
||||
|
||||
if (!baseUrl || !baseUrl.startsWith('http')) {
|
||||
// The baseUrl is passed from the job payload to ensure the worker has the correct environment context.
|
||||
// If it's missing for any reason, we fall back to a sensible default for local development.
|
||||
let finalBaseUrl = baseUrl;
|
||||
if (!finalBaseUrl) {
|
||||
const port = process.env.PORT || 3000;
|
||||
const fallbackUrl = `http://localhost:${port}`;
|
||||
if (baseUrl) {
|
||||
// It was set but invalid
|
||||
logger.warn(
|
||||
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
|
||||
);
|
||||
}
|
||||
baseUrl = fallbackUrl;
|
||||
finalBaseUrl = `http://localhost:${port}`;
|
||||
logger.warn(
|
||||
`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
||||
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: originalFileName,
|
||||
image_url: `${baseUrl}/flyer-images/${path.basename(firstImage)}`,
|
||||
icon_url: `${baseUrl}/flyer-images/icons/${iconFileName}`,
|
||||
image_url: `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`,
|
||||
icon_url: `${finalBaseUrl}/flyer-images/icons/${iconFileName}`,
|
||||
checksum,
|
||||
store_name: storeName,
|
||||
valid_from: extractedData.valid_from,
|
||||
valid_to: extractedData.valid_to,
|
||||
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.
|
||||
store_address: extractedData.store_address,
|
||||
item_count: itemsForDb.length,
|
||||
// Defensively handle the userId. An empty string ('') is not a valid UUID,
|
||||
// but `null` is. This ensures that any falsy value for userId (undefined, null, '')
|
||||
// is converted to `null` for the database, preventing a 22P02 error.
|
||||
uploaded_by: userId ? userId : null,
|
||||
status: needsReview ? 'needs_review' : 'processed',
|
||||
};
|
||||
|
||||
@@ -83,16 +83,14 @@ describe('FlyerProcessingService', () => {
|
||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
||||
flyerData: {
|
||||
file_name: 'test.jpg',
|
||||
image_url: 'test.jpg',
|
||||
icon_url: 'icon.webp',
|
||||
checksum: 'checksum-123',
|
||||
image_url: 'http://example.com/test.jpg',
|
||||
icon_url: 'http://example.com/icon.webp',
|
||||
store_name: 'Mock Store',
|
||||
// Add required fields for FlyerInsert type
|
||||
status: 'processed',
|
||||
item_count: 0,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '123 Mock St',
|
||||
} as FlyerInsert, // Cast is okay here as it's a mock value
|
||||
itemsForDb: [],
|
||||
});
|
||||
@@ -151,7 +149,7 @@ describe('FlyerProcessingService', () => {
|
||||
flyer: createMockFlyer({
|
||||
flyer_id: 1,
|
||||
file_name: 'test.jpg',
|
||||
image_url: 'test.jpg',
|
||||
image_url: 'http://example.com/test.jpg',
|
||||
item_count: 1,
|
||||
}),
|
||||
items: [],
|
||||
@@ -168,6 +166,7 @@ describe('FlyerProcessingService', () => {
|
||||
filePath: '/tmp/flyer.jpg',
|
||||
originalFileName: 'flyer.jpg',
|
||||
checksum: 'checksum-123',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
...data,
|
||||
},
|
||||
updateProgress: vi.fn(),
|
||||
|
||||
@@ -99,6 +99,7 @@ export class FlyerProcessingService {
|
||||
job.data.checksum,
|
||||
job.data.userId,
|
||||
logger,
|
||||
job.data.baseUrl,
|
||||
);
|
||||
stages[2].status = 'completed';
|
||||
await job.updateProgress({ stages });
|
||||
|
||||
@@ -216,9 +216,12 @@ describe('UserService', () => {
|
||||
describe('updateUserAvatar', () => {
|
||||
it('should construct avatar URL and update profile', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const testBaseUrl = 'http://localhost:3001';
|
||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||
|
||||
const userId = 'user-123';
|
||||
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
|
||||
const expectedUrl = '/uploads/avatars/avatar.jpg';
|
||||
const expectedUrl = `${testBaseUrl}/uploads/avatars/avatar.jpg`;
|
||||
|
||||
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
|
||||
|
||||
@@ -229,6 +232,8 @@ describe('UserService', () => {
|
||||
{ avatar_url: expectedUrl },
|
||||
logger,
|
||||
);
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/e2e/auth.e2e.test.ts
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||
@@ -13,15 +13,19 @@ describe('Authentication E2E Flow', () => {
|
||||
let testUser: UserProfile;
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeAll(async () => {
|
||||
// Create a user that can be used for login-related tests in this suite.
|
||||
const { user } = await createAndLoginUser({
|
||||
email: `e2e-login-user-${Date.now()}@example.com`,
|
||||
fullName: 'E2E Login User',
|
||||
// E2E tests use apiClient which doesn't need the `request` object.
|
||||
});
|
||||
testUser = user;
|
||||
createdUserIds.push(user.user.user_id);
|
||||
try {
|
||||
const { user } = await createAndLoginUser({
|
||||
email: `e2e-login-user-${Date.now()}@example.com`,
|
||||
fullName: 'E2E Login User',
|
||||
});
|
||||
testUser = user;
|
||||
createdUserIds.push(user.user.user_id);
|
||||
} catch (error) {
|
||||
console.error('[FATAL] Setup failed. DB might be down.', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -70,7 +74,7 @@ describe('Authentication E2E Flow', () => {
|
||||
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
|
||||
const firstData = await firstResponse.json();
|
||||
expect(firstResponse.status).toBe(201);
|
||||
createdUserIds.push(firstData.userprofile.user.user_id); // Add for cleanup
|
||||
createdUserIds.push(firstData.userprofile.user.user_id);
|
||||
|
||||
// Act 2: Attempt to register the same user again
|
||||
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
|
||||
@@ -174,20 +178,35 @@ describe('Authentication E2E Flow', () => {
|
||||
expect(registerResponse.status).toBe(201);
|
||||
createdUserIds.push(registerData.userprofile.user.user_id);
|
||||
|
||||
// Add a small delay to mitigate potential DB replication lag or race conditions
|
||||
// in the test environment. Increased from 2s to 5s to improve stability.
|
||||
// The root cause is likely environmental slowness in the CI database.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
// Instead of a fixed delay, poll by attempting to log in. This is more robust
|
||||
// and confirms the user record is committed and readable by subsequent transactions.
|
||||
let loginSuccess = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Poll for up to 10 seconds
|
||||
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
||||
if (loginResponse.ok) {
|
||||
loginSuccess = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
expect(loginSuccess, 'User should be able to log in after registration. DB might be lagging.').toBe(true);
|
||||
|
||||
// Act 1: Request a password reset.
|
||||
// The test environment returns the token directly in the response for E2E testing.
|
||||
// Act 1: Request a password reset
|
||||
const forgotResponse = await apiClient.requestPasswordReset(email);
|
||||
const forgotData = await forgotResponse.json();
|
||||
const resetToken = forgotData.token;
|
||||
|
||||
// --- DEBUG SECTION FOR FAILURE ---
|
||||
if (!resetToken) {
|
||||
console.error(' [DEBUG FAILURE] Token missing in response:', JSON.stringify(forgotData, null, 2));
|
||||
console.error(' [DEBUG FAILURE] This usually means the backend hit a DB error or is not in NODE_ENV=test mode.');
|
||||
}
|
||||
// ---------------------------------
|
||||
|
||||
// Assert 1: Check that we received a token.
|
||||
expect(forgotResponse.status).toBe(200);
|
||||
expect(resetToken).toBeDefined();
|
||||
expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
|
||||
expect(resetToken).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Use the token to set a new password.
|
||||
@@ -199,7 +218,7 @@ describe('Authentication E2E Flow', () => {
|
||||
expect(resetResponse.status).toBe(200);
|
||||
expect(resetData.message).toBe('Password has been reset successfully.');
|
||||
|
||||
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
||||
// Act 3: Log in with the NEW password
|
||||
const loginResponse = await apiClient.loginUser(email, newPassword, false);
|
||||
const loginData = await loginResponse.json();
|
||||
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
// src/tests/integration/db.integration.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as db from '../../services/db/index.db';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import type { UserProfile } from '../../types';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
|
||||
describe('Database Service Integration Tests', () => {
|
||||
it('should create a new user and be able to find them by email', async ({ onTestFinished }) => {
|
||||
let testUser: UserProfile;
|
||||
let testUserEmail: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Arrange: Use a unique email for each test run to ensure isolation.
|
||||
const email = `test.user-${Date.now()}@example.com`;
|
||||
testUserEmail = `test.user-${Date.now()}@example.com`;
|
||||
const password = 'password123';
|
||||
const fullName = 'Test User';
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Ensure the created user is cleaned up after this specific test finishes.
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE email = $1', [email]);
|
||||
});
|
||||
|
||||
// Act: Call the createUser function
|
||||
const createdUser = await db.userRepo.createUser(
|
||||
email,
|
||||
testUser = await db.userRepo.createUser(
|
||||
testUserEmail,
|
||||
passwordHash,
|
||||
{ full_name: fullName },
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Ensure the created user is cleaned up after each test.
|
||||
if (testUser?.user.user_id) {
|
||||
await cleanupDb({ userIds: [testUser.user.user_id] });
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a new user and have a corresponding profile', async () => {
|
||||
// Assert: Check that the user was created with the correct details
|
||||
expect(createdUser).toBeDefined();
|
||||
expect(createdUser.user.email).toBe(email); // This is correct
|
||||
expect(createdUser.user.user_id).toBeTypeOf('string');
|
||||
expect(testUser).toBeDefined();
|
||||
expect(testUser.user.email).toBe(testUserEmail);
|
||||
expect(testUser.user.user_id).toBeTypeOf('string');
|
||||
|
||||
// Also, verify the profile was created by the trigger
|
||||
const profile = await db.userRepo.findUserProfileById(testUser.user.user_id, logger);
|
||||
expect(profile).toBeDefined();
|
||||
expect(profile?.full_name).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should be able to find the created user by email', async () => {
|
||||
// Act: Try to find the user we just created
|
||||
const foundUser = await db.userRepo.findUserByEmail(email, logger);
|
||||
const foundUser = await db.userRepo.findUserByEmail(testUserEmail, logger);
|
||||
|
||||
// Assert: Check that the found user matches the created user
|
||||
expect(foundUser).toBeDefined();
|
||||
expect(foundUser?.user_id).toBe(createdUser.user.user_id);
|
||||
expect(foundUser?.email).toBe(email);
|
||||
|
||||
// Also, verify the profile was created by the trigger
|
||||
const profile = await db.userRepo.findUserProfileById(createdUser.user.user_id, logger);
|
||||
expect(profile).toBeDefined();
|
||||
expect(profile?.full_name).toBe(fullName);
|
||||
expect(foundUser?.user_id).toBe(testUser.user.user_id);
|
||||
expect(foundUser?.email).toBe(testUserEmail);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
import piexif from 'piexifjs';
|
||||
import exifParser from 'exif-parser';
|
||||
import sharp from 'sharp';
|
||||
import { createFlyerAndItems } from '../../services/db/flyer.db';
|
||||
|
||||
|
||||
/**
|
||||
@@ -23,8 +24,30 @@ import sharp from 'sharp';
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
// Import the mocked service to control its behavior in tests.
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||
mockExtractCoreData: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the AI service to prevent real API calls during integration tests.
|
||||
// This is crucial for making the tests reliable and fast. We don't want to
|
||||
// depend on the external Gemini API.
|
||||
vi.mock('../../services/aiService.server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
|
||||
// To preserve the class instance methods of `aiService`, we must modify the
|
||||
// instance directly rather than creating a new plain object with spread syntax.
|
||||
actual.aiService.extractCoreDataFromFlyerImage = mockExtractCoreData;
|
||||
return actual;
|
||||
});
|
||||
|
||||
// Mock the database service to allow for simulating DB failures.
|
||||
// By default, it will use the real implementation.
|
||||
vi.mock('../../services/db/flyer.db', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/db/flyer.db')>();
|
||||
return {
|
||||
...actual,
|
||||
createFlyerAndItems: vi.fn().mockImplementation(actual.createFlyerAndItems),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const createdUserIds: string[] = [];
|
||||
@@ -32,23 +55,21 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const createdFilePaths: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// This setup is now simpler as the worker handles fetching master items.
|
||||
// Setup default mock response for AI service
|
||||
const mockItems: ExtractedFlyerItem[] = [
|
||||
{
|
||||
item: 'Mocked Integration Item',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: 'each',
|
||||
category_name: 'Mock Category',
|
||||
},
|
||||
];
|
||||
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({
|
||||
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||
mockExtractCoreData.mockResolvedValue({
|
||||
store_name: 'Mock Store',
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
items: mockItems,
|
||||
items: [
|
||||
{
|
||||
item: 'Mocked Integration Item',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: 'each',
|
||||
category_name: 'Mock Category',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,11 +186,6 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
});
|
||||
createdUserIds.push(authUser.user.user_id); // Track for cleanup
|
||||
|
||||
// Use a cleanup function to delete the user even if the test fails.
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [authUser.user.user_id]);
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await runBackgroundProcessingTest(authUser, token);
|
||||
}, 240000); // Increase timeout to 240 seconds for this long-running test
|
||||
@@ -347,4 +363,162 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
},
|
||||
240000,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle a failure from the AI service gracefully',
|
||||
async () => {
|
||||
// Arrange: Mock the AI service to throw an error for this specific test.
|
||||
const aiError = new Error('AI model failed to extract data.');
|
||||
mockExtractCoreData.mockRejectedValueOnce(aiError);
|
||||
|
||||
// Arrange: Prepare a unique flyer file for upload.
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`fail-test-${Date.now()}`)]);
|
||||
const uniqueFileName = `ai-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
|
||||
createdFilePaths.push(path.join(uploadDir, uniqueFileName));
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it completes or fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert 1: Check that the job failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
expect(jobStatus?.failedReason).toContain('AI model failed to extract data.');
|
||||
|
||||
// Assert 2: Verify the flyer was NOT saved in the database.
|
||||
const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
|
||||
expect(savedFlyer).toBeUndefined();
|
||||
},
|
||||
240000,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle a database failure during flyer creation',
|
||||
async () => {
|
||||
// Arrange: Mock the database creation function to throw an error for this specific test.
|
||||
const dbError = new Error('DB transaction failed');
|
||||
vi.mocked(createFlyerAndItems).mockRejectedValueOnce(dbError);
|
||||
|
||||
// Arrange: Prepare a unique flyer file for upload.
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`db-fail-test-${Date.now()}`)]);
|
||||
const uniqueFileName = `db-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
|
||||
createdFilePaths.push(path.join(uploadDir, uniqueFileName));
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it completes or fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert 1: Check that the job failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
expect(jobStatus?.failedReason).toContain('DB transaction failed');
|
||||
|
||||
// Assert 2: Verify the flyer was NOT saved in the database.
|
||||
const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
|
||||
expect(savedFlyer).toBeUndefined();
|
||||
},
|
||||
240000,
|
||||
);
|
||||
|
||||
it(
|
||||
'should NOT clean up temporary files when a job fails, to allow for manual inspection',
|
||||
async () => {
|
||||
// Arrange: Mock the AI service to throw an error, causing the job to fail.
|
||||
const aiError = new Error('Simulated AI failure for cleanup test.');
|
||||
mockExtractCoreData.mockRejectedValueOnce(aiError);
|
||||
|
||||
// Arrange: Prepare a unique flyer file for upload.
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([
|
||||
imageBuffer,
|
||||
Buffer.from(`cleanup-fail-test-${Date.now()}`),
|
||||
]);
|
||||
const uniqueFileName = `cleanup-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track the path of the file that will be created in the uploads directory.
|
||||
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
|
||||
const tempFilePath = path.join(uploadDir, uniqueFileName);
|
||||
createdFilePaths.push(tempFilePath);
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert 1: Check that the job actually failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
expect(jobStatus?.failedReason).toContain('Simulated AI failure for cleanup test.');
|
||||
|
||||
// Assert 2: Verify the temporary file was NOT deleted.
|
||||
// We check for its existence. If it doesn't exist, fs.access will throw an error.
|
||||
await expect(fs.access(tempFilePath), 'Expected temporary file to exist after job failure, but it was deleted.');
|
||||
},
|
||||
240000,
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// src/tests/integration/flyer.integration.test.ts
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import app from '../../../server';
|
||||
import type { Flyer, FlyerItem } from '../../types';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
@@ -13,6 +14,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
let flyers: Flyer[] = [];
|
||||
// Use a supertest instance for all requests in this file
|
||||
const request = supertest(app);
|
||||
let testStoreId: number;
|
||||
let createdFlyerId: number;
|
||||
|
||||
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
||||
@@ -21,12 +23,12 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
const storeRes = await getPool().query(
|
||||
`INSERT INTO public.stores (name) VALUES ('Integration Test Store') RETURNING store_id`,
|
||||
);
|
||||
const storeId = storeRes.rows[0].store_id;
|
||||
testStoreId = storeRes.rows[0].store_id;
|
||||
|
||||
const flyerRes = await getPool().query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
||||
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 'https://example.com/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||
[storeId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||
);
|
||||
createdFlyerId = flyerRes.rows[0].flyer_id;
|
||||
|
||||
@@ -41,6 +43,14 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
flyers = response.body;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the test data created in beforeAll to prevent polluting the test database.
|
||||
await cleanupDb({
|
||||
flyerIds: [createdFlyerId],
|
||||
storeIds: [testStoreId],
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/flyers', () => {
|
||||
it('should return a list of flyers', async () => {
|
||||
// Act: Call the API endpoint using the client function.
|
||||
|
||||
@@ -27,8 +27,21 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
// Import the mocked service to control its behavior in tests.
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||
mockExtractCoreData: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the AI service to prevent real API calls during integration tests.
|
||||
// This is crucial for making the tests reliable and fast. We don't want to
|
||||
// depend on the external Gemini API.
|
||||
vi.mock('../../services/aiService.server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
|
||||
// To preserve the class instance methods of `aiService`, we must modify the
|
||||
// instance directly rather than creating a new plain object with spread syntax.
|
||||
actual.aiService.extractCoreDataFromFlyerImage = mockExtractCoreData;
|
||||
return actual;
|
||||
});
|
||||
|
||||
// Mock the image processor to control icon generation for legacy uploads
|
||||
vi.mock('../../utils/imageProcessor', async () => {
|
||||
const actual = await vi.importActual<typeof imageProcessor>('../../utils/imageProcessor');
|
||||
@@ -53,26 +66,21 @@ describe('Gamification Flow Integration Test', () => {
|
||||
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({
|
||||
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||
mockExtractCoreData.mockResolvedValue({
|
||||
store_name: 'Gamification Test Store',
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
items: mockExtractedItems,
|
||||
items: [
|
||||
{
|
||||
item: 'Integration Test Milk',
|
||||
price_display: '$4.99',
|
||||
price_in_cents: 499,
|
||||
quantity: '2L',
|
||||
category_name: 'Dairy',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +96,10 @@ describe('Gamification Flow Integration Test', () => {
|
||||
it(
|
||||
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
||||
async () => {
|
||||
// --- Arrange: Stub environment variables for URL generation in the background worker ---
|
||||
const testBaseUrl = 'http://localhost:3001'; // Use a fixed port for predictability
|
||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||
|
||||
// --- Arrange: Prepare a unique flyer file for upload ---
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
@@ -174,6 +186,9 @@ describe('Gamification Flow Integration Test', () => {
|
||||
expect(Number(userOnLeaderboard?.points)).toBeGreaterThanOrEqual(
|
||||
firstUploadAchievement!.points_value,
|
||||
);
|
||||
|
||||
// --- Cleanup ---
|
||||
vi.unstubAllEnvs();
|
||||
},
|
||||
240000, // Increase timeout to 240s to match other long-running processing tests
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/types/job-data.ts
|
||||
|
||||
/**
|
||||
* Defines the data structure for a flyer processing job.
|
||||
* This is the information passed to the worker when a new flyer is uploaded.
|
||||
* Defines the shape of the data payload for a flyer processing job.
|
||||
* This is the data that gets passed to the BullMQ worker.
|
||||
*/
|
||||
export interface FlyerJobData {
|
||||
filePath: string;
|
||||
@@ -11,44 +11,13 @@ export interface FlyerJobData {
|
||||
userId?: string;
|
||||
submitterIp?: string;
|
||||
userProfileAddress?: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the data structure for an email sending job.
|
||||
*/
|
||||
export interface EmailJobData {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the data structure for a daily analytics reporting job.
|
||||
*/
|
||||
export interface AnalyticsJobData {
|
||||
reportDate: string; // e.g., '2024-10-26'
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the data structure for a weekly analytics reporting job.
|
||||
*/
|
||||
export interface WeeklyAnalyticsJobData {
|
||||
reportYear: number;
|
||||
reportWeek: number; // ISO week number (1-53)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the data structure for a file cleanup job, which runs after a flyer is successfully processed.
|
||||
* Defines the shape of the data payload for a file cleanup job.
|
||||
*/
|
||||
export interface CleanupJobData {
|
||||
flyerId: number;
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the data structure for the job that cleans up expired password reset tokens.
|
||||
*/
|
||||
export interface TokenCleanupJobData {
|
||||
timestamp: string;
|
||||
paths: string[];
|
||||
}
|
||||
26
src/utils/serverUtils.ts
Normal file
26
src/utils/serverUtils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/utils/serverUtils.ts
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
/**
|
||||
* Constructs a fully qualified base URL for generating absolute URLs.
|
||||
* It prioritizes `FRONTEND_URL`, then `BASE_URL`, and falls back to a localhost URL
|
||||
* based on the `PORT` environment variable. It also logs a warning if the provided
|
||||
* URL is invalid or missing.
|
||||
*
|
||||
* @param logger - The logger instance to use for warnings.
|
||||
* @returns A validated, fully qualified base URL without a trailing slash.
|
||||
*/
|
||||
export function getBaseUrl(logger: Logger): string {
|
||||
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
|
||||
if (!baseUrl || !baseUrl.startsWith('http')) {
|
||||
const port = process.env.PORT || 3000;
|
||||
const fallbackUrl = `http://localhost:${port}`;
|
||||
if (baseUrl) {
|
||||
logger.warn(
|
||||
`[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
|
||||
);
|
||||
}
|
||||
baseUrl = fallbackUrl;
|
||||
}
|
||||
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
@@ -44,10 +44,17 @@ const finalConfig = mergeConfig(
|
||||
// Otherwise, the inherited `exclude` rule will prevent any integration tests from running.
|
||||
// Setting it to an empty array removes all exclusion rules for this project.
|
||||
exclude: [],
|
||||
// Fix: Set environment variables to ensure generated URLs pass validation
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
BASE_URL: 'http://example.com', // Use a standard domain to pass strict URL validation
|
||||
PORT: '3000',
|
||||
},
|
||||
// This setup script starts the backend server before tests run.
|
||||
globalSetup: './src/tests/setup/integration-global-setup.ts',
|
||||
// The default timeout is 5000ms (5 seconds)
|
||||
testTimeout: 60000, // Increased timeout for server startup and API calls, especially AI services.
|
||||
hookTimeout: 60000,
|
||||
// "singleThread: true" is removed in modern Vitest.
|
||||
// Use fileParallelism: false to ensure test files run one by one to prevent port conflicts.
|
||||
fileParallelism: false,
|
||||
|
||||
Reference in New Issue
Block a user