Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.25",
|
||||
"version": "0.9.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.25",
|
||||
"version": "0.9.27",
|
||||
"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.25",
|
||||
"version": "0.9.27",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import { FlyerList } from './FlyerList';
|
||||
import { formatShortDate } from './dateUtils';
|
||||
import { formatShortDate } from '../../utils/dateUtils';
|
||||
import type { Flyer, UserProfile } from '../../types';
|
||||
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||
import { createMockFlyer } from '../../tests/utils/mockFactories';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -24,6 +24,19 @@ vi.mock('../services/logger.server', () => ({
|
||||
// Mock the date utility to control the output for the weekly analytics job
|
||||
vi.mock('../utils/dateUtils', () => ({
|
||||
getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
|
||||
getCurrentDateISOString: vi.fn(() => '2024-10-18'),
|
||||
}));
|
||||
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
analyticsQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
weeklyAnalyticsQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
emailQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
|
||||
@@ -32,6 +45,7 @@ import type { PersonalizationRepository } from './db/personalization.db';
|
||||
import type { NotificationRepository } from './db/notification.db';
|
||||
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger
|
||||
import { analyticsQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
|
||||
|
||||
describe('Background Job Service', () => {
|
||||
// Create mock dependencies that will be injected into the service
|
||||
@@ -118,6 +132,35 @@ describe('Background Job Service', () => {
|
||||
mockServiceLogger,
|
||||
);
|
||||
|
||||
describe('Manual Triggers', () => {
|
||||
it('triggerAnalyticsReport should add a daily report job to the queue', async () => {
|
||||
vi.mocked(analyticsQueue.add).mockResolvedValue({ id: 'manual-job-1' } as any);
|
||||
const jobId = await service.triggerAnalyticsReport();
|
||||
|
||||
expect(jobId).toContain('manual-report-');
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith(
|
||||
'generate-daily-report',
|
||||
{ reportDate: '2024-10-18' },
|
||||
{ jobId: expect.stringContaining('manual-report-') },
|
||||
);
|
||||
});
|
||||
|
||||
it('triggerWeeklyAnalyticsReport should add a weekly report job to the queue', async () => {
|
||||
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue({ id: 'manual-weekly-job-1' } as any);
|
||||
const jobId = await service.triggerWeeklyAnalyticsReport();
|
||||
|
||||
expect(jobId).toContain('manual-weekly-report-');
|
||||
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith(
|
||||
'generate-weekly-report',
|
||||
{
|
||||
reportYear: 2024, // From mocked dateUtils
|
||||
reportWeek: 42, // From mocked dateUtils
|
||||
},
|
||||
{ jobId: expect.stringContaining('manual-weekly-report-') },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should do nothing if no deals are found for any user', async () => {
|
||||
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
|
||||
await service.runDailyDealCheck();
|
||||
@@ -153,24 +196,27 @@ describe('Background Job Service', () => {
|
||||
// Check that in-app notifications were created for both users
|
||||
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
|
||||
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
|
||||
expect(notificationPayload).toHaveLength(2);
|
||||
// Use expect.arrayContaining to be order-agnostic.
|
||||
expect(notificationPayload).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
user_id: 'user-1',
|
||||
content: 'You have 1 new deal(s) on your watched items!',
|
||||
link_url: '/dashboard/deals',
|
||||
updated_at: expect.any(String),
|
||||
},
|
||||
{
|
||||
user_id: 'user-2',
|
||||
content: 'You have 2 new deal(s) on your watched items!',
|
||||
link_url: '/dashboard/deals',
|
||||
updated_at: expect.any(String),
|
||||
},
|
||||
]),
|
||||
|
||||
// Sort by user_id to ensure a consistent order for a direct `toEqual` comparison.
|
||||
// This provides a clearer diff on failure than `expect.arrayContaining`.
|
||||
const sortedPayload = [...notificationPayload].sort((a, b) =>
|
||||
a.user_id.localeCompare(b.user_id),
|
||||
);
|
||||
|
||||
expect(sortedPayload).toEqual([
|
||||
{
|
||||
user_id: 'user-1',
|
||||
content: 'You have 1 new deal(s) on your watched items!',
|
||||
link_url: '/dashboard/deals',
|
||||
updated_at: expect.any(String),
|
||||
},
|
||||
{
|
||||
user_id: 'user-2',
|
||||
content: 'You have 2 new deal(s) on your watched items!',
|
||||
link_url: '/dashboard/deals',
|
||||
updated_at: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle and log errors for individual users without stopping the process', async () => {
|
||||
@@ -252,7 +298,7 @@ describe('Background Job Service', () => {
|
||||
vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
|
||||
});
|
||||
|
||||
it('should schedule three cron jobs with the correct schedules', () => {
|
||||
it('should schedule four cron jobs with the correct schedules', () => {
|
||||
startBackgroundJobs(
|
||||
mockBackgroundJobService,
|
||||
mockAnalyticsQueue,
|
||||
|
||||
@@ -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,70 +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'>[] =
|
||||
[];
|
||||
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) {
|
||||
await this.notificationRepo.createBulkNotifications(allNotifications, this.logger);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock image processor functions
|
||||
vi.mock('../utils/imageProcessor', () => ({
|
||||
processAndSaveImage: vi.fn(),
|
||||
generateFlyerIcon: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import service and dependencies (FlyerJobData already imported from types above)
|
||||
import { FlyerProcessingService } from './flyerProcessingService.server';
|
||||
import * as db from './db/index.db';
|
||||
@@ -42,6 +48,7 @@ import { NotFoundError } from './db/errors.db';
|
||||
import { FlyerFileHandler } from './flyerFileHandler.server';
|
||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||
import { processAndSaveImage, generateFlyerIcon } from '../utils/imageProcessor';
|
||||
import type { AIService } from './aiService.server';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -172,6 +179,10 @@ describe('FlyerProcessingService', () => {
|
||||
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
|
||||
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.mocked(processAndSaveImage).mockResolvedValue('processed-flyer.jpg');
|
||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
|
||||
});
|
||||
|
||||
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
|
||||
return {
|
||||
@@ -200,22 +211,49 @@ describe('FlyerProcessingService', () => {
|
||||
};
|
||||
|
||||
describe('processJob (Orchestrator)', () => {
|
||||
it('should process an image file successfully and enqueue a cleanup job', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
|
||||
it('should process an image file successfully, using processed image URLs, and enqueue a cleanup job', async () => {
|
||||
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', baseUrl: 'http://test.com' });
|
||||
|
||||
// Simulate the file handler processing the image and returning the path to the new, cleaned file.
|
||||
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
||||
imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
|
||||
createdImagePaths: ['/tmp/flyer-processed.jpeg'],
|
||||
});
|
||||
|
||||
const result = await service.processJob(job);
|
||||
|
||||
expect(result).toEqual({ flyerId: 1 });
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
// Verify that the transaction function was called.
|
||||
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||
// Verify that the functions inside the transaction were called.
|
||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Assert that the image processing functions were called correctly
|
||||
// The first image path from prepareImageInputs is the *processed* one.
|
||||
expect(processAndSaveImage).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp', 'flyer.jpg', expect.any(Object));
|
||||
// The icon is generated from the *newly processed* image from processAndSaveImage
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/processed-flyer.jpg', '/tmp/icons', expect.any(Object));
|
||||
|
||||
// Assert that createFlyerAndItems was called with the CORRECT, overwritten URLs
|
||||
const createFlyerAndItemsCall = vi.mocked(createFlyerAndItems).mock.calls[0];
|
||||
const flyerDataArg = createFlyerAndItemsCall[0]; // The flyerData object
|
||||
|
||||
expect(flyerDataArg.image_url).toBe('http://test.com/flyer-images/processed-flyer.jpg');
|
||||
expect(flyerDataArg.icon_url).toBe('http://test.com/flyer-images/icons/icon-flyer.webp');
|
||||
|
||||
expect(mocks.mockAdminLogActivity).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Assert that the cleanup job includes all original and generated files
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
|
||||
{
|
||||
flyerId: 1,
|
||||
paths: [
|
||||
'/tmp/flyer.jpg', // original job path
|
||||
'/tmp/flyer-processed.jpeg', // from prepareImageInputs
|
||||
'/tmp/processed-flyer.jpg', // from processAndSaveImage
|
||||
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
|
||||
],
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -226,7 +264,10 @@ describe('FlyerProcessingService', () => {
|
||||
// Mock the file handler to return multiple created paths
|
||||
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
|
||||
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
||||
imagePaths: createdPaths.map(p => ({ path: p, mimetype: 'image/jpeg' })),
|
||||
imagePaths: [
|
||||
{ path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' },
|
||||
{ path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' },
|
||||
],
|
||||
createdImagePaths: createdPaths,
|
||||
});
|
||||
|
||||
@@ -237,15 +278,17 @@ describe('FlyerProcessingService', () => {
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||
// Verify cleanup job includes original PDF and both generated images
|
||||
// Verify cleanup job includes original PDF and all generated/processed images
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{
|
||||
flyerId: 1,
|
||||
paths: [
|
||||
'/tmp/flyer.pdf',
|
||||
'/tmp/flyer-1.jpg',
|
||||
'/tmp/flyer-2.jpg',
|
||||
'/tmp/flyer.pdf', // original job path
|
||||
'/tmp/flyer-1.jpg', // from prepareImageInputs
|
||||
'/tmp/flyer-2.jpg', // from prepareImageInputs
|
||||
'/tmp/processed-flyer.jpg', // from processAndSaveImage
|
||||
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
|
||||
],
|
||||
},
|
||||
expect.any(Object),
|
||||
@@ -387,7 +430,15 @@ describe('FlyerProcessingService', () => {
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{ flyerId: 1, paths: ['/tmp/flyer.gif', convertedPath] },
|
||||
{
|
||||
flyerId: 1,
|
||||
paths: [
|
||||
'/tmp/flyer.gif', // original job path
|
||||
convertedPath, // from prepareImageInputs
|
||||
'/tmp/processed-flyer.jpg', // from processAndSaveImage
|
||||
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
|
||||
],
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -633,5 +684,30 @@ describe('FlyerProcessingService', () => {
|
||||
'Job received no paths and could not derive any from the database. Skipping.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should derive paths from DB and delete files if job paths are empty', async () => {
|
||||
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
|
||||
const mockFlyer = createMockFlyer({
|
||||
image_url: 'http://localhost:3000/flyer-images/flyer-abc.jpg',
|
||||
icon_url: 'http://localhost:3000/flyer-images/icons/icon-flyer-abc.webp',
|
||||
});
|
||||
// Mock DB call to return a flyer
|
||||
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
|
||||
// Mock process.env.STORAGE_PATH
|
||||
vi.stubEnv('STORAGE_PATH', '/var/www/app/flyer-images');
|
||||
|
||||
const result = await service.processCleanupJob(job);
|
||||
|
||||
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
||||
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/flyer-abc.jpg');
|
||||
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/icons/icon-flyer-abc.webp');
|
||||
const { logger } = await import('./logger.server');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user