Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
275741c79e ci: Bump version to 0.9.27 [skip ci] 2026-01-05 15:32:08 +05:00
3a40249ddb even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m19s
2026-01-05 02:30:28 -08:00
8 changed files with 76 additions and 66 deletions

4
package-lock.json generated
View File

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

View File

@@ -81,6 +81,7 @@ vi.mock('./db/flyer.db', () => ({
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
processAndSaveImage: vi.fn(),
}));
vi.mock('./db/admin.db', () => ({
@@ -93,8 +94,8 @@ vi.mock('./db/admin.db', () => ({
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { withTransaction } from './db/index.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
@@ -808,9 +809,11 @@ describe('AI Service (Server)', () => {
expect(
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
).toBeNull(); // This was a duplicate, fixed.
// The code now fails earlier because it can't find the closing brace.
// We need to update the assertion to match the actual error log.
expect(localLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
{ responseText }, // The log includes the full response text.
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
);
});
});
@@ -1052,6 +1055,7 @@ describe('AI Service (Server)', () => {
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: {

View File

@@ -34,6 +34,9 @@ vi.mock('../services/queueService.server', () => ({
weeklyAnalyticsQueue: {
add: vi.fn(),
},
emailQueue: {
add: vi.fn(),
},
}));
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';

View File

@@ -10,6 +10,11 @@ import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
type UserDealGroup = {
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
};
interface EmailJobData {
to: string;
subject: string;
@@ -93,6 +98,33 @@ export class BackgroundJobService {
};
}
private async _processDealsForUser({
userProfile,
deals,
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
}
/**
* Checks for new deals on watched items for all users and sends notifications.
* This function is designed to be run periodically (e.g., daily).
@@ -112,76 +144,47 @@ export class BackgroundJobService {
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
// 2. Group deals by user in memory.
const dealsByUser = allDeals.reduce<
Record<
string,
{
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
}
>
>((acc, deal) => {
if (!acc[deal.user_id]) {
acc[deal.user_id] = {
const dealsByUser = new Map<string, UserDealGroup>();
for (const deal of allDeals) {
let userGroup = dealsByUser.get(deal.user_id);
if (!userGroup) {
userGroup = {
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
deals: [],
};
dealsByUser.set(deal.user_id, userGroup);
}
acc[deal.user_id].deals.push(deal);
return acc;
}, {});
const allNotifications: Omit<
Notification,
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
>[] = [];
userGroup.deals.push(deal);
}
// 3. Process each user's deals in parallel.
const userProcessingPromises = Object.values(dealsByUser).map(
async ({ userProfile, deals }) => {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// 4. Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// 5. Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
},
const userProcessingPromises = Array.from(dealsByUser.values()).map((userGroup) =>
this._processDealsForUser(userGroup),
);
// Wait for all user processing to complete.
const results = await Promise.allSettled(userProcessingPromises);
// 6. Collect all successfully created notifications.
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
allNotifications.push(result.value);
}
});
const successfulNotifications = results
.filter(
(
result,
): result is PromiseFulfilledResult<
Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'>
> => result.status === 'fulfilled' && !!result.value,
)
.map((result) => result.value);
// 7. Bulk insert all in-app notifications in a single query.
if (allNotifications.length > 0) {
const notificationsForDb = allNotifications.map((n) => ({
if (successfulNotifications.length > 0) {
const notificationsForDb = successfulNotifications.map((n) => ({
...n,
updated_at: new Date().toISOString(),
}));
await this.notificationRepo.createBulkNotifications(notificationsForDb, this.logger);
this.logger.info(
`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`,
`[BackgroundJob] Successfully created ${successfulNotifications.length} in-app notifications.`,
);
}

View File

@@ -377,7 +377,7 @@ it(
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);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -426,7 +426,7 @@ it(
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);
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -475,7 +475,7 @@ it(
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);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');

View File

@@ -333,7 +333,7 @@ describe('User API Routes Integration Tests', () => {
const updatedProfile = response.body;
expect(updatedProfile.avatar_url).toBeDefined();
expect(updatedProfile.avatar_url).not.toBeNull();
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/avatar-');
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar');
// Assert (Verification): Fetch the profile again to ensure the change was persisted
const verifyResponse = await request
@@ -375,6 +375,6 @@ describe('User API Routes Integration Tests', () => {
// Assert: Check for a 400 Bad Request response from the multer error handler.
expect(response.status).toBe(400);
expect(response.body.message).toBe('File too large');
expect(response.body.message).toBe('File upload error: File too large');
});
});

View File

@@ -71,7 +71,7 @@ describe('generateFlyerIcon', () => {
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
// Check that sharp was called with the correct source
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath);
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath, { failOn: 'none' });
// Check the processing chain
expect(mocks.resize).toHaveBeenCalledWith(64, 64, { fit: 'cover' });
@@ -87,8 +87,8 @@ describe('generateFlyerIcon', () => {
mocks.toFile.mockRejectedValue(sharpError);
await expect(
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger),
).rejects.toThrow('Icon generation failed.');
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger), // This was a duplicate, fixed.
).rejects.toThrow('Failed to generate icon for /path/to/bad-image.jpg.');
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), 'Failed to generate flyer icon:');
});
});