Compare commits

...

28 Commits

Author SHA1 Message Date
Gitea Actions
be5bda169e ci: Bump version to 0.2.11 [skip ci] 2025-12-29 00:08:54 +05:00
4ede403356 refactor flyer processing etc to be more atomic
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m54s
2025-12-28 11:07:46 -08:00
5d31605b80 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com 2025-12-27 23:36:06 -08:00
ddd4ad024e pm2 worker fixes 2025-12-27 23:29:46 -08:00
Gitea Actions
4e927f48bd ci: Bump version to 0.2.10 [skip ci] 2025-12-28 11:55:35 +05:00
af5644d17a add backoffs etc
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m40s
2025-12-27 22:54:51 -08:00
Gitea Actions
016c0a883a ci: Bump version to 0.2.9 [skip ci] 2025-12-28 11:28:27 +05:00
c6a5f889b4 unit test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m12s
2025-12-27 22:27:39 -08:00
Gitea Actions
c895ecdb28 ci: Bump version to 0.2.8 [skip ci] 2025-12-28 10:30:44 +05:00
05e3f8a61c minor fix
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m10s
2025-12-27 21:29:37 -08:00
Gitea Actions
f79a2abc65 ci: Bump version to 0.2.7 [skip ci] 2025-12-28 10:17:24 +05:00
a726c270bb Refactor the "God Component" (App.tsx) Your App.tsx has lower branch coverage (77%) and uncovered lines. This usually means it's doing too much: managing routing, auth state checks, theme toggling, and global error handling. Move Logic to "Initialization Hooks": Create a useAppInitialization hook that handles the OAuth token check, version check, and theme sync. Use Layouts for Routing: Move the "What's New" modal and "Anonymous Banner" into the MainLayout or a specialized AppGuard component, leaving App.tsx as a clean list of Routes.
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s
2025-12-27 21:13:15 -08:00
Gitea Actions
8a4965c45b ci: Bump version to 0.2.6 [skip ci] 2025-12-28 00:01:33 +05:00
93497bf7c7 unit test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m2s
2025-12-27 11:00:19 -08:00
Gitea Actions
20584af729 ci: Bump version to 0.2.5 [skip ci] 2025-12-27 22:11:57 +05:00
be9f452656 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 12m5s
2025-12-27 09:11:00 -08:00
ef4b8e58fe several fixes to various tests 2025-12-27 09:10:51 -08:00
Gitea Actions
a42f7d7007 ci: Bump version to 0.2.4 [skip ci] 2025-12-27 12:38:49 +05:00
768d02b9ed several fixes to various tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m14s
2025-12-26 23:37:39 -08:00
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
Gitea Actions
7a1421d5c2 ci: Bump version to 0.2.1 [skip ci] 2025-12-27 07:51:11 +05:00
1b52478f97 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 11m56s
2025-12-26 18:50:22 -08:00
fe8b000737 try to make upload better using tan-react library 2025-12-26 18:49:54 -08:00
61 changed files with 3329 additions and 1372 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")
@@ -154,7 +158,7 @@ jobs:
else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
echo "Production backend server reloaded successfully."
else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."

View File

@@ -90,10 +90,11 @@ jobs:
# integration test suite can launch its own, fresh server instance.
# '|| true' ensures the workflow doesn't fail if the process isn't running.
run: |
pm2 stop flyer-crawler-api-test || true
pm2 stop flyer-crawler-worker-test || true
pm2 delete flyer-crawler-api-test || true
pm2 delete flyer-crawler-worker-test || true
echo "--- Stopping and deleting all test processes ---"
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
# It prevents the accumulation of duplicate processes from previous test runs.
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test 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, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
- name: Run All Tests and Generate Merged Coverage Report
# This single step runs both unit and integration tests, then merges their
@@ -397,10 +398,15 @@ 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.
pm2 startOrReload ecosystem.config.cjs --env test && pm2 save
pm2 startOrReload ecosystem.config.cjs --env test --update-env && pm2 save
echo "Test backend server reloaded successfully."
# After a successful deployment, update the schema hash in the database.

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")
@@ -153,7 +157,7 @@ jobs:
else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
echo "Production backend server reloaded successfully."
else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."

View File

@@ -3,22 +3,37 @@
// It allows us to define all the settings for our application in one place.
// The .cjs extension is required because the project's package.json has "type": "module".
// --- Environment Variable Validation ---
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
if (missingSecrets.length > 0) {
console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:');
missingSecrets.forEach(key => console.warn(` - ${key}`));
console.warn('[ecosystem.config.cjs] The application may crash if these are required for startup.\n');
} else {
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
}
module.exports = {
apps: [
{
// --- API Server ---
// The name is now dynamically set based on the environment.
// This is a common pattern but requires you to call pm2 with the correct name.
// The deploy script handles this by using 'flyer-crawler-api' for prod and 'flyer-crawler-api-test' for test.
name: 'flyer-crawler-api',
script: './node_modules/.bin/tsx',
args: 'server.ts', // tsx will execute this file
args: 'server.ts',
max_memory_restart: '500M',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production', // Set the Node.js environment to production
NODE_ENV: 'production',
name: 'flyer-crawler-api',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -38,10 +53,9 @@ module.exports = {
},
// Test Environment Settings
env_test: {
NODE_ENV: 'test', // Set to 'test' to match the environment purpose and disable pino-pretty
NODE_ENV: 'test',
name: 'flyer-crawler-api-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -65,7 +79,6 @@ module.exports = {
name: 'flyer-crawler-api-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -88,13 +101,19 @@ module.exports = {
// --- General Worker ---
name: 'flyer-crawler-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts', // tsx will execute this file
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -117,7 +136,6 @@ module.exports = {
NODE_ENV: 'test',
name: 'flyer-crawler-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -141,7 +159,6 @@ module.exports = {
name: 'flyer-crawler-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -164,13 +181,19 @@ module.exports = {
// --- Analytics Worker ---
name: 'flyer-crawler-analytics-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts', // tsx will execute this file
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-analytics-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -193,7 +216,6 @@ module.exports = {
NODE_ENV: 'test',
name: 'flyer-crawler-analytics-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -217,7 +239,6 @@ module.exports = {
name: 'flyer-crawler-analytics-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,

118
notes-to-ai4.txt Normal file
View File

@@ -0,0 +1,118 @@
RULES:
1) if you do not have a file that you need, stop, and request it immediately.
2) never remove logging or comments
3) you cannot ever use 'any' or 'unknown' to solve possible typescript issues
4) when creating new files, output there entire path in your explanation, to make it easier to know where to save those new files and directories to
5) add comments when you can, as that will help ensure ideas persist into the app
6) Your knowledge of package version, like nodejs, is always old, like a year or more old - ask me for the best version to use, as your knowledge is incomplete
7) Stop making predictions and/or guessing at solutions. Focus on adding logging and debugging to issues that are not solved right away.
8) Do not make obsequious statements - we're here to do a job, not get patted on the shoulder for insignificant achievements.
9) Provide me with the npm command to execute rather than wanting to edit the package.json file. That is not the correct way to handle a package update.
10) Provide the code solution in DIFF format for brevity.
11) Always add logging and debugging to prove a solution works, and only remove logging and debugging when it is not possible to adapt it to the new conditions.
UPC SCANNING !
EXPIRY DATES !
are there any other unit test files that should be refactored :
Extract Shared Utility Functions Several utility functions are duplicated across multiple route files.
Update Route Files to Use New Utilities route files will be updated to import these new utility functions
Review the `getJobStatus` API client function and add more robust error handling for non-JSON responses.
Review the `src/services/apiClient.test.ts` file. It seems to have a lot of manual data creation for API payloads. Can we refactor it to use factories where appropriate, or create new factories for API payloads if needed?
The `useApi` hook has complex state management. Can you suggest a way to simplify it, perhaps by using a reducer or a state machine?
Are there any opportunities to improve the mock factories themselves, for example, by adding more realistic default data?
Now that the user creation is centralized, can we add more complex test user scenarios to the helper, like a user with a pre-existing shopping list?
How can I add a new "Recipes Created" stat to the AdminStatsPage and its tests?
NO CODE just outline the steps
Gemini
Outlining the Implementation Path
Gemini 2.5 generated this response due to high traffic on Gemini 3
Of course! Here is a step-by-step outline of how to add a new "Recipes Created" statistic to your AdminStatsPage.
Backend Steps
Update Database Query:
In the database repository file responsible for fetching admin statistics (e.g., admin.db.ts), you'll need to add a query that performs a COUNT(*) on your recipes table.
Update Service Layer:
The service function that aggregates the statistics (which is called by your API controller) needs to be modified to execute the new recipe count query.
The result of this query should be added to the statistics object that is returned.
Update API Endpoint:
The controller for the /api/admin/stats endpoint must be updated to include the new recipeCount field in the JSON object it sends to the frontend.
Frontend Steps
Update AppStats Type:
In src/services/apiClient.ts, where the AppStats type is defined, add the new property: recipeCount: number;.
Update Mock Factory:
In src/tests/utils/mockFactories.ts, modify the createMockAppStats function. Add recipeCount to the defaultStats object with a default value (e.g., recipeCount: 500). This ensures any test that uses the factory will not break due to the missing property.
Update AdminStatsPage Component (AdminStatsPage.tsx):
Import a suitable icon for recipes, like BookOpenIcon, at the top of the file.
Inside the main div where the other StatCard components are rendered, add a new one for the recipe count:
jsx
<StatCard title="Total Recipes" value={stats.recipeCount.toLocaleString()} icon={<BookOpenIcon className="w-6 h-6" />} />
Update AdminStatsPage Tests (AdminStatsPage.test.tsx):
In the test 'should display stats cards when data is fetched successfully', update the mockStats object to include a value for recipeCount. Add a new expect assertion to verify that the "Total Recipes" title and its corresponding value are rendered.
In the test 'should format large numbers with commas for readability', add a large number for recipeCount in the mock data and assert that it is rendered with commas.
In the test 'should correctly display zero values for all stats', add recipeCount: 0 to the mock data and increase the expected count of "0" elements found in the document from 5 to 6.

31
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "flyer-crawler",
"version": "0.2.0",
"version": "0.2.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.2.0",
"version": "0.2.11",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",
"@google/genai": "^1.30.0",
"@tanstack/react-query": "^5.90.12",
"@types/connect-timeout": "^1.9.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.65.1",
@@ -4882,6 +4883,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@testcontainers/postgresql": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.2.0",
"version": "0.2.11",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",
@@ -30,6 +30,7 @@
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",
"@google/genai": "^1.30.0",
"@tanstack/react-query": "^5.90.12",
"@types/connect-timeout": "^1.9.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.65.1",

View File

