many fixes resulting from latest refactoring
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m7s

This commit is contained in:
2025-12-09 02:08:20 -08:00
parent 2ad8fadb6e
commit 8504f69c09
11 changed files with 171 additions and 23 deletions

View File

@@ -4,8 +4,9 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Outlet } from 'react-router-dom';
import App from './App';
import * as aiApiClient from './services/aiApiClient'; // Import aiApiClient
import * as apiClient from './services/apiClient';
import type { UserProfile } from './types';
import type { User, UserProfile } from './types';
// Mock useAuth to allow overriding the user state in tests
const mockUseAuth = vi.fn();
vi.mock('./hooks/useAuth', () => ({
@@ -38,6 +39,7 @@ vi.mock('pdfjs-dist', () => ({
})),
}));
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
const mockedApiClient = vi.mocked(apiClient);
// Mock the useData hook as it's a dependency of App.tsx
@@ -55,12 +57,27 @@ describe('App Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default auth state: loading or guest
// Mock the login function to simulate a successful login
const mockLogin = vi.fn(async (user: User, token: string) => {
// Simulate fetching profile after login
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
const userProfile = await profileResponse.json();
mockUseAuth.mockReturnValue({
user: userProfile.user,
profile: userProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: mockLogin, // Self-reference the mock
logout: vi.fn(),
updateProfile: vi.fn(),
});
});
mockUseAuth.mockReturnValue({
user: null,
profile: null,
authStatus: 'SIGNED_OUT',
isLoading: true,
login: vi.fn(),
isLoading: false, // Start with isLoading: false for most tests
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
@@ -85,9 +102,19 @@ describe('App Component', () => {
// Use mockImplementation to create a new Response object for each call,
// preventing "Body has already been read" errors.
mockedApiClient.fetchFlyers.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
// Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({
user_id: 'test-user-id',
user: { user_id: 'test-user-id', email: 'test@example.com' },
full_name: 'Test User',
avatar_url: '',
role: 'user',
points: 0,
}))));
mockedApiClient.fetchMasterItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedAiApiClient.rescanImageArea.mockResolvedValue(new Response(JSON.stringify({ text: 'mocked text' }))); // Mock for FlyerCorrectionTool
});
const renderApp = (initialEntries = ['/']) => {

View File

@@ -35,6 +35,8 @@ const setupSuccessMocks = () => {
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(
new Response(JSON.stringify({ message: 'Password reset email sent.' }))
);
// Add a mock for geocodeAddress to prevent import errors, even if not directly used in auth flows.
(mockedApiClient.geocodeAddress as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({ lat: 0, lng: 0 }) });
};
describe('ProfileManager Authentication Flows', () => {

View File

@@ -65,6 +65,8 @@ const setupSuccessMocks = () => {
(mockedApiClient.updateUserPreferences as Mock).mockImplementation((prefs) => Promise.resolve({ ok: true, json: () => Promise.resolve({ ...authenticatedProfile, preferences: { ...authenticatedProfile.preferences, ...prefs } }) } as Response));
(mockedApiClient.exportUserData as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({ profile: authenticatedProfile, watchedItems: [], shoppingLists: [] }) } as Response);
(mockedApiClient.deleteUserAccount as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({ message: 'Account deleted successfully.' }) } as Response);
// Add a mock for geocodeAddress to prevent import errors and support address form functionality.
(mockedApiClient.geocodeAddress as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({ lat: 43.1, lng: -79.1 }) });
};
describe('ProfileManager Authenticated User Features', () => {

View File

@@ -46,11 +46,14 @@ vi.mock('../services/queueService.server', () => ({
emailQueue: { name: 'email-sending', add: vi.fn(), getJob: vi.fn() },
analyticsQueue: { name: 'analytics-reporting', add: vi.fn(), getJob: vi.fn() },
cleanupQueue: { name: 'file-cleanup', add: vi.fn(), getJob: vi.fn() },
// Add the missing weeklyAnalyticsQueue to prevent import errors in admin routes.
weeklyAnalyticsQueue: { name: 'weekly-analytics-reporting', add: vi.fn(), getJob: vi.fn() },
// Also mock the workers, as they are imported by admin.routes.ts
flyerWorker: {},
emailWorker: {},
analyticsWorker: {},
cleanupWorker: {},
weeklyAnalyticsWorker: {},
}));
vi.mock('@bull-board/api');

View File

@@ -15,8 +15,8 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker } from '../services/queueService.server'; // Import your queues
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker } from '../services/queueService.server'; // Import your queues
import { getSimpleWeekAndYear } from '../utils/dateUtils';
const router = Router();
@@ -43,8 +43,8 @@ createBullBoard({
new BullMQAdapter(flyerQueue),
new BullMQAdapter(emailQueue),
new BullMQAdapter(analyticsQueue),
new BullMQAdapter(cleanupQueue), // Add the new cleanup queue here
new BullMQAdapter((await import('../services/queueService.server')).weeklyAnalyticsQueue),
new BullMQAdapter(cleanupQueue),
new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board
],
serverAdapter: serverAdapter,
});

