Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a30cf983 | ||
| 0bc0676b33 | |||
|
|
73484d3eb4 | ||
| b3253d5bbc |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.10",
|
"version": "0.9.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.10",
|
"version": "0.9.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.10",
|
"version": "0.9.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -208,6 +208,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.
|
* NEW ENDPOINT: Checks the status of a background job.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -216,9 +216,12 @@ describe('UserService', () => {
|
|||||||
describe('updateUserAvatar', () => {
|
describe('updateUserAvatar', () => {
|
||||||
it('should construct avatar URL and update profile', async () => {
|
it('should construct avatar URL and update profile', async () => {
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
|
const testBaseUrl = 'http://localhost:3001';
|
||||||
|
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||||
|
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
|
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);
|
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
|
||||||
|
|
||||||
@@ -229,6 +232,8 @@ describe('UserService', () => {
|
|||||||
{ avatar_url: expectedUrl },
|
{ avatar_url: expectedUrl },
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -174,10 +174,19 @@ describe('Authentication E2E Flow', () => {
|
|||||||
expect(registerResponse.status).toBe(201);
|
expect(registerResponse.status).toBe(201);
|
||||||
createdUserIds.push(registerData.userprofile.user.user_id);
|
createdUserIds.push(registerData.userprofile.user.user_id);
|
||||||
|
|
||||||
// Add a small delay to mitigate potential DB replication lag or race conditions
|
// Instead of a fixed delay, poll by attempting to log in. This is more robust
|
||||||
// in the test environment. Increased from 2s to 5s to improve stability.
|
// and confirms the user record is committed and readable by subsequent transactions.
|
||||||
// The root cause is likely environmental slowness in the CI database.
|
let loginSuccess = false;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
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 before password reset is attempted.').toBe(true);
|
||||||
|
|
||||||
// Act 1: Request a password reset.
|
// Act 1: Request a password reset.
|
||||||
// The test environment returns the token directly in the response for E2E testing.
|
// The test environment returns the token directly in the response for E2E testing.
|
||||||
|
|||||||
@@ -1,48 +1,59 @@
|
|||||||
// src/tests/integration/db.integration.test.ts
|
// 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 db from '../../services/db/index.db';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
|
import type { UserProfile } from '../../types';
|
||||||
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
|
||||||
describe('Database Service Integration Tests', () => {
|
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.
|
// 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 password = 'password123';
|
||||||
const fullName = 'Test User';
|
const fullName = 'Test User';
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
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
|
// Act: Call the createUser function
|
||||||
const createdUser = await db.userRepo.createUser(
|
testUser = await db.userRepo.createUser(
|
||||||
email,
|
testUserEmail,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
{ full_name: fullName },
|
{ full_name: fullName },
|
||||||
logger,
|
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
|
// Assert: Check that the user was created with the correct details
|
||||||
expect(createdUser).toBeDefined();
|
expect(testUser).toBeDefined();
|
||||||
expect(createdUser.user.email).toBe(email); // This is correct
|
expect(testUser.user.email).toBe(testUserEmail);
|
||||||
expect(createdUser.user.user_id).toBeTypeOf('string');
|
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
|
// 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
|
// Assert: Check that the found user matches the created user
|
||||||
expect(foundUser).toBeDefined();
|
expect(foundUser).toBeDefined();
|
||||||
expect(foundUser?.user_id).toBe(createdUser.user.user_id);
|
expect(foundUser?.user_id).toBe(testUser.user.user_id);
|
||||||
expect(foundUser?.email).toBe(email);
|
expect(foundUser?.email).toBe(testUserEmail);
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
|||||||
import piexif from 'piexifjs';
|
import piexif from 'piexifjs';
|
||||||
import exifParser from 'exif-parser';
|
import exifParser from 'exif-parser';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { createFlyerAndItems } from '../../services/db/flyer.db';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +24,34 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
const request = supertest(app);
|
const request = supertest(app);
|
||||||
|
|
||||||
// Import the mocked service to control its behavior in tests.
|
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||||
import { aiService } from '../../services/aiService.server';
|
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')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
aiService: {
|
||||||
|
...actual.aiService,
|
||||||
|
// Replace the real method with our hoisted mock function.
|
||||||
|
extractCoreDataFromFlyerImage: mockExtractCoreData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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', () => {
|
describe('Flyer Processing Background Job Integration Test', () => {
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
@@ -32,23 +59,21 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const createdFilePaths: string[] = [];
|
const createdFilePaths: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// This setup is now simpler as the worker handles fetching master items.
|
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||||
// Setup default mock response for AI service
|
mockExtractCoreData.mockResolvedValue({
|
||||||
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({
|
|
||||||
store_name: 'Mock Store',
|
store_name: 'Mock Store',
|
||||||
valid_from: null,
|
valid_from: null,
|
||||||
valid_to: null,
|
valid_to: null,
|
||||||
store_address: 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 +190,6 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
});
|
});
|
||||||
createdUserIds.push(authUser.user.user_id); // Track for cleanup
|
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
|
// Act & Assert
|
||||||
await runBackgroundProcessingTest(authUser, token);
|
await runBackgroundProcessingTest(authUser, token);
|
||||||
}, 240000); // Increase timeout to 240 seconds for this long-running test
|
}, 240000); // Increase timeout to 240 seconds for this long-running test
|
||||||
@@ -347,4 +367,162 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
},
|
},
|
||||||
240000,
|
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
|
// 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 supertest from 'supertest';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import app from '../../../server';
|
import app from '../../../server';
|
||||||
import type { Flyer, FlyerItem } from '../../types';
|
import type { Flyer, FlyerItem } from '../../types';
|
||||||
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -13,6 +14,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
let flyers: Flyer[] = [];
|
let flyers: Flyer[] = [];
|
||||||
// Use a supertest instance for all requests in this file
|
// Use a supertest instance for all requests in this file
|
||||||
const request = supertest(app);
|
const request = supertest(app);
|
||||||
|
let testStoreId: number;
|
||||||
let createdFlyerId: number;
|
let createdFlyerId: number;
|
||||||
|
|
||||||
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
// 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(
|
const storeRes = await getPool().query(
|
||||||
`INSERT INTO public.stores (name) VALUES ('Integration Test Store') RETURNING store_id`,
|
`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(
|
const flyerRes = await getPool().query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
`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`,
|
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;
|
createdFlyerId = flyerRes.rows[0].flyer_id;
|
||||||
|
|
||||||
@@ -41,6 +43,14 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
flyers = response.body;
|
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', () => {
|
describe('GET /api/flyers', () => {
|
||||||
it('should return a list of flyers', async () => {
|
it('should return a list of flyers', async () => {
|
||||||
// Act: Call the API endpoint using the client function.
|
// Act: Call the API endpoint using the client function.
|
||||||
|
|||||||
@@ -27,8 +27,24 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
|||||||
|
|
||||||
const request = supertest(app);
|
const request = supertest(app);
|
||||||
|
|
||||||
// Import the mocked service to control its behavior in tests.
|
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||||
import { aiService } from '../../services/aiService.server';
|
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')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
aiService: {
|
||||||
|
...actual.aiService,
|
||||||
|
extractCoreDataFromFlyerImage: mockExtractCoreData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the image processor to control icon generation for legacy uploads
|
// Mock the image processor to control icon generation for legacy uploads
|
||||||
vi.mock('../../utils/imageProcessor', async () => {
|
vi.mock('../../utils/imageProcessor', async () => {
|
||||||
const actual = await vi.importActual<typeof imageProcessor>('../../utils/imageProcessor');
|
const actual = await vi.importActual<typeof imageProcessor>('../../utils/imageProcessor');
|
||||||
@@ -53,26 +69,21 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
request,
|
request,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the AI service's method to prevent actual API calls during integration tests.
|
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||||
// This is crucial for making the integration test reliable. We don't want to
|
mockExtractCoreData.mockResolvedValue({
|
||||||
// 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',
|
store_name: 'Gamification Test Store',
|
||||||
valid_from: null,
|
valid_from: null,
|
||||||
valid_to: null,
|
valid_to: null,
|
||||||
store_address: 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 +99,10 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
it(
|
it(
|
||||||
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
||||||
async () => {
|
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 ---
|
// --- Arrange: Prepare a unique flyer file for upload ---
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
@@ -174,6 +189,9 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
expect(Number(userOnLeaderboard?.points)).toBeGreaterThanOrEqual(
|
expect(Number(userOnLeaderboard?.points)).toBeGreaterThanOrEqual(
|
||||||
firstUploadAchievement!.points_value,
|
firstUploadAchievement!.points_value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Cleanup ---
|
||||||
|
vi.unstubAllEnvs();
|
||||||
},
|
},
|
||||||
240000, // Increase timeout to 240s to match other long-running processing tests
|
240000, // Increase timeout to 240s to match other long-running processing tests
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user