@@ -20,6 +20,7 @@ import {
mockUseUserData,
mockUseFlyerItems,
} from './tests/setup/mockHooks';
import { useAppInitialization } from './hooks/useAppInitialization';
// Mock top-level components rendered by App's routes
@@ -52,6 +53,9 @@ vi.mock('./hooks/useFlyerItems', async () => {
return { useFlyerItems: hooks.mockUseFlyerItems };
});
vi.mock('./hooks/useAppInitialization');
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
vi.mock('./hooks/useAuth', async () => {
const hooks = await import('./tests/setup/mockHooks');
return { useAuth: hooks.mockUseAuth };
@@ -122,7 +126,23 @@ vi.mock('./layouts/MainLayout', async () => {
return { MainLayout: MockMainLayout };
});
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
vi.mock('./components/AppGuard', async () => {
// We need to use the real useModal hook inside our mock AppGuard
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
return {
AppGuard: ({ children }: { children: React.ReactNode }) => {
const { isModalOpen } = useModal();
return (
<div data-testid="app-guard-mock">
{children}
{isModalOpen('whatsNew') && <div data-testid="whats-new-modal-mock" />}
</div>
);
},
};
});
const mockedAiApiClient = vi.mocked(aiApiClient);
const mockedApiClient = vi.mocked(apiClient);
const mockFlyers: Flyer[] = [
@@ -131,33 +151,6 @@ const mockFlyers: Flyer[] = [
];
describe('App Component', () => {
// Mock localStorage
let storage: { [key: string]: string } = {};
const localStorageMock = {
getItem: vi.fn((key: string) => storage[key] || null),
setItem: vi.fn((key: string, value: string) => {
storage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete storage[key];
}),
clear: vi.fn(() => {
storage = {};
}),
};
// Mock matchMedia
const matchMediaMock = vi.fn().mockImplementation((query) => ({
matches: false, // Default to light mode
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
beforeEach(() => {
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
vi.clearAllMocks();
@@ -205,11 +198,9 @@ describe('App Component', () => {
mockUseFlyerItems.mockReturnValue({
flyerItems: [],
isLoading: false,
error: null,
});
// Clear local storage to prevent state from leaking between tests.
localStorage.clear();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true });
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
// Default mocks for API calls
// Use mockImplementation to create a new Response object for each call,
@@ -261,6 +252,7 @@ describe('App Component', () => {
it('should render the main layout and header', async () => {
// Simulate the auth hook finishing its initial check
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
@@ -272,6 +264,7 @@ describe('App Component', () => {
renderApp();
await waitFor(() => {
expect(screen.getByTestId('app-guard-mock')).toBeInTheDocument();
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
// Check that the main layout and home page are rendered for the root path
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument();
@@ -364,193 +357,6 @@ describe('App Component', () => {
});
});
describe('Theme and Unit System Synchronization', () => {
it('should set dark mode based on user profile preferences', async () => {
console.log(
'[TEST DEBUG] Test Start: should set dark mode based on user profile preferences',
);
const profileWithDarkMode: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }),
role: 'user',
points: 0,
preferences: { darkMode: true },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithDarkMode,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App');
renderApp();
// The useEffect that sets the theme is asynchronous. We must wait for the update.
await waitFor(() => {
console.log(
'[TEST DEBUG] Checking for dark class. Current classes:',
document.documentElement.className,
);
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set light mode based on user profile preferences', async () => {
const profileWithLightMode: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }),
role: 'user',
points: 0,
preferences: { darkMode: false },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithLightMode,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
renderApp();
await waitFor(() => {
expect(document.documentElement).not.toHaveClass('dark');
});
});
it('should set dark mode based on localStorage if profile has no preference', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set dark mode based on system preference if no other setting exists', async () => {
matchMediaMock.mockImplementationOnce((query) => ({ matches: true, media: query }));
renderApp();
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set unit system based on user profile preferences', async () => {
const profileWithMetric: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }),
role: 'user',
points: 0,
preferences: { unitSystem: 'metric' },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithMetric,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
renderApp();
// The unit system is passed as a prop to Header, which is mocked.
// We can't directly see the result in the DOM easily, so we trust the state is set.
// A more integrated test would be needed to verify the Header receives the prop.
// For now, this test ensures the useEffect logic runs without crashing.
await waitFor(() => {
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
});
});
});
describe('OAuth Token Handling', () => {
it('should call login when a googleAuthToken is in the URL', async () => {
console.log(
'[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL',
);
const mockLogin = vi.fn().mockResolvedValue(undefined);
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
renderApp(['/?googleAuthToken=test-google-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalledWith('test-google-token');
});
});
it('should call login when a githubAuthToken is in the URL', async () => {
console.log(
'[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL',
);
const mockLogin = vi.fn().mockResolvedValue(undefined);
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
renderApp(['/?githubAuthToken=test-github-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalledWith('test-github-token');
});
});
it('should log an error if login with a GitHub token fails', async () => {
console.log(
'[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails',
);
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
renderApp(['/?githubAuthToken=bad-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalled();
});
});
it('should log an error if login with a token fails', async () => {
console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails');
const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed'));
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
renderApp(['/?googleAuthToken=bad-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalled();
});
});
});
describe('Flyer Selection from URL', () => {
it('should select a flyer when flyerId is present in the URL', async () => {
renderApp(['/flyers/2']);
@@ -583,23 +389,9 @@ describe('App Component', () => {
});
});
describe('Version and "What\'s New" Modal', () => {
it('should show the "What\'s New" modal if the app version is new', async () => {
// Mock the config module for this specific test
vi.mock('./config', () => ({
default: {
app: { version: '20250101-1200:abc1234:1.0.1', commitMessage: 'New feature!', commitUrl: '#' },
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:1.0.0');
renderApp();
await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument();
});
});
describe('Modal Interactions', () => {
it('should open and close the ProfileManager modal', async () => {
console.log('[TEST DEBUG] Test Start: should open and close the ProfileManager modal');
renderApp();
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
@@ -607,11 +399,13 @@ describe('App Component', () => {
fireEvent.click(screen.getByText('Open Profile'));
expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument();
console.log('[TEST DEBUG] ProfileManager modal opened. Now closing...');
// Close modal
fireEvent.click(screen.getByText('Close Profile'));
await waitFor(() => {
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
});
console.log('[TEST DEBUG] ProfileManager modal closed.');
});
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
@@ -636,7 +430,7 @@ describe('App Component', () => {
fireEvent.click(screen.getByText('Open Voice Assistant'));
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument();
expect(await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 })).toBeInTheDocument();
// Close modal
fireEvent.click(screen.getByText('Close Voice Assistant'));
@@ -735,64 +529,6 @@ describe('App Component', () => {
});
});
describe("Version Display and What's New", () => {
beforeEach(() => {
// Also mock the config module to reflect this change
vi.mock('./config', () => ({
default: {
app: {
version: '20250101-1200:abc1234:2.0.0',
commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0',
},
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
});
it('should display the version number and commit link', () => {
renderApp();
const versionLink = screen.getByText(`Version: 20250101-1200:abc1234:2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
// Pre-set the localStorage to prevent the modal from opening automatically
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:2.0.0');
renderApp();
expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument();
const openButton = await screen.findByTitle("Show what's new in this version");
fireEvent.click(openButton);
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
});
describe('Dynamic Toaster Styles', () => {
it('should render the correct CSS variables for toast styling in light mode', async () => {
renderApp();
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
});
});
it('should render the correct CSS variables for toast styling in dark mode', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
});
});
});
describe('Profile and Login Handlers', () => {
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
console.log(
@@ -841,12 +577,19 @@ describe('App Component', () => {
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Mock the login function to simulate a successful login. Signature: (token, profile)
const mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => {
// Simulate fetching profile after login
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
const userProfileData: UserProfile = await profileResponse.json();
mockUseAuth.mockReturnValue({ ...mockUseAuth(), userProfile: userProfileData, authStatus: 'AUTHENTICATED' });
});
console.log('[TEST DEBUG] Rendering App');
renderApp();
console.log('[TEST DEBUG] Opening Profile');
fireEvent.click(screen.getByText('Open Profile'));
const loginButton = await screen.findByText('Login');
const loginButton = await screen.findByRole('button', { name: 'Login' });
console.log('[TEST DEBUG] Clicking Login');
fireEvent.click(loginButton);
@@ -857,4 +600,33 @@ describe('App Component', () => {
});
});
});
describe("Version Display and What's New", () => {
beforeEach(() => {
vi.mock('./config', () => ({
default: {
app: {
version: '2.0.0',
commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0',
},
},
}));
});
it('should display the version number and commit link', () => {
renderApp();
const versionLink = screen.getByText(`Version: 2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
renderApp();
const openButton = await screen.findByTitle("Show what's new in this version");
fireEvent.click(openButton);
// The mock AppGuard now renders the modal when it's open
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
});
});

View File

@@ -1,9 +1,9 @@
// src/App.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { Routes, Route, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as pdfjsLib from 'pdfjs-dist';
import { Footer } from './components/Footer'; // Assuming this is where your Footer component will live
import { Footer } from './components/Footer';
import { Header } from './components/Header';
import { logger } from './services/logger.client';
import type { Flyer, Profile, UserProfile } from './types';
@@ -15,16 +15,17 @@ import { CorrectionsPage } from './pages/admin/CorrectionsPage';
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { VoiceLabPage } from './pages/VoiceLabPage';
import { WhatsNewModal } from './components/WhatsNewModal';
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
import { useAuth } from './hooks/useAuth';
import { useFlyers } from './hooks/useFlyers'; // Assuming useFlyers fetches all flyers
import { useFlyerItems } from './hooks/useFlyerItems'; // Import the new hook for flyer items
import { useFlyers } from './hooks/useFlyers';
import { useFlyerItems } from './hooks/useFlyerItems';
import { useModal } from './hooks/useModal';
import { MainLayout } from './layouts/MainLayout';
import config from './config';
import { HomePage } from './pages/HomePage';
import { AppGuard } from './components/AppGuard';
import { useAppInitialization } from './hooks/useAppInitialization';
// pdf.js worker configuration
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
@@ -35,15 +36,20 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
import.meta.url,
).toString();
// Create a client
const queryClient = new QueryClient();
function App() {
const { userProfile, authStatus, login, logout, updateProfile } = useAuth();
const { flyers } = useFlyers();
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const { openModal, closeModal, isModalOpen } = useModal();
const location = useLocation();
const navigate = useNavigate();
const params = useParams<{ flyerId?: string }>();
// This hook now handles initialization effects (OAuth, version check, theme)
// and returns the theme/unit state needed by other components.
const { isDarkMode, unitSystem } = useAppInitialization();
// Debugging: Log renders to identify infinite loops
useEffect(() => {
if (process.env.NODE_ENV === 'test') {
@@ -53,14 +59,11 @@ function App() {
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
authStatus,
profileId: userProfile?.user.user_id,
locationSearch: location.search,
});
}
});
const [isDarkMode, setIsDarkMode] = useState(false);
const { flyerItems } = useFlyerItems(selectedFlyer);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
// Define modal handlers with useCallback at the top level to avoid Rules of Hooks violations
const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]);
@@ -105,37 +108,6 @@ function App() {
// --- State Synchronization and Error Handling ---
// Effect to set initial theme based on user profile, local storage, or system preference
useEffect(() => {
if (process.env.NODE_ENV === 'test')
console.log('[App] Effect: Theme Update', { profileId: userProfile?.user.user_id });
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
// Preference from DB
const dbDarkMode = userProfile.preferences.darkMode;
setIsDarkMode(dbDarkMode);
document.documentElement.classList.toggle('dark', dbDarkMode);
} else {
// Fallback to local storage or system preference
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark;
setIsDarkMode(initialDarkMode);
document.documentElement.classList.toggle('dark', initialDarkMode);
}
}, [userProfile?.preferences?.darkMode, userProfile?.user.user_id]);
// Effect to set initial unit system based on user profile or local storage
useEffect(() => {
if (userProfile && userProfile.preferences?.unitSystem) {
setUnitSystem(userProfile.preferences.unitSystem);
} else {
const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null;
if (savedSystem) {
setUnitSystem(savedSystem);
}
}
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
// This is the login handler that will be passed to the ProfileManager component.
const handleLoginSuccess = useCallback(
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
@@ -153,36 +125,6 @@ function App() {
[login],
);
// Effect to handle the token from Google OAuth redirect
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const googleToken = urlParams.get('googleAuthToken');
if (googleToken) {
logger.info('Received Google Auth token from URL. Authenticating...');
// The login flow is now handled by the useAuth hook. We just need to trigger it.
// We pass only the token; the AuthProvider will fetch the user profile.
login(googleToken).catch((err) =>
logger.error('Failed to log in with Google token', { error: err }),
);
// Clean the token from the URL
navigate(location.pathname, { replace: true });
}
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
login(githubToken).catch((err) => {
logger.error('Failed to log in with GitHub token', { error: err });
// Optionally, redirect to a page with an error message
// navigate('/login?error=github_auth_failed');
});
// Clean the token from the URL
navigate(location.pathname, { replace: true });
}
}, [login, location.search, navigate, location.pathname]);
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer);
}, []);
@@ -210,31 +152,10 @@ function App() {
// Read the application version injected at build time.
// This will only be available in the production build, not during local development.
const appVersion = config.app.version;
const commitMessage = config.app.commitMessage;
useEffect(() => {
if (appVersion) {
logger.info(`Application version: ${appVersion}`);
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
// If the current version is new, show the "What's New" modal.
if (appVersion !== lastSeenVersion) {
openModal('whatsNew');
localStorage.setItem('lastSeenVersion', appVersion);
}
}
}, [appVersion]);
return (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
{/* Toaster component for displaying notifications. It's placed at the top level. */}
<Toaster position="top-center" reverseOrder={false} />
{/* Add CSS variables for toast theming based on dark mode */}
<style>{`
:root {
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
}
`}</style>
// AppGuard now handles the main page wrapper, theme styles, and "What's New" modal
<AppGuard>
<Header
isDarkMode={isDarkMode}
unitSystem={unitSystem}
@@ -261,15 +182,6 @@ function App() {
/>
)}
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isModalOpen('whatsNew')}
onClose={handleCloseWhatsNew}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{selectedFlyer && (
<FlyerCorrectionTool
isOpen={isModalOpen('correctionTool')}
@@ -341,8 +253,14 @@ function App() {
)}
<Footer />
</div>
</AppGuard>
);
}
export default App;
const WrappedApp = () => (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
export default WrappedApp;

View File

@@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
import { AnonymousUserBanner } from './AnonymousUserBanner';
// Mock the icon to ensure it is rendered correctly
vi.mock('../../../components/icons/InformationCircleIcon', () => ({
vi.mock('./icons/InformationCircleIcon', () => ({
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="info-icon" {...props} />
),

View File

@@ -1,6 +1,6 @@
// src/pages/admin/components/AnonymousUserBanner.tsx
// src/components/AnonymousUserBanner.tsx
import React from 'react';
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
import { InformationCircleIcon } from './icons/InformationCircleIcon';
interface AnonymousUserBannerProps {
/**

View File

@@ -0,0 +1,93 @@
// src/components/AppGuard.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AppGuard } from './AppGuard';
import { useAppInitialization } from '../hooks/useAppInitialization';
import { useModal } from '../hooks/useModal';
// Mock dependencies
vi.mock('../hooks/useAppInitialization');
vi.mock('../hooks/useModal');
vi.mock('./WhatsNewModal', () => ({
WhatsNewModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="whats-new-modal-mock" /> : null,
}));
vi.mock('../config', () => ({
default: {
app: { version: '1.0.0', commitMessage: 'Test commit' },
},
}));
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
const mockedUseModal = vi.mocked(useModal);
describe('AppGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mocks
mockedUseAppInitialization.mockReturnValue({
isDarkMode: false,
unitSystem: 'imperial',
});
mockedUseModal.mockReturnValue({
isModalOpen: vi.fn().mockReturnValue(false),
openModal: vi.fn(),
closeModal: vi.fn(),
});
});
it('should render children', () => {
render(
<AppGuard>
<div>Child Content</div>
</AppGuard>,
);
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
it('should render WhatsNewModal when it is open', () => {
mockedUseModal.mockReturnValue({
...mockedUseModal(),
isModalOpen: (modalId) => modalId === 'whatsNew',
});
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
expect(screen.getByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
it('should set dark mode styles for toaster', async () => {
mockedUseAppInitialization.mockReturnValue({
isDarkMode: true,
unitSystem: 'imperial',
});
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
expect(styleTag!.innerHTML).toContain('--toast-color: #F9FAFB');
});
});
it('should set light mode styles for toaster', async () => {
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
});
});
});

View File

@@ -0,0 +1,47 @@
// src/components/AppGuard.tsx
import React, { useCallback } from 'react';
import { Toaster } from 'react-hot-toast';
import { useAppInitialization } from '../hooks/useAppInitialization';
import { useModal } from '../hooks/useModal';
import { WhatsNewModal } from './WhatsNewModal';
import config from '../config';
interface AppGuardProps {
children: React.ReactNode;
}
export const AppGuard: React.FC<AppGuardProps> = ({ children }) => {
// This hook handles OAuth tokens, version checks, and returns theme state.
const { isDarkMode } = useAppInitialization();
const { isModalOpen, closeModal } = useModal();
const handleCloseWhatsNew = useCallback(() => closeModal('whatsNew'), [closeModal]);
const appVersion = config.app.version;
const commitMessage = config.app.commitMessage;
return (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
{/* Toaster component for displaying notifications. It's placed at the top level. */}
<Toaster position="top-center" reverseOrder={false} />
{/* Add CSS variables for toast theming based on dark mode */}
<style>{`
:root {
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
}
`}</style>
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isModalOpen('whatsNew')}
onClose={handleCloseWhatsNew}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{children}
</div>
);
};

View File

@@ -1,7 +1,7 @@
// src/pages/admin/components/PasswordInput.tsx
// src/components/PasswordInput.tsx
import React, { useState } from 'react';
import { EyeIcon } from '../../../components/icons/EyeIcon';
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from './icons/EyeSlashIcon';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
/**

View File

@@ -1,4 +1,5 @@
// src/pages/admin/components/PasswordStrengthIndicator.tsx
// src/components/PasswordStrengthIndicator.tsx
import React from 'react';
import zxcvbn from 'zxcvbn';

View File

@@ -38,8 +38,26 @@ vi.mock('recharts', () => ({
),
CartesianGrid: () => <div data-testid="cartesian-grid" />,
XAxis: () => <div data-testid="x-axis" />,
YAxis: () => <div data-testid="y-axis" />,
Tooltip: () => <div data-testid="tooltip" />,
YAxis: ({ tickFormatter, domain }: any) => {
// Execute functions for coverage
if (typeof tickFormatter === 'function') {
tickFormatter(1000);
}
if (Array.isArray(domain)) {
domain.forEach((d) => {
if (typeof d === 'function') d(100);
});
}
return <div data-testid="y-axis" />;
},
Tooltip: ({ formatter }: any) => {
// Execute formatter for coverage
if (typeof formatter === 'function') {
formatter(1000);
formatter(undefined);
}
return <div data-testid="tooltip" />;
},
Legend: () => <div data-testid="legend" />,
// Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
@@ -301,4 +319,66 @@ describe('PriceHistoryChart', () => {
expect(chartData).toHaveLength(2);
});
});
it('should handle malformed data points and unmatched items gracefully', async () => {
const malformedData: any[] = [
{ master_item_id: null, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // Missing ID
{ master_item_id: 1, summary_date: null, avg_price_in_cents: 100 }, // Missing date
{ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: null }, // Missing price
{ master_item_id: 999, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // ID not in watchlist
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(malformedData)),
);
render(<PriceHistoryChart />);
await waitFor(() => {
// Should show "Not enough historical data" because all points are invalid or filtered
expect(
screen.getByText(
'Not enough historical data for your watched items. Process more flyers to build a trend.',
),
).toBeInTheDocument();
});
});
it('should ignore higher prices for the same day', async () => {
const dataWithHigherPrice: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
avg_price_in_cents: 100,
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
avg_price_in_cents: 150, // Higher price should be ignored
}),
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-08',
avg_price_in_cents: 100,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithHigherPrice)),
);
render(<PriceHistoryChart />);
await waitFor(() => {
const chart = screen.getByTestId('line-chart');
const chartData = JSON.parse(chart.getAttribute('data-chartdata')!);
const dataPoint = chartData.find((d: any) => d.date === 'Oct 1');
expect(dataPoint['Organic Bananas']).toBe(100);
});
});
it('should handle non-Error objects thrown during fetch', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue('String Error');
render(<PriceHistoryChart />);
await waitFor(() => {
expect(screen.getByText('Failed to load price history.')).toBeInTheDocument();
});
});
});

View File

@@ -406,6 +406,74 @@ describe('ExtractedDataTable', () => {
render(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />);
expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument();
});
it('should allow switching filter back to All Categories', () => {
render(<ExtractedDataTable {...defaultProps} />);
const categoryFilter = screen.getByLabelText('Filter by category');
// Filter to Dairy
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
expect(screen.queryByText('Gala Apples')).not.toBeInTheDocument();
expect(screen.getByText('2% Milk')).toBeInTheDocument();
// Filter back to All
fireEvent.change(categoryFilter, { target: { value: 'all' } });
expect(screen.getByText('Gala Apples')).toBeInTheDocument();
expect(screen.getByText('2% Milk')).toBeInTheDocument();
});
it('should sort items alphabetically within watched and unwatched groups', () => {
const items = [
createMockFlyerItem({
flyer_item_id: 1,
item: 'Yam',
master_item_id: 3,
category_name: 'Produce',
}), // Unwatched
createMockFlyerItem({
flyer_item_id: 2,
item: 'Zebra',
master_item_id: 1,
category_name: 'Produce',
}), // Watched
createMockFlyerItem({
flyer_item_id: 3,
item: 'Banana',
master_item_id: 4,
category_name: 'Produce',
}), // Unwatched
createMockFlyerItem({
flyer_item_id: 4,
item: 'Apple',
master_item_id: 2,
category_name: 'Produce',
}), // Watched
];
vi.mocked(useUserData).mockReturnValue({
watchedItems: [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Zebra' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Apple' }),
],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} items={items} />);
const rows = screen.getAllByRole('row');
// Extract item names based on the bold/semibold classes used for names
const itemNames = rows.map((row) => {
const nameEl = row.querySelector('.font-bold, .font-semibold');
return nameEl?.textContent;
});
// Expected: Watched items first (Apple, Zebra), then Unwatched (Banana, Yam)
expect(itemNames).toEqual(['Apple', 'Zebra', 'Banana', 'Yam']);
});
});
describe('Data Edge Cases', () => {
@@ -460,5 +528,46 @@ describe('ExtractedDataTable', () => {
// Check for the unit suffix, which might be in a separate element or part of the string
expect(within(chickenItemRow).getAllByText(/\/kg/i).length).toBeGreaterThan(0);
});
it('should handle activeListId pointing to a non-existent list', () => {
vi.mocked(useShoppingLists).mockReturnValue({
activeListId: 999, // Non-existent
shoppingLists: mockShoppingLists,
addItemToList: mockAddItemToList,
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
// Should behave as if item is not in list (Add button enabled)
const appleItemRow = screen.getByText('Gala Apples').closest('tr')!;
const addToListButton = within(appleItemRow).getByTitle('Add Apples to list');
expect(addToListButton).toBeInTheDocument();
expect(addToListButton).not.toBeDisabled();
});
it('should display numeric quantity in parentheses if available', () => {
const itemWithQtyNum = createMockFlyerItem({
flyer_item_id: 999,
item: 'Bulk Rice',
quantity: 'Bag',
quantity_num: 5,
unit_price: { value: 10, unit: 'kg' },
category_name: 'Pantry',
flyer_id: 1,
});
render(<ExtractedDataTable {...defaultProps} items={[itemWithQtyNum]} />);
expect(screen.getByText('(5)')).toBeInTheDocument();
});
});
});

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, onlineManager } 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>,
);
};
@@ -50,10 +60,11 @@ describe('FlyerUploader', () => {
const navigateSpy = vi.fn();
beforeEach(() => {
// Disable react-query's online manager to prevent it from interfering with fake timers
onlineManager.setEventListener((setOnline) => {
return () => {};
});
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.');
vi.resetAllMocks(); // Resets mock implementations AND call history.
console.log('--- [TEST LOG] ---: Mocks reset.');
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
@@ -61,7 +72,6 @@ describe('FlyerUploader', () => {
});
afterEach(() => {
vi.useRealTimers();
console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`);
});
@@ -104,21 +114,18 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');
await act(async () => {
console.log('--- [TEST LOG] ---: 8a. vi.advanceTimersByTime(3000) starting...');
vi.advanceTimersByTime(3000);
console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.');
});
// With real timers, we now wait for the polling interval to elapse.
console.log(
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
);
try {
// The polling interval is 3s, so we wait for a bit longer.
await waitFor(() => {
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
});
}, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
} catch (error) {
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
@@ -181,24 +188,21 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.');
await act(async () => {
console.log(`--- [TEST LOG] ---: 6. Advancing timers by 4000ms for the second poll...`);
vi.advanceTimersByTime(4000);
});
console.log(`--- [TEST LOG] ---: 7. Timers advanced. Now AWAITING completion message.`);
try {
console.log(
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
);
// Wait for the second poll to occur and the UI to update.
await waitFor(() => {
console.log(
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${mockedAiApiClient.getJobStatus.mock.calls.length}`,
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${
mockedAiApiClient.getJobStatus.mock.calls.length
}`,
);
expect(
screen.getByText('Processing complete! Redirecting to flyer 42...'),
).toBeInTheDocument();
});
}, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
} catch (error) {
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
@@ -208,12 +212,9 @@ describe('FlyerUploader', () => {
}
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
await act(async () => {
console.log(`--- [TEST LOG] ---: 10. Advancing timers by 2000ms for redirect...`);
vi.advanceTimersByTime(2000);
});
// Wait for the redirect timer (1.5s in component) to fire.
await act(() => new Promise((r) => setTimeout(r, 2000)));
console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
expect(onProcessingComplete).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
@@ -224,7 +225,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 +264,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' });
@@ -270,22 +279,16 @@ describe('FlyerUploader', () => {
// Wait for the first poll to complete and UI to update to "Working..."
await screen.findByText('Working...');
// Advance time to trigger the second poll
await act(async () => {
vi.advanceTimersByTime(3000);
});
// Wait for the failure UI
await screen.findByText(/Processing failed: Fatal Error/i);
await waitFor(() => expect(screen.getByText(/Processing failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
// Verify clearTimeout was called
expect(clearTimeoutSpy).toHaveBeenCalled();
// Verify no further polling occurs
const callsBefore = mockedAiApiClient.getJobStatus.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(10000);
});
// Wait for a duration longer than the polling interval
await act(() => new Promise((r) => setTimeout(r, 4000)));
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBefore);
clearTimeoutSpy.mockRestore();
@@ -340,7 +343,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 +456,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 +498,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

@@ -1,208 +1,62 @@
// src/features/flyer/FlyerUploader.tsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { uploadAndProcessFlyer, getJobStatus } from '../../services/aiApiClient';
import { generateFileChecksum } from '../../utils/checksum';
import { logger } from '../../services/logger.client';
import { ProcessingStatus } from './ProcessingStatus';
import type { ProcessingStage } from '../../types';
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
import { useFlyerUploader } from '../../hooks/useFlyerUploader';
interface FlyerUploaderProps {
onProcessingComplete: () => void;
}
export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComplete }) => {
const [processingState, setProcessingState] = useState<ProcessingState>('idle');
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [duplicateFlyerId, setDuplicateFlyerId] = useState<number | null>(null);
const navigate = useNavigate();
const pollingTimeoutRef = useRef<number | null>(null);
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
const [estimatedTime, setEstimatedTime] = useState(0);
const [currentFile, setCurrentFile] = useState<string | null>(null);
// DEBUG: Log component mount and unmount
useEffect(() => {
console.debug('[DEBUG] FlyerUploader: Component did mount.');
return () => {
console.debug('[DEBUG] FlyerUploader: Component will unmount.');
};
}, []);
// DEBUG: Log state changes
useEffect(() => {
console.debug(`[DEBUG] FlyerUploader: processingState changed to -> ${processingState}`);
}, [processingState]);
const {
processingState,
statusMessage,
errorMessage,
duplicateFlyerId,
processingStages,
estimatedTime,
currentFile,
flyerId,
upload,
resetUploaderState,
} = useFlyerUploader();
useEffect(() => {
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
}, [statusMessage]);
// Handle completion and navigation
useEffect(() => {
console.debug(`[DEBUG] Polling Effect Triggered: state=${processingState}, jobId=${jobId}`);
if (processingState !== 'polling' || !jobId) {
if (pollingTimeoutRef.current) {
console.debug(
`[DEBUG] Polling Effect: Clearing timeout ID ${pollingTimeoutRef.current} because state is not 'polling' or no jobId exists.`,
);
clearTimeout(pollingTimeoutRef.current);
}
return;
if (processingState === 'completed' && flyerId) {
onProcessingComplete();
// Small delay to show the "Complete" state before redirecting
const timer = setTimeout(() => {
navigate(`/flyers/${flyerId}`);
}, 1500);
return () => clearTimeout(timer);
}
const pollStatus = async () => {
console.debug(`[DEBUG] pollStatus(): Polling for jobId: ${jobId}`);
try {
const job = await getJobStatus(jobId); // Now returns parsed JSON directly
console.debug('[DEBUG] pollStatus(): Job status received:', job); // The rest of the logic remains the same
if (job.progress) {
setProcessingStages(job.progress.stages || []);
setEstimatedTime(job.progress.estimatedTimeRemaining || 0);
setStatusMessage(job.progress.message || null);
}
switch (job.state) {
case 'completed':
console.debug('[DEBUG] pollStatus(): Job state is "completed".');
const flyerId = job.returnValue?.flyerId;
if (flyerId) {
setStatusMessage(`Processing complete! Redirecting to flyer ${flyerId}...`);
setProcessingState('completed');
onProcessingComplete();
console.debug('[DEBUG] pollStatus(): Setting 1500ms timeout for redirect.');
setTimeout(() => {
console.debug(`[DEBUG] pollStatus(): Redirecting to /flyers/${flyerId}`);
navigate(`/flyers/${flyerId}`);
}, 1500);
} else {
throw new Error('Job completed but did not return a flyer ID.');
}
break;
case 'failed':
console.debug(
`[DEBUG] pollStatus(): Job state is "failed". Reason: ${job.failedReason}`,
);
// Explicitly clear any pending timeout to stop the polling loop immediately.
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
setErrorMessage(`Processing failed: ${job.failedReason || 'Unknown error'}`);
// Clear any stale "in-progress" messages to avoid user confusion.
setStatusMessage(null);
setProcessingState('error');
break;
case 'active':
case 'waiting':
default:
console.debug(
`[DEBUG] pollStatus(): Job state is "${job.state}". Setting timeout for next poll (3000ms).`,
);
pollingTimeoutRef.current = window.setTimeout(pollStatus, 3000);
console.debug(`[DEBUG] pollStatus(): Timeout ID ${pollingTimeoutRef.current} set.`);
break;
}
} catch (error) {
logger.error({ error }, 'Error during polling:');
setErrorMessage(
error instanceof Error ? error.message : 'An unexpected error occurred during polling.',
);
setProcessingState('error');
}
};
pollStatus();
return () => {
if (pollingTimeoutRef.current) {
console.debug(
`[DEBUG] Polling Effect Cleanup: Clearing timeout ID ${pollingTimeoutRef.current}`,
);
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
} else {
console.debug('[DEBUG] Polling Effect Cleanup: No active timeout to clear.');
}
};
}, [processingState, jobId, onProcessingComplete, navigate]);
const processFile = useCallback(async (file: File) => {
console.debug('[DEBUG] processFile(): Starting file processing for', file.name);
setProcessingState('uploading');
setErrorMessage(null);
setDuplicateFlyerId(null);
setCurrentFile(file.name);
try {
console.debug('[DEBUG] processFile(): Generating file checksum.');
const checksum = await generateFileChecksum(file);
setStatusMessage('Uploading file...');
console.debug(
`[DEBUG] processFile(): Checksum generated: ${checksum}. Calling uploadAndProcessFlyer.`,
);
// The API client now returns parsed JSON on success or throws a structured error on failure.
const { jobId: newJobId } = await uploadAndProcessFlyer(file, checksum);
console.debug(`[DEBUG] processFile(): Upload successful. Received jobId: ${newJobId}`);
setJobId(newJobId);
setProcessingState('polling');
} catch (error: any) {
// Handle the structured error thrown by the API client.
logger.error({ error }, 'An error occurred during file upload:');
// Handle 409 Conflict for duplicate flyers
if (error?.status === 409 && error.body?.flyerId) {
setErrorMessage(`This flyer has already been processed. You can view it here:`);
setDuplicateFlyerId(error.body.flyerId);
} else {
// Handle other errors (e.g., validation, server errors)
const message =
error?.body?.message || error?.message || 'An unexpected error occurred during upload.';
setErrorMessage(message);
}
setProcessingState('error');
}
}, []);
}, [processingState, flyerId, onProcessingComplete, navigate]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.debug('[DEBUG] handleFileChange(): File input changed.');
const file = event.target.files?.[0];
if (file) {
processFile(file);
upload(file);
}
event.target.value = '';
};
const resetUploaderState = useCallback(() => {
console.debug(
`[DEBUG] resetUploaderState(): User triggered reset. Previous jobId was: ${jobId}`,
);
setProcessingState('idle');
setJobId(null);
setErrorMessage(null);
setDuplicateFlyerId(null);
setCurrentFile(null);
setProcessingStages([]);
setEstimatedTime(0);
logger.info('Uploader state has been reset. Previous job ID was:', jobId);
}, [jobId]);
const onFilesDropped = useCallback(
(files: FileList) => {
console.debug('[DEBUG] onFilesDropped(): Files were dropped.');
if (files && files.length > 0) {
processFile(files[0]);
upload(files[0]);
}
},
[processFile],
[upload],
);
const isProcessing = processingState === 'uploading' || processingState === 'polling';
@@ -216,11 +70,6 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
? 'bg-brand-light/50 dark:bg-brand-dark/20'
: 'bg-gray-50/50 dark:bg-gray-800/20';
// If processing, show the detailed status component. Otherwise, show the uploader.
console.debug(
`[DEBUG] FlyerUploader: Rendering. State=${processingState}, Msg=${statusMessage}, Err=${!!errorMessage}`,
);
if (isProcessing || processingState === 'completed' || processingState === 'error') {
return (
<div className="max-w-4xl mx-auto">
@@ -230,19 +79,26 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
currentFile={currentFile}
/>
<div className="mt-4 text-center">
{/* Display the current status message to the user and the test runner */}
{statusMessage && (
{/* Display status message if not completed (completed has its own redirect logic) */}
{statusMessage && processingState !== 'completed' && (
<p className="text-gray-600 dark:text-gray-400 mt-2 italic animate-pulse">
{statusMessage}
</p>
)}
{processingState === 'completed' && (
<p className="text-green-600 dark:text-green-400 mt-2 font-bold">
Processing complete! Redirecting to flyer {flyerId}...
</p>
)}
{errorMessage && (
<div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md">
<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

@@ -0,0 +1,174 @@
// src/hooks/useAppInitialization.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { useAppInitialization } from './useAppInitialization';
import { useAuth } from './useAuth';
import { useModal } from './useModal';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock dependencies
vi.mock('./useAuth');
vi.mock('./useModal');
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return {
...actual,
useNavigate: vi.fn(),
};
});
vi.mock('../services/logger.client');
vi.mock('../config', () => ({
default: {
app: { version: '1.0.1' },
},
}));
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseModal = vi.mocked(useModal);
const mockedUseNavigate = vi.mocked(useNavigate);
const mockLogin = vi.fn().mockResolvedValue(undefined);
const mockNavigate = vi.fn();
const mockOpenModal = vi.fn();
// Wrapper with MemoryRouter is needed because the hook uses useLocation and useNavigate
const wrapper = ({
children,
initialEntries = ['/'],
}: {
children: React.ReactNode;
initialEntries?: string[];
}) => <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>;
describe('useAppInitialization Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseNavigate.mockReturnValue(mockNavigate);
mockedUseAuth.mockReturnValue({
userProfile: null,
login: mockLogin,
authStatus: 'SIGNED_OUT',
isLoading: false,
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseModal.mockReturnValue({
openModal: mockOpenModal,
closeModal: vi.fn(),
isModalOpen: vi.fn(),
});
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query) => ({
matches: false, // default to light mode
})),
writable: true,
configurable: true,
});
});
it('should call login when googleAuthToken is in URL', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/?googleAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
});
it('should call login when githubAuthToken is in URL', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/?githubAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
});
it('should call navigate to clean the URL after processing a token', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/some/path?googleAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
expect(mockNavigate).toHaveBeenCalledWith('/some/path', { replace: true });
});
it("should open \"What's New\" modal if version is new", () => {
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.0');
renderHook(() => useAppInitialization(), { wrapper });
expect(mockOpenModal).toHaveBeenCalledWith('whatsNew');
expect(window.localStorage.setItem).toHaveBeenCalledWith('lastSeenVersion', '1.0.1');
});
it("should not open \"What's New\" modal if version is the same", () => {
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.1');
renderHook(() => useAppInitialization(), { wrapper });
expect(mockOpenModal).not.toHaveBeenCalled();
});
it('should set dark mode from user profile', async () => {
mockedUseAuth.mockReturnValue({
...mockedUseAuth(),
userProfile: createMockUserProfile({ preferences: { darkMode: true } }),
});
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set dark mode from localStorage', async () => {
vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) =>
key === 'darkMode' ? 'true' : null,
);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set dark mode from system preference', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as any);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set unit system from user profile', async () => {
mockedUseAuth.mockReturnValue({
...mockedUseAuth(),
userProfile: createMockUserProfile({ preferences: { unitSystem: 'metric' } }),
});
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.unitSystem).toBe('metric');
});
});
it('should set unit system from localStorage', async () => {
vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) =>
key === 'unitSystem' ? 'metric' : null,
);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.unitSystem).toBe('metric');
});
});
});

View File

@@ -0,0 +1,88 @@
// src/hooks/useAppInitialization.ts
import { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './useAuth';
import { useModal } from './useModal';
import { logger } from '../services/logger.client';
import config from '../config';
export const useAppInitialization = () => {
const { userProfile, login } = useAuth();
const { openModal } = useModal();
const location = useLocation();
const navigate = useNavigate();
const [isDarkMode, setIsDarkMode] = useState(false);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
// Effect to handle the token from Google/GitHub OAuth redirect
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const googleToken = urlParams.get('googleAuthToken');
if (googleToken) {
logger.info('Received Google Auth token from URL. Authenticating...');
login(googleToken).catch((err) =>
logger.error('Failed to log in with Google token', { error: err }),
);
navigate(location.pathname, { replace: true });
}
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
login(githubToken).catch((err) => {
logger.error('Failed to log in with GitHub token', { error: err });
});
navigate(location.pathname, { replace: true });
}
}, [login, location.search, navigate, location.pathname]);
// Effect to handle "What's New" modal
useEffect(() => {
const appVersion = config.app.version;
if (appVersion) {
logger.info(`Application version: ${appVersion}`);
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
if (appVersion !== lastSeenVersion) {
openModal('whatsNew');
localStorage.setItem('lastSeenVersion', appVersion);
}
}
}, [openModal]);
// Effect to set initial theme based on user profile, local storage, or system preference
useEffect(() => {
let darkModeValue: boolean;
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
// Preference from DB
darkModeValue = userProfile.preferences.darkMode;
} else {
// Fallback to local storage or system preference
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
darkModeValue = savedMode !== null ? savedMode === 'true' : prefersDark;
}
setIsDarkMode(darkModeValue);
document.documentElement.classList.toggle('dark', darkModeValue);
// Also save to local storage if coming from profile, to persist on logout
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
localStorage.setItem('darkMode', String(userProfile.preferences.darkMode));
}
}, [userProfile]);
// Effect to set initial unit system based on user profile or local storage
useEffect(() => {
if (userProfile && userProfile.preferences?.unitSystem) {
setUnitSystem(userProfile.preferences.unitSystem);
localStorage.setItem('unitSystem', userProfile.preferences.unitSystem);
} else {
const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null;
if (savedSystem) {
setUnitSystem(savedSystem);
}
}
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
return { isDarkMode, unitSystem };
};

View File

@@ -6,24 +6,28 @@ import { useAuth } from './useAuth';
import { AuthProvider } from '../providers/AuthProvider';
import * as apiClient from '../services/apiClient';
import type { UserProfile } from '../types';
import * as tokenStorage from '../services/tokenStorage';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { logger } from '../services/logger.client';
// Mock the dependencies
vi.mock('../services/apiClient', () => ({
// Mock other functions if needed
getAuthenticatedUserProfile: vi.fn(),
}));
vi.mock('../services/tokenStorage');
// Mock the logger to see auth provider logs during test execution
// Mock the logger to spy on its methods
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn((...args) => console.log('[AUTH-INFO]', ...args)),
warn: vi.fn((...args) => console.warn('[AUTH-WARN]', ...args)),
error: vi.fn((...args) => console.error('[AUTH-ERROR]', ...args)),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedTokenStorage = vi.mocked(tokenStorage);
const mockProfile: UserProfile = createMockUserProfile({
full_name: 'Test User',
@@ -36,26 +40,9 @@ const mockProfile: UserProfile = createMockUserProfile({
const wrapper = ({ children }: { children: ReactNode }) => <AuthProvider>{children}</AuthProvider>;
describe('useAuth Hook and AuthProvider', () => {
// Mock localStorage
let storage: { [key: string]: string } = {};
const localStorageMock = {
getItem: vi.fn((key: string) => storage[key] || null),
setItem: vi.fn((key: string, value: string) => {
storage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete storage[key];
}),
clear: vi.fn(() => {
storage = {};
}),
};
beforeEach(() => {
// Reset mocks and storage before each test
vi.clearAllMocks();
storage = {};
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
});
afterEach(() => {
@@ -85,7 +72,8 @@ describe('useAuth Hook and AuthProvider', () => {
});
describe('Initial Auth Check (useEffect)', () => {
it('sets state to SIGNED_OUT if no token is found', async () => {
it('sets state to SIGNED_OUT if no token is found in storage', async () => {
mockedTokenStorage.getToken.mockReturnValue(null);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
@@ -97,7 +85,7 @@ describe('useAuth Hook and AuthProvider', () => {
});
it('sets state to AUTHENTICATED if a valid token is found', async () => {
localStorageMock.setItem('authToken', 'valid-token');
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
status: 200,
@@ -121,7 +109,7 @@ describe('useAuth Hook and AuthProvider', () => {
});
it('sets state to SIGNED_OUT and removes token if validation fails', async () => {
localStorageMock.setItem('authToken', 'invalid-token');
mockedTokenStorage.getToken.mockReturnValue('invalid-token');
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid token'));
const { result } = renderHook(() => useAuth(), { wrapper });
@@ -132,13 +120,40 @@ describe('useAuth Hook and AuthProvider', () => {
expect(result.current.authStatus).toBe('SIGNED_OUT');
expect(result.current.userProfile).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('authToken');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
});
});
it('sets state to SIGNED_OUT and removes token if profile fetch returns null after token validation', async () => {
mockedTokenStorage.getToken.mockReturnValue('valid-token');
// Mock getAuthenticatedUserProfile to return a 200 OK response with a null body
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(null), // Simulate API returning no profile data
} as unknown as Response);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.authStatus).toBe('SIGNED_OUT');
expect(result.current.userProfile).toBeNull();
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.',
);
});
describe('login function', () => {
// This was the failing test
it('sets token, fetches profile, and updates state on successful login', async () => {
// --- FIX ---
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
mockedTokenStorage.getToken.mockReturnValue(null);
// --- FIX ---
// The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object,
// as this is the return type of the actual function. The `useApi` hook then
@@ -172,7 +187,7 @@ describe('useAuth Hook and AuthProvider', () => {
console.log('[TEST-DEBUG] State immediately after login `act` call:', result.current);
// 3. Assertions
expect(localStorageMock.setItem).toHaveBeenCalledWith('authToken', 'new-valid-token');
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('new-valid-token');
// 4. We must wait for the state update inside the hook to propagate
await waitFor(() => {
@@ -202,16 +217,44 @@ describe('useAuth Hook and AuthProvider', () => {
});
// Should trigger the logout flow
expect(localStorageMock.removeItem).toHaveBeenCalledWith('authToken');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(result.current.authStatus).toBe('SIGNED_OUT'); // This was a duplicate, fixed.
expect(result.current.userProfile).toBeNull();
});
it('logs out and throws an error if profile fetch returns null after login (no profileData)', async () => {
// Simulate successful token setting, but subsequent profile fetch returns null
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(null), // Simulate API returning no profile data
} as unknown as Response);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
// Call login without profileData, forcing a profile fetch
await act(async () => {
await expect(result.current.login('new-token-no-profile-data')).rejects.toThrow(
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.',
);
});
// Should trigger the logout flow
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(result.current.authStatus).toBe('SIGNED_OUT');
expect(result.current.userProfile).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.any(String), // The error message
expect.objectContaining({ error: 'Received null or undefined profile from API.' }),
);
});
});
describe('logout function', () => {
it('removes token and resets auth state', async () => {
// Start in a logged-in state
localStorageMock.setItem('authToken', 'valid-token');
// Start in a logged-in state by mocking the token storage
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
status: 200,
@@ -227,16 +270,15 @@ describe('useAuth Hook and AuthProvider', () => {
result.current.logout();
});
expect(localStorageMock.removeItem).toHaveBeenCalledWith('authToken');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(result.current.authStatus).toBe('SIGNED_OUT');
expect(result.current.userProfile).toBeNull();
});
});
describe('updateProfile function', () => {
it('merges new data into the existing profile state', async () => {
// Start in a logged-in state
localStorageMock.setItem('authToken', 'valid-token');
it('merges new data into the existing profile state', async () => { // Start in a logged-in state
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
status: 200,
@@ -264,6 +306,10 @@ describe('useAuth Hook and AuthProvider', () => {
});
it('should not update profile if user is not authenticated', async () => {
// --- FIX ---
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
mockedTokenStorage.getToken.mockReturnValue(null);
const { result } = renderHook(() => useAuth(), { wrapper });
// Wait for initial check to complete

View File

@@ -0,0 +1,136 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi, beforeEach } from 'vitest';
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');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
const mockedAiApiClient = vi.mocked(aiApiClient);
const mockedChecksumUtil = vi.mocked(checksumUtil);
// Helper to wrap the hook with QueryClientProvider, which is required by react-query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries for tests for predictable behavior
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useFlyerUploader Hook with React Query', () => {
beforeEach(() => {
vi.resetAllMocks();
mockedChecksumUtil.generateFileChecksum.mockResolvedValue('mock-checksum');
});
it('should handle a successful upload and polling flow', async () => {
// Arrange
const mockJobId = 'job-123';
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: mockJobId });
mockedAiApiClient.getJobStatus
.mockResolvedValueOnce({
// First poll: active
id: mockJobId,
state: 'active',
progress: { message: 'Processing...' },
returnValue: null,
failedReason: null,
} as aiApiClient.JobStatus)
.mockResolvedValueOnce({
// Second poll: completed
id: mockJobId,
state: 'completed',
progress: { message: 'Complete!' },
returnValue: { flyerId: 777 },
failedReason: null,
} as aiApiClient.JobStatus);
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
const mockFile = new File([''], 'flyer.pdf');
// Act
await act(async () => {
result.current.upload(mockFile);
});
// Assert initial upload state
await waitFor(() => expect(result.current.processingState).toBe('polling'));
expect(result.current.jobId).toBe(mockJobId);
// Assert polling state
await waitFor(() => expect(result.current.statusMessage).toBe('Processing...'));
// Assert completed state
await waitFor(() => expect(result.current.processingState).toBe('completed'), { timeout: 5000 });
expect(result.current.flyerId).toBe(777);
});
it('should handle an upload failure', async () => {
// Arrange
const uploadError = {
status: 409,
body: { message: 'Duplicate flyer detected.', flyerId: 99 },
};
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(uploadError);
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
const mockFile = new File([''], 'flyer.pdf');
// Act
await act(async () => {
result.current.upload(mockFile);
});
// Assert error state
await waitFor(() => expect(result.current.processingState).toBe('error'));
expect(result.current.errorMessage).toBe('Duplicate flyer detected.');
expect(result.current.duplicateFlyerId).toBe(99);
expect(result.current.jobId).toBeNull();
});
it('should handle a job failure during polling', async () => {
// Arrange
const mockJobId = 'job-456';
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: mockJobId });
// Mock getJobStatus to throw a JobFailedError
mockedAiApiClient.getJobStatus.mockRejectedValue(
new JobFailedError('AI validation failed.', 'AI_VALIDATION_FAILED'),
);
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
const mockFile = new File([''], 'flyer.pdf');
// Act
await act(async () => {
result.current.upload(mockFile);
});
// Assert error state after polling fails
await waitFor(() => expect(result.current.processingState).toBe('error'));
expect(result.current.errorMessage).toBe('Polling failed: AI validation failed.');
expect(result.current.flyerId).toBeNull();
});
});

View File

@@ -0,0 +1,127 @@
// src/hooks/useFlyerUploader.ts
// src/hooks/useFlyerUploader.ts
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
uploadAndProcessFlyer,
getJobStatus,
type JobStatus,
JobFailedError,
} from '../services/aiApiClient';
import { logger } from '../services/logger.client';
import { generateFileChecksum } from '../utils/checksum';
import type { ProcessingStage } from '../types';
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
export const useFlyerUploader = () => {
const queryClient = useQueryClient();
const [jobId, setJobId] = useState<string | null>(null);
const [currentFile, setCurrentFile] = useState<string | null>(null);
// Mutation for the initial file upload
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
setCurrentFile(file.name);
const checksum = await generateFileChecksum(file);
return uploadAndProcessFlyer(file, checksum);
},
onSuccess: (data) => {
// When upload is successful, we get a jobId and can start polling.
setJobId(data.jobId);
},
// onError is handled automatically by react-query and exposed in `uploadMutation.error`
});
// Query for polling the job status
const { data: jobStatus, error: pollError } = useQuery({
queryKey: ['jobStatus', jobId],
queryFn: () => {
if (!jobId) throw new Error('No job ID to poll');
return getJobStatus(jobId);
},
// Only run this query if there is a jobId
enabled: !!jobId,
// Polling logic: react-query handles the interval
refetchInterval: (query) => {
const data = query.state.data;
// Stop polling if the job is completed or has failed
if (data?.state === 'completed' || data?.state === 'failed') {
return false;
}
// Otherwise, poll every 3 seconds
return 3000;
},
refetchOnWindowFocus: false, // No need to refetch on focus, interval is enough
// If a poll fails (e.g., network error), don't retry automatically.
// The user can see the error and choose to retry manually if we build that feature.
retry: false,
});
const upload = useCallback(
(file: File) => {
// Reset previous state before a new upload
setJobId(null);
setCurrentFile(null);
queryClient.removeQueries({ queryKey: ['jobStatus'] });
uploadMutation.mutate(file);
},
[uploadMutation, queryClient],
);
const resetUploaderState = useCallback(() => {
setJobId(null);
setCurrentFile(null);
uploadMutation.reset();
queryClient.removeQueries({ queryKey: ['jobStatus'] });
}, [uploadMutation, queryClient]);
// Consolidate state for the UI from the react-query hooks
const processingState = ((): ProcessingState => {
if (uploadMutation.isPending) return 'uploading';
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
return 'polling';
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';
})();
const getErrorMessage = () => {
const uploadError = uploadMutation.error as any;
if (uploadMutation.isError) {
return uploadError?.body?.message || uploadError?.message || 'Upload failed.';
}
if (pollError) return `Polling failed: ${pollError.message}`;
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;
};
const errorMessage = getErrorMessage();
const duplicateFlyerId = (uploadMutation.error as any)?.body?.flyerId ?? null;
const flyerId = jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId : null;
return {
processingState,
statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
errorMessage,
duplicateFlyerId,
processingStages: jobStatus?.progress?.stages || [],
estimatedTime: jobStatus?.progress?.estimatedTimeRemaining || 0,
currentFile,
flyerId,
upload,
resetUploaderState,
jobId,
};
};

View File

@@ -79,7 +79,7 @@ vi.mock('../pages/admin/ActivityLog', async () => {
),
};
});
vi.mock('../pages/admin/components/AnonymousUserBanner', () => ({
vi.mock('../components/AnonymousUserBanner', () => ({
AnonymousUserBanner: () => <div data-testid="anonymous-banner" />,
}));
vi.mock('../components/ErrorDisplay', () => ({

View File

@@ -16,7 +16,7 @@ import { PriceChart } from '../features/charts/PriceChart';
import { PriceHistoryChart } from '../features/charts/PriceHistoryChart';
import Leaderboard from '../components/Leaderboard';
import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog';
import { AnonymousUserBanner } from '../pages/admin/components/AnonymousUserBanner';
import { AnonymousUserBanner } from '../components/AnonymousUserBanner';
import { ErrorDisplay } from '../components/ErrorDisplay';
export interface MainLayoutProps {

View File

@@ -0,0 +1,74 @@
// src/middleware/multer.middleware.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
const mocks = vi.hoisted(() => ({
mkdir: vi.fn(),
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
// 2. Mock node:fs/promises.
// We mock the default export because that's how it's imported in the source file.
vi.mock('node:fs/promises', () => ({
default: {
mkdir: mocks.mkdir,
},
}));
// 3. Mock the logger service.
vi.mock('../services/logger.server', () => ({
logger: mocks.logger,
}));
// 4. Mock multer to prevent it from doing anything during import.
vi.mock('multer', () => ({
default: vi.fn(() => ({
single: vi.fn(),
array: vi.fn(),
})),
diskStorage: vi.fn(),
}));
describe('Multer Middleware Directory Creation', () => {
beforeEach(() => {
// Critical: Reset modules to ensure the top-level IIFE runs again for each test.
vi.resetModules();
vi.clearAllMocks();
});
it('should attempt to create directories on module load and log success', async () => {
// Arrange
mocks.mkdir.mockResolvedValue(undefined);
// Act: Dynamic import triggers the top-level code execution
await import('./multer.middleware');
// Assert
// It should try to create both the flyer storage and avatar storage paths
expect(mocks.mkdir).toHaveBeenCalledTimes(2);
expect(mocks.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
expect(mocks.logger.info).toHaveBeenCalledWith('Ensured multer storage directories exist.');
expect(mocks.logger.error).not.toHaveBeenCalled();
});
it('should log an error if directory creation fails', async () => {
// Arrange
const error = new Error('Permission denied');
mocks.mkdir.mockRejectedValue(error);
// Act
await import('./multer.middleware');
// Assert
expect(mocks.mkdir).toHaveBeenCalled();
expect(mocks.logger.error).toHaveBeenCalledWith(
{ error },
'Failed to create multer storage directories on startup.',
);
});
});

View File

@@ -0,0 +1,123 @@
// src/middleware/multer.middleware.ts
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';
import { Request, Response, NextFunction } from 'express';
import { UserProfile } from '../types';
import { sanitizeFilename } from '../utils/stringUtils';
import { logger } from '../services/logger.server';
export const flyerStoragePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', 'avatars');
// Ensure directories exist at startup
(async () => {
try {
await fs.mkdir(flyerStoragePath, { recursive: true });
await fs.mkdir(avatarStoragePath, { recursive: true });
logger.info('Ensured multer storage directories exist.');
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ error: err }, 'Failed to create multer storage directories on startup.');
}
})();
type StorageType = 'flyer' | 'avatar';
const getStorageConfig = (type: StorageType) => {
switch (type) {
case 'avatar':
return multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarStoragePath),
filename: (req, file, cb) => {
const user = req.user as UserProfile | undefined;
if (!user) {
// This should ideally not happen if auth middleware runs first.
return cb(new Error('User not authenticated for avatar upload'), '');
}
if (process.env.NODE_ENV === 'test') {
// Use a predictable filename for test avatars for easy cleanup.
return cb(null, `test-avatar${path.extname(file.originalname) || '.png'}`);
}
const uniqueSuffix = `${user.user.user_id}-${Date.now()}${path.extname(
file.originalname,
)}`;
cb(null, uniqueSuffix);
},
});
case 'flyer':
default:
return multer.diskStorage({
destination: (req, file, cb) => cb(null, flyerStoragePath),
filename: (req, file, cb) => {
if (process.env.NODE_ENV === 'test') {
// Use a predictable filename for test flyers for easy cleanup.
const ext = path.extname(file.originalname);
return cb(null, `${file.fieldname}-test-flyer-image${ext || '.jpg'}`);
}
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const sanitizedOriginalName = sanitizeFilename(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}-${sanitizedOriginalName}`);
},
});
}
};
const imageFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error that can be caught by a middleware.
const err = new Error('Only image files are allowed!');
cb(err);
}
};
interface MulterOptions {
storageType: StorageType;
fileSize?: number;
fileFilter?: 'image';
}
/**
* Creates a configured multer instance for file uploads.
* @param options - Configuration for storage type, file size, and file filter.
* @returns A multer instance.
*/
export const createUploadMiddleware = (options: MulterOptions) => {
const multerOptions: multer.Options = {
storage: getStorageConfig(options.storageType),
};
if (options.fileSize) {
multerOptions.limits = { fileSize: options.fileSize };
}
if (options.fileFilter === 'image') {
multerOptions.fileFilter = imageFileFilter;
}
return multer(multerOptions);
};
/**
* A general error handler for multer. Place this after all routes using multer in your router file.
* It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
*/
export const handleMulterError = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
if (err instanceof multer.MulterError) {
// A Multer error occurred when uploading (e.g., file too large).
return res.status(400).json({ message: `File upload error: ${err.message}` });
} else if (err && err.message === 'Only image files are allowed!') {
// A custom error from our fileFilter.
return res.status(400).json({ message: err.message });
}
// If it's not a multer error, pass it on.
next(err);
};

View File

@@ -4,7 +4,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger.client';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { PasswordInput } from './admin/components/PasswordInput';
import { PasswordInput } from '../components/PasswordInput';
export const ResetPasswordPage: React.FC = () => {
const { token } = useParams<{ token: string }>();

View File

@@ -1,6 +1,6 @@
// src/pages/admin/components/AuthView.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AuthView } from './AuthView';
import * as apiClient from '../../../services/apiClient';
@@ -12,6 +12,11 @@ const mockedApiClient = vi.mocked(apiClient, true);
const mockOnClose = vi.fn();
const mockOnLoginSuccess = vi.fn();
vi.mock('../../../components/PasswordInput', () => ({
// Mock the moved component
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
}));
const defaultProps = {
onClose: mockOnClose,
onLoginSuccess: mockOnLoginSuccess,
@@ -353,4 +358,27 @@ describe('AuthView', () => {
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument();
});
});
it('should show loading state during registration submission', async () => {
// Mock a promise that doesn't resolve immediately
(mockedApiClient.registerUser as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
// Switch to registration view
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'test@example.com' },
});
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'password' } });
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
const submitButton = screen.getByTestId('auth-form').querySelector('button[type="submit"]');
expect(submitButton).toBeInTheDocument();
expect(submitButton).toBeDisabled();
// Verify the text 'Register' is gone from any button
expect(screen.queryByRole('button', { name: 'Register' })).not.toBeInTheDocument();
});
});
});

View File

@@ -7,7 +7,7 @@ import { notifySuccess } from '../../../services/notificationService';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { PasswordInput } from './PasswordInput';
import { PasswordInput } from '../../../components/PasswordInput';
interface AuthResponse {
userprofile: UserProfile;

View File

@@ -1,7 +1,7 @@
// src/pages/admin/components/ProfileManager.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock, test } from 'vitest';
import { ProfileManager } from './ProfileManager';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
@@ -16,6 +16,11 @@ import {
// Unmock the component to test the real implementation
vi.unmock('./ProfileManager');
vi.mock('../../../components/PasswordInput', () => ({
// Mock the moved component
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
}));
const mockedApiClient = vi.mocked(apiClient, true);
vi.mock('../../../services/notificationService');
@@ -242,6 +247,17 @@ describe('ProfileManager', () => {
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
});
it('should close the modal when clicking the backdrop', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
// The backdrop is the element with role="dialog"
const backdrop = screen.getByRole('dialog');
fireEvent.click(backdrop);
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should reset state when the modal is closed and reopened', async () => {
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User'));
@@ -308,6 +324,41 @@ describe('ProfileManager', () => {
});
});
it('should handle partial success when saving profile and address', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'warn');
// Mock profile update to succeed
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({ ...authenticatedProfile, full_name: 'New Name' })),
);
// Mock address update to fail (useApi will return null)
mockedApiClient.updateUserAddress.mockRejectedValue(new Error('Address update failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Change both profile and address data
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
// The useApi hook for the failed call will show its own error
expect(notifyError).toHaveBeenCalledWith('Address update failed');
// The profile update should still go through
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ full_name: 'New Name' }),
);
// The specific warning for partial failure should be logged
expect(loggerSpy).toHaveBeenCalledWith(
'[handleProfileSave] One or more operations failed. The useApi hook should have shown an error. The modal will remain open.',
);
// The modal should remain open and no global success message shown
expect(mockOnClose).not.toHaveBeenCalled();
expect(notifySuccess).not.toHaveBeenCalledWith('Profile updated successfully!');
});
});
it('should handle unexpected critical error during profile save', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'error');
mockedApiClient.updateUserProfile.mockRejectedValue(new Error('Catastrophic failure'));
@@ -324,6 +375,31 @@ describe('ProfileManager', () => {
});
});
it('should handle unexpected Promise.allSettled rejection during save', async () => {
const allSettledSpy = vi
.spyOn(Promise, 'allSettled')
.mockRejectedValueOnce(new Error('AllSettled failed'));
const loggerSpy = vi.spyOn(logger.logger, 'error');
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(loggerSpy).toHaveBeenCalledWith(
{ err: new Error('AllSettled failed') },
"[CRITICAL] An unexpected error was caught directly in handleProfileSave's catch block.",
);
expect(notifyError).toHaveBeenCalledWith(
'An unexpected critical error occurred: AllSettled failed',
);
});
allSettledSpy.mockRestore();
});
it('should show map view when address has coordinates', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => {
@@ -365,51 +441,52 @@ describe('ProfileManager', () => {
});
});
it('should automatically geocode address after user stops typing', async () => {
it('should automatically geocode address after user stops typing (using fake timers)', async () => {
// Use fake timers for the entire test to control the debounce.
vi.useFakeTimers();
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)),
);
console.log('[TEST LOG] Rendering for automatic geocode test (Real Timers + Wait)');
render(<ProfileManager {...defaultAuthenticatedProps} />);
console.log('[TEST LOG] Waiting for initial address load...');
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
console.log('[TEST LOG] Initial address loaded. Changing city...');
// Wait for initial async address load to complete by flushing promises.
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
// Change address, geocode should not be called immediately
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
console.log('[TEST LOG] Waiting 1600ms for debounce...');
// Wait for debounce (1500ms) + buffer using real timers to avoid freeze
// Advance timers to fire the debounce and resolve the subsequent geocode promise.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1600));
await vi.runAllTimersAsync();
});
console.log('[TEST LOG] Wait complete. Checking results.');
await waitFor(() => {
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
expect.stringContaining('NewCity'),
expect.anything(),
);
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
// Now check the final result.
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
expect.stringContaining('NewCity'),
expect.anything(),
);
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
});
it('should not geocode if address already has coordinates', async () => {
console.log('[TEST LOG] Rendering for no-geocode test (Real Timers + Wait)');
it('should not geocode if address already has coordinates (using fake timers)', async () => {
// Use real timers for the initial async render and data fetch
vi.useRealTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />);
console.log('[TEST LOG] Waiting for initial address load...');
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
console.log(
'[TEST LOG] Initial address loaded. Waiting 1600ms to ensure no geocode triggers...',
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1600));
// Switch to fake timers to control the debounce check
vi.useFakeTimers();
// Advance timers past the debounce threshold. Nothing should happen.
act(() => {
vi.advanceTimersByTime(1600);
});
console.log('[TEST LOG] Wait complete. Verifying no geocode call.');
@@ -434,6 +511,29 @@ describe('ProfileManager', () => {
});
});
it('should switch between all tabs correctly', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
// Initial state: Profile tab
expect(screen.getByLabelText('Profile Form')).toBeInTheDocument();
// Switch to Security
fireEvent.click(screen.getByRole('button', { name: /security/i }));
expect(await screen.findByLabelText('New Password')).toBeInTheDocument();
// Switch to Data & Privacy
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
expect(await screen.findByRole('heading', { name: /export your data/i })).toBeInTheDocument();
// Switch to Preferences
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
expect(await screen.findByRole('heading', { name: /theme/i })).toBeInTheDocument();
// Switch back to Profile
fireEvent.click(screen.getByRole('button', { name: /^profile$/i }));
expect(await screen.findByLabelText('Profile Form')).toBeInTheDocument();
});
it('should show an error if password is too short', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i }));
@@ -442,7 +542,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'short' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Password must be at least 6 characters long.');
@@ -456,7 +556,7 @@ describe('ProfileManager', () => {
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), {
fireEvent.change(screen.getByTestId('password-input'), {
target: { value: 'password' },
});
fireEvent.submit(screen.getByTestId('delete-account-form'));
@@ -593,7 +693,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'newpassword123' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith(
@@ -614,7 +714,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'mismatch' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Passwords do not match.');
@@ -641,9 +741,10 @@ describe('ProfileManager', () => {
});
it('should handle account deletion flow', async () => {
// Use spy instead of fake timers to avoid blocking waitFor during async API calls
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
const { unmount } = render(<ProfileManager {...defaultAuthenticatedProps} />);
// Use fake timers to control the setTimeout call for the entire test.
vi.useFakeTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
@@ -654,39 +755,28 @@ describe('ProfileManager', () => {
).toBeInTheDocument();
// Fill password and submit to open modal
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), {
fireEvent.change(screen.getByTestId('password-input'), {
target: { value: 'correctpassword' },
});
fireEvent.submit(screen.getByTestId('delete-account-form'));
// Confirm in the modal
const confirmButton = await screen.findByRole('button', { name: /yes, delete my account/i });
// Use getByRole since the modal appears synchronously after the form submit.
const confirmButton = screen.getByRole('button', { name: /yes, delete my account/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalledWith(
'correctpassword',
expect.objectContaining({ signal: expect.anything() }),
);
expect(notifySuccess).toHaveBeenCalledWith(
'Account deleted successfully. You will be logged out shortly.',
);
});
// Verify setTimeout was called with 3000ms
const deletionTimeoutCall = setTimeoutSpy.mock.calls.find((call) => call[1] === 3000);
expect(deletionTimeoutCall).toBeDefined();
// Manually trigger the callback to verify cleanup
act(() => {
if (deletionTimeoutCall) (deletionTimeoutCall[0] as Function)();
// The async deleteAccount call is now pending. We need to flush promises
// and then advance the timers to run the subsequent setTimeout.
// `runAllTimersAsync` will resolve pending promises and run timers recursively.
await act(async () => {
await vi.runAllTimersAsync();
});
// Now that all timers and promises have been flushed, we can check the final state.
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalled();
expect(notifySuccess).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnSignOut).toHaveBeenCalled();
unmount();
setTimeoutSpy.mockRestore();
});
it('should allow toggling dark mode', async () => {

View File

@@ -9,8 +9,8 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { ConfirmationModal } from '../../../components/ConfirmationModal';
import { PasswordInput } from './PasswordInput';
import { ConfirmationModal } from '../../../components/ConfirmationModal'; // This path is correct
import { PasswordInput } from '../../../components/PasswordInput';
import { MapView } from '../../../components/MapView';
import type { AuthStatus } from '../../../hooks/useAuth';
import { AuthView } from './AuthView';

View File

@@ -4,6 +4,7 @@ import { AuthContext, AuthContextType } from '../contexts/AuthContext';
import type { UserProfile } from '../types';
import * as apiClient from '../services/apiClient';
import { useApi } from '../hooks/useApi';
import { getToken, setToken, removeToken } from '../services/tokenStorage';
import { logger } from '../services/logger.client';
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
@@ -27,7 +28,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logger.info('[AuthProvider-Effect] Starting initial authentication check.');
const checkAuthToken = async () => {
const token = localStorage.getItem('authToken');
const token = getToken();
if (token) {
logger.info('[AuthProvider-Effect] Found auth token. Validating...');
try {
@@ -41,7 +42,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logger.warn(
'[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.',
);
localStorage.removeItem('authToken');
removeToken();
setUserProfile(null);
setAuthStatus('SIGNED_OUT');
}
@@ -49,7 +50,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// This catch block is now primarily for unexpected errors, as useApi handles API errors.
logger.warn('Auth token validation failed. Clearing token.', { error: e });
if (isMounted) {
localStorage.removeItem('authToken');
removeToken();
setUserProfile(null);
setAuthStatus('SIGNED_OUT');
}
@@ -79,7 +80,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const logout = useCallback(() => {
logger.info('[AuthProvider-Logout] Clearing user data and auth token.');
localStorage.removeItem('authToken');
removeToken();
setUserProfile(null);
setAuthStatus('SIGNED_OUT');
}, []);
@@ -87,7 +88,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const login = useCallback(
async (token: string, profileData?: UserProfile) => {
logger.info(`[AuthProvider-Login] Attempting login.`);
localStorage.setItem('authToken', token);
setToken(token);
if (profileData) {
// If profile is provided (e.g., from credential login), use it directly.

View File

@@ -12,6 +12,7 @@ import {
} from '../tests/utils/mockFactories';
import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's a type/class not a module with side effects.
import fs from 'node:fs/promises';
import { createTestApp } from '../tests/utils/createTestApp';
// Mock the file upload middleware to allow testing the controller's internal check
@@ -243,7 +244,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body.message).toBe('Brand logo updated successfully.');
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
brandId,
expect.stringContaining('/assets/'),
expect.stringContaining('/flyer-images/'),
expect.anything(),
);
});
@@ -265,6 +266,22 @@ describe('Admin Content Management Routes (/api/admin)', () => {
);
});
it('should clean up the uploaded file if updating the brand logo fails', async () => {
const brandId = 55;
const dbError = new Error('DB Connection Failed');
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(dbError);
const response = await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(500);
// Verify that the cleanup function was called via the mocked fs module
expect(fs.unlink).toHaveBeenCalledTimes(1);
// The filename is predictable because of the multer config in admin.routes.ts
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('logoImage-'));
});
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
const response = await supertest(app)
.post('/api/admin/brands/abc/logo')

View File

@@ -9,6 +9,10 @@ import * as db from '../services/db/index.db';
import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
@@ -41,6 +45,20 @@ import {
optionalNumeric,
} from '../utils/zodUtils';
import { logger } from '../services/logger.server';
import fs from 'node:fs/promises';
/**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded logo file.');
}
};
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -88,19 +106,7 @@ const jobRetrySchema = z.object({
const router = Router();
// --- Multer Configuration for File Uploads ---
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
},
});
const upload = multer({ storage: storage });
const upload = createUploadMiddleware({ storageType: 'flyer' });
// --- Bull Board (Job Queue UI) Setup ---
const serverAdapter = new ExpressAdapter();
@@ -254,12 +260,16 @@ router.post(
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
// The storage path is 'flyer-images', so the URL should reflect that for consistency.
const logoUrl = `/flyer-images/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
await cleanupUploadedFile(req.file);
logger.error({ error }, 'Error updating brand logo');
next(error);
}
@@ -680,4 +690,7 @@ router.post(
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -83,36 +83,6 @@ describe('AI Routes (/api/ai)', () => {
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
describe('Module-level error handling', () => {
it('should log an error if storage path creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => {
const mockFs = {
...fs,
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
};
return { ...mockFs, default: mockFs };
});
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call
await import('./ai.routes');
// Assert
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
vi.doUnmock('node:fs'); // Cleanup
});
});
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
describe('Diagnostic Middleware Error Handling', () => {
it('should log an error if logger.debug throws an object with a message property', async () => {
@@ -285,6 +255,21 @@ describe('AI Routes (/api/ai)', () => {
'123 Pacific St, Anytown, BC, V8T 1A1, CA',
);
});
it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => {
// Spy on the unlink function to ensure it's called on error
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.attach('flyerFile', imagePath); // No checksum field, will cause validation to throw
expect(response.status).toBe(400);
// The validation error is now caught inside the route handler, which then calls cleanup.
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
});
describe('GET /jobs/:jobId/status', () => {
@@ -559,6 +544,51 @@ describe('AI Routes (/api/ai)', () => {
});
});
describe('POST /flyers/process (Legacy Error Handling)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
it('should handle malformed JSON in data field and return 400', async () => {
const malformedDataString = '{"checksum":'; // Invalid JSON
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', malformedDataString)
.attach('flyerImage', imagePath);
// The outer catch block should be hit, leading to empty parsed data.
// The handler then fails the checksum validation.
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
// It should log the critical error during parsing.
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(Error) }),
'[API /ai/flyers/process] Unexpected error while parsing request body',
);
});
it('should return 400 if checksum is missing from legacy payload', async () => {
const payloadWithoutChecksum = {
originalFileName: 'flyer.jpg',
extractedData: { store_name: 'Test Store', items: [] },
};
// Spy on fs.promises.unlink to verify file cleanup
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(payloadWithoutChecksum))
.attach('flyerImage', imagePath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
// Ensure the uploaded file is cleaned up
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
});
describe('POST /check-flyer', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
it('should return 400 if no image is provided', async () => {
@@ -828,6 +858,39 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.message).toBe('Maps API key invalid');
});
it('POST /deep-dive should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Deep dive logging failed');
});
const response = await supertest(app)
.post('/api/ai/deep-dive')
.send({ items: [{ name: 'test' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Deep dive logging failed');
});
it('POST /search-web should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Search web logging failed');
});
const response = await supertest(app)
.post('/api/ai/search-web')
.send({ query: 'test query' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Search web logging failed');
});
it('POST /compare-prices should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Compare prices logging failed');
});
const response = await supertest(app)
.post('/api/ai/compare-prices')
.send({ items: [{ name: 'Milk' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Compare prices logging failed');
});
it('POST /quick-insights should return 400 if items are missing', async () => {
const response = await supertest(app).post('/api/ai/quick-insights').send({});
expect(response.status).toBe(400);

View File

@@ -1,6 +1,5 @@
// src/routes/ai.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'node:fs';
import { z } from 'zod';
@@ -9,8 +8,11 @@ import { optionalAuth } from './passport.routes';
import * as db from '../services/db/index.db';
import { createFlyerAndItems } from '../services/db/flyer.db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { sanitizeFilename } from '../utils/stringUtils';
import { logger } from '../services/logger.server';
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
import { flyerQueue } from '../services/queueService.server';
@@ -59,6 +61,13 @@ const cleanupUploadedFile = async (file?: Express.Multer.File) => {
}
};
const cleanupUploadedFiles = async (files?: Express.Multer.File[]) => {
if (!files || !Array.isArray(files)) return;
// Use Promise.all to run cleanups in parallel for efficiency,
// as cleanupUploadedFile is designed to not throw errors.
await Promise.all(files.map((file) => cleanupUploadedFile(file)));
};
const cropAreaObjectSchema = z.object({
x: z.number(),
y: z.number(),
@@ -87,7 +96,6 @@ const rescanAreaSchema = z.object({
})
.pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], {
// This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'.",
}),
}),
@@ -148,40 +156,7 @@ const searchWebSchema = z.object({
body: z.object({ query: requiredString('A search query is required.') }),
});
// --- Multer Configuration for File Uploads ---
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// Ensure the storage path exists at startup so multer can write files there.
try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error(
{ error: errMsg(err) },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// Sanitize the original filename to remove spaces and special characters
return cb(
null,
file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname),
);
}
},
});
const uploadToDisk = multer({ storage: diskStorage });
const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {
@@ -207,15 +182,19 @@ router.post(
'/upload-and-process',
optionalAuth,
uploadToDisk.single('flyerFile'),
validateRequest(uploadAndProcessSchema),
// Validation is now handled inside the route to ensure file cleanup on failure.
// validateRequest(uploadAndProcessSchema),
async (req, res, next: NextFunction) => {
try {
// Manually validate the request body. This will throw if validation fails.
uploadAndProcessSchema.parse({ body: req.body });
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
}
logger.debug(
{ filename: req.file.originalname, size: req.file.size, checksum: req.body.checksum },
{ filename: req.file.originalname, size: req.file.size, checksum: req.body?.checksum },
'Handling /upload-and-process',
);
@@ -267,6 +246,9 @@ router.post(
jobId: job.id,
});
} catch (error) {
// If any error occurs (including validation), ensure the uploaded file is cleaned up.
await cleanupUploadedFile(req.file);
// Pass the error to the global error handler.
next(error);
}
},
@@ -516,6 +498,8 @@ router.post(
res.status(200).json({ is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
@@ -533,6 +517,8 @@ router.post(
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
@@ -550,6 +536,8 @@ router.post(
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFiles(req.files as Express.Multer.File[]);
}
},
);
@@ -697,8 +685,13 @@ router.post(
res.status(200).json(result);
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -53,7 +53,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
* This is important for features like file uploads.
*/
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import * as bcrypt from 'bcrypt';
import fs from 'node:fs/promises';
import {
createMockUserProfile,
createMockMasterGroceryItem,
@@ -148,8 +149,8 @@ describe('User Routes (/api/users)', () => {
// Assert
expect(logger.error).toHaveBeenCalledWith(
{ err: mkdirError },
'Failed to create avatar upload directory',
{ error: mkdirError },
'Failed to create multer storage directories on startup.',
);
vi.doUnmock('node:fs/promises'); // Clean up
});
@@ -1135,6 +1136,27 @@ describe('User Routes (/api/users)', () => {
expect(response.body.message).toBe('No avatar file uploaded.');
});
it('should clean up the uploaded file if updating the profile fails', async () => {
// Spy on the unlink function to ensure it's called on error
const unlinkSpy = vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
const dbError = new Error('DB Connection Failed');
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(500);
// Verify that the cleanup function was called
expect(unlinkSpy).toHaveBeenCalledTimes(1);
// The filename is predictable because of the multer config in user.routes.ts
expect(unlinkSpy).toHaveBeenCalledWith(expect.stringContaining('test-avatar.png'));
unlinkSpy.mockRestore();
});
it('should return 400 for a non-numeric address ID', async () => {
const response = await supertest(app).get('/api/users/addresses/abc');
expect(response.status).toBe(400);

View File

@@ -1,13 +1,16 @@
// src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes';
import multer from 'multer';
import path from 'path';
import multer from 'multer'; // Keep for MulterError type check
import fs from 'node:fs/promises';
import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed.
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { userService } from '../services/userService';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
@@ -20,6 +23,19 @@ import {
} from '../utils/zodUtils';
import * as db from '../services/db/index.db';
/**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded avatar file.');
}
};
const router = express.Router();
const updateProfileSchema = z.object({
@@ -72,35 +88,10 @@ const emptySchema = z.object({});
// Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false }));
// --- Multer Configuration for Avatar Uploads ---
// Ensure the directory for avatar uploads exists.
const avatarUploadDir = path.join(process.cwd(), 'public', 'uploads', 'avatars');
fs.mkdir(avatarUploadDir, { recursive: true }).catch((err) => {
logger.error({ err }, 'Failed to create avatar upload directory');
});
// Define multer storage configuration. The `req.user` object will be available
// here because the passport middleware runs before this route handler.
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarUploadDir),
filename: (req, file, cb) => {
const uniqueSuffix = `${(req.user as UserProfile).user.user_id}-${Date.now()}${path.extname(file.originalname)}`;
cb(null, uniqueSuffix);
},
});
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB file size limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error
cb(new Error('Only image files are allowed!'));
}
},
const avatarUpload = createUploadMiddleware({
storageType: 'avatar',
fileSize: 1 * 1024 * 1024, // 1MB
fileFilter: 'image',
});
/**
@@ -110,8 +101,8 @@ router.post(
'/profile/avatar',
avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
// The try-catch block was already correct here.
try {
// The try-catch block was already correct here.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const userProfile = req.user as UserProfile;
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
@@ -122,6 +113,9 @@ router.post(
);
res.json(updatedProfile);
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
await cleanupUploadedFile(req.file);
logger.error({ error }, 'Error uploading avatar');
next(error);
}
@@ -841,18 +835,7 @@ router.put(
},
);
// --- General Multer Error Handler ---
// This should be placed after all routes that use multer.
// It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof multer.MulterError) {
// A Multer error occurred when uploading (e.g., file too large).
return res.status(400).json({ message: `File upload error: ${err.message}` });
} else if (err && err.message === 'Only image files are allowed!') {
// A custom error from our fileFilter.
return res.status(400).json({ message: err.message });
}
next(err); // Pass on to the next error handler if it's not a multer error we handle.
});
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -178,6 +178,45 @@ describe('AI API Client (Network Mocking with MSW)', () => {
});
});
describe('uploadAndProcessFlyer error handling', () => {
it('should throw a structured error with JSON body on non-ok response', async () => {
const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const checksum = 'checksum-abc-123';
const errorBody = { message: 'Checksum already exists', flyerId: 99 };
server.use(
http.post('http://localhost/api/ai/upload-and-process', () => {
return HttpResponse.json(errorBody, { status: 409 });
}),
);
// The function now throws a structured object, not an Error instance.
await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({
status: 409,
body: errorBody,
});
});
it('should throw a structured error with text body on non-ok, non-JSON response', async () => {
const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const checksum = 'checksum-abc-123';
const errorText = 'Internal Server Error';
server.use(
http.post('http://localhost/api/ai/upload-and-process', () => {
return HttpResponse.text(errorText, { status: 500 });
}),
);
// The function now throws a structured object, not an Error instance.
// The catch block in the implementation wraps the text in a message property.
await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({
status: 500,
body: { message: errorText },
});
});
});
describe('getJobStatus', () => {
it('should send a GET request to the correct job status URL', async () => {
const jobId = 'job-id-456';
@@ -192,6 +231,66 @@ describe('AI API Client (Network Mocking with MSW)', () => {
});
});
describe('getJobStatus error handling', () => {
const jobId = 'job-id-789';
it('should throw a JobFailedError if job state is "failed"', async () => {
const failedStatus: aiApiClient.JobStatus = {
id: jobId,
state: 'failed',
progress: { message: 'AI model exploded', errorCode: 'AI_ERROR' },
returnValue: null,
failedReason: 'Raw error from BullMQ',
};
server.use(
http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
return HttpResponse.json(failedStatus);
}),
);
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
new aiApiClient.JobFailedError('AI model exploded', 'AI_ERROR'),
);
});
it('should use failedReason for JobFailedError if progress message is missing', async () => {
const failedStatus: aiApiClient.JobStatus = {
id: jobId,
state: 'failed',
progress: null, // No progress object
returnValue: null,
failedReason: 'Raw error from BullMQ',
};
server.use(
http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
return HttpResponse.json(failedStatus);
}),
);
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
new aiApiClient.JobFailedError('Raw error from BullMQ', 'UNKNOWN_ERROR'),
);
});
it('should throw a generic error if the API response is not ok', async () => {
const errorBody = { message: 'Job not found' };
server.use(
http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
return HttpResponse.json(errorBody, { status: 404 });
}),
);
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Job not found');
});
it('should throw a generic error if the API response is not valid JSON', async () => {
server.use(http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => HttpResponse.text('Invalid JSON')));
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(expect.any(SyntaxError));
});
});
describe('isImageAFlyer', () => {
it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });

View File

@@ -44,10 +44,12 @@ export const uploadAndProcessFlyer = async (
if (!response.ok) {
let errorBody;
// Clone the response so we can read the body twice (once as JSON, and as text on failure).
const clonedResponse = response.clone();
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
errorBody = { message: await clonedResponse.text() };
}
// Throw a structured error so the component can inspect the status and body
throw { status: response.status, body: errorBody };
@@ -63,12 +65,28 @@ export interface JobStatus {
progress: {
stages?: ProcessingStage[];
estimatedTimeRemaining?: number;
// The structured error payload from the backend worker
errorCode?: string;
message?: string;
} | null;
returnValue: {
flyerId?: number;
} | null;
failedReason: string | null;
failedReason: string | null; // The raw error string from BullMQ
}
/**
* Custom error class for job failures to make `catch` blocks more specific.
* This allows the UI to easily distinguish between a job failure and a network error.
*/
export class JobFailedError extends Error {
public errorCode: string;
constructor(message: string, errorCode: string) {
super(message);
this.name = 'JobFailedError';
this.errorCode = errorCode;
}
}
/**
@@ -77,7 +95,7 @@ export interface JobStatus {
* @param jobId The ID of the job to check.
* @param tokenOverride Optional token for testing.
* @returns A promise that resolves to the parsed job status object.
* @throws An error if the network request fails or if the response is not valid JSON.
* @throws A `JobFailedError` if the job has failed, or a generic `Error` for other issues.
*/
export const getJobStatus = async (
jobId: string,
@@ -85,22 +103,36 @@ export const getJobStatus = async (
): Promise<JobStatus> => {
const response = await apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride });
if (!response.ok) {
let errorText = `API Error: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.text();
if (errorBody) errorText = `API Error ${response.status}: ${errorBody}`;
} catch (e) {
// ignore if reading body fails
}
throw new Error(errorText);
}
try {
return await response.json();
const statusData: JobStatus = await response.json();
if (!response.ok) {
// If the HTTP response itself is an error (e.g., 404, 500), throw an error.
// Use the message from the JSON body if available.
const errorMessage = (statusData as any).message || `API Error: ${response.status}`;
throw new Error(errorMessage);
}
// If the job itself has failed, we should treat this as an error condition
// for the polling logic by rejecting the promise. This will stop the polling loop.
if (statusData.state === 'failed') {
// The structured error payload is in the 'progress' object.
const progress = statusData.progress;
const userMessage =
progress?.message || statusData.failedReason || 'Job failed with an unknown error.';
const errorCode = progress?.errorCode || 'UNKNOWN_ERROR';
logger.error(`Job ${jobId} failed with code: ${errorCode}, message: ${userMessage}`);
// Throw a custom, structured error so the frontend can react to the errorCode.
throw new JobFailedError(userMessage, errorCode);
}
return statusData;
} catch (error) {
const rawText = await response.text();
throw new Error(`Failed to parse JSON response from server. Body: ${rawText}`);
// This block catches errors from `response.json()` (if the body is not valid JSON)
// and also re-throws the errors we created above.
throw error;
}
};

View File

@@ -4,7 +4,7 @@ import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server';
import { AIService, AiFlyerDataSchema, aiService as aiServiceSingleton } from './aiService.server';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
@@ -65,6 +65,25 @@ describe('AI Service (Server)', () => {
});
});
describe('AiFlyerDataSchema', () => {
it('should fail validation if store_name is null or empty, covering requiredString', () => {
const dataWithNull = { store_name: null, items: [] };
const dataWithEmpty = { store_name: '', items: [] };
const resultNull = AiFlyerDataSchema.safeParse(dataWithNull);
const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);
expect(resultNull.success).toBe(false);
if (!resultNull.success) {
expect(resultNull.error.issues[0].message).toBe('Store name cannot be empty');
}
expect(resultEmpty.success).toBe(false);
if (!resultEmpty.success) {
expect(resultEmpty.error.issues[0].message).toBe('Store name cannot be empty');
}
});
});
describe('Constructor', () => {
const originalEnv = process.env;
@@ -706,4 +725,36 @@ describe('AI Service (Server)', () => {
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation: GeolocationCoordinates = {
latitude: 45,
longitude: -75,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
};
const mockStore = { name: 'Test Store' };
it('should throw a "feature disabled" error', async () => {
// This test verifies the current implementation which has the feature disabled.
await expect(
aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
// Also verify that the warning is logged
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
);
});
});
describe('Singleton Export', () => {
it('should export a singleton instance of AIService', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService);
});
});
});

View File

@@ -551,6 +551,11 @@ export class AIService {
private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
return items.map((item: RawFlyerItem) => ({
...item,
// Ensure 'item' is always a string, defaulting to 'Unknown Item' if null/undefined.
item:
item.item === null || item.item === undefined || String(item.item).trim() === ''
? 'Unknown Item'
: String(item.item),
price_display:
item.price_display === null || item.price_display === undefined
? ''

View File

@@ -0,0 +1,79 @@
// src/services/analyticsService.server.ts
import type { Job } from 'bullmq';
import { logger as globalLogger } from './logger.server';
import type { AnalyticsJobData, WeeklyAnalyticsJobData } from './queues.server';
/**
* A service class to encapsulate business logic for analytics-related background jobs.
*/
export class AnalyticsService {
/**
* Processes a job to generate a daily analytics report.
* This is currently a mock implementation.
* @param job The BullMQ job object.
*/
async processDailyReportJob(job: Job<AnalyticsJobData>) {
const { reportDate } = job.data;
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
reportDate,
});
logger.info(`Picked up daily analytics job.`);
try {
// This is mock logic, but we keep it in the service
if (reportDate === 'FAIL') {
throw new Error('This is a test failure for the analytics job.');
}
// Simulate work
await new Promise((resolve) => setTimeout(resolve, 10000));
logger.info(`Successfully generated report for ${reportDate}.`);
return { status: 'success', reportDate };
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{
err: wrappedError,
attemptsMade: job.attemptsMade,
},
`Daily analytics job failed.`,
);
throw wrappedError;
}
}
/**
* Processes a job to generate a weekly analytics report.
* This is currently a mock implementation.
* @param job The BullMQ job object.
*/
async processWeeklyReportJob(job: Job<WeeklyAnalyticsJobData>) {
const { reportYear, reportWeek } = job.data;
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
reportYear,
reportWeek,
});
logger.info(`Picked up weekly analytics job.`);
try {
// Mock logic
await new Promise((resolve) => setTimeout(resolve, 30000));
logger.info(`Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`);
return { status: 'success', reportYear, reportWeek };
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{ err: wrappedError, attemptsMade: job.attemptsMade },
`Weekly analytics job failed.`,
);
throw wrappedError;
}
}
}
export const analyticsService = new AnalyticsService();

View File

@@ -358,6 +358,39 @@ describe('Background Job Service', () => {
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
it('should handle unhandled rejections in the daily deal check cron wrapper', async () => {
// Use fake timers to control promise resolution
vi.useFakeTimers();
// Make the first call hang indefinitely to keep the lock active
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {}));
// Make logger.warn throw an error. This is outside the main try/catch in the cron job.
const warnError = new Error('Logger warn failed');
vi.mocked(globalMockLogger.warn).mockImplementation(() => {
throw warnError;
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
// Trigger the job once, it will hang and set the lock. Then trigger it a second time
// to enter the `if (isDailyDealCheckRunning)` block and call the throwing logger.warn.
await Promise.allSettled([dailyDealCheckCallback(), dailyDealCheckCallback()]);
// The outer catch block should have been called with the error from logger.warn
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: warnError },
'[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.',
);
});
it('should enqueue an analytics job when the second cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
@@ -421,6 +454,31 @@ describe('Background Job Service', () => {
);
});
it('should handle unhandled rejections in the analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Make logger.info throw, which is outside the try/catch in the cron job.
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError }, // The implementation uses `err` key here
'[BackgroundJob] Unhandled rejection in analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a weekly analytics job when the third cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
@@ -483,6 +541,30 @@ describe('Background Job Service', () => {
);
});
it('should handle unhandled rejections in the weekly analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError },
'[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a token cleanup job when the fourth cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
@@ -542,6 +624,30 @@ describe('Background Job Service', () => {
);
});
it('should handle unhandled rejections in the token cleanup cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError },
'[BackgroundJob] Unhandled rejection in token cleanup cron wrapper.',
);
infoSpy.mockRestore();
});
it('should log a critical error if scheduling fails', () => {
mockCronSchedule.mockImplementation(() => {
throw new Error('Scheduling failed');

View File

@@ -212,7 +212,7 @@ export function startBackgroundJobs(
})().catch((error: unknown) => {
// This catch is for unhandled promise rejections from the async wrapper itself.
logger.error(
{ error },
{ err: error },
'[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.',
);
isDailyDealCheckRunning = false;

View File

@@ -4,8 +4,11 @@
* It is configured via environment variables and should only be used on the server.
*/
import nodemailer from 'nodemailer';
import type { Job } from 'bullmq';
import type { Logger } from 'pino';
import { logger as globalLogger } from './logger.server';
import { WatchedItemDeal } from '../types';
import type { EmailJobData } from './queues.server';
// 1. Create a Nodemailer transporter using SMTP configuration from environment variables.
// For development, you can use a service like Ethereal (https://ethereal.email/)
@@ -20,18 +23,11 @@ const transporter = nodemailer.createTransport({
},
});
interface EmailOptions {
to: string;
subject: string;
text: string;
html: string;
}
/**
* Sends an email using the pre-configured transporter.
* @param options The email options, including recipient, subject, and body.
*/
export const sendEmail = async (options: EmailOptions, logger: Logger) => {
export const sendEmail = async (options: EmailJobData, logger: Logger) => {
const mailOptions = {
from: `"Flyer Crawler" <${process.env.SMTP_FROM_EMAIL}>`, // sender address
to: options.to,
@@ -40,16 +36,37 @@ export const sendEmail = async (options: EmailOptions, logger: Logger) => {
html: options.html,
};
const info = await transporter.sendMail(mailOptions);
logger.info(
{ to: options.to, subject: options.subject, messageId: info.messageId },
`Email sent successfully.`,
);
};
/**
* Processes an email sending job from the queue.
* This is the entry point for the email worker.
* It encapsulates logging and error handling for the job.
* @param job The BullMQ job object.
*/
export const processEmailJob = async (job: Job<EmailJobData>) => {
const jobLogger = globalLogger.child({
jobId: job.id,
jobName: job.name,
recipient: job.data.to,
});
jobLogger.info(`Picked up email job.`);
try {
const info = await transporter.sendMail(mailOptions);
logger.info(
{ to: options.to, subject: options.subject, messageId: info.messageId },
`Email sent successfully.`,
);
await sendEmail(job.data, jobLogger);
} catch (error) {
logger.error({ err: error, to: options.to, subject: options.subject }, 'Failed to send email.');
// Re-throwing the error is important so the background job knows it failed.
throw error;
const wrappedError = error instanceof Error ? error : new Error(String(error));
jobLogger.error(
{ err: wrappedError, jobData: job.data, attemptsMade: job.attemptsMade },
`Email job failed.`,
);
throw wrappedError;
}
};

View File

@@ -153,6 +153,9 @@ describe('FlyerDataTransformer', () => {
expect(mockLogger.info).toHaveBeenCalledWith(
'Starting data transformation from AI output to database format.',
);
expect(mockLogger.warn).toHaveBeenCalledWith(
'AI did not return a store name. Using fallback "Unknown Store (auto)".',
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ itemCount: 0, storeName: 'Unknown Store (auto)' },
'Data transformation complete.',

View File

@@ -40,18 +40,37 @@ export class FlyerDataTransformer {
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => ({
...item,
// Ensure 'item' is always a string, defaulting to 'Unknown Item' if null/undefined/empty.
item:
item.item === null || item.item === undefined || String(item.item).trim() === ''
? 'Unknown Item'
: String(item.item),
// Ensure 'price_display' is always a string, defaulting to empty if null/undefined.
price_display:
item.price_display === null || item.price_display === undefined
? ''
: String(item.price_display),
// Ensure 'quantity' is always a string, defaulting to empty if null/undefined.
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
// Ensure 'category_name' is always a string, defaulting to 'Other/Miscellaneous' if null/undefined.
category_name: item.category_name === null || item.category_name === undefined ? 'Other/Miscellaneous' : String(item.category_name),
master_item_id: item.master_item_id === null ? undefined : item.master_item_id, // Convert null to undefined
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
const storeName = extractedData.store_name || 'Unknown Store (auto)';
if (!extractedData.store_name) {
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
}
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${path.basename(firstImage)}`,
icon_url: `/flyer-images/icons/${iconFileName}`,
checksum,
store_name: extractedData.store_name || 'Unknown Store (auto)',
store_name: storeName,
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.

View File

@@ -1,13 +1,13 @@
// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import sharp from 'sharp';
import { Job } from 'bullmq';
import { Job, UnrecoverableError } from 'bullmq';
import type { Dirent } from 'node:fs';
import type { Logger } from 'pino';
import { z } from 'zod';
import { AiFlyerDataSchema } from './flyerProcessingService.server';
import type { Flyer, FlyerInsert } from '../types';
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
import type { CleanupJobData } from './flyerProcessingService.server';
export interface FlyerJobData {
filePath: string;
originalFileName: string;
@@ -55,7 +55,7 @@ import * as imageProcessor from '../utils/imageProcessor';
import { createMockFlyer } from '../tests/utils/mockFactories';
import { FlyerDataTransformer } from './flyerDataTransformer';
import {
AiDataValidationError,
AiDataValidationError, // This was a duplicate, fixed.
PdfConversionError,
UnsupportedFileTypeError,
} from './processingErrors';
@@ -181,6 +181,16 @@ describe('FlyerProcessingService', () => {
} as unknown as Job<FlyerJobData>;
};
const createMockCleanupJob = (data: CleanupJobData): Job<CleanupJobData> => {
return {
id: `cleanup-job-${data.flyerId}`,
data,
opts: { attempts: 3 },
attemptsMade: 1,
updateProgress: vi.fn(),
} as unknown as Job<CleanupJobData>;
};
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' });
@@ -243,25 +253,61 @@ describe('FlyerProcessingService', () => {
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const aiError = new Error('AI model exploded');
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
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',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw UnrecoverableError for quota issues and not enqueue cleanup', async () => {
const job = createMockJob({});
// Simulate an AI error that contains a keyword for unrecoverable errors
const quotaError = new Error('AI model quota exceeded');
const { logger } = await import('./logger.server');
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(
quotaError,
);
await expect(service.processJob(job)).rejects.toThrow(UnrecoverableError);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
const { logger } = await import('./logger.server');
const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
// Make the conversion step fail
mocks.execAsync.mockRejectedValue(conversionError);
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.', // This was a duplicate, fixed.
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => {
@@ -280,8 +326,15 @@ 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.", // This was a duplicate, fixed.
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
// FIX: This test was incorrect. The service *does* support GIF conversion.
@@ -347,15 +400,20 @@ describe('FlyerProcessingService', () => {
it('should throw an error and not enqueue cleanup if the database service fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const dbError = new Error('Database transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
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',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
@@ -363,24 +421,23 @@ describe('FlyerProcessingService', () => {
filePath: '/tmp/document.txt',
originalFileName: 'document.txt',
});
const { logger } = await import('./logger.server');
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.', // This was a duplicate, fixed.
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
const job = createMockJob({});
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
await expect(service.processJob(job)).rejects.toThrow();
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const iconError = new Error('Icon generation failed.');
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
// to always succeed. For this test, we override that mock to simulate a failure
@@ -390,9 +447,13 @@ 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.',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
});
@@ -429,6 +490,26 @@ describe('FlyerProcessingService', () => {
});
describe('_extractFlyerDataWithAI (private method)', () => {
it('should call AI service and return validated data on success', async () => {
const { logger } = await import('./logger.server');
const jobData = createMockJob({}).data;
const mockAiResponse = {
store_name: 'AI Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 AI St',
items: [],
};
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue(
mockAiResponse,
);
const result = await (service as any)._extractFlyerDataWithAI([], jobData, logger);
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockAiResponse);
});
it('should throw AiDataValidationError if AI response validation fails', async () => {
const { logger } = await import('./logger.server');
const jobData = createMockJob({}).data;
@@ -444,6 +525,45 @@ describe('FlyerProcessingService', () => {
});
});
describe('_validateAiData (private method)', () => {
it('should return validated data on success', async () => {
const { logger } = await import('./logger.server');
const validData = {
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [
{ item: 'Apple', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Produce' },
],
};
const result = (service as any)._validateAiData(validData, logger);
expect(result).toEqual(validData);
expect(logger.info).toHaveBeenCalledWith('AI extracted 1 items.');
});
it('should throw AiDataValidationError on invalid data', async () => {
const { logger } = await import('./logger.server');
const invalidData = {
// store_name is missing, which will fail validation
items: [],
};
expect(() => (service as any)._validateAiData(invalidData, logger)).toThrow(
AiDataValidationError,
);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
errors: expect.any(Object),
rawData: invalidData,
}),
'AI response failed validation.',
);
});
});
describe('_enqueueCleanup (private method)', () => {
it('should enqueue a cleanup job with the correct parameters', async () => {
const { logger } = await import('./logger.server');
@@ -478,43 +598,40 @@ describe('FlyerProcessingService', () => {
});
describe('_saveProcessedFlyerData (private method)', () => {
it('should transform data, create flyer in DB, and log activity', async () => {
it('should save flyer to DB and log activity', async () => {
const { logger } = await import('./logger.server');
// Arrange
const mockExtractedData = {
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Mock St',
items: [
{
item: 'Test Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Test Category',
master_item_id: 1,
},
],
};
const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const mockJobData = {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
const mockFlyerData: FlyerInsert = {
file_name: 'flyer.jpg',
image_url: '/flyer-images/flyer.jpg',
icon_url: '/flyer-images/icons/icon-flyer.webp',
checksum: 'checksum-123',
userId: 'user-abc',
store_name: 'Test Store',
item_count: 1,
valid_from: null,
valid_to: null,
store_address: null,
uploaded_by: 'user-abc',
};
const mockItemsForDb: FlyerItemInsert[] = [
{
item: 'Test Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Test Category',
master_item_id: 1,
view_count: 0,
click_count: 0,
},
];
const userId = 'user-abc';
// The DB create function is also mocked in beforeEach.
// Create a complete mock that satisfies the Flyer type.
const mockNewFlyer = createMockFlyer({
flyer_id: 1,
file_name: 'flyer.jpg',
image_url: '/flyer-images/flyer.jpg',
icon_url: '/flyer-images/icons/icon-flyer.webp',
checksum: 'checksum-123',
store_id: 1,
item_count: 1,
store: { name: 'Test Store' }, // Match the logActivity expectation
});
vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] });
@@ -522,30 +639,22 @@ describe('FlyerProcessingService', () => {
const result = await (
service as unknown as {
_saveProcessedFlyerData: (
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
flyerData: FlyerInsert,
itemsForDb: FlyerItemInsert[],
userId: string | undefined,
logger: Logger,
) => Promise<Flyer>;
}
)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData, logger);
)._saveProcessedFlyerData(mockFlyerData, mockItemsForDb, userId, logger);
// Assert
// 1. Transformer was called correctly
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
mockExtractedData,
mockImagePaths,
mockJobData.originalFileName,
mockJobData.checksum,
mockJobData.userId,
logger,
);
// 1. Transformer should NOT be called from this method anymore.
expect(FlyerDataTransformer.prototype.transform).not.toHaveBeenCalled();
// 2. DB function was called with the transformed data
// The data comes from the mock defined in `beforeEach`.
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({ store_name: 'Mock Store', checksum: 'checksum-123' }),
[], // itemsForDb from the mock
mockFlyerData,
mockItemsForDb,
logger,
);
@@ -554,8 +663,8 @@ describe('FlyerProcessingService', () => {
{
userId: 'user-abc',
action: 'flyer_processed' as const,
displayText: 'Processed a new flyer for Mock Store.', // This was a duplicate, fixed.
details: { flyerId: 1, storeName: 'Mock Store' },
displayText: 'Processed a new flyer for Test Store.',
details: { flyerId: 1, storeName: 'Test Store' },
},
logger,
);
@@ -565,6 +674,45 @@ describe('FlyerProcessingService', () => {
});
});
describe('_logFlyerProcessedActivity (private method)', () => {
it('should log the flyer processing activity with a user ID', async () => {
const { logger } = await import('./logger.server');
const mockFlyer = createMockFlyer({ flyer_id: 1, store: { name: 'Test Store' } });
const userId = 'user-123';
// Access and call the private method for testing
await (service as any)._logFlyerProcessedActivity(mockFlyer, userId, logger);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(
{
userId: 'user-123',
action: 'flyer_processed',
displayText: 'Processed a new flyer for Test Store.',
details: { flyerId: 1, storeName: 'Test Store' },
},
logger,
);
});
it('should log the activity without a user ID for anonymous uploads', async () => {
const { logger } = await import('./logger.server');
const mockFlyer = createMockFlyer({ flyer_id: 2, store: { name: 'Another Store' } });
// Call with undefined userId
await (service as any)._logFlyerProcessedActivity(mockFlyer, undefined, logger);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(
{
userId: undefined,
action: 'flyer_processed',
displayText: 'Processed a new flyer for Another Store.',
details: { flyerId: 2, storeName: 'Another Store' },
},
logger,
);
});
});
describe('_convertPdfToImages (private method)', () => {
it('should call pdftocairo and return sorted image paths on success', async () => {
const { logger } = await import('./logger.server');
@@ -622,4 +770,106 @@ describe('FlyerProcessingService', () => {
).rejects.toThrow(commandError);
});
});
describe('_reportErrorAndThrow (private method)', () => {
it('should update progress and throw UnrecoverableError for quota messages', async () => {
const job = createMockJob({});
const quotaError = new Error('RESOURCE_EXHAUSTED');
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(quotaError, job, {} as Logger)).rejects.toThrow(
UnrecoverableError,
);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
});
it('should update progress and re-throw standard errors', async () => {
const job = createMockJob({});
const genericError = new Error('A standard failure');
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(genericError, job, {} as Logger)).rejects.toThrow(genericError);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNKNOWN_ERROR',
message: 'A standard failure', // This was a duplicate, fixed.
});
});
it('should wrap and throw non-Error objects', async () => {
const job = createMockJob({});
const nonError = 'just a string error';
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(nonError, job, {} as Logger)).rejects.toThrow('just a string error');
});
});
describe('processCleanupJob', () => {
it('should delete all files successfully', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
mocks.unlink.mockResolvedValue(undefined);
const result = await service.processCleanupJob(job);
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1');
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2');
expect(result).toEqual({ status: 'success', deletedCount: 2 });
});
it('should handle ENOENT errors gracefully and still succeed', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
const enoentError: NodeJS.ErrnoException = new Error('File not found');
enoentError.code = 'ENOENT';
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError);
const result = await service.processCleanupJob(job);
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(result).toEqual({ status: 'success', deletedCount: 2 });
// Check that the warning was logged
const { logger } = await import('./logger.server');
expect(logger.warn).toHaveBeenCalledWith(
'File not found during cleanup (already deleted?): /tmp/file2',
);
});
it('should throw an aggregate error if a non-ENOENT error occurs', async () => {
const job = createMockCleanupJob({
flyerId: 1,
paths: ['/tmp/file1', '/tmp/permission-denied'],
});
const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
permissionError.code = 'EACCES';
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(permissionError);
await expect(service.processCleanupJob(job)).rejects.toThrow(
'Failed to delete 1 file(s): /tmp/permission-denied',
);
// Check that the error was logged
const { logger } = await import('./logger.server');
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: permissionError, path: '/tmp/permission-denied' }),
'Failed to delete temporary file.',
);
});
it('should skip processing and return "skipped" if paths array is empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
const result = await service.processCleanupJob(job);
expect(mocks.unlink).not.toHaveBeenCalled();
expect(result).toEqual({ status: 'skipped', reason: 'no paths' });
const { logger } = await import('./logger.server');
expect(logger.warn).toHaveBeenCalledWith('Job received no paths to clean. Skipping.');
});
});
});

View File

@@ -1,5 +1,5 @@
// src/services/flyerProcessingService.server.ts
import type { Job, JobsOptions } from 'bullmq';
import { Job, JobsOptions, UnrecoverableError } from 'bullmq';
import sharp from 'sharp';
import path from 'path';
import type { Dirent } from 'node:fs';
@@ -16,6 +16,7 @@ import {
import { FlyerDataTransformer } from './flyerDataTransformer';
import { logger as globalLogger } from './logger.server';
import type { Logger } from 'pino';
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) =>
@@ -47,7 +48,7 @@ export interface FlyerJobData {
userProfileAddress?: string;
}
interface CleanupJobData {
export interface CleanupJobData {
flyerId: number;
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
paths?: string[];
@@ -63,16 +64,16 @@ interface ICleanupQueue {
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
item: z.string(),
price_display: z.string(),
item: z.string().nullable(), // AI might return null or empty, normalize later
price_display: z.string().nullable(), // AI might return null or empty, normalize later
price_in_cents: z.number().nullable(),
quantity: z.string(),
category_name: z.string(),
quantity: z.string().nullable(), // AI might return null or empty, normalize later
category_name: z.string().nullable(), // AI might return null or empty, normalize later
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: requiredString('Store name cannot be empty'),
store_name: z.string().nullable(), // AI might return null or empty, normalize later
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
@@ -93,6 +94,49 @@ export class FlyerProcessingService {
private transformer: FlyerDataTransformer,
) {}
/**
* Executes the pdftocairo command to convert the PDF.
*/
private async _executePdfConversion(
filePath: string,
outputFilePrefix: string,
logger: Logger,
): Promise<{ stdout: string; stderr: string }> {
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`Executing PDF conversion command`);
logger.debug({ command });
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug({ stdout }, `[Worker] pdftocairo stdout for ${filePath}:`);
if (stderr) logger.warn({ stderr }, `[Worker] pdftocairo stderr for ${filePath}:`);
return { stdout, stderr };
}
/**
* Scans the output directory for generated JPEG images and returns their paths.
*/
private async _collectGeneratedImages(
outputDir: string,
outputFilePrefix: string,
logger: Logger,
): Promise<string[]> {
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter((f) => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(
{ imageNames: generatedImages.map((f) => f.name) },
`Filtered down to ${generatedImages.length} generated JPGs.`,
);
return generatedImages.map((img) => path.join(outputDir, img.name));
}
/**
* Converts a PDF file to a series of JPEG images using an external tool.
* @param filePath The path to the PDF file.
@@ -111,34 +155,17 @@ export class FlyerProcessingService {
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug({ outputDir, outputFilePrefix }, `PDF output details`);
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`Executing PDF conversion command`);
logger.debug({ command });
const { stdout, stderr } = await this.exec(command);
const { stderr } = await this._executePdfConversion(filePath, outputFilePrefix, logger);
if (stdout) logger.debug({ stdout }, `[Worker] pdftocairo stdout for ${filePath}:`);
if (stderr) logger.warn({ stderr }, `[Worker] pdftocairo stderr for ${filePath}:`);
const imagePaths = await this._collectGeneratedImages(outputDir, outputFilePrefix, logger);
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter((f) => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(
{ imageNames: generatedImages.map((f) => f.name) },
`Filtered down to ${generatedImages.length} generated JPGs.`,
);
if (generatedImages.length === 0) {
if (imagePaths.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error({ stderr }, `PdfConversionError: ${errorMessage}`);
throw new PdfConversionError(errorMessage, stderr);
}
return generatedImages.map((img) => path.join(outputDir, img.name));
return imagePaths;
}
/**
@@ -175,33 +202,62 @@ export class FlyerProcessingService {
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
let imagePaths: { path: string; mimetype: string }[] = [];
let createdImagePaths: string[] = [];
// Handle PDF conversion separately
if (fileExt === '.pdf') {
const createdImagePaths = await this._convertPdfToImages(filePath, job, logger);
const imagePaths = createdImagePaths.map((p) => ({ path: p, mimetype: 'image/jpeg' }));
createdImagePaths = await this._convertPdfToImages(filePath, job, logger);
imagePaths = createdImagePaths.map((p) => ({ path: p, mimetype: 'image/jpeg' }));
logger.info(`Converted PDF to ${imagePaths.length} images.`);
return { imagePaths, createdImagePaths };
// Handle directly supported single-image formats
} else if (SUPPORTED_IMAGE_EXTENSIONS.includes(fileExt)) {
logger.info(`Processing as a single image file: ${filePath}`);
// Normalize .jpg to image/jpeg for consistency
const mimetype =
fileExt === '.jpg' || fileExt === '.jpeg' ? 'image/jpeg' : `image/${fileExt.slice(1)}`;
const imagePaths = [{ path: filePath, mimetype }];
return { imagePaths, createdImagePaths: [] };
imagePaths = [{ path: filePath, mimetype }];
// No new images created, so createdImagePaths remains empty.
// Handle convertible image formats
} else if (CONVERTIBLE_IMAGE_EXTENSIONS.includes(fileExt)) {
const createdPngPath = await this._convertImageToPng(filePath, logger);
const imagePaths = [{ path: createdPngPath, mimetype: 'image/png' }];
imagePaths = [{ path: createdPngPath, mimetype: 'image/png' }];
// The new PNG is a temporary file that needs to be cleaned up.
return { imagePaths, createdImagePaths: [createdPngPath] };
createdImagePaths = [createdPngPath];
} else {
// If the file is neither a PDF nor a supported image, throw an error.
const errorMessage = `Unsupported file type: ${fileExt}. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.`;
logger.error({ originalFileName: job.data.originalFileName, fileExt }, errorMessage);
throw new UnsupportedFileTypeError(errorMessage);
}
return { imagePaths, createdImagePaths };
}
/**
* Validates the raw data from the AI against the Zod schema.
* @param extractedData The raw, unknown data from the AI service.
* @param logger The job-specific logger instance.
* @returns The validated and typed data.
* @throws {AiDataValidationError} If the data does not conform to the schema.
*/
private _validateAiData(
extractedData: unknown,
logger: Logger,
): z.infer<typeof AiFlyerDataSchema> {
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error({ errors, rawData: extractedData }, 'AI response failed validation.');
throw new AiDataValidationError(
'AI response validation failed. The returned data structure is incorrect.',
errors,
extractedData,
);
}
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
return validationResult.data;
}
/**
@@ -228,19 +284,7 @@ export class FlyerProcessingService {
logger,
);
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error({ errors, rawData: extractedData }, 'AI response failed validation.');
throw new AiDataValidationError(
'AI response validation failed. The returned data structure is incorrect.',
errors,
extractedData,
);
}
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
return validationResult.data;
return this._validateAiData(extractedData, logger);
}
/**
@@ -251,39 +295,44 @@ export class FlyerProcessingService {
* @returns A promise that resolves to the newly created flyer record.
*/
private async _saveProcessedFlyerData(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
flyerData: FlyerInsert,
itemsForDb: FlyerItemInsert[],
userId: string | undefined,
logger: Logger,
) {
logger.info(`Preparing to save extracted data to database.`);
// 1. Transform the AI data into database-ready records.
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
jobData.originalFileName,
jobData.checksum,
jobData.userId,
// Pass the job-specific logger to the transformer
logger,
);
// 2. Save the transformed data to the database.
// 1. Save the transformed data to the database.
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb, logger);
logger.info({ newFlyerId: newFlyer.flyer_id }, `Successfully saved new flyer.`);
// 2. Log the activity.
await this._logFlyerProcessedActivity(newFlyer, userId, logger);
return newFlyer;
}
/**
* Logs the successful processing of a flyer to the admin activity log.
* @param newFlyer The newly created flyer record from the database.
* @param userId The ID of the user who uploaded the flyer, if available.
* @param logger The job-specific logger instance.
*/
private async _logFlyerProcessedActivity(
newFlyer: Flyer,
userId: string | undefined,
logger: Logger,
) {
const storeName = newFlyer.store?.name || 'Unknown Store';
await this.database.adminRepo.logActivity(
{
userId: jobData.userId,
userId: userId,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
displayText: `Processed a new flyer for ${storeName}.`,
details: { flyerId: newFlyer.flyer_id, storeName },
},
logger,
);
return newFlyer;
}
/**
@@ -305,10 +354,136 @@ export class FlyerProcessingService {
logger.info({ flyerId }, `Enqueued cleanup job.`);
}
async processJob(job: Job<FlyerJobData>) {
const { filePath, originalFileName } = job.data;
/**
* Centralized error handler for the `processJob` method. It logs the error,
* updates the job's progress with a user-friendly message, and re-throws the
* error for the worker to handle retries or final failure. It also identifies
* unrecoverable errors to prevent unnecessary retries.
* @param error The error caught during processing.
* @param job The BullMQ job instance.
* @param logger The job-specific logger.
*/
private async _reportErrorAndThrow(
error: unknown,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<never> {
const wrappedError = error instanceof Error ? error : new Error(String(error));
const errorMessage = wrappedError.message || '';
// First, check for unrecoverable quota-related errors.
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') ||
errorMessage.includes('RESOURCE_EXHAUSTED')
) {
logger.error(
{ err: wrappedError, jobId: job.id },
'[FlyerProcessingService] Unrecoverable quota error detected. Failing job immediately.',
);
await job.updateProgress({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
// This specific error type tells the BullMQ worker to fail the job without retries.
throw new UnrecoverableError(errorMessage);
}
// Define a structured error payload for job progress updates.
// This allows the frontend to provide more specific feedback.
let errorPayload = {
errorCode: 'UNKNOWN_ERROR',
message: 'An unexpected error occurred during processing.',
};
if (error instanceof UnsupportedFileTypeError) {
logger.error({ err: error }, `Unsupported file type error.`);
errorPayload = {
errorCode: 'UNSUPPORTED_FILE_TYPE',
message: error.message, // The message is already user-friendly
};
} else if (error instanceof PdfConversionError) {
logger.error({ err: error, stderr: error.stderr }, `PDF Conversion failed.`);
errorPayload = {
errorCode: 'PDF_CONVERSION_FAILED',
message:
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.',
};
} else if (error instanceof AiDataValidationError) {
logger.error(
{ err: error, validationErrors: error.validationErrors, rawData: error.rawData },
`AI Data Validation failed.`,
);
errorPayload = {
errorCode: 'AI_VALIDATION_FAILED',
message:
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
};
} else if (error instanceof Error) {
logger.error(
{ err: error, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts },
`A generic error occurred in job.`,
);
errorPayload.message = error.message;
}
await job.updateProgress(errorPayload);
throw wrappedError;
}
/**
* Orchestrates the series of steps involved in processing a flyer.
* This "happy path" method is called by the main `processJob` method.
* @param job The BullMQ job instance.
* @param logger The job-specific logger.
* @returns A promise that resolves with the new flyer's ID.
*/
private async _runProcessingSteps(
job: Job<FlyerJobData>,
logger: Logger,
): Promise<{ flyerId: number }> {
const { filePath } = job.data;
const createdImagePaths: string[] = [];
let newFlyerId: number | undefined;
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(
filePath,
job,
logger,
);
createdImagePaths.push(...tempImagePaths);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data, logger);
await job.updateProgress({ message: 'Transforming data...' });
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
job.data.originalFileName,
job.data.checksum,
job.data.userId,
logger,
);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(
flyerData,
itemsForDb,
job.data.userId,
logger,
);
logger.info({ flyerId: newFlyer.flyer_id }, `Job processed successfully.`);
// On success, enqueue the cleanup job for all temporary files.
const pathsToClean = [filePath, ...createdImagePaths];
await this._enqueueCleanup(newFlyer.flyer_id, pathsToClean, logger);
return { flyerId: newFlyer.flyer_id };
}
async processJob(job: Job<FlyerJobData>) {
const { originalFileName } = job.data;
// Create a job-specific logger instance with context, as per ADR-004
const logger = globalLogger.child({
@@ -322,60 +497,62 @@ export class FlyerProcessingService {
logger.info(`Picked up job.`);
try {
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(
filePath,
job,
logger,
);
createdImagePaths.push(...tempImagePaths);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data, logger);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(
extractedData,
imagePaths,
job.data,
logger,
); // Pass logger
newFlyerId = newFlyer.flyer_id;
logger.info({ flyerId: newFlyerId }, `Job processed successfully.`);
return { flyerId: newFlyer.flyer_id };
return await this._runProcessingSteps(job, logger);
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred';
if (error instanceof PdfConversionError) {
errorMessage = error.message;
logger.error({ err: error, stderr: error.stderr }, `PDF Conversion failed.`);
} else if (error instanceof AiDataValidationError) {
errorMessage = error.message;
logger.error(
{ err: error, validationErrors: error.validationErrors, rawData: error.rawData },
`AI Data Validation failed.`,
);
} else if (error instanceof UnsupportedFileTypeError) {
errorMessage = error.message;
logger.error({ err: error }, `Unsupported file type error.`);
} else if (error instanceof Error) {
errorMessage = error.message;
logger.error(
{ err: error, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts },
`A generic error occurred in job.`,
);
// On failure, explicitly log that we are not cleaning up files to allow for manual inspection.
logger.warn(
`Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.`,
);
// Delegate all error handling to a separate, testable method.
await this._reportErrorAndThrow(error, job, logger);
}
}
async processCleanupJob(job: Job<CleanupJobData>) {
const { flyerId, paths } = job.data;
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
flyerId,
});
logger.info({ paths }, `Picked up file cleanup job.`);
try {
if (!paths || paths.length === 0) {
logger.warn(`Job received no paths to clean. Skipping.`);
return { status: 'skipped', reason: 'no paths' };
}
await job.updateProgress({ message: `Error: ${errorMessage}` });
throw error;
} finally {
if (newFlyerId) {
const pathsToClean = [filePath, ...createdImagePaths];
await this._enqueueCleanup(newFlyerId, pathsToClean, logger);
} else {
logger.warn(
`Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.`,
);
for (const filePath of paths) {
try {
await this.fs.unlink(filePath);
logger.info(`Deleted temporary file: ${filePath}`);
} catch (unlinkError: unknown) {
if (
unlinkError instanceof Error &&
'code' in unlinkError &&
(unlinkError as NodeJS.ErrnoException).code === 'ENOENT'
) {
logger.warn(`File not found during cleanup (already deleted?): ${filePath}`);
} else {
// Re-throw other errors to be caught by the outer catch block
throw unlinkError;
}
}
}
logger.info(`Successfully cleaned up ${paths.length} file(s).`);
return { status: 'success', deletedCount: paths.length };
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{
err: wrappedError,
attemptsMade: job.attemptsMade,
},
`File cleanup job failed.`,
);
throw wrappedError;
}
}
}

View File

@@ -3,13 +3,23 @@
/**
* Base class for all flyer processing errors.
* This allows for catching all processing-related errors with a single `catch` block.
* Each custom error should define its own `errorCode` and a user-friendly `message`.
*/
export class FlyerProcessingError extends Error {
constructor(message: string) {
super(message);
public errorCode: string;
public userMessage: string;
constructor(message: string, errorCode: string = 'UNKNOWN_ERROR', userMessage?: string) {
super(message); // The 'message' property of Error is for internal/developer use.
this.name = this.constructor.name;
this.errorCode = errorCode;
this.userMessage = userMessage || message; // User-friendly message for UI
Object.setPrototypeOf(this, new.target.prototype);
}
toErrorPayload(): { errorCode: string; message: string; [key: string]: any } {
return { errorCode: this.errorCode, message: this.userMessage };
}
}
/**
@@ -18,9 +28,17 @@ export class FlyerProcessingError extends Error {
export class PdfConversionError extends FlyerProcessingError {
public stderr?: string;
constructor(message: string, stderr?: string) {
super(message);
super(
message,
'PDF_CONVERSION_FAILED',
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.',
);
this.stderr = stderr;
}
toErrorPayload(): { errorCode: string; message: string; [key: string]: any } {
return { ...super.toErrorPayload(), stderr: this.stderr };
}
}
/**
@@ -32,7 +50,15 @@ export class AiDataValidationError extends FlyerProcessingError {
public validationErrors: object,
public rawData: unknown,
) {
super(message);
super(
message,
'AI_VALIDATION_FAILED',
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
);
}
toErrorPayload(): { errorCode: string; message: string; [key: string]: any } {
return { ...super.toErrorPayload(), validationErrors: this.validationErrors, rawData: this.rawData };
}
}
@@ -41,7 +67,7 @@ export class AiDataValidationError extends FlyerProcessingError {
*/
export class GeocodingFailedError extends FlyerProcessingError {
constructor(message: string) {
super(message);
super(message, 'GEOCODING_FAILED', 'Failed to geocode the address.');
}
}
@@ -50,6 +76,6 @@ export class GeocodingFailedError extends FlyerProcessingError {
*/
export class UnsupportedFileTypeError extends FlyerProcessingError {
constructor(message: string) {
super(message);
super(message, 'UNSUPPORTED_FILE_TYPE', message); // The message is already user-friendly.
}
}

View File

@@ -8,12 +8,17 @@ const mocks = vi.hoisted(() => {
const capturedProcessors: Record<string, (job: Job) => Promise<unknown>> = {};
return {
sendEmail: vi.fn(),
unlink: vi.fn(),
// Service method mocks
processFlyerJob: vi.fn(),
processCleanupJob: vi.fn(),
processEmailJob: vi.fn(),
processDailyReportJob: vi.fn(),
processWeeklyReportJob: vi.fn(),
processTokenCleanupJob: vi.fn(),
// Test utilities
capturedProcessors,
deleteExpiredResetTokens: vi.fn(),
// Mock the Worker constructor to capture the processor function. It must be a
// Mock the Worker constructor to capture the processor function. It must be a`
// `function` and not an arrow function so it can be called with `new`.
MockWorker: vi.fn(function (name: string, processor: (job: Job) => Promise<unknown>) {
if (processor) {
@@ -26,23 +31,20 @@ const mocks = vi.hoisted(() => {
});
// --- Mock Modules ---
vi.mock('./emailService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('./emailService.server')>();
return {
...actual,
// We only need to mock the specific function being called by the worker.
// The rest of the module can retain its original implementation if needed elsewhere.
sendEmail: mocks.sendEmail,
};
});
vi.mock('./emailService.server', () => ({
processEmailJob: mocks.processEmailJob,
}));
// The workers use an `fsAdapter`. We can mock the underlying `fsPromises`
// that the adapter is built from in queueService.server.ts.
vi.mock('node:fs/promises', () => ({
default: {
unlink: mocks.unlink,
// Add other fs functions if needed by other tests
readdir: vi.fn(),
vi.mock('./analyticsService.server', () => ({
analyticsService: {
processDailyReportJob: mocks.processDailyReportJob,
processWeeklyReportJob: mocks.processWeeklyReportJob,
},
}));
vi.mock('./userService', () => ({
userService: {
processTokenCleanupJob: mocks.processTokenCleanupJob,
},
}));
@@ -56,26 +58,22 @@ vi.mock('./logger.server', () => ({
},
}));
vi.mock('./db/index.db', () => ({
userRepo: {
deleteExpiredResetTokens: mocks.deleteExpiredResetTokens,
},
}));
// Mock bullmq to capture the processor functions passed to the Worker constructor
import { logger as mockLogger } from './logger.server';
vi.mock('bullmq', () => ({
Worker: mocks.MockWorker,
// FIX: Use a standard function for the mock constructor to allow `new Queue(...)` to work.
Queue: vi.fn(function () {
return { add: vi.fn() };
}),
// Add UnrecoverableError to the mock so it can be used in tests
UnrecoverableError: class UnrecoverableError extends Error {},
}));
// Mock flyerProcessingService.server as flyerWorker depends on it
// Mock flyerProcessingService.server as flyerWorker and cleanupWorker depend on it
vi.mock('./flyerProcessingService.server', () => ({
FlyerProcessingService: class {
processJob = mocks.processFlyerJob;
processCleanupJob = mocks.processCleanupJob;
},
}));
@@ -110,15 +108,16 @@ describe('Queue Workers', () => {
let tokenCleanupProcessor: (job: Job) => Promise<unknown>;
beforeEach(async () => {
// Reset default mock implementations for hoisted mocks
mocks.processFlyerJob.mockResolvedValue({ flyerId: 123 });
mocks.processCleanupJob.mockResolvedValue({ status: 'success' });
mocks.processEmailJob.mockResolvedValue(undefined);
mocks.processDailyReportJob.mockResolvedValue({ status: 'success' });
mocks.processWeeklyReportJob.mockResolvedValue({ status: 'success' });
mocks.processTokenCleanupJob.mockResolvedValue({ deletedCount: 5 });
vi.clearAllMocks();
vi.resetModules();
// 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
mocks.deleteExpiredResetTokens.mockResolvedValue(5);
await import('./workers.server');
flyerProcessor = mocks.capturedProcessors['flyer-processing'];
@@ -155,10 +154,24 @@ describe('Queue Workers', () => {
await expect(flyerProcessor(job)).rejects.toThrow('Flyer processing failed');
});
it('should re-throw UnrecoverableError from the service layer', async () => {
const { UnrecoverableError } = await import('bullmq');
const job = createMockJob({
filePath: '/tmp/fail.pdf',
originalFileName: 'fail.pdf',
checksum: 'def',
});
const unrecoverableError = new UnrecoverableError('Quota exceeded');
mocks.processFlyerJob.mockRejectedValue(unrecoverableError);
// The worker should just let this specific error type pass through.
await expect(flyerProcessor(job)).rejects.toThrow(unrecoverableError);
});
});
describe('emailWorker', () => {
it('should call emailService.sendEmail with the job data', async () => {
it('should call emailService.processEmailJob with the job', async () => {
const jobData = {
to: 'test@example.com',
subject: 'Test Email',
@@ -166,173 +179,84 @@ describe('Queue Workers', () => {
text: 'Hello',
};
const job = createMockJob(jobData);
await emailProcessor(job);
expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
// The implementation passes the logger as the second argument
expect(mocks.sendEmail).toHaveBeenCalledWith(jobData, expect.anything());
expect(mocks.processEmailJob).toHaveBeenCalledTimes(1);
expect(mocks.processEmailJob).toHaveBeenCalledWith(job);
});
it('should log and re-throw an error if sendEmail fails with a non-Error object', async () => {
const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' });
const emailError = 'SMTP server is down'; // Reject with a string
mocks.sendEmail.mockRejectedValue(emailError);
await expect(emailProcessor(job)).rejects.toThrow(emailError);
// The worker should wrap the string in an Error object for logging
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: new Error(emailError), jobData: job.data },
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
});
it('should re-throw an error if sendEmail fails', async () => {
it('should re-throw an error if processEmailJob fails', async () => {
const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' });
const emailError = new Error('SMTP server is down');
mocks.sendEmail.mockRejectedValue(emailError);
mocks.processEmailJob.mockRejectedValue(emailError);
await expect(emailProcessor(job)).rejects.toThrow('SMTP server is down');
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: emailError, jobData: job.data },
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
});
});
describe('analyticsWorker', () => {
it('should complete successfully for a valid report date', async () => {
vi.useFakeTimers();
it('should call analyticsService.processDailyReportJob with the job', async () => {
const job = createMockJob({ reportDate: '2024-01-01' });
const promise = analyticsProcessor(job);
// Advance timers to simulate the 10-second task completing
await vi.advanceTimersByTimeAsync(10000);
await promise; // Wait for the promise to resolve
// No error should be thrown
expect(true).toBe(true);
vi.useRealTimers();
await analyticsProcessor(job);
expect(mocks.processDailyReportJob).toHaveBeenCalledTimes(1);
expect(mocks.processDailyReportJob).toHaveBeenCalledWith(job);
});
it('should throw an error if reportDate is "FAIL"', async () => {
it('should re-throw an error if processDailyReportJob fails', async () => {
const job = createMockJob({ reportDate: 'FAIL' });
await expect(analyticsProcessor(job)).rejects.toThrow(
'This is a test failure for the analytics job.',
);
const analyticsError = new Error('Analytics processing failed');
mocks.processDailyReportJob.mockRejectedValue(analyticsError);
await expect(analyticsProcessor(job)).rejects.toThrow('Analytics processing failed');
});
});
describe('cleanupWorker', () => {
it('should call unlink for each path provided in the job data', async () => {
it('should call flyerProcessingService.processCleanupJob with the job', async () => {
const jobData = {
flyerId: 123,
paths: ['/tmp/file1.jpg', '/tmp/file2.pdf'],
};
const job = createMockJob(jobData);
mocks.unlink.mockResolvedValue(undefined);
await cleanupProcessor(job);
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1.jpg');
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2.pdf');
expect(mocks.processCleanupJob).toHaveBeenCalledTimes(1);
expect(mocks.processCleanupJob).toHaveBeenCalledWith(job);
});
it('should not throw an error if a file is already deleted (ENOENT)', async () => {
const jobData = {
flyerId: 123,
paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'],
};
it('should re-throw an error if processCleanupJob fails', async () => {
const jobData = { flyerId: 123, paths: ['/tmp/protected-file.jpg'] };
const job = createMockJob(jobData);
// Use the built-in NodeJS.ErrnoException type for mock system errors.
const enoentError: NodeJS.ErrnoException = new Error('File not found');
enoentError.code = 'ENOENT';
// First call succeeds, second call fails with ENOENT
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError);
// The processor should complete without throwing
await expect(cleanupProcessor(job)).resolves.toBeUndefined();
expect(mocks.unlink).toHaveBeenCalledTimes(2);
});
it('should re-throw an error for issues other than ENOENT (e.g., permissions)', async () => {
const jobData = {
flyerId: 123,
paths: ['/tmp/protected-file.jpg'],
};
const job = createMockJob(jobData);
// Use the built-in NodeJS.ErrnoException type for mock system errors.
const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
permissionError.code = 'EACCES';
mocks.unlink.mockRejectedValue(permissionError);
const cleanupError = new Error('Permission denied');
mocks.processCleanupJob.mockRejectedValue(cleanupError);
await expect(cleanupProcessor(job)).rejects.toThrow('Permission denied');
// Verify the error was logged by the worker's catch block
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: permissionError },
expect.stringContaining(
`[CleanupWorker] Job ${job.id} for flyer ${job.data.flyerId} failed.`,
),
);
});
});
describe('weeklyAnalyticsWorker', () => {
it('should complete successfully for a valid report date', async () => {
vi.useFakeTimers();
it('should call analyticsService.processWeeklyReportJob with the job', async () => {
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();
await weeklyAnalyticsProcessor(job);
expect(mocks.processWeeklyReportJob).toHaveBeenCalledTimes(1);
expect(mocks.processWeeklyReportJob).toHaveBeenCalledWith(job);
});
it('should re-throw an error if the job fails', async () => {
vi.useFakeTimers();
it('should re-throw an error if processWeeklyReportJob fails', async () => {
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);
});
const weeklyError = new Error('Weekly analytics job failed');
mocks.processWeeklyReportJob.mockRejectedValue(weeklyError);
await expect(weeklyAnalyticsProcessor(job)).rejects.toThrow('Weekly analytics job failed');
vi.useRealTimers();
vi.restoreAllMocks(); // Restore setTimeout mock
});
});
describe('tokenCleanupWorker', () => {
it('should call userRepo.deleteExpiredResetTokens and return the count', async () => {
it('should call userService.processTokenCleanupJob with the job', async () => {
const job = createMockJob({ timestamp: new Date().toISOString() });
mocks.deleteExpiredResetTokens.mockResolvedValue(10);
const result = await tokenCleanupProcessor(job);
expect(mocks.deleteExpiredResetTokens).toHaveBeenCalledTimes(1);
expect(result).toEqual({ deletedCount: 10 });
await tokenCleanupProcessor(job);
expect(mocks.processTokenCleanupJob).toHaveBeenCalledTimes(1);
expect(mocks.processTokenCleanupJob).toHaveBeenCalledWith(job);
});
it('should re-throw an error if the database call fails', async () => {
it('should re-throw an error if processTokenCleanupJob fails', async () => {
const job = createMockJob({ timestamp: new Date().toISOString() });
const dbError = new Error('DB cleanup failed');
mocks.deleteExpiredResetTokens.mockRejectedValue(dbError);
mocks.processTokenCleanupJob.mockRejectedValue(dbError);
await expect(tokenCleanupProcessor(job)).rejects.toThrow(dbError);
});
});

View File

@@ -0,0 +1,124 @@
// src/services/tokenStorage.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getToken, setToken, removeToken } from './tokenStorage';
// --- Mock localStorage ---
// We create a simple in-memory storage object to simulate localStorage.
let storage: { [key: string]: string } = {};
const localStorageMock = {
getItem: vi.fn((key: string) => storage[key] || null),
setItem: vi.fn((key: string, value: string) => {
storage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete storage[key];
}),
clear: vi.fn(() => {
storage = {};
}),
};
// Before each test, we replace the global `localStorage` with our mock.
beforeEach(() => {
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
configurable: true,
});
// Also clear the in-memory storage and mock call history.
storage = {};
vi.clearAllMocks();
});
afterEach(() => {
// Restore any spied-on objects
vi.restoreAllMocks();
});
// --- Test Suite ---
describe('tokenStorage', () => {
const TOKEN_KEY = 'authToken';
const TEST_TOKEN = 'test-jwt-token';
describe('setToken', () => {
it('should call localStorage.setItem with the correct key and token', () => {
setToken(TEST_TOKEN);
expect(localStorageMock.setItem).toHaveBeenCalledWith(TOKEN_KEY, TEST_TOKEN);
expect(storage[TOKEN_KEY]).toBe(TEST_TOKEN);
});
it('should handle errors when localStorage is not available', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('localStorage is disabled');
localStorageMock.setItem.mockImplementationOnce(() => {
throw error;
});
setToken(TEST_TOKEN);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'SecurityError: Failed to access localStorage to set token.',
error,
);
});
});
describe('getToken', () => {
it('should call localStorage.getItem with the correct key', () => {
getToken();
expect(localStorageMock.getItem).toHaveBeenCalledWith(TOKEN_KEY);
});
it('should return the token if it exists', () => {
storage[TOKEN_KEY] = TEST_TOKEN;
const token = getToken();
expect(token).toBe(TEST_TOKEN);
});
it('should return null if the token does not exist', () => {
const token = getToken();
expect(token).toBeNull();
});
it('should handle errors when localStorage is not available', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('localStorage is disabled');
localStorageMock.getItem.mockImplementationOnce(() => {
throw error;
});
const token = getToken();
expect(token).toBeNull();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'SecurityError: Failed to access localStorage to get token.',
error,
);
});
});
describe('removeToken', () => {
it('should call localStorage.removeItem with the correct key', () => {
storage[TOKEN_KEY] = TEST_TOKEN; // Set a token first
removeToken();
expect(localStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_KEY);
expect(storage[TOKEN_KEY]).toBeUndefined();
});
it('should handle errors when localStorage is not available', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('localStorage is disabled');
localStorageMock.removeItem.mockImplementationOnce(() => {
throw error;
});
removeToken();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'SecurityError: Failed to access localStorage to remove token.',
error,
);
});
});
});

View File

@@ -0,0 +1,46 @@
// src/services/tokenStorage.ts
/**
* A centralized module for handling authentication token storage.
* This abstraction layer makes it easy to change the storage mechanism
* (e.g., from localStorage to sessionStorage or an in-memory store for testing)
* without altering the application's authentication logic.
*/
const TOKEN_KEY = 'authToken';
/**
* Retrieves the authentication token from storage.
* @returns The token string, or null if not found or if storage is unavailable.
*/
export const getToken = (): string | null => {
try {
return window.localStorage.getItem(TOKEN_KEY);
} catch (error) {
console.error('SecurityError: Failed to access localStorage to get token.', error);
return null;
}
};
/**
* Stores the authentication token.
* @param token The token string to store.
*/
export const setToken = (token: string): void => {
try {
window.localStorage.setItem(TOKEN_KEY, token);
} catch (error) {
console.error('SecurityError: Failed to access localStorage to set token.', error);
}
};
/**
* Removes the authentication token from storage.
*/
export const removeToken = (): void => {
try {
window.localStorage.removeItem(TOKEN_KEY);
} catch (error) {
console.error('SecurityError: Failed to access localStorage to remove token.', error);
}
};

View File

@@ -1,9 +1,12 @@
// src/services/userService.ts
import * as db from './db/index.db';
import type { Job } from 'bullmq';
import type { Logger } from 'pino';
import { AddressRepository } from './db/address.db';
import { UserRepository } from './db/user.db';
import type { Address, UserProfile } from '../types';
import { logger as globalLogger } from './logger.server';
import type { TokenCleanupJobData } from './queues.server';
/**
* Encapsulates user-related business logic that may involve multiple repository calls.
@@ -44,6 +47,35 @@ class UserService {
return addressId;
});
}
/**
* Processes a job to clean up expired password reset tokens from the database.
* @param job The BullMQ job object.
* @returns An object containing the count of deleted tokens.
*/
async processTokenCleanupJob(
job: Job<TokenCleanupJobData>,
): Promise<{ deletedCount: number }> {
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
});
logger.info('Picked up expired token cleanup job.');
try {
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
return { deletedCount };
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{ err: wrappedError, attemptsMade: job.attemptsMade },
'Expired token cleanup job failed.',
);
throw wrappedError;
}
}
}
export const userService = new UserService();

View File

@@ -6,6 +6,8 @@ import { promisify } from 'util';
import { logger } from './logger.server';
import { connection } from './redis.server';
import { aiService } from './aiService.server';
import { analyticsService } from './analyticsService.server';
import { userService } from './userService';
import * as emailService from './emailService.server';
import * as db from './db/index.db';
import {
@@ -69,20 +71,11 @@ export const flyerWorker = new Worker<FlyerJobData>(
try {
return await flyerProcessingService.processJob(job);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
const errorMessage = wrappedError.message || '';
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') ||
errorMessage.includes('RESOURCE_EXHAUSTED')
) {
logger.error(
{ err: wrappedError, jobId: job.id },
'[FlyerWorker] Unrecoverable quota error detected. Failing job immediately.',
);
throw new UnrecoverableError(errorMessage);
}
throw error;
// The service layer now handles identifying unrecoverable errors and throws
// a `BullMQ.UnrecoverableError` which the worker respects. This catch block
// simply ensures any non-Error exceptions are properly wrapped before being
// re-thrown for BullMQ to handle retries.
throw normalizeError(error);
}
},
{
@@ -94,20 +87,13 @@ export const flyerWorker = new Worker<FlyerJobData>(
export const emailWorker = new Worker<EmailJobData>(
'email-sending',
async (job: Job<EmailJobData>) => {
const { to, subject } = job.data;
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
jobLogger.info({ to, subject }, `[EmailWorker] Sending email for job ${job.id}`);
try {
await emailService.sendEmail(job.data, jobLogger);
// Delegate all logic to the service layer
return await emailService.processEmailJob(job);
} catch (error: unknown) {
// The service layer now handles logging. This block just ensures
// any unexpected errors are normalized before BullMQ handles them.
const wrappedError = normalizeError(error);
logger.error(
{
err: wrappedError,
jobData: job.data,
},
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw wrappedError;
}
},
@@ -120,20 +106,12 @@ export const emailWorker = new Worker<EmailJobData>(
export const analyticsWorker = new Worker<AnalyticsJobData>(
'analytics-reporting',
async (job: Job<AnalyticsJobData>) => {
const { reportDate } = job.data;
logger.info({ reportDate }, `[AnalyticsWorker] Starting report generation for job ${job.id}`);
try {
if (reportDate === 'FAIL') {
throw new Error('This is a test failure for the analytics job.');
}
await new Promise((resolve) => setTimeout(resolve, 10000));
logger.info(`[AnalyticsWorker] Successfully generated report for ${reportDate}.`);
return await analyticsService.processDailyReportJob(job);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
logger.error({ err: wrappedError, jobData: job.data },
`[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw wrappedError;
// The service layer now handles logging. This block just ensures
// any unexpected errors are normalized before BullMQ handles them.
throw normalizeError(error);
}
},
{
@@ -145,48 +123,12 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
export const cleanupWorker = new Worker<CleanupJobData>(
'file-cleanup',
async (job: Job<CleanupJobData>) => {
const { flyerId, paths } = job.data;
logger.info(
{ paths },
`[CleanupWorker] Starting file cleanup for job ${job.id} (Flyer ID: ${flyerId})`,
);
try {
if (!paths || paths.length === 0) {
logger.warn(
`[CleanupWorker] Job ${job.id} for flyer ${flyerId} received no paths to clean. Skipping.`,
);
return;
}
for (const filePath of paths) {
try {
await fsAdapter.unlink(filePath);
logger.info(`[CleanupWorker] Deleted temporary file: ${filePath}`);
} catch (unlinkError: unknown) {
if (
unlinkError instanceof Error &&
'code' in unlinkError &&
(unlinkError as any).code === 'ENOENT'
) {
logger.warn(
`[CleanupWorker] File not found during cleanup (already deleted?): ${filePath}`,
);
} else {
throw unlinkError;
}
}
}
logger.info(
`[CleanupWorker] Successfully cleaned up ${paths.length} file(s) for flyer ${flyerId}.`,
);
return await flyerProcessingService.processCleanupJob(job);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
logger.error(
{ err: wrappedError },
`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw wrappedError;
// The service layer now handles logging. This block just ensures
// any unexpected errors are normalized before BullMQ handles them.
throw normalizeError(error);
}
},
{
@@ -198,23 +140,12 @@ export const cleanupWorker = new Worker<CleanupJobData>(
export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
'weekly-analytics-reporting',
async (job: Job<WeeklyAnalyticsJobData>) => {
const { reportYear, reportWeek } = job.data;
logger.info(
{ reportYear, reportWeek },
`[WeeklyAnalyticsWorker] Starting weekly report generation for job ${job.id}`,
);
try {
await new Promise((resolve) => setTimeout(resolve, 30000));
logger.info(
`[WeeklyAnalyticsWorker] Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`,
);
return await analyticsService.processWeeklyReportJob(job);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
logger.error(
{ err: wrappedError, jobData: job.data },
`[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw wrappedError;
// The service layer now handles logging. This block just ensures
// any unexpected errors are normalized before BullMQ handles them.
throw normalizeError(error);
}
},
{
@@ -226,16 +157,12 @@ export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
export const tokenCleanupWorker = new Worker<TokenCleanupJobData>(
'token-cleanup',
async (job: Job<TokenCleanupJobData>) => {
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
jobLogger.info('[TokenCleanupWorker] Starting cleanup of expired password reset tokens.');
try {
const deletedCount = await db.userRepo.deleteExpiredResetTokens(jobLogger);
jobLogger.info(`[TokenCleanupWorker] Successfully deleted ${deletedCount} expired tokens.`);
return { deletedCount };
return await userService.processTokenCleanupJob(job);
} catch (error: unknown) {
const wrappedError = normalizeError(error);
jobLogger.error({ err: wrappedError }, `[TokenCleanupWorker] Job ${job.id} failed.`);
throw wrappedError;
// The service layer now handles logging. This block just ensures
// any unexpected errors are normalized before BullMQ handles them.
throw normalizeError(error);
}
},
{