View File

@@ -1,3 +1,8 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor,
// causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`.
// --- END FIX REGISTRY ---
// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { Job } from 'bullmq';
@@ -37,11 +42,12 @@ vi.mock('./aiService.server', () => ({
}));
vi.mock('./db/flyer.db');
vi.mock('./db/index.db');
vi.mock('../utils/imageProcessor');
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
}));
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
}));
vi.mock('./flyerDataTransformer');
}));
const mockedAiService = aiService as Mocked<typeof aiService>;
const mockedDb = db as Mocked<typeof db>;
@@ -56,6 +62,13 @@ describe('FlyerProcessingService', () => {
beforeEach(() => {
vi.clearAllMocks();
// Spy on the real transformer's method and provide a mock implementation.
// This is more robust than mocking the entire class constructor.
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
flyerData: { file_name: 'test.jpg', image_url: 'test.jpg', icon_url: 'icon.webp', checksum: 'checksum-123', store_name: 'Mock Store' } as any,
itemsForDb: [],
});
// Default mock implementation for the promisified exec
mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' });
@@ -91,7 +104,6 @@ describe('FlyerProcessingService', () => {
items: [],
});
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
});
@@ -122,7 +134,7 @@ describe('FlyerProcessingService', () => {
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-upload',
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
expect.any(Object)
);
@@ -156,7 +168,7 @@ describe('FlyerProcessingService', () => {
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify cleanup job includes original PDF and both generated images
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-upload',
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.pdf', expect.stringContaining('flyer-1.jpg'), expect.stringContaining('flyer-2.jpg')] },
expect.any(Object)
);
@@ -169,7 +181,7 @@ describe('FlyerProcessingService', () => {
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI response validation failed. The returned data structure is incorrect.' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
@@ -180,7 +192,7 @@ describe('FlyerProcessingService', () => {
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: A generic error occurred during AI or DB processing.' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});

View File

@@ -214,7 +214,7 @@ export class FlyerProcessingService {
private async _enqueueCleanup(flyerId: number, paths: string[]): Promise<void> {
if (paths.length === 0) return;
await this.cleanupQueue.add('cleanup-flyer-upload', { flyerId, paths }, {
await this.cleanupQueue.add('cleanup-flyer-files', { flyerId, paths }, {
jobId: `cleanup-flyer-${flyerId}`,
removeOnComplete: true,
});

View File

@@ -1,3 +1,8 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Fixed `ioredis` mock to be a constructible function. The previous mock returned an object directly,
// which is not compatible with the `new IORedis()` syntax used in `queueService.server.ts`.
// --- END FIX REGISTRY ---
// src/services/queueService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
@@ -32,8 +37,11 @@ vi.mock('bullmq', () => ({
}));
vi.mock('ioredis', () => ({
// Mock the default export which is the IORedis class constructor
default: vi.fn(() => mocks.mockRedisConnection),
// Mock the default export which is the IORedis class.
// It must be a function that can be called with `new`.
// This mock constructor returns the singleton mockRedisConnection instance.
// This resolves the "is not a constructor" error.
default: vi.fn().mockImplementation(() => mocks.mockRedisConnection),
}));
vi.mock('./logger.server', () => ({

View File

@@ -1,3 +1,7 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Added `weeklyAnalyticsWorker` to the `gracefulShutdown` sequence to ensure all workers are closed.
// --- END FIX REGISTRY ---
// src/services/queueService.server.ts
import { Queue, Worker, Job } from 'bullmq';
import IORedis from 'ioredis'; // Correctly imported
@@ -329,6 +333,7 @@ export const gracefulShutdown = async (signal: string) => {
emailWorker.close(),
analyticsWorker.close(),
cleanupWorker.close(),
weeklyAnalyticsWorker.close(), // Add the weekly analytics worker to the shutdown sequence
]);
logger.info('[Shutdown] All workers have been closed.');

View File

@@ -1,11 +1,17 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Added mocks and tests for `flyerWorker` and `weeklyAnalyticsWorker` processors.
// These were missing, causing `undefined` errors when the test suite tried to access them.
// --- END FIX REGISTRY ---
// src/services/queueService.workers.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Job, Worker } from 'bullmq';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => ({
sendEmail: vi.fn(),
sendEmail: vi.fn(), // Mock for emailService.sendEmail
unlink: vi.fn(),
processFlyerJob: vi.fn(), // Mock for flyerProcessingService.processJob
}));
// --- Mock Modules ---
@@ -27,17 +33,34 @@ vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
// Mock bullmq to capture the processor function passed to the Worker constructor
// Mock bullmq to capture the processor functions passed to the Worker constructor
vi.mock('bullmq');
// Mock flyerProcessingService.server as flyerWorker depends on it
vi.mock('./flyerProcessingService.server', () => ({
FlyerProcessingService: class {
processJob = mocks.processFlyerJob;
},
}));
// Mock flyerDataTransformer as it's a dependency of FlyerProcessingService
vi.mock('./flyerDataTransformer', () => ({
FlyerDataTransformer: class {
transform = vi.fn(); // Mock transform method
},
}));
// Import the module under test AFTER the mocks are set up.
// This will trigger the instantiation of the workers.
import './queueService.server';
// Capture the processor functions from the mocked Worker constructor calls
// Ensure all workers defined in queueService.server.ts are captured.
const flyerProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'flyer-processing')?.[1] as (job: Job) => Promise<void>;
const emailProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'email-sending')?.[1] as (job: Job) => Promise<void>;
const analyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'analytics-reporting')?.[1] as (job: Job) => Promise<void>;
const cleanupProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'file-cleanup')?.[1] as (job: Job) => Promise<void>;
const weeklyAnalyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'weekly-analytics-reporting')?.[1] as (job: Job) => Promise<void>;
// Helper to create a mock BullMQ Job object
const createMockJob = <T>(data: T): Job<T> => {
@@ -57,6 +80,30 @@ const createMockJob = <T>(data: T): Job<T> => {
describe('Queue Workers', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset default mock implementations for hoisted mocks
mocks.sendEmail.mockResolvedValue(undefined);
mocks.unlink.mockResolvedValue(undefined);
mocks.processFlyerJob.mockResolvedValue({ flyerId: 123 }); // Default success for flyer processing
});
describe('flyerWorker', () => {
it('should call flyerProcessingService.processJob with the job data', async () => {
const jobData = { filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf', checksum: 'abc' };
const job = createMockJob(jobData);
await flyerProcessor(job);
expect(mocks.processFlyerJob).toHaveBeenCalledTimes(1);
expect(mocks.processFlyerJob).toHaveBeenCalledWith(job);
});
it('should re-throw an error if flyerProcessingService.processJob fails', async () => {
const job = createMockJob({ filePath: '/tmp/fail.pdf', originalFileName: 'fail.pdf', checksum: 'def' });
const processingError = new Error('Flyer processing failed');
mocks.processFlyerJob.mockRejectedValue(processingError);
await expect(flyerProcessor(job)).rejects.toThrow('Flyer processing failed');
});
});
describe('emailWorker', () => {
@@ -156,4 +203,37 @@ describe('Queue Workers', () => {
await expect(cleanupProcessor(job)).rejects.toThrow('Permission denied');
});
});
});
describe('weeklyAnalyticsWorker', () => {
it('should complete successfully for a valid report date', async () => {
vi.useFakeTimers();
const job = createMockJob({ reportYear: 2024, reportWeek: 1 });
const promise = weeklyAnalyticsProcessor(job);
// Advance timers to simulate the 30-second task completing
await vi.advanceTimersByTimeAsync(30000);
await promise; // Wait for the promise to resolve
// No error should be thrown
expect(true).toBe(true);
vi.useRealTimers();
});
it('should re-throw an error if the job fails', async () => {
vi.useFakeTimers();
const job = createMockJob({ reportYear: 2024, reportWeek: 1 });
// Mock the internal logic to throw an error
const originalSetTimeout = setTimeout;
vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
if (ms === 30000) { // Target the simulated delay
throw new Error('Weekly analytics job failed');
}
return originalSetTimeout(callback, ms);
});
await expect(weeklyAnalyticsProcessor(job)).rejects.toThrow('Weekly analytics job failed');
vi.useRealTimers();
vi.restoreAllMocks(); // Restore setTimeout mock
});
});
});

