Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions
c4742959e4 ci: Bump version to 0.2.3 [skip ci] 2025-12-27 09:17:15 +05:00
97c54c0c5c Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m48s
2025-12-26 20:16:33 -08:00
7cc50907d1 more testing fixes 2025-12-26 20:16:19 -08:00
Gitea Actions
b4199f7c48 ci: Bump version to 0.2.2 [skip ci] 2025-12-27 08:37:04 +05:00
dda36f7bc5 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m49s
2025-12-26 19:36:34 -08:00
27810bbb36 some pm2 fiex + unit test work for new flyeruploader 2025-12-26 19:35:44 -08:00
11 changed files with 93 additions and 29 deletions

View File

@@ -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")

View File

@@ -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.

View File

@@ -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")

View File

@@ -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',

4
package-lock.json generated
View File

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

View File

@@ -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(
<MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter>
</QueryClientProvider>,
);
};
@@ -51,9 +61,8 @@ describe('FlyerUploader', () => {
beforeEach(() => {
console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`);
// Use the 'modern' implementation of fake timers to handle promise microtasks correctly.
vi.useFakeTimers({ toFake: ['setTimeout'], shouldAdvanceTime: true });
console.log('--- [TEST LOG] ---: MODERN fake timers enabled.');
// Use fake timers to control polling intervals.
vi.useFakeTimers();
vi.resetAllMocks(); // Resets mock implementations AND call history.
console.log('--- [TEST LOG] ---: Mocks reset.');
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
@@ -224,7 +233,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 +272,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' });
@@ -340,7 +357,7 @@ describe('FlyerUploader', () => {
try {
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
expect(
await screen.findByText('This flyer has already been processed. You can view it here:'),
await screen.findByText(/This flyer has already been processed/i),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
} catch (error) {
@@ -453,7 +470,7 @@ describe('FlyerUploader', () => {
fireEvent.change(input, { target: { files: [file] } });
console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
expect(await screen.findByText(/Polling Network Error/i)).toBeInTheDocument();
expect(await screen.findByText(/Polling failed: Polling Network Error/i)).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
});
@@ -495,7 +512,9 @@ describe('FlyerUploader', () => {
fireEvent.change(input, { target: { files: [file] } });
console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
expect(await screen.findByText(/Failed to parse JSON response from server/i)).toBeInTheDocument();
expect(
await screen.findByText(/Polling failed: Failed to parse JSON response from server/i),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
});

View File

@@ -87,7 +87,9 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
)}
{processingState === 'completed' && (
<p className="text-green-600 dark:text-green-400 mt-2 font-bold">Processing complete! Redirecting...</p>
<p className="text-green-600 dark:text-green-400 mt-2 font-bold">
Processing complete! Redirecting to flyer {flyerId}...
</p>
)}
{errorMessage && (
@@ -95,7 +97,8 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
<p>{errorMessage}</p>
{duplicateFlyerId && (
<p>
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline">
This flyer has already been processed. You can view it here:{' '}
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline" data-discover="true">
Flyer #{duplicateFlyerId}
</Link>
</p>

View File

@@ -5,6 +5,11 @@ import { useFlyerUploader } from './useFlyerUploader';
import * as aiApiClient from '../services/aiApiClient';
import * as checksumUtil from '../utils/checksum';
// Import the actual error class because the module is mocked
const { JobFailedError } = await vi.importActual<typeof import('../services/aiApiClient')>(
'../services/aiApiClient',
);
// Mock dependencies
vi.mock('../services/aiApiClient');
vi.mock('../utils/checksum');
@@ -36,7 +41,7 @@ const createWrapper = () => {
describe('useFlyerUploader Hook with React Query', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mockedChecksumUtil.generateFileChecksum.mockResolvedValue('mock-checksum');
});
@@ -78,7 +83,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);
});
@@ -111,11 +116,9 @@ describe('useFlyerUploader Hook with React Query', () => {
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: mockJobId });
// Mock getJobStatus to throw a JobFailedError
const jobFailedError = new aiApiClient.JobFailedError(
'AI validation failed.',
'AI_VALIDATION_FAILED',
mockedAiApiClient.getJobStatus.mockRejectedValue(
new JobFailedError('AI validation failed.', 'AI_VALIDATION_FAILED'),
);
mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
const mockFile = new File([''], 'flyer.pdf');

View File

@@ -86,7 +86,13 @@ export const useFlyerUploader = () => {
if (uploadMutation.isPending) return 'uploading';
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
return 'polling';
if (jobStatus?.state === 'completed') return 'completed';
if (jobStatus?.state === 'completed') {
// If the job is complete but didn't return a flyerId, it's an error state.
if (!jobStatus.returnValue?.flyerId) {
return 'error';
}
return 'completed';
}
if (uploadMutation.isError || jobStatus?.state === 'failed' || pollError) return 'error';
return 'idle';
})();
@@ -100,6 +106,9 @@ export const useFlyerUploader = () => {
if (jobStatus?.state === 'failed') {
return `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason}`;
}
if (jobStatus?.state === 'completed' && !jobStatus.returnValue?.flyerId) {
return 'Job completed but did not return a flyer ID.';
}
return null;
};

View File

@@ -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,8 +378,9 @@ 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.',
'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
@@ -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();
});