Compare commits

...

50 Commits

Author SHA1 Message Date
Gitea Actions
c182ef6d30 ci: Bump version to 0.2.26 [skip ci] 2025-12-30 04:38:22 +05:00
fdb3b76cbd fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m59s
2025-12-29 15:37:51 -08:00
Gitea Actions
01e7c843cb ci: Bump version to 0.2.25 [skip ci] 2025-12-30 04:15:41 +05:00
a0dbefbfa0 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m4s
2025-12-29 15:14:44 -08:00
Gitea Actions
ab3fc318a0 ci: Bump version to 0.2.24 [skip ci] 2025-12-30 02:44:22 +05:00
e658b35e43 ffs
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m3s
2025-12-29 13:43:41 -08:00
Gitea Actions
67e106162a ci: Bump version to 0.2.23 [skip ci] 2025-12-30 02:35:43 +05:00
b7f3182fd6 clean up routes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 4m24s
2025-12-29 13:34:26 -08:00
Gitea Actions
ac60072d88 ci: Bump version to 0.2.22 [skip ci] 2025-12-29 12:09:21 +05:00
9390f38bf6 maybe a few too many fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m45s
2025-12-28 23:08:40 -08:00
Gitea Actions
236d5518c9 ci: Bump version to 0.2.21 [skip ci] 2025-12-29 11:45:13 +05:00
fd52a79a72 fixin
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-28 22:38:26 -08:00
Gitea Actions
f72819e343 ci: Bump version to 0.2.20 [skip ci] 2025-12-29 11:26:09 +05:00
1af8be3f15 more fixings
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 38s
2025-12-28 22:20:28 -08:00
Gitea Actions
28d03f4e21 ci: Bump version to 0.2.19 [skip ci] 2025-12-29 10:39:22 +05:00
2e72ee81dd maybe a few too many fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2025-12-28 21:38:31 -08:00
Gitea Actions
ba67ace190 ci: Bump version to 0.2.18 [skip ci] 2025-12-29 04:33:54 +05:00
Gitea Actions
50782c30e5 ci: Bump version to 0.2.16 [skip ci] 2025-12-29 04:33:54 +05:00
4a2ff8afc5 fix unit tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m39s
2025-12-28 15:33:22 -08:00
Gitea Actions
7a1c14ce89 ci: Bump version to 0.2.15 [skip ci] 2025-12-29 04:12:16 +05:00
6fafc3d089 test secrets better
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m47s
2025-12-28 15:11:46 -08:00
Gitea Actions
4316866bce ci: Bump version to 0.2.14 [skip ci] 2025-12-29 03:54:44 +05:00
356c1a1894 jwtsecret issue
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 24s
2025-12-28 14:50:57 -08:00
Gitea Actions
2a310648ca ci: Bump version to 0.2.13 [skip ci] 2025-12-29 03:42:41 +05:00
8592633c22 unit test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-28 14:42:11 -08:00
Gitea Actions
0a9cdb8709 ci: Bump version to 0.2.12 [skip ci] 2025-12-29 02:50:56 +05:00
0d21e098f8 Merge branches 'main' and '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 13m7s
2025-12-28 13:49:58 -08:00
b6799ed167 test fixing and flyer processor refactor 2025-12-28 13:48:27 -08:00
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
108 changed files with 5850 additions and 4076 deletions

View File