View File

@@ -1,3 +1,8 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Added default mock implementations for `countFlyerItemsForFlyers` and `uploadAndProcessFlyer`.
// These functions were returning `undefined`, causing async hooks/components to time out.
// --- END FIX REGISTRY ---
// src/tests/setup/tests-setup-unit.ts
import { vi, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
@@ -145,8 +150,9 @@ vi.mock('../../services/apiClient', () => ({
// --- Data Fetching & Manipulation ---
fetchFlyers: vi.fn(),
fetchFlyerItems: vi.fn(),
fetchFlyerItemsForFlyers: vi.fn(),
countFlyerItemsForFlyers: vi.fn(),
// Provide a default implementation that returns a valid Response object to prevent timeouts.
fetchFlyerItemsForFlyers: vi.fn(() => Promise.resolve(new Response(JSON.stringify([])))),
countFlyerItemsForFlyers: vi.fn(() => Promise.resolve(new Response(JSON.stringify({ count: 0 })))),
fetchMasterItems: vi.fn(),
fetchWatchedItems: vi.fn(),
addWatchedItem: vi.fn(),
@@ -186,6 +192,8 @@ vi.mock('../../services/apiClient', () => ({
// FIX: Mock the aiApiClient module as well, which is used by AnalysisPanel
vi.mock('../../services/aiApiClient', () => ({
// Provide a default implementation that returns a valid Response object to prevent timeouts.
uploadAndProcessFlyer: vi.fn(() => Promise.resolve(new Response(JSON.stringify({ jobId: 'mock-job-id' })))),
isImageAFlyer: vi.fn(),
extractAddressFromImage: vi.fn(),
extractLogoFromImage: vi.fn(),
@@ -196,6 +204,7 @@ vi.mock('../../services/aiApiClient', () => ({
generateImageFromText: vi.fn(),
generateSpeechFromText: vi.fn(),
startVoiceSession: vi.fn(),
rescanImageArea: vi.fn(() => Promise.resolve(new Response(JSON.stringify({ text: 'mocked text' })))),
}));
/**