diff --git a/.gitea/workflows/deploy-to-prod.yml b/.gitea/workflows/deploy-to-prod.yml index 3fedd2d..5a7475d 100644 --- a/.gitea/workflows/deploy-to-prod.yml +++ b/.gitea/workflows/deploy-to-prod.yml @@ -138,6 +138,10 @@ jobs: cd /var/www/flyer-crawler.projectium.com npm install --omit=dev + # --- Cleanup Errored Processes --- + echo "Cleaning up errored or stopped PM2 processes..." + node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }" + # --- Version Check Logic --- # Get the version from the newly deployed package.json NEW_VERSION=$(node -p "require('./package.json').version") diff --git a/.gitea/workflows/deploy-to-test.yml b/.gitea/workflows/deploy-to-test.yml index 8469c49..5e8770b 100644 --- a/.gitea/workflows/deploy-to-test.yml +++ b/.gitea/workflows/deploy-to-test.yml @@ -397,6 +397,11 @@ jobs: echo "Installing production dependencies and restarting test server..." cd /var/www/flyer-crawler-test.projectium.com npm install --omit=dev + + # --- Cleanup Errored Processes --- + echo "Cleaning up errored or stopped PM2 processes..." + node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }" + # Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy. # It will START the process if it's not running, or RELOAD it if it is. # We also add `&& pm2 save` to persist the process list across server reboots. diff --git a/.gitea/workflows/manual-deploy-major.yml b/.gitea/workflows/manual-deploy-major.yml index d73402d..cc95be6 100644 --- a/.gitea/workflows/manual-deploy-major.yml +++ b/.gitea/workflows/manual-deploy-major.yml @@ -137,6 +137,10 @@ jobs: cd /var/www/flyer-crawler.projectium.com npm install --omit=dev + # --- Cleanup Errored Processes --- + echo "Cleaning up errored or stopped PM2 processes..." + node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }" + # --- Version Check Logic --- # Get the version from the newly deployed package.json NEW_VERSION=$(node -p "require('./package.json').version") diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 774ff74..01a8b1d 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -13,6 +13,7 @@ module.exports = { name: 'flyer-crawler-api', script: './node_modules/.bin/tsx', args: 'server.ts', // tsx will execute this file + max_memory_restart: '500M', // Restart if memory usage exceeds 500MB // Production Environment Settings env_production: { NODE_ENV: 'production', // Set the Node.js environment to production @@ -89,6 +90,7 @@ module.exports = { name: 'flyer-crawler-worker', script: './node_modules/.bin/tsx', args: 'src/services/worker.ts', // tsx will execute this file + max_memory_restart: '1G', // Restart if memory usage exceeds 1GB // Production Environment Settings env_production: { NODE_ENV: 'production', @@ -165,6 +167,7 @@ module.exports = { name: 'flyer-crawler-analytics-worker', script: './node_modules/.bin/tsx', args: 'src/services/worker.ts', // tsx will execute this file + max_memory_restart: '1G', // Restart if memory usage exceeds 1GB // Production Environment Settings env_production: { NODE_ENV: 'production', diff --git a/src/features/flyer/FlyerUploader.test.tsx b/src/features/flyer/FlyerUploader.test.tsx index cdcb0d8..70f48ef 100644 --- a/src/features/flyer/FlyerUploader.test.tsx +++ b/src/features/flyer/FlyerUploader.test.tsx @@ -6,6 +6,7 @@ import { FlyerUploader } from './FlyerUploader'; import * as aiApiClientModule from '../../services/aiApiClient'; import * as checksumModule from '../../utils/checksum'; import { useNavigate, MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Mock dependencies vi.mock('../../services/aiApiClient'); @@ -39,10 +40,19 @@ const mockedChecksumModule = checksumModule as unknown as { const renderComponent = (onProcessingComplete = vi.fn()) => { console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.'); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); return render( - - - , + + + + + , ); }; @@ -224,7 +234,11 @@ describe('FlyerUploader', () => { mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' }); mockedAiApiClient.getJobStatus.mockResolvedValue({ state: 'failed', - failedReason: 'AI model exploded', + progress: { + errorCode: 'UNKNOWN_ERROR', + message: 'AI model exploded', + }, + failedReason: 'This is the raw error message.', // The UI should prefer the progress message. }); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); @@ -259,7 +273,11 @@ describe('FlyerUploader', () => { // We need at least one 'active' response to establish a timeout loop so we have something to clear mockedAiApiClient.getJobStatus .mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' } }) - .mockResolvedValueOnce({ state: 'failed', failedReason: 'Fatal Error' }); + .mockResolvedValueOnce({ + state: 'failed', + progress: { errorCode: 'UNKNOWN_ERROR', message: 'Fatal Error' }, + failedReason: 'Fatal Error', + }); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); diff --git a/src/hooks/useFlyerUploader.test.tsx b/src/hooks/useFlyerUploader.test.tsx index 8e83294..7749de7 100644 --- a/src/hooks/useFlyerUploader.test.tsx +++ b/src/hooks/useFlyerUploader.test.tsx @@ -36,7 +36,7 @@ const createWrapper = () => { describe('useFlyerUploader Hook with React Query', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); mockedChecksumUtil.generateFileChecksum.mockResolvedValue('mock-checksum'); }); @@ -78,7 +78,7 @@ describe('useFlyerUploader Hook with React Query', () => { await waitFor(() => expect(result.current.statusMessage).toBe('Processing...')); // Assert completed state - await waitFor(() => expect(result.current.processingState).toBe('completed')); + await waitFor(() => expect(result.current.processingState).toBe('completed'), { timeout: 5000 }); expect(result.current.flyerId).toBe(777); }); diff --git a/src/services/flyerProcessingService.server.test.ts b/src/services/flyerProcessingService.server.test.ts index 282aa4f..3d9e539 100644 --- a/src/services/flyerProcessingService.server.test.ts +++ b/src/services/flyerProcessingService.server.test.ts @@ -248,7 +248,10 @@ 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({ + errorCode: 'UNKNOWN_ERROR', + message: 'AI model exploded', + }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); }); @@ -260,7 +263,11 @@ describe('FlyerProcessingService', () => { await expect(service.processJob(job)).rejects.toThrow(conversionError); - expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Conversion failed' }); + expect(job.updateProgress).toHaveBeenCalledWith({ + errorCode: 'PDF_CONVERSION_FAILED', + message: + 'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', + }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); }); @@ -280,7 +287,11 @@ describe('FlyerProcessingService', () => { { err: validationError, validationErrors: {}, rawData: {} }, 'AI Data Validation failed.', ); - expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Validation failed' }); + expect(job.updateProgress).toHaveBeenCalledWith({ + errorCode: 'AI_VALIDATION_FAILED', + message: + "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", + }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); }); @@ -353,7 +364,8 @@ describe('FlyerProcessingService', () => { await expect(service.processJob(job)).rejects.toThrow('Database transaction failed'); expect(job.updateProgress).toHaveBeenCalledWith({ - message: 'Error: Database transaction failed', + errorCode: 'UNKNOWN_ERROR', + message: 'Database transaction failed', }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); }); @@ -366,6 +378,7 @@ describe('FlyerProcessingService', () => { await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError); expect(job.updateProgress).toHaveBeenCalledWith({ + errorCode: 'UNSUPPORTED_FILE_TYPE', message: 'Error: Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.', }); @@ -390,7 +403,8 @@ describe('FlyerProcessingService', () => { await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.'); expect(job.updateProgress).toHaveBeenCalledWith({ - message: 'Error: Icon generation failed.', + errorCode: 'UNKNOWN_ERROR', + message: 'Icon generation failed.', }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); });