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