Compare commits

...

4 Commits

Author SHA1 Message Date
Gitea Actions
a7a30cf983 ci: Bump version to 0.9.12 [skip ci] 2026-01-04 01:01:26 +05:00
0bc0676b33 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m39s
2026-01-03 12:00:20 -08:00
Gitea Actions
73484d3eb4 ci: Bump version to 0.9.11 [skip ci] 2026-01-03 23:52:31 +05:00
b3253d5bbc more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m17s
2026-01-03 10:51:44 -08:00
9 changed files with 329 additions and 70 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.10",
"version": "0.9.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.10",
"version": "0.9.12",
"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.9.10",
"version": "0.9.12",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -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.
*/

View File

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

View File

@@ -174,10 +174,19 @@ 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 before password reset is attempted.').toBe(true);
// Act 1: Request a password reset.
// The test environment returns the token directly in the response for E2E testing.

View File

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

View File

@@ -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,34 @@ 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')>();
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', () => {
const createdUserIds: string[] = [];
@@ -32,23 +59,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 +190,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 +367,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,
);
});

View File

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

View File

@@ -27,8 +27,24 @@ 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')>();
return {
...actual,
aiService: {
...actual.aiService,
extractCoreDataFromFlyerImage: mockExtractCoreData,
},
};
});
// 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 +69,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 +99,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 +189,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
);