Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c182ef6d30 | ||
| fdb3b76cbd | |||
|
|
01e7c843cb | ||
| a0dbefbfa0 | |||
|
|
ab3fc318a0 | ||
| e658b35e43 | |||
|
|
67e106162a | ||
| b7f3182fd6 | |||
|
|
ac60072d88 | ||
| 9390f38bf6 | |||
|
|
236d5518c9 | ||
| fd52a79a72 | |||
|
|
f72819e343 | ||
| 1af8be3f15 | |||
|
|
28d03f4e21 | ||
| 2e72ee81dd | |||
|
|
ba67ace190 | ||
|
|
50782c30e5 | ||
| 4a2ff8afc5 | |||
|
|
7a1c14ce89 | ||
| 6fafc3d089 | |||
|
|
4316866bce | ||
| 356c1a1894 | |||
|
|
2a310648ca | ||
| 8592633c22 | |||
|
|
0a9cdb8709 | ||
| 0d21e098f8 | |||
| b6799ed167 | |||
|
|
be5bda169e | ||
| 4ede403356 | |||
| 5d31605b80 | |||
| ddd4ad024e | |||
|
|
4e927f48bd | ||
| af5644d17a | |||
|
|
016c0a883a | ||
| c6a5f889b4 | |||
|
|
c895ecdb28 | ||
| 05e3f8a61c | |||
|
|
f79a2abc65 | ||
| a726c270bb | |||
|
|
8a4965c45b | ||
| 93497bf7c7 | |||
|
|
20584af729 | ||
| be9f452656 | |||
| ef4b8e58fe | |||
|
|
a42f7d7007 | ||
| 768d02b9ed | |||
|
|
c4742959e4 | ||
| 97c54c0c5c | |||
| 7cc50907d1 |
@@ -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."
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
118
notes-to-ai4.txt
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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\"",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
360
src/App.test.tsx
360
src/App.test.tsx
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
120
src/App.tsx
120
src/App.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
),
|
),
|
||||||
@@ -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 {
|
||||||
/**
|
/**
|
||||||
93
src/components/AppGuard.test.tsx
Normal file
93
src/components/AppGuard.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/components/AppGuard.tsx
Normal file
47
src/components/AppGuard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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';
|
||||||
|
|
||||||
18
src/components/icons/DocumentMagnifyingGlassIcon.tsx
Normal file
18
src/components/icons/DocumentMagnifyingGlassIcon.tsx
Normal 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
Reference in New Issue
Block a user