@@ -158,7 +158,7 @@ jobs:
else else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..." echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi 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." echo "Production backend server reloaded successfully."
else else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload." 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. # integration test suite can launch its own, fresh server instance.
# '|| true' ensures the workflow doesn't fail if the process isn't running. # '|| true' ensures the workflow doesn't fail if the process isn't running.
run: | run: |
pm2 stop flyer-crawler-api-test || true echo "--- Stopping and deleting all test processes ---"
pm2 stop flyer-crawler-worker-test || true # Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
pm2 delete flyer-crawler-api-test || true # This is safer than 'pm2 delete all' and more robust than naming each process individually.
pm2 delete flyer-crawler-worker-test || true # 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 - name: Run All Tests and Generate Merged Coverage Report
# This single step runs both unit and integration tests, then merges their # This single step runs both unit and integration tests, then merges their
@@ -126,7 +127,7 @@ jobs:
# --- Increase Node.js memory limit to prevent heap out of memory errors --- # --- Increase Node.js memory limit to prevent heap out of memory errors ---
# This is crucial for memory-intensive tasks like running tests and coverage. # This is crucial for memory-intensive tasks like running tests and coverage.
NODE_OPTIONS: '--max-old-space-size=8192' NODE_OPTIONS: '--max-old-space-size=8192 --trace-warnings --unhandled-rejections=strict'
run: | run: |
# Fail-fast check to ensure secrets are configured in Gitea for testing. # Fail-fast check to ensure secrets are configured in Gitea for testing.
@@ -375,7 +376,7 @@ jobs:
# Application Secrets # Application Secrets
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com' FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }} JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
@@ -389,8 +390,15 @@ jobs:
run: | run: |
# Fail-fast check to ensure secrets are configured in Gitea. # Fail-fast check to ensure secrets are configured in Gitea.
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then MISSING_SECRETS=""
echo "ERROR: One or more test database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_TEST) are not set in Gitea repository settings." if [ -z "$DB_HOST" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_HOST"; fi
if [ -z "$DB_USER" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_USER"; fi
if [ -z "$DB_PASSWORD" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_PASSWORD"; fi
if [ -z "$DB_NAME" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_NAME"; fi
if [ -z "$JWT_SECRET" ]; then MISSING_SECRETS="${MISSING_SECRETS} JWT_SECRET"; fi
if [ ! -z "$MISSING_SECRETS" ]; then
echo "ERROR: The following required secrets are missing in Gitea:${MISSING_SECRETS}"
exit 1 exit 1
fi fi
@@ -405,7 +413,7 @@ jobs:
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy. # 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. # 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. # 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." echo "Test backend server reloaded successfully."
# After a successful deployment, update the schema hash in the database. # After a successful deployment, update the schema hash in the database.

View File

@@ -157,7 +157,7 @@ jobs:
else else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..." echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi 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." echo "Production backend server reloaded successfully."
else else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload." echo "Version $NEW_VERSION is already running. Skipping PM2 reload."

View File

@@ -3,23 +3,38 @@
// It allows us to define all the settings for our application in one place. // 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". // 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');
process.exit(1); // Fail fast so PM2 doesn't attempt to start a broken app
} else {
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
}
module.exports = { module.exports = {
apps: [ apps: [
{ {
// --- API Server --- // --- 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', name: 'flyer-crawler-api',
script: './node_modules/.bin/tsx', script: './node_modules/.bin/tsx',
args: 'server.ts', // tsx will execute this file args: 'server.ts',
max_memory_restart: '500M', // Restart if memory usage exceeds 500MB max_memory_restart: '500M',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings // Production Environment Settings
env_production: { env_production: {
NODE_ENV: 'production', // Set the Node.js environment to production NODE_ENV: 'production',
name: 'flyer-crawler-api', name: 'flyer-crawler-api',
cwd: '/var/www/flyer-crawler.projectium.com', cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -39,10 +54,9 @@ module.exports = {
}, },
// Test Environment Settings // Test Environment Settings
env_test: { 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', name: 'flyer-crawler-api-test',
cwd: '/var/www/flyer-crawler-test.projectium.com', cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -66,7 +80,6 @@ module.exports = {
name: 'flyer-crawler-api-dev', name: 'flyer-crawler-api-dev',
watch: true, watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -89,14 +102,19 @@ module.exports = {
// --- General Worker --- // --- General Worker ---
name: 'flyer-crawler-worker', name: 'flyer-crawler-worker',
script: './node_modules/.bin/tsx', 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 if memory usage exceeds 1GB max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings // Production Environment Settings
env_production: { env_production: {
NODE_ENV: 'production', NODE_ENV: 'production',
name: 'flyer-crawler-worker', name: 'flyer-crawler-worker',
cwd: '/var/www/flyer-crawler.projectium.com', cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -119,7 +137,6 @@ module.exports = {
NODE_ENV: 'test', NODE_ENV: 'test',
name: 'flyer-crawler-worker-test', name: 'flyer-crawler-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com', cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -143,7 +160,6 @@ module.exports = {
name: 'flyer-crawler-worker-dev', name: 'flyer-crawler-worker-dev',
watch: true, watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -166,14 +182,19 @@ module.exports = {
// --- Analytics Worker --- // --- Analytics Worker ---
name: 'flyer-crawler-analytics-worker', name: 'flyer-crawler-analytics-worker',
script: './node_modules/.bin/tsx', 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 if memory usage exceeds 1GB max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings // Production Environment Settings
env_production: { env_production: {
NODE_ENV: 'production', NODE_ENV: 'production',
name: 'flyer-crawler-analytics-worker', name: 'flyer-crawler-analytics-worker',
cwd: '/var/www/flyer-crawler.projectium.com', cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -196,7 +217,6 @@ module.exports = {
NODE_ENV: 'test', NODE_ENV: 'test',
name: 'flyer-crawler-analytics-worker-test', name: 'flyer-crawler-analytics-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com', cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, DB_PASSWORD: process.env.DB_PASSWORD,
@@ -220,7 +240,6 @@ module.exports = {
name: 'flyer-crawler-analytics-worker-dev', name: 'flyer-crawler-analytics-worker-dev',
watch: true, watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST, DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER, DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD, 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.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.2.2", "version": "0.2.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.2.2", "version": "0.2.26",
"dependencies": { "dependencies": {
"@bull-board/api": "^6.14.2", "@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2", "@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.2.2", "version": "0.2.26",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"", "dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -115,6 +115,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
valid_from DATE, valid_from DATE,
valid_to DATE, valid_to DATE,
store_address TEXT, store_address TEXT,
status TEXT DEFAULT 'processed' NOT NULL CHECK (status IN ('processed', 'needs_review', 'archived')),
item_count INTEGER DEFAULT 0 NOT NULL, item_count INTEGER DEFAULT 0 NOT NULL,
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL, uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
@@ -130,11 +131,13 @@ COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.'; COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.'; COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.'; COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
COMMENT ON COLUMN public.flyers.status IS 'The processing status of the flyer, e.g., if it needs manual review.';
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.'; COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.'; COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC); CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC); CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
CREATE INDEX IF NOT EXISTS idx_flyers_status ON public.flyers(status);
-- 7. The 'master_grocery_items' table. This is the master dictionary. -- 7. The 'master_grocery_items' table. This is the master dictionary.
CREATE TABLE IF NOT EXISTS public.master_grocery_items ( CREATE TABLE IF NOT EXISTS public.master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,

View File

@@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
valid_from DATE, valid_from DATE,
valid_to DATE, valid_to DATE,
store_address TEXT, store_address TEXT,
status TEXT DEFAULT 'processed' NOT NULL CHECK (status IN ('processed', 'needs_review', 'archived')),
item_count INTEGER DEFAULT 0 NOT NULL, item_count INTEGER DEFAULT 0 NOT NULL,
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL, uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
@@ -146,11 +147,13 @@ COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.'; COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.'; COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.'; COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
COMMENT ON COLUMN public.flyers.status IS 'The processing status of the flyer, e.g., if it needs manual review.';
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.'; COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.'; COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC); CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC); CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
CREATE INDEX IF NOT EXISTS idx_flyers_status ON public.flyers(status);
-- 7. The 'master_grocery_items' table. This is the master dictionary. -- 7. The 'master_grocery_items' table. This is the master dictionary.
CREATE TABLE IF NOT EXISTS public.master_grocery_items ( CREATE TABLE IF NOT EXISTS public.master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,

View File

@@ -20,6 +20,7 @@ import {
mockUseUserData, mockUseUserData,
mockUseFlyerItems, mockUseFlyerItems,
} from './tests/setup/mockHooks'; } from './tests/setup/mockHooks';
import { useAppInitialization } from './hooks/useAppInitialization';
// Mock top-level components rendered by App's routes // Mock top-level components rendered by App's routes
@@ -52,6 +53,9 @@ vi.mock('./hooks/useFlyerItems', async () => {
return { useFlyerItems: hooks.mockUseFlyerItems }; return { useFlyerItems: hooks.mockUseFlyerItems };
}); });
vi.mock('./hooks/useAppInitialization');
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
vi.mock('./hooks/useAuth', async () => { vi.mock('./hooks/useAuth', async () => {
const hooks = await import('./tests/setup/mockHooks'); const hooks = await import('./tests/setup/mockHooks');
return { useAuth: hooks.mockUseAuth }; return { useAuth: hooks.mockUseAuth };
@@ -122,7 +126,23 @@ vi.mock('./layouts/MainLayout', async () => {
return { MainLayout: MockMainLayout }; 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 mockedApiClient = vi.mocked(apiClient);
const mockFlyers: Flyer[] = [ const mockFlyers: Flyer[] = [
@@ -131,33 +151,6 @@ const mockFlyers: Flyer[] = [
]; ];
describe('App Component', () => { 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(() => { beforeEach(() => {
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults'); console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
vi.clearAllMocks(); vi.clearAllMocks();
@@ -205,11 +198,9 @@ describe('App Component', () => {
mockUseFlyerItems.mockReturnValue({ mockUseFlyerItems.mockReturnValue({
flyerItems: [], flyerItems: [],
isLoading: false, isLoading: false,
error: null,
}); });
// Clear local storage to prevent state from leaking between tests. mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
localStorage.clear();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true });
// Default mocks for API calls // Default mocks for API calls
// Use mockImplementation to create a new Response object for each call, // 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 () => { it('should render the main layout and header', async () => {
// Simulate the auth hook finishing its initial check // Simulate the auth hook finishing its initial check
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
userProfile: null, userProfile: null,
authStatus: 'SIGNED_OUT', authStatus: 'SIGNED_OUT',
@@ -272,6 +264,7 @@ describe('App Component', () => {
renderApp(); renderApp();
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('app-guard-mock')).toBeInTheDocument();
expect(screen.getByTestId('header-mock')).toBeInTheDocument(); expect(screen.getByTestId('header-mock')).toBeInTheDocument();
// Check that the main layout and home page are rendered for the root path // Check that the main layout and home page are rendered for the root path
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument(); 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', () => { describe('Flyer Selection from URL', () => {
it('should select a flyer when flyerId is present in the URL', async () => { it('should select a flyer when flyerId is present in the URL', async () => {
renderApp(['/flyers/2']); 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', () => { describe('Modal Interactions', () => {
it('should open and close the ProfileManager modal', async () => { it('should open and close the ProfileManager modal', async () => {
console.log('[TEST DEBUG] Test Start: should open and close the ProfileManager modal');
renderApp(); renderApp();
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
@@ -607,11 +399,13 @@ describe('App Component', () => {
fireEvent.click(screen.getByText('Open Profile')); fireEvent.click(screen.getByText('Open Profile'));
expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument(); expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument();
console.log('[TEST DEBUG] ProfileManager modal opened. Now closing...');
// Close modal // Close modal
fireEvent.click(screen.getByText('Close Profile')); fireEvent.click(screen.getByText('Close Profile'));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); 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 () => { 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')); fireEvent.click(screen.getByText('Open Voice Assistant'));
console.log('[TEST DEBUG] Waiting for voice-assistant-mock'); 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 // Close modal
fireEvent.click(screen.getByText('Close Voice Assistant')); 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', () => { describe('Profile and Login Handlers', () => {
it('should call updateProfile when handleProfileUpdate is triggered', async () => { it('should call updateProfile when handleProfileUpdate is triggered', async () => {
console.log( console.log(
@@ -841,12 +577,19 @@ describe('App Component', () => {
logout: vi.fn(), logout: vi.fn(),
updateProfile: 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'); console.log('[TEST DEBUG] Rendering App');
renderApp(); renderApp();
console.log('[TEST DEBUG] Opening Profile'); console.log('[TEST DEBUG] Opening Profile');
fireEvent.click(screen.getByText('Open 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'); console.log('[TEST DEBUG] Clicking Login');
fireEvent.click(loginButton); 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,10 +1,9 @@
// src/App.tsx // src/App.tsx
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import * as pdfjsLib from 'pdfjs-dist'; 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 { Header } from './components/Header';
import { logger } from './services/logger.client'; import { logger } from './services/logger.client';
import type { Flyer, Profile, UserProfile } from './types'; import type { Flyer, Profile, UserProfile } from './types';
@@ -14,18 +13,20 @@ import { AdminPage } from './pages/admin/AdminPage';
import { AdminRoute } from './components/AdminRoute'; import { AdminRoute } from './components/AdminRoute';
import { CorrectionsPage } from './pages/admin/CorrectionsPage'; import { CorrectionsPage } from './pages/admin/CorrectionsPage';
import { AdminStatsPage } from './pages/admin/AdminStatsPage'; import { AdminStatsPage } from './pages/admin/AdminStatsPage';
import { FlyerReviewPage } from './pages/admin/FlyerReviewPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { VoiceLabPage } from './pages/VoiceLabPage'; import { VoiceLabPage } from './pages/VoiceLabPage';
import { WhatsNewModal } from './components/WhatsNewModal';
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool'; import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon'; import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
import { useAuth } from './hooks/useAuth'; import { useAuth } from './hooks/useAuth';
import { useFlyers } from './hooks/useFlyers'; // Assuming useFlyers fetches all flyers import { useFlyers } from './hooks/useFlyers';
import { useFlyerItems } from './hooks/useFlyerItems'; // Import the new hook for flyer items import { useFlyerItems } from './hooks/useFlyerItems';
import { useModal } from './hooks/useModal'; import { useModal } from './hooks/useModal';
import { MainLayout } from './layouts/MainLayout'; import { MainLayout } from './layouts/MainLayout';
import config from './config'; import config from './config';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { AppGuard } from './components/AppGuard';
import { useAppInitialization } from './hooks/useAppInitialization';
// pdf.js worker configuration // pdf.js worker configuration
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing. // This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
@@ -44,10 +45,12 @@ function App() {
const { flyers } = useFlyers(); const { flyers } = useFlyers();
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null); const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const { openModal, closeModal, isModalOpen } = useModal(); const { openModal, closeModal, isModalOpen } = useModal();
const location = useLocation();
const navigate = useNavigate();
const params = useParams<{ flyerId?: string }>(); 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 // Debugging: Log renders to identify infinite loops
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
@@ -57,14 +60,11 @@ function App() {
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed. paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
authStatus, authStatus,
profileId: userProfile?.user.user_id, profileId: userProfile?.user.user_id,
locationSearch: location.search,
}); });
} }
}); });
const [isDarkMode, setIsDarkMode] = useState(false);
const { flyerItems } = useFlyerItems(selectedFlyer); 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 // Define modal handlers with useCallback at the top level to avoid Rules of Hooks violations
const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]); const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]);
@@ -109,37 +109,6 @@ function App() {
// --- State Synchronization and Error Handling --- // --- 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. // This is the login handler that will be passed to the ProfileManager component.
const handleLoginSuccess = useCallback( const handleLoginSuccess = useCallback(
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => { async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
@@ -157,36 +126,6 @@ function App() {
[login], [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) => { const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer); setSelectedFlyer(flyer);
}, []); }, []);
@@ -214,31 +153,10 @@ function App() {
// Read the application version injected at build time. // Read the application version injected at build time.
// This will only be available in the production build, not during local development. // This will only be available in the production build, not during local development.
const appVersion = config.app.version; 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 ( return (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200"> // AppGuard now handles the main page wrapper, theme styles, and "What's New" modal
{/* Toaster component for displaying notifications. It's placed at the top level. */} <AppGuard>
<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>
<Header <Header
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
unitSystem={unitSystem} unitSystem={unitSystem}
@@ -265,15 +183,6 @@ function App() {
/> />
)} )}
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isModalOpen('whatsNew')}
onClose={handleCloseWhatsNew}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{selectedFlyer && ( {selectedFlyer && (
<FlyerCorrectionTool <FlyerCorrectionTool
isOpen={isModalOpen('correctionTool')} isOpen={isModalOpen('correctionTool')}
@@ -320,6 +229,7 @@ function App() {
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/admin/corrections" element={<CorrectionsPage />} /> <Route path="/admin/corrections" element={<CorrectionsPage />} />
<Route path="/admin/stats" element={<AdminStatsPage />} /> <Route path="/admin/stats" element={<AdminStatsPage />} />
<Route path="/admin/flyer-review" element={<FlyerReviewPage />} />
<Route path="/admin/voice-lab" element={<VoiceLabPage />} /> <Route path="/admin/voice-lab" element={<VoiceLabPage />} />
</Route> </Route>
<Route path="/reset-password/:token" element={<ResetPasswordPage />} /> <Route path="/reset-password/:token" element={<ResetPasswordPage />} />
@@ -345,7 +255,7 @@ function App() {
)} )}
<Footer /> <Footer />
</div> </AppGuard>
); );
} }

View File

@@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
import { AnonymousUserBanner } from './AnonymousUserBanner'; import { AnonymousUserBanner } from './AnonymousUserBanner';
// Mock the icon to ensure it is rendered correctly // Mock the icon to ensure it is rendered correctly
vi.mock('../../../components/icons/InformationCircleIcon', () => ({ vi.mock('./icons/InformationCircleIcon', () => ({
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => ( InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="info-icon" {...props} /> <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 React from 'react';
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon'; import { InformationCircleIcon } from './icons/InformationCircleIcon';
interface AnonymousUserBannerProps { 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 React, { useState } from 'react';
import { EyeIcon } from '../../../components/icons/EyeIcon'; import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon'; import { EyeSlashIcon } from './icons/EyeSlashIcon';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator'; import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
/** /**

View File

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

View File

@@ -0,0 +1,18 @@
import React from 'react';
export const DocumentMagnifyingGlassIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5 4.5L6.75 21.75m0 0L2.25 17.25m4.5 4.5v-4.5m13.5-3V9A2.25 2.25 0 0 0 16.5 6.75h-9A2.25 2.25 0 0 0 5.25 9v9.75m14.25-10.5a2.25 2.25 0 0 0-2.25-2.25H5.25a2.25 2.25 0 0 0-2.25 2.25v10.5a2.25 2.25 0 0 0 2.25 225h5.25"
/>
</svg>
);

Some files were not shown because too many files have changed in this diff Show More