Compare commits

...

102 Commits

Author SHA1 Message Date
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
Gitea Actions
b4199f7c48 ci: Bump version to 0.2.2 [skip ci] 2025-12-27 08:37:04 +05:00
dda36f7bc5 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m49s
2025-12-26 19:36:34 -08:00
27810bbb36 some pm2 fiex + unit test work for new flyeruploader 2025-12-26 19:35:44 -08:00
Gitea Actions
7a1421d5c2 ci: Bump version to 0.2.1 [skip ci] 2025-12-27 07:51:11 +05:00
1b52478f97 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
2025-12-26 18:50:22 -08:00
fe8b000737 try to make upload better using tan-react library 2025-12-26 18:49:54 -08:00
Gitea Actions
d2babbe3b0 ci: Bump version to 0.2.0 for production release [skip ci] 2025-12-27 06:32:10 +05:00
Gitea Actions
684d81db2a ci: Bump version to 0.1.19 [skip ci] 2025-12-27 06:18:02 +05:00
59ffa65562 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 11m37s
2025-12-26 17:17:12 -08:00
0c0dd852ac hanldle uncaught exceptions in the tests 2025-12-26 17:16:34 -08:00
Gitea Actions
cde766872e ci: Bump version to 0.1.18 [skip ci] 2025-12-27 00:00:47 +05:00
604b543c12 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 11m35s
2025-12-26 10:59:48 -08:00
fd67fe2941 more unit test fixes 2025-12-26 10:59:38 -08:00
Gitea Actions
582035b60e ci: Bump version to 0.1.17 [skip ci] 2025-12-26 23:35:02 +05:00
44e7670a89 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 11m37s
2025-12-26 10:34:31 -08:00
2abfb3ed6e more unit tests 2025-12-26 10:32:25 -08:00
Gitea Actions
219de4a25c ci: Bump version to 0.1.16 [skip ci] 2025-12-26 22:53:31 +05:00
1540d5051f 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 12m45s
2025-12-26 09:52:47 -08:00
9c978c26fa not sure why those errors got removed we'll see 2025-12-26 09:52:41 -08:00
Gitea Actions
adb109d8e9 ci: Bump version to 0.1.15 [skip ci] 2025-12-26 22:33:15 +05:00
c668c8785f not sure why those errors got removed we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m39s
2025-12-26 09:32:38 -08:00
Gitea Actions
695bbb61b9 ci: Bump version to 0.1.14 [skip ci] 2025-12-26 22:00:15 +05:00
877c971833 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 12m48s
2025-12-26 08:59:39 -08:00
ed3af07aab not sure why those errors got removed we'll see 2025-12-26 08:59:31 -08:00
Gitea Actions
dd4b34edfa ci: Bump version to 0.1.13 [skip ci] 2025-12-26 21:44:58 +05:00
91fa2f0516 not sure why those errors got removed we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m30s
2025-12-26 08:43:49 -08:00
Gitea Actions
aefd57e57b ci: Bump version to 0.1.12 [skip ci] 2025-12-26 08:12:15 +05:00
2ca4eb47ac 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 11m39s
2025-12-25 19:11:25 -08:00
a4fe30da22 not sure why those errors got removed we'll see 2025-12-25 19:11:00 -08:00
Gitea Actions
abab7fd25e ci: Bump version to 0.1.11 [skip ci] 2025-12-26 07:33:29 +05:00
53dd26d2d9 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 12m34s
2025-12-25 18:32:56 -08:00
ab3da0336c more route work - fuck you ai 2025-12-25 18:32:14 -08:00
Gitea Actions
ed6d6349a2 ci: Bump version to 0.1.10 [skip ci] 2025-12-26 06:54:19 +05:00
d4db2a709a Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 47s
2025-12-25 17:53:28 -08:00
508583809b minor sql fix in schema check 2025-12-25 17:53:21 -08:00
Gitea Actions
6b1f7e7590 ci: Bump version to 0.1.9 [skip ci] 2025-12-26 03:46:16 +05:00
07bb31f4fb 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 13m29s
2025-12-25 14:45:29 -08:00
a42fb76da8 minor sql fix in schema check 2025-12-25 14:45:22 -08:00
Gitea Actions
08c320423c ci: Bump version to 0.1.8 [skip ci] 2025-12-26 01:17:16 +05:00
d2498065ed Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-25 12:16:33 -08:00
56dc96f418 more work on the BullMQ workers 2025-12-25 12:16:22 -08:00
Gitea Actions
4e9aa0efc3 ci: Bump version to 0.1.7 [skip ci] 2025-12-26 00:01:02 +05:00
e5e4b1316c Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 39s
2025-12-25 11:00:21 -08:00
e8d511b4de more work on the BullMQ workers 2025-12-25 10:59:35 -08:00
Gitea Actions
c4bbf5c251 ci: Bump version to 0.1.6 [skip ci] 2025-12-25 07:19:39 +05:00
32a9e6732b Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h0m53s
2025-12-24 18:18:35 -08:00
e7c076e2ed test repair 2025-12-24 18:18:28 -08:00
Gitea Actions
dbe8e72efe ci: Bump version to 0.1.5 [skip ci] 2025-12-25 06:13:16 +05:00
38bd193042 not sure why those errors got removed we'll see
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-24 17:12:20 -08:00
Gitea Actions
57215e2778 ci: Bump version to 0.1.4 [skip ci] 2025-12-25 06:04:17 +05:00
2c1de24e9a undo stupid logging change
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m21s
2025-12-24 16:54:56 -08:00
c8baff7aac Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com 2025-12-24 16:17:45 -08:00
de3f21a7ec not sure why those errors got removed we'll see 2025-12-24 16:16:42 -08:00
Gitea Actions
c6adbf79e7 ci: Bump version to 0.1.3 [skip ci] 2025-12-25 02:26:17 +05:00
7399a27600 add ai agent fallbacks
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h14m13s
2025-12-24 13:25:18 -08:00
Gitea Actions
68aadcaa4e ci: Bump version to 0.1.2 [skip ci] 2025-12-25 01:41:06 +05:00
971d2c3fa7 add ai agent fallbacks
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m18s
2025-12-24 12:39:15 -08:00
Gitea Actions
daaacfde5e ci: Bump version to 0.1.1 [skip ci] 2025-12-24 23:53:27 +05:00
7ac8fe1d29 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
2025-12-24 10:52:13 -08:00
a2462dfb6b testing push to prod etc 2025-12-24 10:51:43 -08:00
Gitea Actions
a911224fb4 ci: Bump version to 0.1.0 for production release [skip ci] 2025-12-24 23:24:53 +05:00
Gitea Actions
bf4bcef890 ci: Bump version to 0.0.30 [skip ci] 2025-12-24 22:59:36 +05:00
ac6cd2e0a1 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 16m10s
2025-12-24 09:58:45 -08:00
eea03880c1 exclude some dirs from coverage 2025-12-24 09:58:37 -08:00
Gitea Actions
7fc263691f ci: Bump version to 0.0.29 [skip ci] 2025-12-24 22:41:17 +05:00
c0912d36d5 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 15m59s
2025-12-24 09:39:56 -08:00
612c2b5943 deploy to test fix 2025-12-24 09:39:49 -08:00
Gitea Actions
8e787ddcf0 ci: Bump version to 0.0.28 [skip ci] 2025-12-24 22:18:18 +05:00
11c52d284c fixing unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m13s
2025-12-24 09:17:09 -08:00
Gitea Actions
b528bd3651 ci: Bump version to 0.0.27 [skip ci] 2025-12-24 22:06:03 +05:00
4c5ceb1bd6 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
2025-12-24 09:05:12 -08:00
bcc4ad64dc fixing unit tests 2025-12-24 09:04:10 -08:00
Gitea Actions
d520980322 ci: Bump version to 0.0.26 [skip ci] 2025-12-24 21:23:30 +05:00
d79955aaa0 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 13m50s
2025-12-24 08:22:30 -08:00
e66027dc8e fix e2e and deploy to prod 2025-12-24 08:21:35 -08:00
113 changed files with 6921 additions and 3388 deletions

View File

@@ -47,6 +47,19 @@ jobs:
- name: Install Dependencies
run: npm ci
- name: Bump Minor Version and Push
run: |
# Configure git for the commit.
git config --global user.name 'Gitea Actions'
git config --global user.email 'actions@gitea.projectium.com'
# Bump the minor version number. This creates a new commit and a new tag.
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
npm version minor -m "ci: Bump version to %s for production release [skip ci]"
# Push the new commit and the new tag back to the main branch.
git push --follow-tags
- name: Check for Production Database Schema Changes
env:
DB_HOST: ${{ secrets.DB_HOST }}
@@ -61,9 +74,10 @@ jobs:
echo "--- Checking for production schema changes ---"
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
echo "Current Git Schema Hash: $CURRENT_HASH"
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A || echo "none")
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
if [ -z "$DEPLOYED_HASH" ]; then
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
@@ -79,8 +93,9 @@ jobs:
exit 1
fi
GITEA_SERVER_URL="https://gitea.projectium.com"
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
PACKAGE_VERSION=$(node -p "require('./package.json').version")
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
@@ -123,6 +138,10 @@ jobs:
cd /var/www/flyer-crawler.projectium.com
npm install --omit=dev
# --- Cleanup Errored Processes ---
echo "Cleaning up errored or stopped PM2 processes..."
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
# --- Version Check Logic ---
# Get the version from the newly deployed package.json
NEW_VERSION=$(node -p "require('./package.json').version")
@@ -139,7 +158,7 @@ jobs:
else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
echo "Production backend server reloaded successfully."
else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
@@ -148,7 +167,12 @@ jobs:
echo "Updating schema hash in production database..."
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
"CREATE TABLE IF NOT EXISTS public.schema_info (
environment VARCHAR(50) PRIMARY KEY,
schema_hash VARCHAR(64) NOT NULL,
deployed_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)

View File

@@ -90,10 +90,11 @@ jobs:
# integration test suite can launch its own, fresh server instance.
# '|| true' ensures the workflow doesn't fail if the process isn't running.
run: |
pm2 stop flyer-crawler-api-test || true
pm2 stop flyer-crawler-worker-test || true
pm2 delete flyer-crawler-api-test || true
pm2 delete flyer-crawler-worker-test || true
echo "--- Stopping and deleting all test processes ---"
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
# It prevents the accumulation of duplicate processes from previous test runs.
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
- name: Run All Tests and Generate Merged Coverage Report
# This single step runs both unit and integration tests, then merges their
@@ -119,6 +120,11 @@ jobs:
# --- JWT Secret for Passport authentication in tests ---
JWT_SECRET: ${{ secrets.JWT_SECRET }}
# --- V8 Coverage for Server Process ---
# This variable tells the Node.js process (our server, started by globalSetup)
# where to output its raw V8 coverage data.
NODE_V8_COVERAGE: '.coverage/tmp/integration-server'
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
# This is crucial for memory-intensive tasks like running tests and coverage.
NODE_OPTIONS: '--max-old-space-size=8192'
@@ -137,15 +143,39 @@ jobs:
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
echo "--- Running Unit Tests ---"
# npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
npm run test:unit -- --coverage \
--coverage.exclude='**/*.test.ts' \
--coverage.exclude='**/tests/**' \
--coverage.exclude='**/mocks/**' \
--coverage.exclude='src/components/icons/**' \
--coverage.exclude='src/db/**' \
--coverage.exclude='src/lib/**' \
--coverage.exclude='src/types/**' \
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
echo "--- Running Integration Tests ---"
npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
npm run test:integration -- --coverage \
--coverage.exclude='**/*.test.ts' \
--coverage.exclude='**/tests/**' \
--coverage.exclude='**/mocks/**' \
--coverage.exclude='src/components/icons/**' \
--coverage.exclude='src/db/**' \
--coverage.exclude='src/lib/**' \
--coverage.exclude='src/types/**' \
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
echo "--- Running E2E Tests ---"
# Run E2E tests using the integration config (for DB setup) but output coverage separately.
# We increase timeout significantly (120s) for E2E flows that involve AI processing.
npx vitest run src/tests/e2e --config vitest.config.integration.ts --coverage --coverage.reportsDirectory=.coverage/e2e --reporter=verbose --testTimeout=120000 --no-file-parallelism || true
# Run E2E tests using the dedicated E2E config which inherits from integration config.
# We still pass --coverage to enable it, but directory and timeout are now in the config.
npx vitest run --config vitest.config.e2e.ts --coverage \
--coverage.exclude='**/*.test.ts' \
--coverage.exclude='**/tests/**' \
--coverage.exclude='**/mocks/**' \
--coverage.exclude='src/components/icons/**' \
--coverage.exclude='src/db/**' \
--coverage.exclude='src/lib/**' \
--coverage.exclude='src/types/**' \
--reporter=verbose --no-file-parallelism || true
# Re-enable secret masking for subsequent steps.
echo "::secret-masking::"
@@ -174,7 +204,7 @@ jobs:
# Run c8: read raw files from the temp dir, and output an Istanbul JSON report.
# We only generate the 'json' report here because it's all nyc needs for merging.
echo "Server coverage report about to be generated..."
npx c8 report --reporter=json --temp-directory .coverage/tmp/integration-server --reports-dir .coverage/integration-server
npx c8 report --exclude='**/*.test.ts' --exclude='**/tests/**' --exclude='**/mocks/**' --reporter=json --temp-directory .coverage/tmp/integration-server --reports-dir .coverage/integration-server
echo "Server coverage report generated. Verifying existence:"
ls -l .coverage/integration-server/coverage-final.json
@@ -213,7 +243,10 @@ jobs:
--reporter=text \
--reporter=html \
--report-dir .coverage/ \
--temp-dir "$NYC_SOURCE_DIR"
--temp-dir "$NYC_SOURCE_DIR" \
--exclude "**/*.test.ts" \
--exclude "**/tests/**" \
--exclude "**/mocks/**"
# Re-enable secret masking for subsequent steps.
echo "::secret-masking::"
@@ -264,18 +297,19 @@ jobs:
# We normalize line endings to ensure the hash is consistent across different OS environments.
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
echo "Current Git Schema Hash: $CURRENT_HASH"
# Query the production database to get the hash of the deployed schema.
# The `psql` command requires PGPASSWORD to be set.
# `\t` sets tuples-only mode and `\A` unaligns output to get just the raw value.
# The `|| echo "none"` ensures the command doesn't fail if the table or row doesn't exist yet.
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A || echo "none")
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A)
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
# Check if the hash is "none" (command failed) OR if it's an empty string (table exists but is empty).
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
if [ -z "$DEPLOYED_HASH" ]; then
echo "WARNING: No schema hash found in the test database."
echo "This is expected for a first-time deployment. The hash will be set after a successful deployment."
echo "--- Debug: Dumping schema_info table ---"
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -P pager=off -c "SELECT * FROM public.schema_info;" || true
echo "----------------------------------------"
# We allow the deployment to continue, but a manual schema update is required.
# You could choose to fail here by adding `exit 1`.
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
@@ -299,8 +333,9 @@ jobs:
fi
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
PACKAGE_VERSION=$(node -p "require('./package.json').version")
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build
@@ -362,11 +397,16 @@ jobs:
echo "Installing production dependencies and restarting test server..."
cd /var/www/flyer-crawler-test.projectium.com
npm install --omit=dev # Install only production dependencies
npm install --omit=dev
# --- Cleanup Errored Processes ---
echo "Cleaning up errored or stopped PM2 processes..."
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
# It will START the process if it's not running, or RELOAD it if it is.
# We also add `&& pm2 save` to persist the process list across server reboots.
pm2 startOrReload ecosystem.config.cjs --env test && pm2 save
pm2 startOrReload ecosystem.config.cjs --env test --update-env && pm2 save
echo "Test backend server reloaded successfully."
# After a successful deployment, update the schema hash in the database.
@@ -374,7 +414,12 @@ jobs:
echo "Updating schema hash in test database..."
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
"CREATE TABLE IF NOT EXISTS public.schema_info (
environment VARCHAR(50) PRIMARY KEY,
schema_hash VARCHAR(64) NOT NULL,
deployed_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
# Verify the hash was updated

View File

@@ -0,0 +1,185 @@
# .gitea/workflows/manual-deploy-major.yml
#
# This workflow provides a MANUAL trigger to perform a MAJOR version bump
# and deploy the application to the PRODUCTION environment.
name: Manual - Deploy Major Version to Production
on:
workflow_dispatch:
inputs:
confirmation:
description: 'Type "deploy-major-to-prod" to confirm you want to deploy a new major version.'
required: true
default: 'do-not-run'
force_reload:
description: 'Force PM2 reload even if version matches (true/false).'
required: false
type: boolean
default: false
jobs:
deploy-production-major:
runs-on: projectium.com
steps:
- name: Verify Confirmation Phrase
run: |
if [ "${{ gitea.event.inputs.confirmation }}" != "deploy-major-to-prod" ]; then
echo "ERROR: Confirmation phrase did not match. Aborting deployment."
exit 1
fi
echo "✅ Confirmation accepted. Proceeding with major version production deployment."
- name: Checkout Code from 'main' branch
uses: actions/checkout@v3
with:
ref: 'main' # Explicitly check out the main branch for production deployment
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install Dependencies
run: npm ci
- name: Bump Major Version and Push
run: |
# Configure git for the commit.
git config --global user.name 'Gitea Actions'
git config --global user.email 'actions@gitea.projectium.com'
# Bump the major version number. This creates a new commit and a new tag.
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
npm version major -m "ci: Bump version to %s for major release [skip ci]"
# Push the new commit and the new tag back to the main branch.
git push --follow-tags
- name: Check for Production Database Schema Changes
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
run: |
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
exit 1
fi
echo "--- Checking for production schema changes ---"
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
echo "Current Git Schema Hash: $CURRENT_HASH"
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
if [ -z "$DEPLOYED_HASH" ]; then
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
exit 1
else
echo "✅ Schema is up to date. No changes detected."
fi
- name: Build React Application for Production
run: |
if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then
echo "ERROR: The VITE_GOOGLE_GENAI_API_KEY secret is not set."
exit 1
fi
GITEA_SERVER_URL="https://gitea.projectium.com"
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
PACKAGE_VERSION=$(node -p "require('./package.json').version")
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
- name: Deploy Application to Production Server
run: |
echo "Deploying application files to /var/www/flyer-crawler.projectium.com..."
APP_PATH="/var/www/flyer-crawler.projectium.com"
mkdir -p "$APP_PATH"
mkdir -p "$APP_PATH/flyer-images/icons" "$APP_PATH/flyer-images/archive"
rsync -avz --delete --exclude 'node_modules' --exclude '.git' --exclude 'dist' --exclude 'flyer-images' ./ "$APP_PATH/"
rsync -avz dist/ "$APP_PATH"
echo "Application deployment complete."
- name: Install Backend Dependencies and Restart Production Server
env:
# --- Production Secrets Injection ---
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
REDIS_URL: 'redis://localhost:6379'
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
FRONTEND_URL: 'https://flyer-crawler.projectium.com'
JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
SMTP_HOST: 'localhost'
SMTP_PORT: '1025'
SMTP_SECURE: 'false'
SMTP_USER: ''
SMTP_PASS: ''
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com'
run: |
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
exit 1
fi
echo "Installing production dependencies and restarting server..."
cd /var/www/flyer-crawler.projectium.com
npm install --omit=dev
# --- Cleanup Errored Processes ---
echo "Cleaning up errored or stopped PM2 processes..."
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
# --- Version Check Logic ---
# Get the version from the newly deployed package.json
NEW_VERSION=$(node -p "require('./package.json').version")
echo "Deployed Package Version: $NEW_VERSION"
# Get the running version from PM2 for the main API process
# We use a small node script to parse the JSON output from pm2 jlist
RUNNING_VERSION=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.version : ''); } catch(e) { console.log(''); }")
echo "Running PM2 Version: $RUNNING_VERSION"
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ] || [ "$NEW_VERSION" != "$RUNNING_VERSION" ] || [ -z "$RUNNING_VERSION" ]; then
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ]; then
echo "Force reload triggered by manual input. Reloading PM2..."
else
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
fi
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
echo "Production backend server reloaded successfully."
else
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
fi
echo "Updating schema hash in production database..."
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
if [ "$CURRENT_HASH" = "$UPDATED_HASH" ]; then
echo "✅ Schema hash successfully updated in the database to: $UPDATED_HASH"
else
echo "ERROR: Failed to update schema hash in the database."
fi
- name: Show PM2 Environment for Production
run: |
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
sleep 5
pm2 describe flyer-crawler-api || echo "Could not find production pm2 process."
pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process."
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."

View File

@@ -3,63 +3,258 @@
// It allows us to define all the settings for our application in one place.
// The .cjs extension is required because the project's package.json has "type": "module".
// --- Environment Variable Validation ---
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
if (missingSecrets.length > 0) {
console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:');
missingSecrets.forEach(key => console.warn(` - ${key}`));
console.warn('[ecosystem.config.cjs] The application may crash if these are required for startup.\n');
} else {
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
}
module.exports = {
apps: [
{
// --- API Server ---
// The name is now dynamically set based on the environment.
// This is a common pattern but requires you to call pm2 with the correct name.
// The deploy script handles this by using 'flyer-crawler-api' for prod and 'flyer-crawler-api-test' for test.
name: 'flyer-crawler-api',
script: './node_modules/.bin/tsx',
args: 'server.ts', // tsx will execute this file
args: 'server.ts',
max_memory_restart: '500M',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production', // Set the Node.js environment to production
NODE_ENV: 'production',
name: 'flyer-crawler-api',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'development', // Use 'development' for test to enable more verbose logging if needed
NODE_ENV: 'test',
name: 'flyer-crawler-api-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Development Environment Settings
env_development: {
NODE_ENV: 'development',
name: 'flyer-crawler-api-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
},
{
// --- General Worker ---
name: 'flyer-crawler-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/queueService.server.ts', // tsx will execute this file
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'development',
NODE_ENV: 'test',
name: 'flyer-crawler-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Development Environment Settings
env_development: {
NODE_ENV: 'development',
name: 'flyer-crawler-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
},
{
// --- Analytics Worker ---
name: 'flyer-crawler-analytics-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/queueService.server.ts', // tsx will execute this file
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-analytics-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'development',
NODE_ENV: 'test',
name: 'flyer-crawler-analytics-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
// Development Environment Settings
env_development: {
NODE_ENV: 'development',
name: 'flyer-crawler-analytics-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
},
},
],

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

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

31
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
})
.catch((err) => {
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
logger.error('Failed to fetch image for correction tool', { error: err });
logger.error({ error: err }, 'Failed to fetch image for correction tool');
notifyError('Could not load the image for correction.');
});
}
@@ -164,7 +164,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
notifyError(msg);
logger.error('Error during rescan:', { error: err });
logger.error({ error: err }, 'Error during rescan:');
} finally {
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
setIsProcessing(false);

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { FlyerUploader } from './FlyerUploader';
import * as aiApiClientModule from '../../services/aiApiClient';
import * as checksumModule from '../../utils/checksum';
import { useNavigate, MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
// Mock dependencies
vi.mock('../../services/aiApiClient');
@@ -39,10 +40,19 @@ const mockedChecksumModule = checksumModule as unknown as {
const renderComponent = (onProcessingComplete = vi.fn()) => {
console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter>
</QueryClientProvider>,
);
};
@@ -50,10 +60,11 @@ describe('FlyerUploader', () => {
const navigateSpy = vi.fn();
beforeEach(() => {
// Disable react-query's online manager to prevent it from interfering with fake timers
onlineManager.setEventListener((setOnline) => {
return () => {};
});
console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`);
// Use the 'modern' implementation of fake timers to handle promise microtasks correctly.
vi.useFakeTimers({ toFake: ['setTimeout'], shouldAdvanceTime: true });
console.log('--- [TEST LOG] ---: MODERN fake timers enabled.');
vi.resetAllMocks(); // Resets mock implementations AND call history.
console.log('--- [TEST LOG] ---: Mocks reset.');
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
@@ -61,7 +72,6 @@ describe('FlyerUploader', () => {
});
afterEach(() => {
vi.useRealTimers();
console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`);
});
@@ -73,12 +83,11 @@ describe('FlyerUploader', () => {
it('should handle file upload and start polling', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
);
mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Checking...' } })),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'active',
progress: { message: 'Checking...' },
});
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.');
renderComponent();
@@ -105,21 +114,18 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');
await act(async () => {
console.log('--- [TEST LOG] ---: 8a. vi.advanceTimersByTime(3000) starting...');
vi.advanceTimersByTime(3000);
console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.');
});
// With real timers, we now wait for the polling interval to elapse.
console.log(
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
);
try {
// The polling interval is 3s, so we wait for a bit longer.
await waitFor(() => {
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
});
}, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
} catch (error) {
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
@@ -131,12 +137,11 @@ describe('FlyerUploader', () => {
it('should handle file upload via drag and drop', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-dnd' }), { status: 200 }),
);
mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Dropped...' } })),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-dnd' });
mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'active',
progress: { message: 'Dropped...' },
});
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.');
renderComponent();
@@ -159,16 +164,10 @@ describe('FlyerUploader', () => {
it('should poll for status, complete successfully, and redirect', async () => {
const onProcessingComplete = vi.fn();
console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
mockedAiApiClient.getJobStatus
.mockResolvedValueOnce(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ state: 'completed', returnValue: { flyerId: 42 } })),
);
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Analyzing...' } })
.mockResolvedValueOnce({ state: 'completed', returnValue: { flyerId: 42 } });
console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.');
renderComponent(onProcessingComplete);
@@ -189,24 +188,21 @@ describe('FlyerUploader', () => {
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.');
await act(async () => {
console.log(`--- [TEST LOG] ---: 6. Advancing timers by 4000ms for the second poll...`);
vi.advanceTimersByTime(4000);
});
console.log(`--- [TEST LOG] ---: 7. Timers advanced. Now AWAITING completion message.`);
try {
console.log(
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
);
// Wait for the second poll to occur and the UI to update.
await waitFor(() => {
console.log(
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${mockedAiApiClient.getJobStatus.mock.calls.length}`,
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${
mockedAiApiClient.getJobStatus.mock.calls.length
}`,
);
expect(
screen.getByText('Processing complete! Redirecting to flyer 42...'),
).toBeInTheDocument();
});
}, { timeout: 4000 });
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
} catch (error) {
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
@@ -216,12 +212,9 @@ describe('FlyerUploader', () => {
}
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
await act(async () => {
console.log(`--- [TEST LOG] ---: 10. Advancing timers by 2000ms for redirect...`);
vi.advanceTimersByTime(2000);
});
// Wait for the redirect timer (1.5s in component) to fire.
await act(() => new Promise((r) => setTimeout(r, 2000)));
console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
expect(onProcessingComplete).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
@@ -229,12 +222,15 @@ describe('FlyerUploader', () => {
it('should handle a failed job', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-fail' }), { status: 200 }),
);
mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'failed', failedReason: 'AI model exploded' })),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'failed',
progress: {
errorCode: 'UNKNOWN_ERROR',
message: 'AI model exploded',
},
failedReason: 'This is the raw error message.', // The UI should prefer the progress message.
});
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
renderComponent();
@@ -260,11 +256,80 @@ describe('FlyerUploader', () => {
console.log('--- [TEST LOG] ---: 6. "Upload Another" button confirmed.');
});
it('should clear the polling timeout when a job fails', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' });
// We need at least one 'active' response to establish a timeout loop so we have something to clear
mockedAiApiClient.getJobStatus
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' } })
.mockResolvedValueOnce({
state: 'failed',
progress: { errorCode: 'UNKNOWN_ERROR', message: 'Fatal Error' },
failedReason: 'Fatal Error',
});
renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i);
fireEvent.change(input, { target: { files: [file] } });
// Wait for the first poll to complete and UI to update to "Working..."
await screen.findByText('Working...');
// Wait for the failure UI
await waitFor(() => expect(screen.getByText(/Processing failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
// Verify clearTimeout was called
expect(clearTimeoutSpy).toHaveBeenCalled();
// Verify no further polling occurs
const callsBefore = mockedAiApiClient.getJobStatus.mock.calls.length;
// Wait for a duration longer than the polling interval
await act(() => new Promise((r) => setTimeout(r, 4000)));
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBefore);
clearTimeoutSpy.mockRestore();
});
it('should clear the polling timeout when the component unmounts', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount timeout clearance.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'active',
progress: { message: 'Polling...' },
});
const { unmount } = renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i);
fireEvent.change(input, { target: { files: [file] } });
// Wait for the first poll to complete and the UI to show the polling state
await screen.findByText('Polling...');
// Now that we are in a polling state (and a timeout is set), unmount the component
console.log('--- [TEST LOG] ---: 2. Unmounting component to trigger cleanup effect.');
unmount();
// Verify that the cleanup function in the useEffect hook was called
expect(clearTimeoutSpy).toHaveBeenCalled();
console.log('--- [TEST LOG] ---: 3. clearTimeout confirmed.');
clearTimeoutSpy.mockRestore();
});
it('should handle a duplicate flyer error (409)', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ flyerId: 99, message: 'Duplicate' }), { status: 409 }),
);
// The API client now throws a structured error for non-2xx responses.
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
status: 409,
body: { flyerId: 99, message: 'Duplicate' },
});
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
renderComponent();
@@ -278,7 +343,7 @@ describe('FlyerUploader', () => {
try {
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
expect(
await screen.findByText('This flyer has already been processed. You can view it here:'),
await screen.findByText(/This flyer has already been processed/i),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
} catch (error) {
@@ -295,12 +360,11 @@ describe('FlyerUploader', () => {
it('should allow the user to stop watching progress', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-stop' }), { status: 200 }),
);
mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-stop' });
mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'active',
progress: { message: 'Analyzing...' },
} as any);
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
renderComponent();
@@ -362,9 +426,11 @@ describe('FlyerUploader', () => {
it('should handle a generic network error during upload', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.');
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(
new Error('Network Error During Upload'),
);
// Simulate a structured error from the API client
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
status: 500,
body: { message: 'Network Error During Upload' },
});
renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i);
@@ -379,9 +445,7 @@ describe('FlyerUploader', () => {
it('should handle a generic network error during polling', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-poll-fail' }), { status: 200 }),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-poll-fail' });
mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error'));
renderComponent();
@@ -392,17 +456,15 @@ describe('FlyerUploader', () => {
fireEvent.change(input, { target: { files: [file] } });
console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
expect(await screen.findByText(/Polling Network Error/i)).toBeInTheDocument();
expect(await screen.findByText(/Polling failed: Polling Network Error/i)).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
});
it('should handle a completed job with a missing flyerId', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-no-flyerid' }), { status: 200 }),
);
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-no-flyerid' });
mockedAiApiClient.getJobStatus.mockResolvedValue(
new Response(JSON.stringify({ state: 'completed', returnValue: {} })), // No flyerId
{ state: 'completed', returnValue: {} }, // No flyerId
);
renderComponent();
@@ -419,6 +481,29 @@ describe('FlyerUploader', () => {
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
});
it('should handle a non-JSON response during polling', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for non-JSON response.');
// The actual function would throw, so we mock the rejection.
// The new getJobStatus would throw an error like "Failed to parse JSON..."
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-bad-json' });
mockedAiApiClient.getJobStatus.mockRejectedValue(
new Error('Failed to parse JSON response from server. Body: <html>502 Bad Gateway</html>'),
);
renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText(/click to select a file/i);
console.log('--- [TEST LOG] ---: 2. Firing file change event.');
fireEvent.change(input, { target: { files: [file] } });
console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
expect(
await screen.findByText(/Polling failed: Failed to parse JSON response from server/i),
).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
});
it('should do nothing if the file input is cancelled', () => {
renderComponent();
const input = screen.getByLabelText(/click to select a file/i);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,16 +15,19 @@ import type { Logger } from 'pino';
// Create a mock logger that we can inject into requests and assert against.
// We only mock the methods we intend to spy on. The rest of the complex Pino
// Logger type is satisfied by casting, which is a common and clean testing practice.
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn().mockReturnThis(),
} as unknown as Logger;
const { mockLogger } = vi.hoisted(() => {
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn().mockReturnThis(),
};
return { mockLogger };
});
// Mock the global logger as a fallback, though our tests will focus on req.log
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
@@ -37,7 +40,7 @@ const app = express();
app.use(express.json());
// Add a middleware to inject our mock logger into each request as `req.log`
app.use((req: Request, res: Response, next: NextFunction) => {
req.log = mockLogger;
req.log = mockLogger as unknown as Logger;
next();
});
@@ -106,7 +109,10 @@ describe('errorHandler Middleware', () => {
it('should return a generic 500 error for a standard Error object', async () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'A generic server error occurred.' });
// In test/dev, we now expect a stack trace for 5xx errors.
expect(response.body.message).toBe('A generic server error occurred.');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -116,7 +122,7 @@ describe('errorHandler Middleware', () => {
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
expect.any(Error),
);
});
@@ -130,15 +136,11 @@ describe('errorHandler Middleware', () => {
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
validationErrors: undefined,
statusCode: 404,
},
'Client Error on GET /http-error-404: Resource not found',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(Error),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a NotFoundError with a 404 status', async () => {
@@ -150,15 +152,11 @@ describe('errorHandler Middleware', () => {
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(NotFoundError),
validationErrors: undefined,
statusCode: 404,
},
'Client Error on GET /not-found-error: Specific resource missing',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(NotFoundError),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a ForeignKeyConstraintError with a 400 status and the specific error message', async () => {
@@ -170,15 +168,11 @@ describe('errorHandler Middleware', () => {
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(ForeignKeyConstraintError),
validationErrors: undefined,
statusCode: 400,
},
'Client Error on GET /fk-error: The referenced item does not exist.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(ForeignKeyConstraintError),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a UniqueConstraintError with a 409 status and the specific error message', async () => {
@@ -190,15 +184,11 @@ describe('errorHandler Middleware', () => {
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(UniqueConstraintError),
validationErrors: undefined,
statusCode: 409,
},
'Client Error on GET /unique-error: This item already exists.',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(UniqueConstraintError),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a ValidationError with a 400 status and include the validation errors array', async () => {
@@ -219,17 +209,17 @@ describe('errorHandler Middleware', () => {
},
'Client Error on GET /validation-error: Input validation failed',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(ValidationError),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
const response = await supertest(app).get('/db-error-500');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'A database connection issue occurred.' });
// In test/dev, we now expect a stack trace for 5xx errors.
expect(response.body.message).toBe('A database connection issue occurred.');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(DatabaseError),
@@ -239,7 +229,7 @@ describe('errorHandler Middleware', () => {
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
expect.any(DatabaseError),
);
});
@@ -249,8 +239,14 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
// 4xx errors log as warn
expect(mockLogger.warn).toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
statusCode: 401,
},
'Client Error on GET /unauthorized-error-no-status: Invalid Token',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle an UnauthorizedError with explicit status', async () => {
@@ -258,6 +254,14 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
statusCode: 401,
},
'Client Error on GET /unauthorized-error-with-status: Invalid Token',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should call next(err) if headers have already been sent', () => {
@@ -302,6 +306,7 @@ describe('errorHandler Middleware', () => {
expect(response.body.message).toMatch(
/An unexpected server error occurred. Please reference error ID: \w+/,
);
expect(response.body.stack).toBeUndefined();
});
it('should return the actual error message for client errors (4xx) in production', async () => {

View File

@@ -1,94 +1,101 @@
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { ZodError } from 'zod';
import {
DatabaseError,
UniqueConstraintError,
ForeignKeyConstraintError,
NotFoundError,
UniqueConstraintError,
ValidationError,
ValidationIssue,
} from '../services/db/errors.db';
import crypto from 'crypto';
import { logger } from '../services/logger.server';
interface HttpError extends Error {
status?: number;
}
export const errorHandler = (err: HttpError, req: Request, res: Response, next: NextFunction) => {
// If the response headers have already been sent, we must delegate to the default Express error handler.
/**
* A centralized error handling middleware for the Express application.
* This middleware should be the LAST `app.use()` call to catch all errors from previous routes and middleware.
*
* It standardizes error responses and ensures consistent logging.
*/
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
// If headers have already been sent, delegate to the default Express error handler.
if (res.headersSent) {
return next(err);
}
// The pino-http middleware guarantees that `req.log` will be available.
const log = req.log;
// Use the request-scoped logger if available, otherwise fall back to the global logger.
const log = req.log || logger;
// --- 1. Determine Final Status Code and Message ---
let statusCode = err.status ?? 500;
const message = err.message;
let validationIssues: ValidationIssue[] | undefined;
let errorId: string | undefined;
// Refine the status code for known error types. Check for most specific types first.
if (err instanceof UniqueConstraintError) {
statusCode = 409; // Conflict
} else if (err instanceof NotFoundError) {
statusCode = 404;
} else if (err instanceof ForeignKeyConstraintError) {
statusCode = 400;
} else if (err instanceof ValidationError) {
statusCode = 400;
validationIssues = err.validationErrors;
} else if (err instanceof DatabaseError) {
// This is a generic fallback for other database errors that are not the specific subclasses above.
statusCode = err.status;
} else if (err.name === 'UnauthorizedError') {
statusCode = err.status || 401;
// --- Handle Zod Validation Errors (from validateRequest middleware) ---
if (err instanceof ZodError) {
const statusCode = 400;
const message = 'The request data is invalid.';
const errors = err.issues.map((e) => ({ path: e.path, message: e.message }));
log.warn({ err, validationErrors: errors, statusCode }, `Client Error on ${req.method} ${req.path}: ${message}`);
return res.status(statusCode).json({ message, errors });
}
// --- 2. Log Based on Final Status Code ---
// Log the full error details for debugging, especially for server errors.
if (statusCode >= 500) {
errorId = crypto.randomBytes(4).toString('hex');
// The request-scoped logger already contains user, IP, and request_id.
// We add the full error and the request object itself.
// Pino's `redact` config will automatically sanitize sensitive fields in `req`.
log.error(
{
err,
errorId,
req: { method: req.method, url: req.originalUrl, headers: req.headers, body: req.body },
},
`Unhandled API Error (ID: ${errorId})`,
);
} else {
// For 4xx errors, log at a lower level (e.g., 'warn') to avoid flooding error trackers.
// We include the validation errors in the log context if they exist.
// --- Handle Custom Operational Errors ---
if (err instanceof NotFoundError) {
const statusCode = 404;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
}
if (err instanceof ValidationError) {
const statusCode = 400;
log.warn(
{
err,
validationErrors: validationIssues, // Add validation issues to the log object
statusCode,
},
`Client Error on ${req.method} ${req.path}: ${message}`,
{ err, validationErrors: err.validationErrors, statusCode },
`Client Error on ${req.method} ${req.path}: ${err.message}`,
);
return res.status(statusCode).json({ message: err.message, errors: err.validationErrors });
}
// --- TEST ENVIRONMENT DEBUGGING ---
if (err instanceof UniqueConstraintError) {
const statusCode = 409;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message }); // Use 409 Conflict for unique constraints
}
if (err instanceof ForeignKeyConstraintError) {
const statusCode = 400;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
}
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
let status = (err as any).status || (err as any).statusCode;
// Default UnauthorizedError to 401 if no status is present, a common case for express-jwt.
if (err.name === 'UnauthorizedError' && !status) {
status = 401;
}
if (status && status >= 400 && status < 500) {
log.warn({ err, statusCode: status }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(status).json({ message: err.message });
}
// --- Handle All Other (500-level) Errors ---
const errorId = crypto.randomBytes(4).toString('hex');
log.error(
{
err,
errorId,
req: { method: req.method, url: req.url, headers: req.headers, body: req.body },
},
`Unhandled API Error (ID: ${errorId})`,
);
// Also log to console in test environment for visibility in test runners
if (process.env.NODE_ENV === 'test') {
console.error('--- [TEST] UNHANDLED ERROR ---', err);
console.error(`--- [TEST] UNHANDLED ERROR (ID: ${errorId}) ---`, err);
}
// --- 3. Send Response ---
// In production, send a generic message for 5xx errors.
// In dev/test, send the actual error message for easier debugging.
const responseMessage =
statusCode >= 500 && process.env.NODE_ENV === 'production'
? `An unexpected server error occurred. Please reference error ID: ${errorId}`
: message;
// In production, send a generic message to avoid leaking implementation details.
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
});
}
res.status(statusCode).json({
message: responseMessage,
...(validationIssues && { errors: validationIssues }), // Conditionally add the 'errors' array if it exists
});
};
// In non-production environments (dev, test, etc.), send more details for easier debugging.
return res.status(500).json({ message: err.message, stack: err.stack, errorId });
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
// src/providers/ApiProvider.test.tsx
import React, { useContext } from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ApiProvider } from './ApiProvider';
import { ApiContext } from '../contexts/ApiContext';
import * as apiClient from '../services/apiClient';
// Mock the apiClient module.
// Since ApiProvider and ApiContext import * as apiClient, mocking it ensures
// we control the reference identity and can verify it's being passed correctly.
vi.mock('../services/apiClient', () => ({
fetchFlyers: vi.fn(),
fetchMasterItems: vi.fn(),
// Add other mocked methods as needed for the shape to be valid-ish
}));
describe('ApiProvider & ApiContext', () => {
const TestConsumer = () => {
const contextValue = useContext(ApiContext);
// We check if the context value is strictly equal to the imported module
return (
<div>
<span data-testid="value-check">
{contextValue === apiClient ? 'Matches apiClient' : 'Does not match'}
</span>
</div>
);
};
it('renders children correctly', () => {
render(
<ApiProvider>
<div data-testid="child">Child Content</div>
</ApiProvider>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
it('provides the apiClient module via context', () => {
render(
<ApiProvider>
<TestConsumer />
</ApiProvider>
);
expect(screen.getByTestId('value-check')).toHaveTextContent('Matches apiClient');
});
it('ApiContext has apiClient as the default value (when no provider is present)', () => {
// This verifies the logic in ApiContext.tsx: createContext(apiClient)
render(<TestConsumer />);
expect(screen.getByTestId('value-check')).toHaveTextContent('Matches apiClient');
});
});

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { Job } from 'bullmq';
import type { UserProfile } from '../types';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the background job service to control its methods.
vi.mock('../services/backgroundJobService', () => ({
@@ -66,8 +65,9 @@ import {
} from '../services/queueService.server';
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware

View File

@@ -5,7 +5,16 @@ import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
import type { UserProfile } from '../types';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn().mockReturnThis(),
},
}));
vi.mock('../lib/queue', () => ({
serverAdapter: {
@@ -27,19 +36,22 @@ vi.mock('../services/db/index.db', () => ({
notificationRepo: {},
}));
// Mock the queue service to control worker statuses
// Mock the queue service for queue status checks
vi.mock('../services/queueService.server', () => ({
flyerQueue: { name: 'flyer-processing', getJobCounts: vi.fn() },
emailQueue: { name: 'email-sending', getJobCounts: vi.fn() },
analyticsQueue: { name: 'analytics-reporting', getJobCounts: vi.fn() },
cleanupQueue: { name: 'file-cleanup', getJobCounts: vi.fn() },
weeklyAnalyticsQueue: { name: 'weekly-analytics-reporting', getJobCounts: vi.fn() },
}));
// Mock the worker service for worker status checks
vi.mock('../services/workers.server', () => ({
flyerWorker: { name: 'flyer-processing', isRunning: vi.fn() },
emailWorker: { name: 'email-sending', isRunning: vi.fn() },
analyticsWorker: { name: 'analytics-reporting', isRunning: vi.fn() },
cleanupWorker: { name: 'file-cleanup', isRunning: vi.fn() },
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
flyerQueue: { name: 'flyer-processing', getJobCounts: vi.fn() },
emailQueue: { name: 'email-sending', getJobCounts: vi.fn() },
analyticsQueue: { name: 'analytics-reporting', getJobCounts: vi.fn() },
cleanupQueue: { name: 'file-cleanup', getJobCounts: vi.fn() },
// FIX: Add the missing weeklyAnalyticsQueue to prevent import errors in admin.routes.ts
weeklyAnalyticsQueue: { name: 'weekly-analytics-reporting', getJobCounts: vi.fn() },
}));
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
@@ -67,8 +79,10 @@ import adminRouter from './admin.routes';
// Import the mocked modules to control them
import * as queueService from '../services/queueService.server';
import * as workerService from '../services/workers.server';
import { adminRepo } from '../services/db/index.db';
const mockedQueueService = queueService as Mocked<typeof queueService>;
const mockedWorkerService = workerService as Mocked<typeof workerService>;
// Mock the logger
vi.mock('../services/logger.server', () => ({
@@ -137,11 +151,11 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
describe('GET /workers/status', () => {
it('should return the status of all registered workers', async () => {
// Arrange: Set the mock status for each worker
vi.mocked(mockedQueueService.flyerWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedQueueService.emailWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedQueueService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
vi.mocked(mockedQueueService.cleanupWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedQueueService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
// Act
const response = await supertest(app).get('/api/admin/workers/status');

View File

@@ -9,6 +9,10 @@ import * as db from '../services/db/index.db';
import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
@@ -25,12 +29,14 @@ import {
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
flyerWorker,
emailWorker,
} from '../services/queueService.server'; // Import your queues
import {
analyticsWorker,
cleanupWorker,
emailWorker,
flyerWorker,
weeklyAnalyticsWorker,
} from '../services/queueService.server'; // Import your queues
} from '../services/workers.server';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import {
requiredString,
@@ -39,6 +45,20 @@ import {
optionalNumeric,
} from '../utils/zodUtils';
import { logger } from '../services/logger.server';
import fs from 'node:fs/promises';
/**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded logo file.');
}
};
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -86,19 +106,7 @@ const jobRetrySchema = z.object({
const router = Router();
// --- Multer Configuration for File Uploads ---
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
},
});
const upload = multer({ storage: storage });
const upload = createUploadMiddleware({ storageType: 'flyer' });
// --- Bull Board (Job Queue UI) Setup ---
const serverAdapter = new ExpressAdapter();
@@ -135,6 +143,7 @@ router.get('/corrections', async (req, res, next: NextFunction) => {
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
res.json(corrections);
} catch (error) {
logger.error({ error }, 'Error fetching suggested corrections');
next(error);
}
});
@@ -144,6 +153,7 @@ router.get('/brands', async (req, res, next: NextFunction) => {
const brands = await db.flyerRepo.getAllBrands(req.log);
res.json(brands);
} catch (error) {
logger.error({ error }, 'Error fetching brands');
next(error);
}
});
@@ -153,6 +163,7 @@ router.get('/stats', async (req, res, next: NextFunction) => {
const stats = await db.adminRepo.getApplicationStats(req.log);
res.json(stats);
} catch (error) {
logger.error({ error }, 'Error fetching application stats');
next(error);
}
});
@@ -162,6 +173,7 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
res.json(dailyStats);
} catch (error) {
logger.error({ error }, 'Error fetching daily stats');
next(error);
}
});
@@ -176,6 +188,7 @@ router.post(
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
logger.error({ error }, 'Error approving correction');
next(error);
}
},
@@ -191,6 +204,7 @@ router.post(
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
logger.error({ error }, 'Error rejecting correction');
next(error);
}
},
@@ -210,6 +224,7 @@ router.put(
);
res.status(200).json(updatedCorrection);
} catch (error) {
logger.error({ error }, 'Error updating suggested correction');
next(error);
}
},
@@ -225,6 +240,7 @@ router.put(
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
} catch (error) {
logger.error({ error }, 'Error updating recipe status');
next(error); // Pass all errors to the central error handler
}
},
@@ -244,12 +260,17 @@ router.post(
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
// The storage path is 'flyer-images', so the URL should reflect that for consistency.
const logoUrl = `/flyer-images/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
await cleanupUploadedFile(req.file);
logger.error({ error }, 'Error updating brand logo');
next(error);
}
},
@@ -260,6 +281,7 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items);
} catch (error) {
logger.error({ error }, 'Error fetching unmatched items');
next(error);
}
});
@@ -279,6 +301,7 @@ router.delete(
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
} catch (error: unknown) {
logger.error({ error }, 'Error deleting recipe');
next(error);
}
},
@@ -297,6 +320,7 @@ router.delete(
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
} catch (error: unknown) {
logger.error({ error }, 'Error deleting flyer');
next(error);
}
},
@@ -316,6 +340,7 @@ router.put(
); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
} catch (error: unknown) {
logger.error({ error }, 'Error updating comment status');
next(error);
}
},
@@ -326,6 +351,7 @@ router.get('/users', async (req, res, next: NextFunction) => {
const users = await db.adminRepo.getAllUsers(req.log);
res.json(users);
} catch (error) {
logger.error({ error }, 'Error fetching users');
next(error);
}
});
@@ -345,6 +371,7 @@ router.get(
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
res.json(logs);
} catch (error) {
logger.error({ error }, 'Error fetching activity log');
next(error);
}
},
@@ -360,6 +387,7 @@ router.get(
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
} catch (error) {
logger.error({ error }, 'Error fetching user profile');
next(error);
}
},
@@ -395,6 +423,7 @@ router.delete(
await db.userRepo.deleteUserById(params.id, req.log);
res.status(204).send();
} catch (error) {
logger.error({ error }, 'Error deleting user');
next(error);
}
},
@@ -478,6 +507,7 @@ router.post(
.status(202)
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
} catch (error) {
logger.error({ error }, 'Error enqueuing cleanup job');
next(error);
}
},
@@ -500,6 +530,7 @@ router.post('/trigger/failing-job', async (req: Request, res: Response, next: Ne
.status(202)
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
} catch (error) {
logger.error({ error }, 'Error enqueuing failing job');
next(error);
}
});
@@ -572,6 +603,7 @@ router.get('/queues/status', async (req: Request, res: Response, next: NextFunct
);
res.json(queueStatuses);
} catch (error) {
logger.error({ error }, 'Error fetching queue statuses');
next(error);
}
});
@@ -620,6 +652,7 @@ router.post(
);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
logger.error({ error }, 'Error retrying job');
next(error);
}
},
@@ -651,9 +684,13 @@ router.post(
.status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
} catch (error) {
logger.error({ error }, 'Error enqueuing weekly analytics job');
next(error);
}
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -5,7 +5,6 @@ import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { UserProfile } from '../types';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
vi.mock('../services/db/index.db', () => ({
adminRepo: {
@@ -45,8 +44,9 @@ import adminRouter from './admin.routes';
import { adminRepo } from '../services/db/index.db';
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware

View File

@@ -4,7 +4,6 @@ import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock dependencies
vi.mock('../services/geocodingService.server', () => ({
@@ -50,8 +49,9 @@ import adminRouter from './admin.routes';
import { geocodingService } from '../services/geocodingService.server';
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware

View File

@@ -6,7 +6,6 @@ import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/m
import type { UserProfile, Profile } from '../types';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
vi.mock('../services/db/index.db', () => ({
adminRepo: {
@@ -44,8 +43,9 @@ vi.mock('@bull-board/express', () => ({
}));
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the router AFTER all mocks are defined.

View File

@@ -55,8 +55,9 @@ import aiRouter from './ai.routes';
import { flyerQueue } from '../services/queueService.server';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport module to control authentication for different tests.
@@ -82,36 +83,6 @@ describe('AI Routes (/api/ai)', () => {
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
describe('Module-level error handling', () => {
it('should log an error if storage path creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => {
const mockFs = {
...fs,
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
};
return { ...mockFs, default: mockFs };
});
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call
await import('./ai.routes');
// Assert
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
vi.doUnmock('node:fs'); // Cleanup
});
});
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
describe('Diagnostic Middleware Error Handling', () => {
it('should log an error if logger.debug throws an object with a message property', async () => {
@@ -284,6 +255,21 @@ describe('AI Routes (/api/ai)', () => {
'123 Pacific St, Anytown, BC, V8T 1A1, CA',
);
});
it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => {
// Spy on the unlink function to ensure it's called on error
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.attach('flyerFile', imagePath); // No checksum field, will cause validation to throw
expect(response.status).toBe(400);
// The validation error is now caught inside the route handler, which then calls cleanup.
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
});
describe('GET /jobs/:jobId/status', () => {
@@ -353,10 +339,11 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(400);
});
it('should return 409 Conflict if flyer checksum already exists', async () => {
it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => {
// Arrange
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 });
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -368,6 +355,10 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(409);
expect(response.body.message).toBe('This flyer has already been processed.');
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled();
// Assert that the file was deleted
expect(unlinkSpy).toHaveBeenCalledTimes(1);
// The filename is predictable in the test environment because of the multer config in ai.routes.ts
expect(unlinkSpy).toHaveBeenCalledWith(expect.stringContaining('flyerImage-test-flyer-image.jpg'));
});
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
@@ -530,6 +521,72 @@ describe('AI Routes (/api/ai)', () => {
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toBe('Root Store');
});
it('should default item quantity to 1 if missing', async () => {
const payloadMissingQuantity = {
checksum: 'qty-checksum',
originalFileName: 'flyer-qty.jpg',
extractedData: {
store_name: 'Qty Store',
items: [{ name: 'Item without qty', price: 100 }],
},
};
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(payloadMissingQuantity))
.attach('flyerImage', imagePath);
expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
const itemsArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][1];
expect(itemsArg[0].quantity).toBe(1);
});
});
describe('POST /flyers/process (Legacy Error Handling)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
it('should handle malformed JSON in data field and return 400', async () => {
const malformedDataString = '{"checksum":'; // Invalid JSON
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', malformedDataString)
.attach('flyerImage', imagePath);
// The outer catch block should be hit, leading to empty parsed data.
// The handler then fails the checksum validation.
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
// It should log the critical error during parsing.
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(Error) }),
'[API /ai/flyers/process] Unexpected error while parsing request body',
);
});
it('should return 400 if checksum is missing from legacy payload', async () => {
const payloadWithoutChecksum = {
originalFileName: 'flyer.jpg',
extractedData: { store_name: 'Test Store', items: [] },
};
// Spy on fs.promises.unlink to verify file cleanup
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(payloadWithoutChecksum))
.attach('flyerImage', imagePath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
// Ensure the uploaded file is cleaned up
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
});
describe('POST /check-flyer', () => {
@@ -801,6 +858,39 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.message).toBe('Maps API key invalid');
});
it('POST /deep-dive should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Deep dive logging failed');
});
const response = await supertest(app)
.post('/api/ai/deep-dive')
.send({ items: [{ name: 'test' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Deep dive logging failed');
});
it('POST /search-web should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Search web logging failed');
});
const response = await supertest(app)
.post('/api/ai/search-web')
.send({ query: 'test query' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Search web logging failed');
});
it('POST /compare-prices should return 500 on a generic error', async () => {
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
throw new Error('Compare prices logging failed');
});
const response = await supertest(app)
.post('/api/ai/compare-prices')
.send({ items: [{ name: 'Milk' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Compare prices logging failed');
});
it('POST /quick-insights should return 400 if items are missing', async () => {
const response = await supertest(app).post('/api/ai/quick-insights').send({});
expect(response.status).toBe(400);

View File

@@ -1,6 +1,5 @@
// src/routes/ai.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'node:fs';
import { z } from 'zod';
@@ -9,8 +8,11 @@ import { optionalAuth } from './passport.routes';
import * as db from '../services/db/index.db';
import { createFlyerAndItems } from '../services/db/flyer.db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { sanitizeFilename } from '../utils/stringUtils';
import { logger } from '../services/logger.server';
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
import { flyerQueue } from '../services/queueService.server';
@@ -50,6 +52,22 @@ const errMsg = (e: unknown) => {
return String(e || 'An unknown error occurred.');
};
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.promises.unlink(file.path);
} catch (err) {
// Ignore cleanup errors (e.g. file already deleted)
}
};
const cleanupUploadedFiles = async (files?: Express.Multer.File[]) => {
if (!files || !Array.isArray(files)) return;
// Use Promise.all to run cleanups in parallel for efficiency,
// as cleanupUploadedFile is designed to not throw errors.
await Promise.all(files.map((file) => cleanupUploadedFile(file)));
};
const cropAreaObjectSchema = z.object({
x: z.number(),
y: z.number(),
@@ -78,7 +96,6 @@ const rescanAreaSchema = z.object({
})
.pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], {
// This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'.",
}),
}),
@@ -139,40 +156,7 @@ const searchWebSchema = z.object({
body: z.object({ query: requiredString('A search query is required.') }),
});
// --- Multer Configuration for File Uploads ---
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// Ensure the storage path exists at startup so multer can write files there.
try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error(
{ error: errMsg(err) },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// Sanitize the original filename to remove spaces and special characters
return cb(
null,
file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname),
);
}
},
});
const uploadToDisk = multer({ storage: diskStorage });
const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {
@@ -185,7 +169,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
'[API /ai] Incoming request',
);
} catch (e: unknown) {
logger.error({ error: e }, 'Failed to log incoming AI request headers');
logger.error({ error: errMsg(e) }, 'Failed to log incoming AI request headers');
}
next();
});
@@ -198,15 +182,19 @@ router.post(
'/upload-and-process',
optionalAuth,
uploadToDisk.single('flyerFile'),
validateRequest(uploadAndProcessSchema),
// Validation is now handled inside the route to ensure file cleanup on failure.
// validateRequest(uploadAndProcessSchema),
async (req, res, next: NextFunction) => {
try {
// Manually validate the request body. This will throw if validation fails.
uploadAndProcessSchema.parse({ body: req.body });
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
}
logger.debug(
{ filename: req.file.originalname, size: req.file.size, checksum: req.body.checksum },
{ filename: req.file.originalname, size: req.file.size, checksum: req.body?.checksum },
'Handling /upload-and-process',
);
@@ -258,6 +246,9 @@ router.post(
jobId: job.id,
});
} catch (error) {
// If any error occurs (including validation), ensure the uploaded file is cleaned up.
await cleanupUploadedFile(req.file);
// Pass the error to the global error handler.
next(error);
}
},
@@ -316,7 +307,7 @@ router.post(
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> = {};
let extractedData: Partial<ExtractedCoreData> | null | undefined = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
@@ -337,7 +328,7 @@ router.post(
) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
extractedData = 'extractedData' in parsed ? parsed.extractedData : (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
@@ -383,6 +374,12 @@ router.post(
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
if (!checksum) {
await cleanupUploadedFile(req.file);
return res.status(400).json({ message: 'Checksum is required.' });
}
const originalFileName =
parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const userProfile = req.user as UserProfile | undefined;
@@ -409,6 +406,7 @@ router.post(
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
quantity: item.quantity ?? 1, // Default to 1 to satisfy DB constraint
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
@@ -429,6 +427,7 @@ router.post(
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
await cleanupUploadedFile(req.file);
return res.status(409).json({ message: 'This flyer has already been processed.' });
}
@@ -476,6 +475,7 @@ router.post(
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
} catch (error) {
await cleanupUploadedFile(req.file);
next(error);
}
},
@@ -498,6 +498,8 @@ router.post(
res.status(200).json({ is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
@@ -515,6 +517,8 @@ router.post(
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
@@ -532,6 +536,8 @@ router.post(
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
} finally {
await cleanupUploadedFiles(req.files as Express.Multer.File[]);
}
},
);
@@ -679,8 +685,13 @@ router.post(
res.status(200).json(result);
} catch (error) {
next(error);
} finally {
await cleanupUploadedFile(req.file);
}
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -9,7 +9,6 @@ import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
// --- FIX: Hoist passport mocks to be available for vi.mock ---
const passportMocks = vi.hoisted(() => {
@@ -111,8 +110,9 @@ vi.mock('../services/db/connection.db', () => ({
}));
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the email service
@@ -144,6 +144,8 @@ import { UniqueConstraintError } from '../services/db/errors.db'; // Import actu
import express from 'express';
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
const { mockLogger } = await import('../tests/utils/mockLogger');
const app = express();
app.use(express.json());
app.use(cookieParser()); // Mount BEFORE router

View File

@@ -134,8 +134,8 @@ router.post(
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
}
// The createUser method now handles its own transaction logging, so we just log the route failure.
logger.error({ error }, `User registration route failed for email: ${email}.`);
// Pass the error to the centralized handler
return next(error);
}
},

View File

@@ -7,7 +7,6 @@ import {
createMockBudget,
createMockSpendingByCategory,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
// 1. Mock the Service Layer directly.
@@ -26,8 +25,9 @@ vi.mock('../services/db/index.db', () => ({
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the router and mocked DB AFTER all mocks are defined.

View File

@@ -4,7 +4,6 @@ import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import type { WatchedItemDeal } from '../types';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
// 1. Mock the Service Layer directly.
@@ -17,10 +16,12 @@ vi.mock('../services/db/deals.db', () => ({
// Import the router and mocked repo AFTER all mocks are defined.
import dealsRouter from './deals.routes';
import { dealsRepo } from '../services/db/deals.db';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware

View File

@@ -23,8 +23,9 @@ import * as db from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Define a reusable matcher for the logger object.

View File

@@ -108,6 +108,7 @@ router.post(
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching batch flyer items');
next(error);
}
},
@@ -127,6 +128,7 @@ router.post(
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
res.json({ count });
} catch (error) {
req.log.error({ error }, 'Error counting batch flyer items');
next(error);
}
},

View File

@@ -27,8 +27,9 @@ import gamificationRouter from './gamification.routes';
import * as db from '../services/db/index.db';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Use vi.hoisted to create mutable mock function references.

View File

@@ -32,8 +32,9 @@ import healthRouter from './health.routes';
import * as dbConnection from '../services/db/connection.db';
// Mock the logger to keep test output clean.
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
@@ -160,10 +161,14 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith(
{ error: 'DB connection failed' },
'Error during DB schema check:',
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
@@ -175,10 +180,13 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error during DB schema check:',
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'DB connection failed' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
});
@@ -208,9 +216,11 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
expect.stringContaining('Storage check failed for path:'),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
@@ -225,9 +235,11 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith(
{ error: accessError },
expect.stringContaining('Storage check failed for path:'),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
});
@@ -282,10 +294,13 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith(
{ error: 'Pool is not initialized' },
'Error during DB pool health check:',
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
@@ -299,10 +314,51 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith(
{ error: poolError },
'Error during DB pool health check:',
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'Pool is not initialized' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
describe('GET /redis', () => {
it('should return 500 if Redis ping fails', async () => {
const redisError = new Error('Connection timed out');
mockedRedisConnection.ping.mockRejectedValue(redisError);
const response = await supertest(app).get('/api/health/redis');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Connection timed out');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
it('should return 500 if Redis ping returns an unexpected response', async () => {
mockedRedisConnection.ping.mockResolvedValue('OK'); // Not 'PONG'
const response = await supertest(app).get('/api/health/redis');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
});
});
});

View File

@@ -39,11 +39,12 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
} catch (error: unknown) {
logger.error(
{ error: error instanceof Error ? error.message : error },
'Error during DB schema check:',
);
next(error);
if (error instanceof Error) {
return next(error);
}
const message =
(error as any)?.message || 'An unknown error occurred during DB schema check.';
return next(new Error(message));
}
});
@@ -52,7 +53,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
* This is important for features like file uploads.
*/
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res
@@ -62,10 +63,6 @@ router.get('/storage', validateRequest(emptySchema), async (req, res, next: Next
message: `Storage directory '${storagePath}' is accessible and writable.`,
});
} catch (error: unknown) {
logger.error(
{ error: error instanceof Error ? error.message : error },
`Storage check failed for path: ${storagePath}`,
);
next(
new Error(
`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`,
@@ -96,11 +93,12 @@ router.get(
.json({ success: false, message: `Pool may be under stress. ${message}` });
}
} catch (error: unknown) {
logger.error(
{ error: error instanceof Error ? error.message : error },
'Error during DB pool health check:',
);
next(error);
if (error instanceof Error) {
return next(error);
}
const message =
(error as any)?.message || 'An unknown error occurred during DB pool check.';
return next(new Error(message));
}
},
);
@@ -133,7 +131,12 @@ router.get(
}
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
} catch (error: unknown) {
next(error);
if (error instanceof Error) {
return next(error);
}
const message =
(error as any)?.message || 'An unknown error occurred during Redis health check.';
return next(new Error(message));
}
},
);

View File

@@ -56,7 +56,6 @@ import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock dependencies before importing the passport configuration
vi.mock('../services/db/index.db', () => ({
@@ -74,9 +73,10 @@ vi.mock('../services/db/index.db', () => ({
const mockedDb = db as Mocked<typeof db>;
vi.mock('../services/logger.server', () => ({
// This mock is used by the module under test and can be imported in the test file.
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
// Note: We need to await the import inside the factory
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock bcrypt for password comparisons

View File

@@ -6,7 +6,6 @@ import {
createMockDietaryRestriction,
createMockAppliance,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
// 1. Mock the Service Layer directly.
@@ -21,10 +20,12 @@ vi.mock('../services/db/index.db', () => ({
// Import the router and mocked DB AFTER all mocks are defined.
import personalizationRouter from './personalization.routes';
import * as db from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
describe('Personalization Routes (/api/personalization)', () => {

View File

@@ -12,8 +12,9 @@ vi.mock('../services/db/price.db', () => ({
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the router AFTER other setup.
@@ -96,7 +97,8 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Expected array, received string');
// The actual message is "Invalid input: expected array, received string"
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received string');
});
it('should return 400 if masterItemIds contains non-positive integers', async () => {
@@ -112,7 +114,8 @@ describe('Price Routes (/api/price-history)', () => {
const response = await supertest(app).post('/api/price-history').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Required');
// The actual message is "Invalid input: expected array, received undefined"
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received undefined');
});
it('should return 400 for invalid limit and offset', async () => {
@@ -122,8 +125,9 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
expect(response.body.errors[0].message).toBe('Number must be greater than 0');
expect(response.body.errors[1].message).toBe('Expected number, received string');
// The actual message is "Too small: expected number to be >0"
expect(response.body.errors[0].message).toBe('Too small: expected number to be >0');
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
});
});
});

View File

@@ -1,7 +1,6 @@
// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { mockLogger } from '../tests/utils/mockLogger';
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -20,10 +19,12 @@ vi.mock('../services/db/index.db', () => ({
// Import the router and mocked DB AFTER all mocks are defined.
import recipeRouter from './recipe.routes';
import * as db from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the mocked db module to control its functions in tests

View File

@@ -1,7 +1,6 @@
// src/routes/stats.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
// 1. Mock the Service Layer directly.
@@ -14,10 +13,12 @@ vi.mock('../services/db/index.db', () => ({
// Import the router and mocked DB AFTER all mocks are defined.
import statsRouter from './stats.routes';
import * as db from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
const expectLogger = expect.objectContaining({

View File

@@ -46,7 +46,6 @@ router.get(
}
// Check if there was output to stderr, even if the exit code was 0 (success).
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
if (stderr && stderr.trim().length > 0) {
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
return next(new Error(`PM2 command produced an error: ${stderr}`));
@@ -86,6 +85,7 @@ router.post(
res.json(coordinates);
} catch (error) {
logger.error({ error }, 'Error geocoding address');
next(error);
}
},

View File

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

View File

@@ -1,13 +1,16 @@
// src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes';
import multer from 'multer';
import path from 'path';
import multer from 'multer'; // Keep for MulterError type check
import fs from 'node:fs/promises';
import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed.
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { userService } from '../services/userService';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
@@ -20,6 +23,19 @@ import {
} from '../utils/zodUtils';
import * as db from '../services/db/index.db';
/**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded avatar file.');
}
};
const router = express.Router();
const updateProfileSchema = z.object({
@@ -72,35 +88,10 @@ const emptySchema = z.object({});
// Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false }));
// --- Multer Configuration for Avatar Uploads ---
// Ensure the directory for avatar uploads exists.
const avatarUploadDir = path.join(process.cwd(), 'public', 'uploads', 'avatars');
fs.mkdir(avatarUploadDir, { recursive: true }).catch((err) => {
logger.error('Failed to create avatar upload directory:', err);
});
// Define multer storage configuration. The `req.user` object will be available
// here because the passport middleware runs before this route handler.
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarUploadDir),
filename: (req, file, cb) => {
const uniqueSuffix = `${(req.user as UserProfile).user.user_id}-${Date.now()}${path.extname(file.originalname)}`;
cb(null, uniqueSuffix);
},
});
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB file size limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error
cb(new Error('Only image files are allowed!'));
}
},
const avatarUpload = createUploadMiddleware({
storageType: 'avatar',
fileSize: 1 * 1024 * 1024, // 1MB
fileFilter: 'image',
});
/**
@@ -110,8 +101,8 @@ router.post(
'/profile/avatar',
avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
// The try-catch block was already correct here.
try {
// The try-catch block was already correct here.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const userProfile = req.user as UserProfile;
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
@@ -122,6 +113,10 @@ router.post(
);
res.json(updatedProfile);
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
await cleanupUploadedFile(req.file);
logger.error({ error }, 'Error uploading avatar');
next(error);
}
},
@@ -151,6 +146,7 @@ router.get(
);
res.json(notifications);
} catch (error) {
logger.error({ error }, 'Error fetching notifications');
next(error);
}
},
@@ -168,6 +164,7 @@ router.post(
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error) {
logger.error({ error }, 'Error marking all notifications as read');
next(error);
}
},
@@ -193,6 +190,7 @@ router.post(
);
res.status(204).send(); // Success, no content to return
} catch (error) {
logger.error({ error }, 'Error marking notification as read');
next(error);
}
},
@@ -345,11 +343,7 @@ router.post(
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
});
logger.error({ error, body: req.body }, 'Failed to add watched item');
next(error);
}
},
@@ -453,11 +447,7 @@ router.post(
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
});
logger.error({ error, body: req.body }, 'Failed to create shopping list');
next(error);
}
},
@@ -516,12 +506,7 @@ router.post(
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
params: req.params,
body: req.body,
});
logger.error({ error, params: req.params, body: req.body }, 'Failed to add shopping list item');
next(error);
}
},
@@ -661,11 +646,7 @@ router.put(
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
});
logger.error({ error, body: req.body }, 'Failed to set user dietary restrictions');
next(error);
}
},
@@ -709,11 +690,7 @@ router.put(
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
});
logger.error({ error, body: req.body }, 'Failed to set user appliances');
next(error);
}
},
@@ -743,6 +720,7 @@ router.get(
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address);
} catch (error) {
logger.error({ error }, 'Error fetching user address');
next(error);
}
},
@@ -781,6 +759,7 @@ router.put(
const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed.
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) {
logger.error({ error }, 'Error updating user address');
next(error);
}
},
@@ -856,18 +835,7 @@ router.put(
},
);
// --- General Multer Error Handler ---
// This should be placed after all routes that use multer.
// It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof multer.MulterError) {
// A Multer error occurred when uploading (e.g., file too large).
return res.status(400).json({ message: `File upload error: ${err.message}` });
} else if (err && err.message === 'Only image files are allowed!') {
// A custom error from our fileFilter.
return res.status(400).json({ message: err.message });
}
next(err); // Pass on to the next error handler if it's not a multer error we handle.
});
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -51,9 +51,7 @@ export class AiAnalysisService {
// Normalize sources to a consistent format.
const mappedSources = (response.sources || []).map(
(s: RawSource) =>
(s.web
? { uri: s.web.uri || '', title: s.web.title || 'Untitled' }
: { uri: '', title: 'Untitled' }) as Source,
(s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source,
);
return { ...response, sources: mappedSources };
}
@@ -84,9 +82,7 @@ export class AiAnalysisService {
// Normalize sources to a consistent format.
const mappedSources = (response.sources || []).map(
(s: RawSource) =>
(s.web
? { uri: s.web.uri || '', title: s.web.title || 'Untitled' }
: { uri: '', title: 'Untitled' }) as Source,
(s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source,
);
return { ...response, sources: mappedSources };
}

View File

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

View File

@@ -4,9 +4,15 @@
* It communicates with the application's own backend endpoints, which then securely
* call the Google AI services. This ensures no API keys are exposed on the client.
*/
import type { FlyerItem, Store, MasterGroceryItem } from '../types';
import type {
FlyerItem,
Store,
MasterGroceryItem,
ProcessingStage,
GroundedResponse,
} from '../types';
import { logger } from './logger.client';
import { apiFetch } from './apiClient';
import { apiFetch, authedGet, authedPost, authedPostForm } from './apiClient';
/**
* Uploads a flyer file to the backend to be processed asynchronously.
@@ -20,68 +26,147 @@ export const uploadAndProcessFlyer = async (
file: File,
checksum: string,
tokenOverride?: string,
): Promise<Response> => {
): Promise<{ jobId: string }> => {
const formData = new FormData();
formData.append('flyerFile', file);
formData.append('checksum', checksum);
logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`);
return apiFetch(
'/ai/upload-and-process',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
const response = await authedPostForm('/ai/upload-and-process', formData, { tokenOverride });
if (!response.ok) {
let errorBody;
// Clone the response so we can read the body twice (once as JSON, and as text on failure).
const clonedResponse = response.clone();
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await clonedResponse.text() };
}
// Throw a structured error so the component can inspect the status and body
throw { status: response.status, body: errorBody };
}
return response.json();
};
// Define the expected shape of the job status response
export interface JobStatus {
id: string;
state: 'completed' | 'failed' | 'active' | 'waiting' | 'delayed' | 'paused';
progress: {
stages?: ProcessingStage[];
estimatedTimeRemaining?: number;
// The structured error payload from the backend worker
errorCode?: string;
message?: string;
} | null;
returnValue: {
flyerId?: number;
} | null;
failedReason: string | null; // The raw error string from BullMQ
}
/**
* Custom error class for job failures to make `catch` blocks more specific.
* This allows the UI to easily distinguish between a job failure and a network error.
*/
export class JobFailedError extends Error {
public errorCode: string;
constructor(message: string, errorCode: string) {
super(message);
this.name = 'JobFailedError';
this.errorCode = errorCode;
}
}
/**
* Fetches the status of a background processing job.
* This is the second step in the new background processing flow.
* @param jobId The ID of the job to check.
* @param tokenOverride Optional token for testing.
* @returns A promise that resolves to the API response with the job's status.
* @returns A promise that resolves to the parsed job status object.
* @throws A `JobFailedError` if the job has failed, or a generic `Error` for other issues.
*/
export const getJobStatus = async (jobId: string, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride });
export const getJobStatus = async (
jobId: string,
tokenOverride?: string,
): Promise<JobStatus> => {
const response = await authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
// Handle non-OK responses first, as they might not have a JSON body.
if (!response.ok) {
let errorMessage = `API Error: ${response.status} ${response.statusText}`;
try {
// Try to get a more specific message from the body.
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
} catch (e) {
// The body was not JSON, which is fine for a server error page.
// The default message is sufficient.
logger.warn('getJobStatus received a non-JSON error response.', { status: response.status });
}
throw new Error(errorMessage);
}
// If we get here, the response is OK (2xx). Now parse the body.
try {
const statusData: JobStatus = await response.json();
// If the job itself has failed, we should treat this as an error condition
// for the polling logic by rejecting the promise. This will stop the polling loop.
if (statusData.state === 'failed') {
// The structured error payload is in the 'progress' object.
const progress = statusData.progress;
const userMessage =
progress?.message || statusData.failedReason || 'Job failed with an unknown error.';
const errorCode = progress?.errorCode || 'UNKNOWN_ERROR';
logger.error(`Job ${jobId} failed with code: ${errorCode}, message: ${userMessage}`);
// Throw a custom, structured error so the frontend can react to the errorCode.
throw new JobFailedError(userMessage, errorCode);
}
return statusData;
} catch (error) {
// If it's the specific error we threw, just re-throw it.
if (error instanceof JobFailedError) {
throw error;
}
// This now primarily catches JSON parsing errors on an OK response, which is unexpected.
logger.error('getJobStatus failed to parse a successful API response.', { error });
throw new Error('Failed to parse job status from a successful API response.');
}
};
export const isImageAFlyer = async (imageFile: File, tokenOverride?: string): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
// The URL must be relative, as the helper constructs the full path.
return apiFetch(
'/ai/check-flyer',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
};
export const extractAddressFromImage = async (
export const isImageAFlyer = (
imageFile: File,
tokenOverride?: string,
): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
return apiFetch(
'/ai/extract-address',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
// The URL must be relative, as the helper constructs the full path.
return authedPostForm('/ai/check-flyer', formData, { tokenOverride });
};
export const extractLogoFromImage = async (
export const extractAddressFromImage = (
imageFile: File,
tokenOverride?: string,
): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
return authedPostForm('/ai/extract-address', formData, { tokenOverride });
};
export const extractLogoFromImage = (
imageFiles: File[],
tokenOverride?: string,
): Promise<Response> => {
@@ -90,65 +175,31 @@ export const extractLogoFromImage = async (
formData.append('images', file);
});
return apiFetch(
'/ai/extract-logo',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
return authedPostForm('/ai/extract-logo', formData, { tokenOverride });
};
export const getQuickInsights = async (
export const getQuickInsights = (
items: Partial<FlyerItem>[],
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
return apiFetch(
'/ai/quick-insights',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
},
{ tokenOverride, signal },
);
return authedPost('/ai/quick-insights', { items }, { tokenOverride, signal });
};
export const getDeepDiveAnalysis = async (
export const getDeepDiveAnalysis = (
items: Partial<FlyerItem>[],
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
return apiFetch(
'/ai/deep-dive',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
},
{ tokenOverride, signal },
);
return authedPost('/ai/deep-dive', { items }, { tokenOverride, signal });
};
export const searchWeb = async (
export const searchWeb = (
query: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
return apiFetch(
'/ai/search-web',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal,
},
{ tokenOverride, signal },
);
return authedPost('/ai/search-web', { query }, { tokenOverride, signal });
};
// ============================================================================
@@ -163,15 +214,7 @@ export const planTripWithMaps = async (
tokenOverride?: string,
): Promise<Response> => {
logger.debug('Stub: planTripWithMaps called with location:', { userLocation });
return apiFetch(
'/ai/plan-trip',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, store, userLocation }),
},
{ signal, tokenOverride },
);
return authedPost('/ai/plan-trip', { items, store, userLocation }, { signal, tokenOverride });
};
/**
@@ -179,22 +222,13 @@ export const planTripWithMaps = async (
* @param prompt A description of the image to generate (e.g., a meal plan).
* @returns A base64-encoded string of the generated PNG image.
*/
export const generateImageFromText = async (
export const generateImageFromText = (
prompt: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
logger.debug('Stub: generateImageFromText called with prompt:', { prompt });
return apiFetch(
'/ai/generate-image',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
},
{ tokenOverride, signal },
);
return authedPost('/ai/generate-image', { prompt }, { tokenOverride, signal });
};
/**
@@ -202,22 +236,13 @@ export const generateImageFromText = async (
* @param text The text to be spoken.
* @returns A base64-encoded string of the raw audio data.
*/
export const generateSpeechFromText = async (
export const generateSpeechFromText = (
text: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
logger.debug('Stub: generateSpeechFromText called with text:', { text });
return apiFetch(
'/ai/generate-speech',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal,
},
{ tokenOverride, signal },
);
return authedPost('/ai/generate-speech', { text }, { tokenOverride, signal });
};
/**
@@ -259,7 +284,7 @@ export const startVoiceSession = (callbacks: {
* @param tokenOverride Optional token for testing.
* @returns A promise that resolves to the API response containing the extracted text.
*/
export const rescanImageArea = async (
export const rescanImageArea = (
imageFile: File,
cropArea: { x: number; y: number; width: number; height: number },
extractionType: 'store_name' | 'dates' | 'item_details',
@@ -270,7 +295,7 @@ export const rescanImageArea = async (
formData.append('cropArea', JSON.stringify(cropArea));
formData.append('extractionType', extractionType);
return apiFetch('/ai/rescan-area', { method: 'POST', body: formData }, { tokenOverride });
return authedPostForm('/ai/rescan-area', formData, { tokenOverride });
};
/**
@@ -278,19 +303,11 @@ export const rescanImageArea = async (
* @param watchedItems An array of the user's watched master grocery items.
* @returns A promise that resolves to the raw `Response` object from the API.
*/
export const compareWatchedItemPrices = async (
export const compareWatchedItemPrices = (
watchedItems: MasterGroceryItem[],
signal?: AbortSignal,
): Promise<Response> => {
// Use the apiFetch wrapper for consistency with other API calls in this file.
// This centralizes token handling and base URL logic.
return apiFetch(
'/ai/compare-prices',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: watchedItems }),
},
{ signal },
);
return authedPost('/ai/compare-prices', { items: watchedItems }, { signal });
};

View File

@@ -4,7 +4,7 @@ import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server';
import { AIService, AiFlyerDataSchema, aiService as aiServiceSingleton } from './aiService.server';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
@@ -65,6 +65,25 @@ describe('AI Service (Server)', () => {
});
});
describe('AiFlyerDataSchema', () => {
it('should fail validation if store_name is null or empty, covering requiredString', () => {
const dataWithNull = { store_name: null, items: [] };
const dataWithEmpty = { store_name: '', items: [] };
const resultNull = AiFlyerDataSchema.safeParse(dataWithNull);
const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);
expect(resultNull.success).toBe(false);
if (!resultNull.success) {
expect(resultNull.error.issues[0].message).toBe('Store name cannot be empty');
}
expect(resultEmpty.success).toBe(false);
if (!resultEmpty.success) {
expect(resultEmpty.error.issues[0].message).toBe('Store name cannot be empty');
}
});
});
describe('Constructor', () => {
const originalEnv = process.env;
@@ -166,6 +185,127 @@ describe('AI Service (Server)', () => {
});
});
describe('Model Fallback Logic', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' };
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
it('should try the next model if the first one fails with a quota error', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const quotaError = new Error('User rate limit exceeded due to quota');
const successResponse = { text: 'Success from fallback model', candidates: [] };
// Mock the generateContent function to fail on the first call and succeed on the second
mockGenerateContent.mockRejectedValueOnce(quotaError).mockResolvedValueOnce(successResponse);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
// Assert
expect(result).toEqual(successResponse);
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
// Check first call
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: 'gemini-2.5-flash',
...request,
});
// Check second call
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: 'gemini-3-flash',
...request,
});
// Check that a warning was logged
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"Model 'gemini-2.5-flash' failed due to quota/rate limit. Trying next model.",
),
);
});
it('should throw immediately for non-retriable errors', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const nonRetriableError = new Error('Invalid API Key');
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
'Invalid API Key',
);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
{ error: nonRetriableError },
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
);
});
it('should throw the last error if all models fail', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const quotaError1 = new Error('Quota exhausted for model 1');
const quotaError2 = new Error('429 Too Many Requests for model 2');
const quotaError3 = new Error('RESOURCE_EXHAUSTED for model 3');
mockGenerateContent
.mockRejectedValueOnce(quotaError1)
.mockRejectedValueOnce(quotaError2)
.mockRejectedValueOnce(quotaError3);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
quotaError3,
);
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: 'gemini-2.5-flash',
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: 'gemini-3-flash',
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, {
model: 'gemini-2.5-flash-lite',
...request,
});
expect(logger.error).toHaveBeenCalledWith(
{ lastError: quotaError3 },
'[AIService Adapter] All AI models failed. Throwing last known error.',
);
});
});
describe('extractItemsFromReceiptImage', () => {
it('should extract items from a valid AI response', async () => {
const mockAiResponseText = `[
@@ -456,40 +596,6 @@ describe('AI Service (Server)', () => {
});
});
describe('_normalizeExtractedItems (private method)', () => {
it('should replace null or undefined fields with default values', () => {
const rawItems: {
item: string;
price_display: null;
quantity: undefined;
category_name: null;
master_item_id: null;
}[] = [
{
item: 'Test',
price_display: null,
quantity: undefined,
category_name: null,
master_item_id: null,
},
];
const [normalized] = (
aiServiceInstance as unknown as {
_normalizeExtractedItems: (items: typeof rawItems) => {
price_display: string;
quantity: string;
category_name: string;
master_item_id: undefined;
}[];
}
)._normalizeExtractedItems(rawItems);
expect(normalized.price_display).toBe('');
expect(normalized.quantity).toBe('');
expect(normalized.category_name).toBe('Other/Miscellaneous');
expect(normalized.master_item_id).toBeUndefined();
});
});
describe('extractTextFromImageArea', () => {
it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
console.log("TEST START: 'should call sharp to crop...'");
@@ -585,4 +691,36 @@ describe('AI Service (Server)', () => {
);
});
});
describe('planTripWithMaps', () => {
const mockUserLocation: GeolocationCoordinates = {
latitude: 45,
longitude: -75,
accuracy: 10,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({}),
};
const mockStore = { name: 'Test Store' };
it('should throw a "feature disabled" error', async () => {
// This test verifies the current implementation which has the feature disabled.
await expect(
aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");
// Also verify that the warning is logged
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
'[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
);
});
});
describe('Singleton Export', () => {
it('should export a singleton instance of AIService', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService);
});
});
});

View File

@@ -72,6 +72,7 @@ export class AIService {
private fs: IFileSystem;
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
private logger: Logger;
private readonly models = ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-flash-lite'];
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger;
@@ -121,17 +122,11 @@ export class AIService {
);
}
// do not change "gemini-2.5-flash" - this is correct
const modelName = 'gemini-2.5-flash';
// We create a shim/adapter that matches the old structure but uses the new SDK call pattern.
// This preserves the dependency injection pattern used throughout the class.
this.aiClient = genAI
? {
generateContent: async (request) => {
// The model name is now injected here, into every call, as the new SDK requires.
// Architectural guard clause: All requests from this service must have content.
// This prevents sending invalid requests to the API and satisfies TypeScript's strictness.
if (!request.contents || request.contents.length === 0) {
this.logger.error(
{ request },
@@ -140,14 +135,7 @@ export class AIService {
throw new Error('AIService.generateContent requires at least one content element.');
}
// Architectural Fix: After the guard clause, assign the guaranteed-to-exist element
// to a new constant. This provides a definitive type-safe variable for the compiler.
const firstContent = request.contents[0];
this.logger.debug(
{ modelName, requestParts: firstContent.parts?.length ?? 0 },
'[AIService] Calling actual generateContent via adapter.',
);
return genAI.models.generateContent({ model: modelName, ...request });
return this._generateWithFallback(genAI, request);
},
}
: {
@@ -182,6 +170,54 @@ export class AIService {
this.logger.info('---------------- [AIService] Constructor End ----------------');
}
private async _generateWithFallback(
genAI: GoogleGenAI,
request: { contents: Content[]; tools?: Tool[] },
): Promise<GenerateContentResponse> {
let lastError: Error | null = null;
for (const modelName of this.models) {
try {
this.logger.info(
`[AIService Adapter] Attempting to generate content with model: ${modelName}`,
);
const result = await genAI.models.generateContent({ model: modelName, ...request });
// If the call succeeds, return the result immediately.
return result;
} catch (error: unknown) {
lastError = error instanceof Error ? error : new Error(String(error));
const errorMessage = (lastError.message || '').toLowerCase(); // Make case-insensitive
// Check for specific error messages indicating quota issues or model unavailability.
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') || // HTTP 429 Too Many Requests
errorMessage.includes('resource_exhausted') || // Make case-insensitive
errorMessage.includes('model is overloaded')
) {
this.logger.warn(
`[AIService Adapter] Model '${modelName}' failed due to quota/rate limit. Trying next model. Error: ${errorMessage}`,
);
continue; // Try the next model in the list.
} else {
// For other errors (e.g., invalid input, safety settings), fail immediately.
this.logger.error(
{ error: lastError },
`[AIService Adapter] Model '${modelName}' failed with a non-retriable error.`,
);
throw lastError;
}
}
}
// If all models in the list have failed, throw the last error encountered.
this.logger.error(
{ lastError },
'[AIService Adapter] All AI models failed. Throwing last known error.',
);
throw lastError || new Error('All AI models failed to generate content.');
}
private async serverFileToGenerativePart(path: string, mimeType: string) {
const fileData = await this.fs.readFile(path);
return {
@@ -515,6 +551,11 @@ export class AIService {
private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
return items.map((item: RawFlyerItem) => ({
...item,
// Ensure 'item' is always a string, defaulting to 'Unknown Item' if null/undefined.
item:
item.item === null || item.item === undefined || String(item.item).trim() === ''
? 'Unknown Item'
: String(item.item),
price_display:
item.price_display === null || item.price_display === undefined
? ''

View File

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

View File

@@ -7,6 +7,17 @@ import { http, HttpResponse } from 'msw';
vi.unmock('./apiClient');
import * as apiClient from './apiClient';
import {
createMockAddressPayload,
createMockBudget,
createMockLoginPayload,
createMockProfileUpdatePayload,
createMockRecipeCommentPayload,
createMockRegisterUserPayload,
createMockSearchQueryPayload,
createMockShoppingListItemPayload,
createMockWatchedItemPayload,
} from '../tests/utils/mockFactories';
// Mock the logger to keep test output clean and verifiable.
vi.mock('./logger', () => ({
@@ -176,15 +187,13 @@ describe('API Client', () => {
// We expect the promise to still resolve with the bad response, but log an error.
await apiClient.apiFetch('/some/failing/endpoint');
// FIX: Use stringContaining to be resilient to port numbers (e.g., localhost:3001)
// This checks for the essential parts of the log message without being brittle.
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('apiFetch: Request to http://'),
'Internal Server Error',
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('/api/some/failing/endpoint failed with status 500'),
'Internal Server Error',
expect.objectContaining({
status: 500,
body: 'Internal Server Error',
url: expect.stringContaining('/some/failing/endpoint'),
}),
'apiFetch: Request failed',
);
});
@@ -231,39 +240,6 @@ describe('API Client', () => {
});
});
describe('Analytics API Functions', () => {
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// We can now await this properly because we added 'return' in apiClient.ts
await apiClient.trackFlyerItemInteraction(123, 'click');
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
});
it('logSearchQuery should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
await apiClient.logSearchQuery({
query_text: 'test',
result_count: 0,
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
});
});
describe('apiFetch (with FormData)', () => {
it('should handle FormData correctly by not setting Content-Type', async () => {
localStorage.setItem('authToken', 'form-data-token');
@@ -325,10 +301,11 @@ describe('API Client', () => {
});
it('addWatchedItem should send a POST request with the correct body', async () => {
await apiClient.addWatchedItem('Apples', 'Produce');
const watchedItemData = createMockWatchedItemPayload({ itemName: 'Apples', category: 'Produce' });
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category);
expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
expect(capturedBody).toEqual({ itemName: 'Apples', category: 'Produce' });
expect(capturedBody).toEqual(watchedItemData);
});
it('removeWatchedItem should send a DELETE request to the correct URL', async () => {
@@ -345,12 +322,12 @@ describe('API Client', () => {
});
it('createBudget should send a POST request with budget data', async () => {
const budgetData = {
const budgetData = createMockBudget({
name: 'Groceries',
amount_cents: 50000,
period: 'monthly' as const,
period: 'monthly',
start_date: '2024-01-01',
};
});
await apiClient.createBudget(budgetData);
expect(capturedUrl?.pathname).toBe('/api/budgets');
@@ -469,7 +446,7 @@ describe('API Client', () => {
it('addShoppingListItem should send a POST request with item data', async () => {
const listId = 42;
const itemData = { customItemName: 'Paper Towels' };
const itemData = createMockShoppingListItemPayload({ customItemName: 'Paper Towels' });
await apiClient.addShoppingListItem(listId, itemData);
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`);
@@ -555,7 +532,7 @@ describe('API Client', () => {
it('addRecipeComment should send a POST request with content and optional parentId', async () => {
const recipeId = 456;
const commentData = { content: 'This is a reply', parentCommentId: 789 };
const commentData = createMockRecipeCommentPayload({ content: 'This is a reply', parentCommentId: 789 });
await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId);
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`);
expect(capturedBody).toEqual(commentData);
@@ -571,7 +548,7 @@ describe('API Client', () => {
describe('User Profile and Settings API Functions', () => {
it('updateUserProfile should send a PUT request with profile data', async () => {
localStorage.setItem('authToken', 'user-settings-token');
const profileData = { full_name: 'John Doe' };
const profileData = createMockProfileUpdatePayload({ full_name: 'John Doe' });
await apiClient.updateUserProfile(profileData, { tokenOverride: 'override-token' });
expect(capturedUrl?.pathname).toBe('/api/users/profile');
expect(capturedBody).toEqual(profileData);
@@ -627,14 +604,14 @@ describe('API Client', () => {
});
it('registerUser should send a POST request with user data', async () => {
await apiClient.registerUser('test@example.com', 'password123', 'Test User');
expect(capturedUrl?.pathname).toBe('/api/auth/register');
expect(capturedBody).toEqual({
const userData = createMockRegisterUserPayload({
email: 'test@example.com',
password: 'password123',
full_name: 'Test User',
avatar_url: undefined,
});
await apiClient.registerUser(userData.email, userData.password, userData.full_name);
expect(capturedUrl?.pathname).toBe('/api/auth/register');
expect(capturedBody).toEqual(userData);
});
it('deleteUserAccount should send a DELETE request with the confirmation password', async () => {
@@ -662,7 +639,7 @@ describe('API Client', () => {
});
it('updateUserAddress should send a PUT request with address data', async () => {
const addressData = { address_line_1: '123 Main St', city: 'Anytown' };
const addressData = createMockAddressPayload({ address_line_1: '123 Main St', city: 'Anytown' });
await apiClient.updateUserAddress(addressData);
expect(capturedUrl?.pathname).toBe('/api/users/profile/address');
expect(capturedBody).toEqual(addressData);
@@ -950,53 +927,49 @@ describe('API Client', () => {
});
it('logSearchQuery should send a POST request with query data', async () => {
const queryData = { query_text: 'apples', result_count: 10, was_successful: true };
const queryData = createMockSearchQueryPayload({ query_text: 'apples', result_count: 10, was_successful: true });
await apiClient.logSearchQuery(queryData);
expect(capturedUrl?.pathname).toBe('/api/search/log');
expect(capturedBody).toEqual(queryData);
});
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
const { logger } = await import('./logger.client');
// We can now await this properly because we added 'return' in apiClient.ts
await apiClient.trackFlyerItemInteraction(123, 'click');
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
});
it('logSearchQuery should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
const { logger } = await import('./logger.client');
await apiClient.logSearchQuery({
const queryData = createMockSearchQueryPayload({
query_text: 'test',
result_count: 0,
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
await apiClient.logSearchQuery(queryData);
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
});
});
describe('Authentication API Functions', () => {
it('loginUser should send a POST request with credentials', async () => {
await apiClient.loginUser('test@example.com', 'password123', true);
expect(capturedUrl?.pathname).toBe('/api/auth/login');
expect(capturedBody).toEqual({
const loginData = createMockLoginPayload({
email: 'test@example.com',
password: 'password123',
rememberMe: true,
});
await apiClient.loginUser(loginData.email, loginData.password, loginData.rememberMe);
expect(capturedUrl?.pathname).toBe('/api/auth/login');
expect(capturedBody).toEqual(loginData);
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
// src/services/db/price.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { getPool } from './connection.db';
import { priceRepo } from './price.db';
import type { PriceHistoryData } from '../../types';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./price.db');
// Mock dependencies
vi.mock('./connection.db', () => ({
getPool: vi.fn(),
}));
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('Price DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
// Make getPool return our mock instance for each test
vi.mocked(getPool).mockReturnValue(mockPoolInstance as any);
});
describe('getPriceHistory', () => {
it('should return an empty array if masterItemIds is empty and not query the db', async () => {
const result = await priceRepo.getPriceHistory([], mockLogger);
expect(result).toEqual([]);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
it('should execute the correct query with default limit and offset', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await priceRepo.getPriceHistory([1, 2], mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2 OFFSET $3'),
[[1, 2], 1000, 0],
);
});
it('should execute the correct query with provided limit and offset', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await priceRepo.getPriceHistory([1, 2], mockLogger, 50, 10);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2 OFFSET $3'),
[[1, 2], 50, 10],
);
});
it('should return price history data on success', async () => {
const mockHistory: PriceHistoryData[] = [
{ master_item_id: 1, price_in_cents: 199, date: '2024-01-01' },
{ master_item_id: 1, price_in_cents: 209, date: '2024-01-08' },
];
mockPoolInstance.query.mockResolvedValue({ rows: mockHistory });
const result = await priceRepo.getPriceHistory([1], mockLogger);
expect(result).toEqual(mockHistory);
});
it('should log the result count on success', async () => {
const mockHistory: PriceHistoryData[] = [
{ master_item_id: 1, price_in_cents: 199, date: '2024-01-01' },
];
mockPoolInstance.query.mockResolvedValue({ rows: mockHistory });
await priceRepo.getPriceHistory([1], mockLogger, 50, 10);
expect(mockLogger.debug).toHaveBeenCalledWith(
{ count: 1, itemIds: 1, limit: 50, offset: 10 },
'Fetched price history from database.',
);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(priceRepo.getPriceHistory([1], mockLogger, 50, 10)).rejects.toThrow(
'Failed to retrieve price history.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, masterItemIds: [1], limit: 50, offset: 10 },
'Database error in getPriceHistory',
);
});
});
});

View File

@@ -43,11 +43,19 @@ export const priceRepo = {
LIMIT $2 OFFSET $3;
`;
const result = await getPool().query(query, [masterItemIds, limit, offset]);
logger.debug(
{ count: result.rows.length, itemIds: masterItemIds.length, limit, offset },
'Fetched price history from database.',
);
return result.rows;
try {
const result = await getPool().query(query, [masterItemIds, limit, offset]);
logger.debug(
{ count: result.rows.length, itemIds: masterItemIds.length, limit, offset },
'Fetched price history from database.',
);
return result.rows;
} catch (error) {
logger.error(
{ err: error, masterItemIds, limit, offset },
'Database error in getPriceHistory',
);
throw new Error('Failed to retrieve price history.');
}
},
};

View File

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

View File

@@ -0,0 +1,84 @@
// src/services/eventBus.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventBus } from './eventBus';
describe('EventBus', () => {
let eventBus: EventBus;
beforeEach(() => {
// Create a new instance for each test to ensure isolation
eventBus = new EventBus();
});
it('should call a listener when an event is dispatched', () => {
const callback = vi.fn();
eventBus.on('test-event', callback);
eventBus.dispatch('test-event');
expect(callback).toHaveBeenCalledTimes(1);
});
it('should pass data to the listener when dispatched', () => {
const callback = vi.fn();
const data = { message: 'hello' };
eventBus.on('data-event', callback);
eventBus.dispatch('data-event', data);
expect(callback).toHaveBeenCalledWith(data);
});
it('should call multiple listeners for the same event', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
eventBus.on('multi-event', callback1);
eventBus.on('multi-event', callback2);
eventBus.dispatch('multi-event');
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
});
it('should stop calling a listener after it has been removed', () => {
const callback = vi.fn();
eventBus.on('remove-event', callback);
eventBus.dispatch('remove-event');
expect(callback).toHaveBeenCalledTimes(1);
eventBus.off('remove-event', callback);
eventBus.dispatch('remove-event');
// The callback should still have been called only once from the first dispatch
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not throw an error when dispatching an event with no listeners', () => {
expect(() => eventBus.dispatch('no-listener-event')).not.toThrow();
});
it('should not throw an error when removing a listener that does not exist for an event', () => {
const existentCallback = vi.fn();
const nonExistentCallback = () => {};
eventBus.on('some-event', existentCallback);
expect(() => eventBus.off('some-event', nonExistentCallback)).not.toThrow();
});
it('should not throw an error when removing a listener from an event that has no listeners', () => {
const callback = vi.fn();
expect(() => eventBus.off('non-existent-event', callback)).not.toThrow();
});
it('should handle removing one of multiple listeners correctly', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
eventBus.on('multi-remove-event', callback1);
eventBus.on('multi-remove-event', callback2);
eventBus.dispatch('multi-remove-event');
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
eventBus.off('multi-remove-event', callback1);
eventBus.dispatch('multi-remove-event');
// callback1 should not be called again
expect(callback1).toHaveBeenCalledTimes(1);
// callback2 should be called again
expect(callback2).toHaveBeenCalledTimes(2);
});
});

31
src/services/eventBus.ts Normal file
View File

@@ -0,0 +1,31 @@
// src/services/eventBus.ts
/**
* A simple, generic event bus for cross-component communication without direct coupling.
* This is particularly useful for broadcasting application-wide events, such as session expiry.
*/
type EventCallback = (data?: any) => void;
export class EventBus {
private listeners: { [key: string]: EventCallback[] } = {};
on(event: string, callback: EventCallback): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
off(event: string, callback: EventCallback): void {
if (!this.listeners[event]) return;
this.listeners[event] = this.listeners[event].filter((l) => l !== callback);
}
dispatch(event: string, data?: any): void {
if (!this.listeners[event]) return;
this.listeners[event].forEach((callback) => callback(data));
}
}
export const eventBus = new EventBus();

View File

@@ -0,0 +1,75 @@
// src/services/flyerAiProcessor.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
import { AiDataValidationError } from './processingErrors';
import { logger } from './logger.server';
import type { AIService } from './aiService.server';
import type { PersonalizationRepository } from './db/personalization.db';
import type { FlyerJobData } from './flyerProcessingService.server';
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
child: vi.fn().mockReturnThis(),
},
}));
const createMockJobData = (data: Partial<FlyerJobData>): FlyerJobData => ({
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
...data,
});
describe('FlyerAiProcessor', () => {
let service: FlyerAiProcessor;
let mockAiService: AIService;
let mockPersonalizationRepo: PersonalizationRepository;
beforeEach(() => {
vi.clearAllMocks();
mockAiService = {
extractCoreDataFromFlyerImage: vi.fn(),
} as unknown as AIService;
mockPersonalizationRepo = {
getAllMasterItems: vi.fn().mockResolvedValue([]),
} as unknown as PersonalizationRepository;
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
});
it('should call AI service and return validated data on success', async () => {
const jobData = createMockJobData({});
const mockAiResponse = {
store_name: 'AI Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 AI St',
items: [],
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
const result = await service.extractAndValidateData([], jobData, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
expect(mockPersonalizationRepo.getAllMasterItems).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockAiResponse);
});
it('should throw AiDataValidationError if AI response validation fails', async () => {
const jobData = createMockJobData({});
// Mock AI to return data missing a required field ('store_name')
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue({
valid_from: '2024-01-01',
items: [],
} as any);
await expect(service.extractAndValidateData([], jobData, logger)).rejects.toThrow(
AiDataValidationError,
);
});
});

View File

@@ -0,0 +1,88 @@
// src/services/flyerAiProcessor.server.ts
import { z } from 'zod';
import type { Logger } from 'pino';
import type { AIService } from './aiService.server';
import type { PersonalizationRepository } from './db/personalization.db';
import { AiDataValidationError } from './processingErrors';
import type { FlyerJobData } from './flyerProcessingService.server';
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// --- Zod Schemas for AI Response Validation ---
const ExtractedFlyerItemSchema = z.object({
item: z.string().nullable(),
price_display: z.string().nullable(),
price_in_cents: z.number().nullable(),
quantity: z.string().nullable(),
category_name: z.string().nullable(),
master_item_id: z.number().nullish(),
});
export const AiFlyerDataSchema = z.object({
store_name: z.string().nullable(),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
export type ValidatedAiDataType = z.infer<typeof AiFlyerDataSchema>;
/**
* This class encapsulates the logic for interacting with the AI service
* to extract and validate data from flyer images.
*/
export class FlyerAiProcessor {
constructor(
private ai: AIService,
private personalizationRepo: PersonalizationRepository,
) {}
/**
* Validates the raw data from the AI against the Zod schema.
*/
private _validateAiData(
extractedData: unknown,
logger: Logger,
): ValidatedAiDataType {
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error({ errors, rawData: extractedData }, 'AI response failed validation.');
throw new AiDataValidationError(
'AI response validation failed. The returned data structure is incorrect.',
errors,
extractedData,
);
}
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
return validationResult.data;
}
/**
* Calls the AI service to extract structured data from the flyer images and validates the response.
*/
public async extractAndValidateData(
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
logger: Logger,
): Promise<ValidatedAiDataType> {
logger.info(`Starting AI data extraction.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.personalizationRepo.getAllMasterItems(logger);
logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
imagePaths,
masterItems,
submitterIp,
userProfileAddress,
logger,
);
return this._validateAiData(extractedData, logger);
}
}

View File

@@ -4,7 +4,7 @@ import { FlyerDataTransformer } from './flyerDataTransformer';
import { logger as mockLogger } from './logger.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { z } from 'zod';
import type { AiFlyerDataSchema } from './flyerProcessingService.server';
import type { AiFlyerDataSchema } from './flyerAiProcessor.server';
import type { FlyerItemInsert } from '../types';
// Mock the dependencies
@@ -109,9 +109,6 @@ describe('FlyerDataTransformer', () => {
view_count: 0,
click_count: 0,
}),
); // Use a more specific type assertion to check for the added property.
expect((itemsForDb[0] as FlyerItemInsert & { updated_at: string }).updated_at).toBeTypeOf(
'string',
);
// 3. Check that generateFlyerIcon was called correctly
@@ -153,6 +150,9 @@ describe('FlyerDataTransformer', () => {
expect(mockLogger.info).toHaveBeenCalledWith(
'Starting data transformation from AI output to database format.',
);
expect(mockLogger.warn).toHaveBeenCalledWith(
'AI did not return a store name. Using fallback "Unknown Store (auto)".',
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ itemCount: 0, storeName: 'Unknown Store (auto)' },
'Data transformation complete.',
@@ -172,4 +172,62 @@ describe('FlyerDataTransformer', () => {
uploaded_by: undefined, // Should be undefined
});
});
it('should correctly normalize item fields with null, undefined, or empty values', async () => {
// Arrange
const extractedData: z.infer<typeof AiFlyerDataSchema> = {
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [
// Case 1: All fields are null or undefined
{
item: null,
price_display: null,
price_in_cents: null,
quantity: null,
category_name: null,
master_item_id: null,
},
// Case 2: Fields are empty strings
{
item: ' ', // whitespace only
price_display: '',
price_in_cents: 200,
quantity: '',
category_name: '',
master_item_id: 20,
},
],
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
extractedData,
imagePaths,
'file.pdf',
'checksum',
'user-1',
mockLogger,
);
// Assert
expect(itemsForDb).toHaveLength(2);
// Check Case 1 (null/undefined values)
expect(itemsForDb[0]).toEqual(
expect.objectContaining({
item: 'Unknown Item', price_display: '', quantity: '', category_name: 'Other/Miscellaneous', master_item_id: undefined,
}),
);
// Check Case 2 (empty string values)
expect(itemsForDb[1]).toEqual(
expect.objectContaining({
item: 'Unknown Item', price_display: '', quantity: '', category_name: 'Other/Miscellaneous', master_item_id: 20,
}),
);
});
});

View File

@@ -3,7 +3,7 @@ import path from 'path';
import type { z } from 'zod';
import type { Logger } from 'pino';
import type { FlyerInsert, FlyerItemInsert } from '../types';
import type { AiFlyerDataSchema } from './flyerProcessingService.server';
import type { AiFlyerDataSchema } from './flyerAiProcessor.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
/**
@@ -11,6 +11,31 @@ import { generateFlyerIcon } from '../utils/imageProcessor';
* into the structured format required for database insertion (FlyerInsert and FlyerItemInsert).
*/
export class FlyerDataTransformer {
/**
* Normalizes a single raw item from the AI, providing default values for nullable fields.
* @param item The raw item object from the AI.
* @returns A normalized item object ready for database insertion.
*/
private _normalizeItem(
item: z.infer<typeof AiFlyerDataSchema>['items'][number],
): FlyerItemInsert {
return {
...item,
// Use logical OR to default falsy values (null, undefined, '') to a fallback.
// The trim is important for cases where the AI returns only whitespace.
item: String(item.item || '').trim() || 'Unknown Item',
// Use nullish coalescing to default only null/undefined to an empty string.
price_display: String(item.price_display ?? ''),
quantity: String(item.quantity ?? ''),
// Use logical OR to default falsy category names (null, undefined, '') to a fallback.
category_name: String(item.category_name || 'Other/Miscellaneous'),
// Use nullish coalescing to convert null to undefined for the database.
master_item_id: item.master_item_id ?? undefined,
view_count: 0,
click_count: 0,
};
}
/**
* Transforms AI-extracted data into database-ready flyer and item records.
* @param extractedData The validated data from the AI.
@@ -38,20 +63,19 @@ export class FlyerDataTransformer {
logger,
);
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id, // Convert null to undefined
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
const storeName = extractedData.store_name || 'Unknown Store (auto)';
if (!extractedData.store_name) {
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
}
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${path.basename(firstImage)}`,
icon_url: `/flyer-images/icons/${iconFileName}`,
checksum,
store_name: extractedData.store_name || 'Unknown Store (auto)',
store_name: storeName,
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.

View File

@@ -0,0 +1,129 @@
// src/services/flyerFileHandler.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Job } from 'bullmq';
import type { Dirent } from 'node:fs';
import sharp from 'sharp';
import { FlyerFileHandler, ICommandExecutor, IFileSystem } from './flyerFileHandler.server';
import { PdfConversionError, UnsupportedFileTypeError } from './processingErrors';
import { logger } from './logger.server';
import type { FlyerJobData } from './flyerProcessingService.server';
// Mock dependencies
vi.mock('sharp', () => {
const mockSharpInstance = {
png: vi.fn().mockReturnThis(),
toFile: vi.fn().mockResolvedValue({}),
};
return {
__esModule: true,
default: vi.fn(() => mockSharpInstance),
};
});
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
child: vi.fn().mockReturnThis(),
},
}));
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
return {
id: 'job-1',
data: {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
...data,
},
updateProgress: vi.fn(),
} as unknown as Job<FlyerJobData>;
};
describe('FlyerFileHandler', () => {
let service: FlyerFileHandler;
let mockFs: IFileSystem;
let mockExec: ICommandExecutor;
beforeEach(() => {
vi.clearAllMocks();
mockFs = {
readdir: vi.fn().mockResolvedValue([]),
unlink: vi.fn(),
};
mockExec = vi.fn().mockResolvedValue({ stdout: 'success', stderr: '' });
service = new FlyerFileHandler(mockFs, mockExec);
});
it('should convert a PDF and return image paths', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.pdf' });
vi.mocked(mockFs.readdir).mockResolvedValue([
{ name: 'flyer-1.jpg' },
{ name: 'flyer-2.jpg' },
] as Dirent[]);
const { imagePaths, createdImagePaths } = await service.prepareImageInputs(
'/tmp/flyer.pdf',
job,
logger,
);
expect(mockExec).toHaveBeenCalledWith('pdftocairo -jpeg -r 150 "/tmp/flyer.pdf" "/tmp/flyer"');
expect(imagePaths).toHaveLength(2);
expect(imagePaths[0].path).toContain('flyer-1.jpg');
expect(createdImagePaths).toHaveLength(2);
});
it('should throw PdfConversionError if PDF conversion yields no images', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.pdf' });
vi.mocked(mockFs.readdir).mockResolvedValue([]); // No images found
await expect(service.prepareImageInputs('/tmp/flyer.pdf', job, logger)).rejects.toThrow(
PdfConversionError,
);
});
it('should handle supported image types directly', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg' });
const { imagePaths, createdImagePaths } = await service.prepareImageInputs(
'/tmp/flyer.jpg',
job,
logger,
);
expect(imagePaths).toEqual([{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }]);
expect(createdImagePaths).toEqual([]);
expect(mockExec).not.toHaveBeenCalled();
expect(sharp).not.toHaveBeenCalled();
});
it('should convert convertible image types to PNG', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.gif' });
const mockSharpInstance = sharp('/tmp/flyer.gif');
vi.mocked(mockSharpInstance.toFile).mockResolvedValue({} as any);
const { imagePaths, createdImagePaths } = await service.prepareImageInputs(
'/tmp/flyer.gif',
job,
logger,
);
expect(sharp).toHaveBeenCalledWith('/tmp/flyer.gif');
expect(mockSharpInstance.png).toHaveBeenCalled();
expect(mockSharpInstance.toFile).toHaveBeenCalledWith('/tmp/flyer-converted.png');
expect(imagePaths).toEqual([{ path: '/tmp/flyer-converted.png', mimetype: 'image/png' }]);
expect(createdImagePaths).toEqual(['/tmp/flyer-converted.png']);
});
it('should throw UnsupportedFileTypeError for unsupported types', async () => {
const job = createMockJob({ filePath: '/tmp/document.txt' });
await expect(service.prepareImageInputs('/tmp/document.txt', job, logger)).rejects.toThrow(
UnsupportedFileTypeError,
);
});
});

View File

@@ -0,0 +1,207 @@
// src/services/flyerFileHandler.server.ts
import path from 'path';
import sharp from 'sharp';
import type { Dirent } from 'node:fs';
import type { Job } from 'bullmq';
import type { Logger } from 'pino';
import { PdfConversionError, UnsupportedFileTypeError } from './processingErrors';
import type { FlyerJobData } from './flyerProcessingService.server';
// Define the image formats supported by the AI model
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'];
// Define image formats that are not directly supported but can be converted to PNG.
const CONVERTIBLE_IMAGE_EXTENSIONS = ['.gif', '.tiff', '.svg', '.bmp'];
export interface IFileSystem {
readdir(path: string, options: { withFileTypes: true }): Promise<Dirent[]>;
unlink(path: string): Promise<void>;
}
export interface ICommandExecutor {
(command: string): Promise<{ stdout: string; stderr: string }>;
}
/**
* This class encapsulates the logic for handling different file types (PDF, images)
* and preparing them for AI processing.
*/
export class FlyerFileHandler {
constructor(
private fs: IFileSystem,
private exec: ICommandExecutor,
) {}
/**
* Executes the pdftocairo command to convert the PDF.
*/
private async _executePdfConversion(
filePath: string,
outputFilePrefix: string,
logger: Logger,
): Promise<{ stdout: string; stderr: string }> {
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`Executing PDF conversion command`);
logger.debug({ command });
try {
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug({ stdout }, `[Worker] pdftocairo stdout for ${filePath}:`);
if (stderr) logger.warn({ stderr }, `[Worker] pdftocairo stderr for ${filePath}:`);
return { stdout, stderr };
} catch (error) {
const execError = error as Error & { stderr?: string };
const errorMessage = `The pdftocairo command failed for file: ${filePath}.`;
logger.error({ err: execError, stderr: execError.stderr }, errorMessage);
throw new PdfConversionError(errorMessage, execError.stderr);
}
}
/**
* Scans the output directory for generated JPEG images and returns their paths.
*/
private async _collectGeneratedImages(
outputDir: string,
outputFilePrefix: string,
logger: Logger,
): Promise<string[]> {
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter((f) => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(
{ imageNames: generatedImages.map((f) => f.name) },
`Filtered down to ${generatedImages.length} generated JPGs.`,
);
return generatedImages.map((img) => path.join(outputDir, img.name));
}
/**
* Converts a PDF file to a series of JPEG images using an external tool.
*/
private async _convertPdfToImages(
filePath: string,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<string[]> {
logger.info(`Starting PDF conversion for: ${filePath}`);
await job.updateProgress({ message: 'Converting PDF to images...' });
const outputDir = path.dirname(filePath);
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug({ outputDir, outputFilePrefix }, `PDF output details`);
const { stderr } = await this._executePdfConversion(filePath, outputFilePrefix, logger);
const imagePaths = await this._collectGeneratedImages(outputDir, outputFilePrefix, logger);
if (imagePaths.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error({ stderr }, `PdfConversionError: ${errorMessage}`);
throw new PdfConversionError(errorMessage, stderr);
}
return imagePaths;
}
/**
* Converts an image file (e.g., GIF, TIFF) to a PNG format that the AI can process.
*/
private async _convertImageToPng(filePath: string, logger: Logger): Promise<string> {
const outputDir = path.dirname(filePath);
const originalFileName = path.parse(path.basename(filePath)).name;
const newFileName = `${originalFileName}-converted.png`;
const outputPath = path.join(outputDir, newFileName);
logger.info({ from: filePath, to: outputPath }, 'Converting unsupported image format to PNG.');
try {
await sharp(filePath).png().toFile(outputPath);
return outputPath;
} catch (error) {
logger.error({ err: error, filePath }, 'Failed to convert image to PNG using sharp.');
throw new Error(`Image conversion to PNG failed for ${path.basename(filePath)}.`);
}
}
/**
* Handles PDF files by converting them to a series of JPEG images.
*/
private async _handlePdfInput(
filePath: string,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
const createdImagePaths = await this._convertPdfToImages(filePath, job, logger);
const imagePaths = createdImagePaths.map((p) => ({ path: p, mimetype: 'image/jpeg' }));
logger.info(`Converted PDF to ${imagePaths.length} images.`);
return { imagePaths, createdImagePaths };
}
/**
* Handles image files that are directly supported by the AI.
*/
private async _handleSupportedImageInput(
filePath: string,
fileExt: string,
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
logger.info(`Processing as a single image file: ${filePath}`);
const mimetype =
fileExt === '.jpg' || fileExt === '.jpeg' ? 'image/jpeg' : `image/${fileExt.slice(1)}`;
const imagePaths = [{ path: filePath, mimetype }];
return { imagePaths, createdImagePaths: [] };
}
/**
* Handles image files that need to be converted to PNG before AI processing.
*/
private async _handleConvertibleImageInput(
filePath: string,
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
const createdPngPath = await this._convertImageToPng(filePath, logger);
const imagePaths = [{ path: createdPngPath, mimetype: 'image/png' }];
const createdImagePaths = [createdPngPath];
return { imagePaths, createdImagePaths };
}
/**
* Throws an error for unsupported file types.
*/
private _handleUnsupportedInput(
fileExt: string,
originalFileName: string,
logger: Logger,
): never {
const errorMessage = `Unsupported file type: ${fileExt}. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.`;
logger.error({ originalFileName, fileExt }, errorMessage);
throw new UnsupportedFileTypeError(errorMessage);
}
/**
* Prepares the input images for the AI service. If the input is a PDF, it's converted to images.
*/
public async prepareImageInputs(
filePath: string,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
if (fileExt === '.pdf') {
return this._handlePdfInput(filePath, job, logger);
}
if (SUPPORTED_IMAGE_EXTENSIONS.includes(fileExt)) {
return this._handleSupportedImageInput(filePath, fileExt, logger);
}
if (CONVERTIBLE_IMAGE_EXTENSIONS.includes(fileExt)) {
return this._handleConvertibleImageInput(filePath, logger);
}
return this._handleUnsupportedInput(fileExt, job.data.originalFileName, logger);
}
}

View File

@@ -1,13 +1,13 @@
// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import sharp from 'sharp';
import { Job } from 'bullmq';
import { Job, UnrecoverableError } from 'bullmq';
import type { Dirent } from 'node:fs';
import type { Logger } from 'pino';
import { z } from 'zod';
import { AiFlyerDataSchema } from './flyerProcessingService.server';
import type { Flyer, FlyerInsert } from '../types';
import { AiFlyerDataSchema } from './flyerAiProcessor.server';
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
import type { CleanupJobData } from './flyerProcessingService.server';
export interface FlyerJobData {
filePath: string;
originalFileName: string;
@@ -36,22 +36,10 @@ vi.mock('node:fs/promises', async (importOriginal) => {
};
});
// Mock sharp for the new image conversion logic
const mockSharpInstance = {
png: vi.fn(() => mockSharpInstance),
toFile: vi.fn().mockResolvedValue({}),
};
vi.mock('sharp', () => ({
__esModule: true,
default: vi.fn(() => mockSharpInstance),
}));
// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as aiService from './aiService.server';
import * as db from './db/index.db';
import { createFlyerAndItems } from './db/flyer.db';
import * as imageProcessor from '../utils/imageProcessor';
import { createMockFlyer } from '../tests/utils/mockFactories';
import { FlyerDataTransformer } from './flyerDataTransformer';
import {
@@ -59,6 +47,10 @@ import {
PdfConversionError,
UnsupportedFileTypeError,
} from './processingErrors';
import { FlyerFileHandler } from './flyerFileHandler.server';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
import type { AIService } from './aiService.server';
// Mock dependencies
vi.mock('./aiService.server', () => ({
@@ -73,9 +65,6 @@ vi.mock('./db/index.db', () => ({
personalizationRepo: { getAllMasterItems: vi.fn() },
adminRepo: { logActivity: vi.fn() },
}));
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
}));
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
@@ -85,13 +74,15 @@ vi.mock('./logger.server', () => ({
child: vi.fn().mockReturnThis(),
},
}));
vi.mock('./flyerFileHandler.server');
vi.mock('./flyerAiProcessor.server');
const mockedAiService = aiService as Mocked<typeof aiService>;
const mockedDb = db as Mocked<typeof db>;
const mockedImageProcessor = imageProcessor as Mocked<typeof imageProcessor>;
describe('FlyerProcessingService', () => {
let service: FlyerProcessingService;
let mockFileHandler: Mocked<FlyerFileHandler>;
let mockAiProcessor: Mocked<FlyerAiProcessor>;
const mockCleanupQueue = {
add: vi.fn(),
};
@@ -112,30 +103,35 @@ describe('FlyerProcessingService', () => {
itemsForDb: [],
});
// Default mock implementation for the promisified exec
mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' });
// Default mock for readdir returns an empty array of Dirent-like objects.
mocks.readdir.mockResolvedValue([]);
// Mock the file system adapter that will be passed to the service
const mockFs = {
const mockFs: IFileSystem = {
readdir: mocks.readdir,
unlink: mocks.unlink,
};
mockFileHandler = new FlyerFileHandler(mockFs, vi.fn()) as Mocked<FlyerFileHandler>;
mockAiProcessor = new FlyerAiProcessor(
{} as AIService,
mockedDb.personalizationRepo,
) as Mocked<FlyerAiProcessor>;
// Instantiate the service with all its dependencies mocked
service = new FlyerProcessingService(
mockedAiService.aiService,
{} as AIService,
mockFileHandler,
mockAiProcessor,
mockedDb,
mockFs,
mocks.execAsync,
vi.fn(),
mockCleanupQueue,
new FlyerDataTransformer(),
);
// Provide default successful mock implementations for dependencies
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
mockAiProcessor.extractAndValidateData.mockResolvedValue({
store_name: 'Mock Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
@@ -151,6 +147,11 @@ describe('FlyerProcessingService', () => {
},
],
});
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }],
createdImagePaths: [],
});
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: createMockFlyer({
flyer_id: 1,
@@ -160,7 +161,6 @@ describe('FlyerProcessingService', () => {
}),
items: [],
});
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
@@ -181,6 +181,16 @@ describe('FlyerProcessingService', () => {
} as unknown as Job<FlyerJobData>;
};
const createMockCleanupJob = (data: CleanupJobData): Job<CleanupJobData> => {
return {
id: `cleanup-job-${data.flyerId}`,
data,
opts: { attempts: 3 },
attemptsMade: 1,
updateProgress: vi.fn(),
} as unknown as Job<CleanupJobData>;
};
describe('processJob (Orchestrator)', () => {
it('should process an image file successfully and enqueue a cleanup job', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
@@ -188,10 +198,10 @@ describe('FlyerProcessingService', () => {
const result = await service.processJob(job);
expect(result).toEqual({ flyerId: 1 });
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
@@ -202,29 +212,17 @@ describe('FlyerProcessingService', () => {
it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' });
// Mock readdir to return Dirent-like objects for the converted files
mocks.readdir.mockResolvedValue([
{ name: 'flyer-1.jpg' },
{ name: 'flyer-2.jpg' },
] as Dirent[]);
// Mock the file handler to return multiple created paths
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: createdPaths.map(p => ({ path: p, mimetype: 'image/jpeg' })),
createdImagePaths: createdPaths,
});
await service.processJob(job);
// Verify that pdftocairo was called
expect(mocks.execAsync).toHaveBeenCalledWith(
expect.stringContaining('pdftocairo -jpeg -r 150'),
);
// Verify AI service was called with the converted images
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ path: expect.stringContaining('flyer-1.jpg') }),
expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }),
]),
expect.any(Array),
undefined, // submitterIp
undefined, // userProfileAddress
expect.any(Object), // The job-specific logger
);
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify cleanup job includes original PDF and both generated images
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
@@ -233,8 +231,8 @@ describe('FlyerProcessingService', () => {
flyerId: 1,
paths: [
'/tmp/flyer.pdf',
expect.stringContaining('flyer-1.jpg'),
expect.stringContaining('flyer-2.jpg'),
'/tmp/flyer-1.jpg',
'/tmp/flyer-2.jpg',
],
},
expect.any(Object),
@@ -243,35 +241,65 @@ describe('FlyerProcessingService', () => {
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const aiError = new Error('AI model exploded');
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
mockAiProcessor.extractAndValidateData.mockRejectedValue(aiError);
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNKNOWN_ERROR',
message: 'AI model exploded',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw UnrecoverableError for quota issues and not enqueue cleanup', async () => {
const job = createMockJob({});
// Simulate an AI error that contains a keyword for unrecoverable errors
const quotaError = new Error('AI model quota exceeded');
const { logger } = await import('./logger.server');
mockAiProcessor.extractAndValidateData.mockRejectedValue(quotaError);
await expect(service.processJob(job)).rejects.toThrow(UnrecoverableError);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
const { logger } = await import('./logger.server');
const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
// Make the conversion step fail
mocks.execAsync.mockRejectedValue(conversionError);
mockFileHandler.prepareImageInputs.mockRejectedValue(conversionError);
await expect(service.processJob(job)).rejects.toThrow(conversionError);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Conversion failed' });
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'PDF_CONVERSION_FAILED',
message:
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', // This was a duplicate, fixed.
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const validationError = new AiDataValidationError('Validation failed', {}, {});
// Make the AI extraction step fail with a validation error
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(
validationError,
);
mockAiProcessor.extractAndValidateData.mockRejectedValue(validationError);
await expect(service.processJob(job)).rejects.toThrow(validationError);
@@ -280,82 +308,54 @@ describe('FlyerProcessingService', () => {
{ err: validationError, validationErrors: {}, rawData: {} },
'AI Data Validation failed.',
);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Validation failed' });
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'AI_VALIDATION_FAILED',
message:
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", // This was a duplicate, fixed.
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
// FIX: This test was incorrect. The service *does* support GIF conversion.
// It is now a success case, verifying that conversion works as intended.
it('should convert a GIF image to PNG and then process it', async () => {
console.log('\n--- [TEST LOG] ---: Starting GIF conversion success test...');
it('should handle convertible image types and include original and converted files in cleanup', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.gif', originalFileName: 'flyer.gif' });
const convertedPath = '/tmp/flyer-converted.png';
// Mock the file handler to return the converted path
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
createdImagePaths: [convertedPath],
});
await service.processJob(job);
console.log('--- [TEST LOG] ---: Verifying sharp conversion for GIF...');
expect(sharp).toHaveBeenCalledWith('/tmp/flyer.gif');
expect(mockSharpInstance.toFile).toHaveBeenCalledWith('/tmp/flyer-converted.png');
console.log('--- [TEST LOG] ---: Verifying AI service call and cleanup for GIF...');
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
[{ path: '/tmp/flyer-converted.png', mimetype: 'image/png' }],
[],
undefined,
undefined,
expect.any(Object),
);
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.gif', '/tmp/flyer-converted.png'] },
expect.any(Object),
);
});
it('should convert a TIFF image to PNG and then process it', async () => {
console.log('\n--- [TEST LOG] ---: Starting TIFF conversion success test...');
const job = createMockJob({ filePath: '/tmp/flyer.tiff', originalFileName: 'flyer.tiff' });
await service.processJob(job);
expect(sharp).toHaveBeenCalledWith('/tmp/flyer.tiff');
expect(mockSharpInstance.png).toHaveBeenCalled();
expect(mockSharpInstance.toFile).toHaveBeenCalledWith('/tmp/flyer-converted.png');
console.log('--- [DEBUG] ---: In TIFF test, logging actual AI call arguments:');
console.log(
JSON.stringify(
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mock.calls[0],
null,
2,
),
);
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
[{ path: '/tmp/flyer-converted.png', mimetype: 'image/png' }], // masterItems is mocked to []
[], // submitterIp is undefined in the mock job
undefined, // userProfileAddress is undefined in the mock job
undefined, // The job-specific logger
expect.any(Object),
);
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.tiff', '/tmp/flyer-converted.png'] },
{ flyerId: 1, paths: ['/tmp/flyer.gif', convertedPath] },
expect.any(Object),
);
});
it('should throw an error and not enqueue cleanup if the database service fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const dbError = new Error('Database transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
expect(job.updateProgress).toHaveBeenCalledWith({
message: 'Error: Database transaction failed',
});
errorCode: 'UNKNOWN_ERROR',
message: 'Database transaction failed',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
@@ -363,24 +363,24 @@ describe('FlyerProcessingService', () => {
filePath: '/tmp/document.txt',
originalFileName: 'document.txt',
});
const fileTypeError = new UnsupportedFileTypeError('Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.');
mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError);
const { logger } = await import('./logger.server');
await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError);
expect(job.updateProgress).toHaveBeenCalledWith({
message:
'Error: Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
errorCode: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
});
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
const job = createMockJob({});
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
await expect(service.processJob(job)).rejects.toThrow();
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const iconError = new Error('Icon generation failed.');
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
// to always succeed. For this test, we override that mock to simulate a failure
@@ -390,236 +390,142 @@ describe('FlyerProcessingService', () => {
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
expect(job.updateProgress).toHaveBeenCalledWith({
message: 'Error: Icon generation failed.',
});
errorCode: 'UNKNOWN_ERROR',
message: 'Icon generation failed.',
}); // This was a duplicate, fixed.
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
);
});
});
describe('_prepareImageInputs (private method)', () => {
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
describe('_reportErrorAndThrow (private method)', () => {
it('should update progress and throw UnrecoverableError for quota messages', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({
filePath: '/tmp/unsupported.doc',
originalFileName: 'unsupported.doc',
const job = createMockJob({});
const quotaError = new Error('RESOURCE_EXHAUSTED');
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(quotaError, job, logger)).rejects.toThrow(
UnrecoverableError,
);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
const privateMethod = (service as any)._prepareImageInputs;
});
await expect(privateMethod('/tmp/unsupported.doc', job, logger)).rejects.toThrow(
UnsupportedFileTypeError,
it('should use toErrorPayload for FlyerProcessingError instances', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const validationError = new AiDataValidationError(
'Validation failed',
{ foo: 'bar' },
{ raw: 'data' },
);
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(validationError, job, logger)).rejects.toThrow(
validationError,
);
// The payload should now come from the error's `toErrorPayload` method
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'AI_VALIDATION_FAILED',
message:
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
validationErrors: { foo: 'bar' },
rawData: { raw: 'data' },
});
});
it('should update progress and re-throw standard errors', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const genericError = new Error('A standard failure');
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(genericError, job, logger)).rejects.toThrow(genericError);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNKNOWN_ERROR',
message: 'A standard failure', // This was a duplicate, fixed.
});
});
it('should wrap and throw non-Error objects', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const nonError = 'just a string error';
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(nonError, job, logger)).rejects.toThrow('just a string error');
});
});
describe('_convertImageToPng (private method)', () => {
it('should throw an error if sharp fails', async () => {
const { logger } = await import('./logger.server');
const sharpError = new Error('Sharp failed');
vi.mocked(mockSharpInstance.toFile).mockRejectedValue(sharpError);
const privateMethod = (service as any)._convertImageToPng;
describe('processCleanupJob', () => {
it('should delete all files successfully', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
mocks.unlink.mockResolvedValue(undefined);
await expect(privateMethod('/tmp/image.gif', logger)).rejects.toThrow(
'Image conversion to PNG failed for image.gif',
const result = await service.processCleanupJob(job);
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1');
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2');
expect(result).toEqual({ status: 'success', deletedCount: 2 });
});
it('should handle ENOENT errors gracefully and still succeed', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
const enoentError: NodeJS.ErrnoException = new Error('File not found');
enoentError.code = 'ENOENT';
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError);
const result = await service.processCleanupJob(job);
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(result).toEqual({ status: 'success', deletedCount: 2 });
// Check that the warning was logged
const { logger } = await import('./logger.server');
expect(logger.warn).toHaveBeenCalledWith(
'File not found during cleanup (already deleted?): /tmp/file2',
);
});
it('should throw an aggregate error if a non-ENOENT error occurs', async () => {
const job = createMockCleanupJob({
flyerId: 1,
paths: ['/tmp/file1', '/tmp/permission-denied'],
});
const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
permissionError.code = 'EACCES';
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(permissionError);
await expect(service.processCleanupJob(job)).rejects.toThrow(
'Failed to delete 1 file(s): /tmp/permission-denied',
);
// Check that the error was logged
const { logger } = await import('./logger.server');
expect(logger.error).toHaveBeenCalledWith(
{ err: sharpError, filePath: '/tmp/image.gif' },
'Failed to convert image to PNG using sharp.',
);
});
});
describe('_extractFlyerDataWithAI (private method)', () => {
it('should throw AiDataValidationError if AI response validation fails', async () => {
const { logger } = await import('./logger.server');
const jobData = createMockJob({}).data;
// Mock AI to return data missing a required field ('store_name')
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
valid_from: '2024-01-01',
items: [],
} as any);
await expect((service as any)._extractFlyerDataWithAI([], jobData, logger)).rejects.toThrow(
AiDataValidationError,
);
});
});
describe('_enqueueCleanup (private method)', () => {
it('should enqueue a cleanup job with the correct parameters', async () => {
const { logger } = await import('./logger.server');
const flyerId = 42;
const paths = ['/tmp/file1.jpg', '/tmp/file2.pdf'];
// Access and call the private method for testing
await (
service as unknown as {
_enqueueCleanup: (flyerId: number, paths: string[], logger: Logger) => Promise<void>;
}
)._enqueueCleanup(flyerId, paths, logger);
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId, paths },
{ jobId: `cleanup-flyer-${flyerId}`, removeOnComplete: true },
expect.objectContaining({ err: permissionError, path: '/tmp/permission-denied' }),
'Failed to delete temporary file.',
);
});
it('should not call the queue if the paths array is empty', async () => {
it('should skip processing and return "skipped" if paths array is empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
const result = await service.processCleanupJob(job);
expect(mocks.unlink).not.toHaveBeenCalled();
expect(result).toEqual({ status: 'skipped', reason: 'no paths' });
const { logger } = await import('./logger.server');
// Access and call the private method with an empty array
await (
service as unknown as {
_enqueueCleanup: (flyerId: number, paths: string[], logger: Logger) => Promise<void>;
}
)._enqueueCleanup(123, [], logger);
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
});
describe('_saveProcessedFlyerData (private method)', () => {
it('should transform data, create flyer in DB, and log activity', async () => {
const { logger } = await import('./logger.server');
// Arrange
const mockExtractedData = {
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Mock St',
items: [
{
item: 'Test Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Test Category',
master_item_id: 1,
},
],
};
const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const mockJobData = {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
userId: 'user-abc',
};
// The DB create function is also mocked in beforeEach.
// Create a complete mock that satisfies the Flyer type.
const mockNewFlyer = createMockFlyer({
flyer_id: 1,
file_name: 'flyer.jpg',
image_url: '/flyer-images/flyer.jpg',
icon_url: '/flyer-images/icons/icon-flyer.webp',
checksum: 'checksum-123',
store_id: 1,
item_count: 1,
});
vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] });
// Act: Access and call the private method for testing
const result = await (
service as unknown as {
_saveProcessedFlyerData: (
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
logger: Logger,
) => Promise<Flyer>;
}
)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData, logger);
// Assert
// 1. Transformer was called correctly
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
mockExtractedData,
mockImagePaths,
mockJobData.originalFileName,
mockJobData.checksum,
mockJobData.userId,
logger,
);
// 2. DB function was called with the transformed data
// The data comes from the mock defined in `beforeEach`.
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({ store_name: 'Mock Store', checksum: 'checksum-123' }),
[], // itemsForDb from the mock
logger,
);
// 3. Activity was logged with all expected fields
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(
{
userId: 'user-abc',
action: 'flyer_processed' as const,
displayText: 'Processed a new flyer for Mock Store.', // This was a duplicate, fixed.
details: { flyerId: 1, storeName: 'Mock Store' },
},
logger,
);
// 4. The method returned the new flyer
expect(result).toEqual(mockNewFlyer);
});
});
describe('_convertPdfToImages (private method)', () => {
it('should call pdftocairo and return sorted image paths on success', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/test.pdf' });
// Mock readdir to return unsorted Dirent-like objects
mocks.readdir.mockResolvedValue([
{ name: 'test-10.jpg' },
{ name: 'test-1.jpg' },
{ name: 'test-2.jpg' },
{ name: 'other-file.txt' },
] as Dirent[]);
// Access and call the private method for testing
const imagePaths = await (
service as unknown as {
_convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]>;
}
)._convertPdfToImages('/tmp/test.pdf', job, logger);
expect(mocks.execAsync).toHaveBeenCalledWith(
'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"',
);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' });
// Verify that the paths are correctly sorted numerically
expect(imagePaths).toEqual(['/tmp/test-1.jpg', '/tmp/test-2.jpg', '/tmp/test-10.jpg']);
});
it('should throw PdfConversionError if no images are generated', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/empty.pdf' });
// Mock readdir to return no matching files
mocks.readdir.mockResolvedValue([]);
await expect(
(
service as unknown as {
_convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]>;
}
)._convertPdfToImages('/tmp/empty.pdf', job, logger),
).rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf');
});
it('should re-throw an error if the exec command fails', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/bad.pdf' });
const commandError = new Error('pdftocairo not found');
mocks.execAsync.mockRejectedValue(commandError);
await expect(
(
service as unknown as {
_convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]>;
}
)._convertPdfToImages('/tmp/bad.pdf', job, logger),
).rejects.toThrow(commandError);
expect(logger.warn).toHaveBeenCalledWith('Job received no paths to clean. Skipping.');
});
});
});

View File

@@ -1,43 +1,25 @@
// src/services/flyerProcessingService.server.ts
import type { Job, JobsOptions } from 'bullmq';
import sharp from 'sharp';
import path from 'path';
import { Job, JobsOptions, UnrecoverableError } from 'bullmq';
import type { Dirent } from 'node:fs';
import { z } from 'zod';
import type { AIService } from './aiService.server';
import * as db from './db/index.db';
import { createFlyerAndItems } from './db/flyer.db';
import {
PdfConversionError,
AiDataValidationError,
UnsupportedFileTypeError,
FlyerProcessingError,
PdfConversionError,
} from './processingErrors';
import { FlyerDataTransformer } from './flyerDataTransformer';
import { logger as globalLogger } from './logger.server';
import type { Logger } from 'pino';
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// Define the image formats supported by the AI model
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'];
// Define image formats that are not directly supported but can be converted to PNG.
const CONVERTIBLE_IMAGE_EXTENSIONS = ['.gif', '.tiff', '.svg', '.bmp'];
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
import { FlyerFileHandler, ICommandExecutor, IFileSystem } from './flyerFileHandler.server';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
// --- Start: Interfaces for Dependency Injection ---
export interface IFileSystem {
readdir(path: string, options: { withFileTypes: true }): Promise<Dirent[]>;
unlink(path: string): Promise<void>;
}
export interface ICommandExecutor {
(command: string): Promise<{ stdout: string; stderr: string }>;
}
export interface FlyerJobData {
filePath: string;
originalFileName: string;
@@ -47,7 +29,7 @@ export interface FlyerJobData {
userProfileAddress?: string;
}
interface CleanupJobData {
export interface CleanupJobData {
flyerId: number;
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
paths?: string[];
@@ -61,24 +43,6 @@ interface ICleanupQueue {
add(name: string, data: CleanupJobData, opts?: JobsOptions): Promise<Job<CleanupJobData>>;
}
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
item: z.string(),
price_display: z.string(),
price_in_cents: z.number().nullable(),
quantity: z.string(),
category_name: z.string(),
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: requiredString('Store name cannot be empty'),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
/**
* This class encapsulates the business logic for processing a flyer from a file.
* It handles PDF conversion, AI data extraction, and saving the results to the database.
@@ -86,6 +50,8 @@ export const AiFlyerDataSchema = z.object({
export class FlyerProcessingService {
constructor(
private ai: AIService,
private fileHandler: FlyerFileHandler,
private aiProcessor: FlyerAiProcessor,
private database: typeof db,
private fs: IFileSystem,
private exec: ICommandExecutor,
@@ -93,156 +59,6 @@ export class FlyerProcessingService {
private transformer: FlyerDataTransformer,
) {}
/**
* Converts a PDF file to a series of JPEG images using an external tool.
* @param filePath The path to the PDF file.
* @param job The BullMQ job instance for progress updates.
* @returns A promise that resolves to an array of paths to the created image files.
*/
private async _convertPdfToImages(
filePath: string,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<string[]> {
logger.info(`Starting PDF conversion for: ${filePath}`);
await job.updateProgress({ message: 'Converting PDF to images...' });
const outputDir = path.dirname(filePath);
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug({ outputDir, outputFilePrefix }, `PDF output details`);
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`Executing PDF conversion command`);
logger.debug({ command });
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug({ stdout }, `[Worker] pdftocairo stdout for ${filePath}:`);
if (stderr) logger.warn({ stderr }, `[Worker] pdftocairo stderr for ${filePath}:`);
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter((f) => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(
{ imageNames: generatedImages.map((f) => f.name) },
`Filtered down to ${generatedImages.length} generated JPGs.`,
);
if (generatedImages.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error({ stderr }, `PdfConversionError: ${errorMessage}`);
throw new PdfConversionError(errorMessage, stderr);
}
return generatedImages.map((img) => path.join(outputDir, img.name));
}
/**
* Converts an image file (e.g., GIF, TIFF) to a PNG format that the AI can process.
* @param filePath The path to the source image file.
* @param logger A logger instance.
* @returns The path to the newly created PNG file.
*/
private async _convertImageToPng(filePath: string, logger: Logger): Promise<string> {
const outputDir = path.dirname(filePath);
const originalFileName = path.parse(path.basename(filePath)).name;
const newFileName = `${originalFileName}-converted.png`;
const outputPath = path.join(outputDir, newFileName);
logger.info({ from: filePath, to: outputPath }, 'Converting unsupported image format to PNG.');
try {
await sharp(filePath).png().toFile(outputPath);
return outputPath;
} catch (error) {
logger.error({ err: error, filePath }, 'Failed to convert image to PNG using sharp.');
throw new Error(`Image conversion to PNG failed for ${path.basename(filePath)}.`);
}
}
/**
* Prepares the input images for the AI service. If the input is a PDF, it's converted to images.
* @param filePath The path to the original uploaded file.
* @param job The BullMQ job instance.
* @returns An object containing the final image paths for the AI and a list of any newly created image files.
*/
private async _prepareImageInputs(
filePath: string,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<{ imagePaths: { path: string; mimetype: string }[]; createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
// Handle PDF conversion separately
if (fileExt === '.pdf') {
const createdImagePaths = await this._convertPdfToImages(filePath, job, logger);
const imagePaths = createdImagePaths.map((p) => ({ path: p, mimetype: 'image/jpeg' }));
logger.info(`Converted PDF to ${imagePaths.length} images.`);
return { imagePaths, createdImagePaths };
// Handle directly supported single-image formats
} else if (SUPPORTED_IMAGE_EXTENSIONS.includes(fileExt)) {
logger.info(`Processing as a single image file: ${filePath}`);
// Normalize .jpg to image/jpeg for consistency
const mimetype =
fileExt === '.jpg' || fileExt === '.jpeg' ? 'image/jpeg' : `image/${fileExt.slice(1)}`;
const imagePaths = [{ path: filePath, mimetype }];
return { imagePaths, createdImagePaths: [] };
// Handle convertible image formats
} else if (CONVERTIBLE_IMAGE_EXTENSIONS.includes(fileExt)) {
const createdPngPath = await this._convertImageToPng(filePath, logger);
const imagePaths = [{ path: createdPngPath, mimetype: 'image/png' }];
// The new PNG is a temporary file that needs to be cleaned up.
return { imagePaths, createdImagePaths: [createdPngPath] };
} else {
// If the file is neither a PDF nor a supported image, throw an error.
const errorMessage = `Unsupported file type: ${fileExt}. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.`;
logger.error({ originalFileName: job.data.originalFileName, fileExt }, errorMessage);
throw new UnsupportedFileTypeError(errorMessage);
}
}
/**
* Calls the AI service to extract structured data from the flyer images.
* @param imagePaths An array of paths and mimetypes for the images.
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the validated, structured flyer data.
*/
private async _extractFlyerDataWithAI(
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
logger: Logger,
): Promise<z.infer<typeof AiFlyerDataSchema>> {
logger.info(`Starting AI data extraction.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.database.personalizationRepo.getAllMasterItems(logger);
logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
imagePaths,
masterItems,
submitterIp, // Pass the job-specific logger
userProfileAddress, // Pass the job-specific logger
logger,
);
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error({ errors, rawData: extractedData }, 'AI response failed validation.');
throw new AiDataValidationError(
'AI response validation failed. The returned data structure is incorrect.',
errors,
extractedData,
);
}
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
return validationResult.data;
}
/**
* Saves the extracted flyer data to the database.
* @param extractedData The structured data from the AI.
@@ -251,39 +67,44 @@ export class FlyerProcessingService {
* @returns A promise that resolves to the newly created flyer record.
*/
private async _saveProcessedFlyerData(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
flyerData: FlyerInsert,
itemsForDb: FlyerItemInsert[],
userId: string | undefined,
logger: Logger,
) {
logger.info(`Preparing to save extracted data to database.`);
// 1. Transform the AI data into database-ready records.
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
jobData.originalFileName,
jobData.checksum,
jobData.userId,
// Pass the job-specific logger to the transformer
logger,
);
// 2. Save the transformed data to the database.
// 1. Save the transformed data to the database.
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb, logger);
logger.info({ newFlyerId: newFlyer.flyer_id }, `Successfully saved new flyer.`);
// 2. Log the activity.
await this._logFlyerProcessedActivity(newFlyer, userId, logger);
return newFlyer;
}
/**
* Logs the successful processing of a flyer to the admin activity log.
* @param newFlyer The newly created flyer record from the database.
* @param userId The ID of the user who uploaded the flyer, if available.
* @param logger The job-specific logger instance.
*/
private async _logFlyerProcessedActivity(
newFlyer: Flyer,
userId: string | undefined,
logger: Logger,
) {
const storeName = newFlyer.store?.name || 'Unknown Store';
await this.database.adminRepo.logActivity(
{
userId: jobData.userId,
userId: userId,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
displayText: `Processed a new flyer for ${storeName}.`,
details: { flyerId: newFlyer.flyer_id, storeName },
},
logger,
);
return newFlyer;
}
/**
@@ -305,10 +126,127 @@ export class FlyerProcessingService {
logger.info({ flyerId }, `Enqueued cleanup job.`);
}
/**
* Centralized error handler for the `processJob` method. It logs the error,
* updates the job's progress with a user-friendly message, and re-throws the
* error for the worker to handle retries or final failure. It also identifies
* unrecoverable errors to prevent unnecessary retries.
* @param error The error caught during processing.
* @param job The BullMQ job instance.
* @param logger The job-specific logger.
*/
private async _reportErrorAndThrow(
error: unknown,
job: Job<FlyerJobData>,
logger: Logger,
): Promise<never> {
const wrappedError = error instanceof Error ? error : new Error(String(error));
const errorMessage = wrappedError.message || 'An unknown error occurred.';
// First, check for unrecoverable quota-related errors.
if (
errorMessage.includes('quota') ||
errorMessage.includes('429') ||
errorMessage.toLowerCase().includes('resource_exhausted')
) {
logger.error(
{ err: wrappedError, jobId: job.id },
'[FlyerProcessingService] Unrecoverable quota error detected. Failing job immediately.',
);
await job.updateProgress({
errorCode: 'QUOTA_EXCEEDED',
message: 'An AI quota has been exceeded. Please try again later.',
});
// This specific error type tells the BullMQ worker to fail the job without retries.
throw new UnrecoverableError(errorMessage);
}
let errorPayload: { errorCode: string; message: string; [key: string]: any };
// Handle our custom, structured processing errors.
if (wrappedError instanceof FlyerProcessingError) {
// Use the properties from the custom error itself.
errorPayload = wrappedError.toErrorPayload();
// Log with specific details based on the error type
if (wrappedError instanceof AiDataValidationError) {
logger.error(
{ err: wrappedError, validationErrors: wrappedError.validationErrors, rawData: wrappedError.rawData },
`AI Data Validation failed.`,
);
} else if (wrappedError instanceof PdfConversionError) {
logger.error({ err: wrappedError, stderr: wrappedError.stderr }, `PDF Conversion failed.`);
} else {
// Generic log for other FlyerProcessingErrors like UnsupportedFileTypeError
logger.error({ err: wrappedError }, `${wrappedError.name} occurred during processing.`);
}
} else {
// Handle generic/unknown errors.
logger.error(
{ err: wrappedError, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts },
`A generic error occurred in job.`,
);
errorPayload = {
errorCode: 'UNKNOWN_ERROR',
message: errorMessage,
};
}
await job.updateProgress(errorPayload);
throw wrappedError;
}
/**
* Orchestrates the series of steps involved in processing a flyer.
* This "happy path" method is called by the main `processJob` method.
* @param job The BullMQ job instance.
* @param logger The job-specific logger.
* @returns A promise that resolves with the new flyer's ID.
*/
private async _runProcessingSteps(
job: Job<FlyerJobData>,
logger: Logger,
): Promise<{ flyerId: number }> {
const { filePath } = job.data;
// Step 1: Prepare image inputs (convert PDF, etc.)
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths } = await this.fileHandler.prepareImageInputs(
filePath,
job,
logger,
);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this.aiProcessor.extractAndValidateData(imagePaths, job.data, logger);
await job.updateProgress({ message: 'Transforming data...' });
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
job.data.originalFileName,
job.data.checksum,
job.data.userId,
logger,
);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(
flyerData,
itemsForDb,
job.data.userId,
logger,
);
logger.info({ flyerId: newFlyer.flyer_id }, `Job processed successfully.`);
// Step 3: On success, enqueue a cleanup job for all temporary files.
const pathsToClean = [filePath, ...createdImagePaths];
await this._enqueueCleanup(newFlyer.flyer_id, pathsToClean, logger);
return { flyerId: newFlyer.flyer_id };
}
async processJob(job: Job<FlyerJobData>) {
const { filePath, originalFileName } = job.data;
const createdImagePaths: string[] = [];
let newFlyerId: number | undefined;
const { originalFileName } = job.data;
// Create a job-specific logger instance with context, as per ADR-004
const logger = globalLogger.child({
@@ -322,60 +260,74 @@ export class FlyerProcessingService {
logger.info(`Picked up job.`);
try {
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(
filePath,
job,
logger,
);
createdImagePaths.push(...tempImagePaths);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data, logger);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(
extractedData,
imagePaths,
job.data,
logger,
); // Pass logger
newFlyerId = newFlyer.flyer_id;
logger.info({ flyerId: newFlyerId }, `Job processed successfully.`);
return { flyerId: newFlyer.flyer_id };
return await this._runProcessingSteps(job, logger);
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred';
if (error instanceof PdfConversionError) {
errorMessage = error.message;
logger.error({ err: error, stderr: error.stderr }, `PDF Conversion failed.`);
} else if (error instanceof AiDataValidationError) {
errorMessage = error.message;
logger.error(
{ err: error, validationErrors: error.validationErrors, rawData: error.rawData },
`AI Data Validation failed.`,
);
} else if (error instanceof UnsupportedFileTypeError) {
errorMessage = error.message;
logger.error({ err: error }, `Unsupported file type error.`);
} else if (error instanceof Error) {
errorMessage = error.message;
logger.error(
{ err: error, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts },
`A generic error occurred in job.`,
);
}
await job.updateProgress({ message: `Error: ${errorMessage}` });
throw error;
} finally {
if (newFlyerId) {
const pathsToClean = [filePath, ...createdImagePaths];
await this._enqueueCleanup(newFlyerId, pathsToClean, logger);
} else {
logger.warn(
`Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.`,
);
}
// On failure, explicitly log that we are not cleaning up files to allow for manual inspection.
logger.warn(
`Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.`,
);
// Delegate all error handling to a separate, testable method.
await this._reportErrorAndThrow(error, job, logger);
}
}
async processCleanupJob(job: Job<CleanupJobData>) {
const { flyerId, paths } = job.data;
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
flyerId,
});
logger.info({ paths }, `Picked up file cleanup job.`);
if (!paths?.length) {
logger.warn(`Job received no paths to clean. Skipping.`);
return { status: 'skipped', reason: 'no paths' };
}
// Use Promise.allSettled to attempt deleting all files and collect results.
// This is more robust than a for-loop as it attempts to delete all files
// even if one of them fails, and then reports on the collective result.
const deletionPromises = paths.map((path) => this.fs.unlink(path));
const results = await Promise.allSettled(deletionPromises);
// Process results using reduce for a more functional approach, avoiding mutable variables.
const { deletedCount, failedDeletions } = results.reduce(
(acc, result, index) => {
const filePath = paths[index];
if (result.status === 'fulfilled') {
logger.info(`Deleted temporary file: ${filePath}`);
acc.deletedCount++;
} else {
const unlinkError = result.reason;
if (
unlinkError instanceof Error &&
'code' in unlinkError &&
(unlinkError as NodeJS.ErrnoException).code === 'ENOENT'
) {
logger.warn(`File not found during cleanup (already deleted?): ${filePath}`);
acc.deletedCount++; // Still counts as a success for the job's purpose.
} else {
logger.error({ err: unlinkError, path: filePath }, 'Failed to delete temporary file.');
acc.failedDeletions.push({ path: filePath, reason: unlinkError });
}
}
return acc;
},
{ deletedCount: 0, failedDeletions: [] as { path: string; reason: unknown }[] },
);
// If any deletions failed for reasons other than 'file not found', fail the job.
if (failedDeletions.length > 0) {
const failedPaths = failedDeletions.map(({ path }) => path).join(', ');
const errorMessage = `Failed to delete ${failedDeletions.length} file(s): ${failedPaths}`;
// Throw an error to make the job fail and be retried by BullMQ.
// The individual errors have already been logged.
throw new Error(errorMessage);
}
logger.info(`Successfully cleaned up ${deletedCount} file(s).`);
return { status: 'success', deletedCount };
}
}

View File

@@ -87,7 +87,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Redis down', cacheKey: expect.any(String) },
{ err: expect.any(Error), cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch
@@ -107,7 +107,7 @@ describe('Geocoding Service', () => {
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
// The service should log the JSON parsing error and continue
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(String), cacheKey: expect.any(String) },
{ err: expect.any(SyntaxError), cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
@@ -185,7 +185,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Network Error' },
{ err: expect.any(Error) },
expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'),
);
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
@@ -223,7 +223,7 @@ describe('Geocoding Service', () => {
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
{ err: 'Redis SET failed', cacheKey: expect.any(String) },
{ err: expect.any(Error), cacheKey: expect.any(String) },
'Redis SET command failed. Result will not be cached.',
);
});
@@ -271,7 +271,7 @@ describe('Geocoding Service', () => {
// Act & Assert
await expect(geocodingService.clearGeocodeCache(logger)).rejects.toThrow(redisError);
expect(logger.error).toHaveBeenCalledWith(
{ err: redisError.message },
{ err: expect.any(Error) },
'Failed to clear geocode cache from Redis.',
);
expect(mocks.mockRedis.del).not.toHaveBeenCalled();

View File

@@ -25,10 +25,7 @@ export class GeocodingService {
return JSON.parse(cached);
}
} catch (error) {
logger.error(
{ err: error instanceof Error ? error.message : error, cacheKey },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
logger.error({ err: error, cacheKey }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
}
if (process.env.GOOGLE_MAPS_API_KEY) {
@@ -44,8 +41,8 @@ export class GeocodingService {
);
} catch (error) {
logger.error(
{ err: error instanceof Error ? error.message : error },
'An error occurred while calling the Google Maps Geocoding API. Falling back to Nominatim.',
{ err: error },
'An error occurred while calling the Google Maps Geocoding API. Falling back to Nominatim.'
);
}
} else {
@@ -72,10 +69,7 @@ export class GeocodingService {
try {
await redis.set(cacheKey, JSON.stringify(result), 'EX', 60 * 60 * 24 * 30); // Cache for 30 days
} catch (error) {
logger.error(
{ err: error instanceof Error ? error.message : error, cacheKey },
'Redis SET command failed. Result will not be cached.',
);
logger.error({ err: error, cacheKey }, 'Redis SET command failed. Result will not be cached.');
}
}
@@ -98,10 +92,7 @@ export class GeocodingService {
logger.info(`Successfully deleted ${totalDeleted} geocode cache entries.`);
return totalDeleted;
} catch (error) {
logger.error(
{ err: error instanceof Error ? error.message : error },
'Failed to clear geocode cache from Redis.',
);
logger.error({ err: error }, 'Failed to clear geocode cache from Redis.');
throw error;
}
}

View File

@@ -34,6 +34,9 @@ export const logger = pino({
'*.body.password',
'*.body.newPassword',
'*.body.currentPassword',
'*.body.confirmPassword',
'*.body.refreshToken',
'*.body.token',
],
censor: '[REDACTED]',
},

View File

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

View File

@@ -1,7 +1,7 @@
// src/services/queueService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger as mockLogger } from './logger.server';
import { EventEmitter } from 'node:events';
import { EventEmitter } from 'node:events'; // This was a duplicate, fixed.
import type { Job, Worker } from 'bullmq';
import type { Mock } from 'vitest';
@@ -31,6 +31,7 @@ mockRedisConnection.quit = vi.fn().mockResolvedValue('OK');
// We make it a mock function that returns our shared `mockRedisConnection` instance.
vi.mock('ioredis', () => ({
default: vi.fn(function () {
// This was a duplicate, fixed.
return mockRedisConnection;
}),
}));
@@ -51,26 +52,35 @@ vi.mock('bullmq', () => ({
this.add = vi.fn();
this.close = vi.fn().mockResolvedValue(undefined);
return this;
}),
}), // This was a duplicate, fixed.
UnrecoverableError: class UnrecoverableError extends Error {},
}));
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
warn: vi.fn(), // This was a duplicate, fixed.
debug: vi.fn(),
child: vi.fn().mockReturnThis(),
},
}));
// Mock other dependencies that are not the focus of this test file.
vi.mock('./aiService.server');
vi.mock('./emailService.server');
vi.mock('./db/index.db');
vi.mock('./db/index.db'); // This was a duplicate, fixed.
vi.mock('./flyerProcessingService.server');
vi.mock('./flyerDataTransformer');
describe('Queue Service Setup and Lifecycle', () => {
let gracefulShutdown: (signal: string) => Promise<void>;
let flyerWorker: Worker, emailWorker: Worker, analyticsWorker: Worker, cleanupWorker: Worker;
describe('Worker Service Lifecycle', () => {
let gracefulShutdown: (signal: string) => Promise<void>; // This was a duplicate, fixed.
let flyerWorker: Worker,
emailWorker: Worker,
analyticsWorker: Worker,
cleanupWorker: Worker,
weeklyAnalyticsWorker: Worker,
tokenCleanupWorker: Worker;
beforeEach(async () => {
vi.clearAllMocks();
@@ -79,22 +89,27 @@ describe('Queue Service Setup and Lifecycle', () => {
vi.resetModules();
// Dynamically import the modules after mocks are set up
const queueService = await import('./queueService.server');
const workerService = await import('./workers.server');
// Capture the imported instances for use in tests
gracefulShutdown = queueService.gracefulShutdown;
flyerWorker = queueService.flyerWorker;
emailWorker = queueService.emailWorker;
analyticsWorker = queueService.analyticsWorker;
cleanupWorker = queueService.cleanupWorker;
gracefulShutdown = workerService.gracefulShutdown;
flyerWorker = workerService.flyerWorker;
emailWorker = workerService.emailWorker;
analyticsWorker = workerService.analyticsWorker;
cleanupWorker = workerService.cleanupWorker;
weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker;
tokenCleanupWorker = workerService.tokenCleanupWorker;
});
afterEach(() => {
// Clean up all event listeners on the mock connection to prevent open handles.
mockRedisConnection.removeAllListeners();
vi.useRealTimers();
});
it('should log a success message when Redis connects', () => {
// Re-import redis.server to trigger its event listeners with the mock
import('./redis.server');
// Act: Simulate the 'connect' event on the mock Redis connection
mockRedisConnection.emit('connect');
@@ -103,6 +118,7 @@ describe('Queue Service Setup and Lifecycle', () => {
});
it('should log an error message when Redis connection fails', () => {
import('./redis.server');
const redisError = new Error('Connection refused');
mockRedisConnection.emit('error', redisError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');
@@ -111,7 +127,14 @@ describe('Queue Service Setup and Lifecycle', () => {
it('should attach completion and failure listeners to all workers', () => {
// The workers are instantiated when the module is imported in beforeEach.
// We just need to check that the 'on' method was called for each event.
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker];
const workers = [
flyerWorker,
emailWorker,
analyticsWorker,
cleanupWorker,
weeklyAnalyticsWorker,
tokenCleanupWorker,
];
for (const worker of workers) {
expect(worker.on).toHaveBeenCalledWith('completed', expect.any(Function));
expect(worker.on).toHaveBeenCalledWith('failed', expect.any(Function));
@@ -171,15 +194,40 @@ describe('Queue Service Setup and Lifecycle', () => {
});
it('should close all workers, queues, the redis connection, and exit the process', async () => {
// We need to import the queues to check if their close methods are called.
const {
flyerQueue,
emailQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
} = await import('./queues.server');
await gracefulShutdown('SIGINT');
expect((flyerWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((analyticsWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((cleanupWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
// Verify workers are closed
expect((flyerWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect((emailWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect((analyticsWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect((cleanupWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect((weeklyAnalyticsWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect((tokenCleanupWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
// Verify queues are closed
expect((flyerQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((emailQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((analyticsQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((cleanupQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((weeklyAnalyticsQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((tokenCleanupQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
// Verify the redis connection is also closed
expect(mockRedisConnection.quit).toHaveBeenCalledTimes(1);
// Check for the correct success log message from workers.server.ts
expect(mockLogger.info).toHaveBeenCalledWith(
'[Shutdown] All workers, queues, and connections closed successfully.',
'[Shutdown] All resources closed successfully.',
);
expect(processExitSpy).toHaveBeenCalledWith(0);
});
@@ -192,12 +240,34 @@ describe('Queue Service Setup and Lifecycle', () => {
await gracefulShutdown('SIGTERM');
// It should still attempt to close all workers
expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect((emailWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: closeError, resource: 'flyerWorker' },
'[Shutdown] Error closing resource.',
`[Shutdown] Error closing flyerWorker.`,
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should timeout if shutdown takes too long', async () => {
vi.useFakeTimers();
// Make one of the close calls hang indefinitely
(flyerWorker.close as Mock).mockReturnValue(new Promise(() => {}));
// Run shutdown but don't await it fully, as it will hang
const shutdownPromise = gracefulShutdown('SIGTERM');
// Advance timers past the timeout threshold
await vi.advanceTimersByTimeAsync(31000);
// Now await the promise to see the timeout result
await shutdownPromise;
expect(mockLogger.error).toHaveBeenCalledWith(
`[Shutdown] Graceful shutdown timed out after 30 seconds. Forcing exit.`,
);
expect(processExitSpy).toHaveBeenCalledWith(1);
vi.useRealTimers();
});
});
});

View File

@@ -1,420 +1,32 @@
// src/services/queueService.server.ts
import { Queue, Worker, Job } from 'bullmq';
import IORedis from 'ioredis'; // Correctly imported
import fsPromises from 'node:fs/promises';
import { exec } from 'child_process';
import { promisify } from 'util';
import { logger } from './logger.server';
import { aiService } from './aiService.server';
import * as emailService from './emailService.server';
import * as db from './db/index.db';
import { connection } from './redis.server';
import {
FlyerProcessingService,
type FlyerJobData,
type IFileSystem,
} from './flyerProcessingService.server';
import { FlyerDataTransformer } from './flyerDataTransformer';
flyerQueue,
emailQueue,
analyticsQueue,
weeklyAnalyticsQueue,
cleanupQueue,
tokenCleanupQueue,
} from './queues.server';
export const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null, // Important for BullMQ
password: process.env.REDIS_PASSWORD, // Add the password from environment variables
});
// Re-export everything for backward compatibility where possible
export { connection } from './redis.server';
export * from './queues.server';
// --- Redis Connection Event Listeners ---
connection.on('connect', () => {
logger.info('[Redis] Connection established successfully.');
});
connection.on('error', (err) => {
// This is crucial for diagnosing Redis connection issues. // The patch requested this specific error handling.
logger.error({ err }, '[Redis] Connection error.');
});
const execAsync = promisify(exec);
// --- Queues ---
export const flyerQueue = new Queue<FlyerJobData>('flyer-processing', {
connection,
defaultJobOptions: {
attempts: 3, // Attempt a job 3 times before marking it as failed.
backoff: {
type: 'exponential',
delay: 5000, // Start with a 5-second delay for the first retry
},
},
});
export const emailQueue = new Queue<EmailJobData>('email-sending', {
connection,
defaultJobOptions: {
attempts: 5, // Emails can be retried more aggressively
backoff: {
type: 'exponential',
delay: 10000, // Start with a 10-second delay
},
},
});
export const analyticsQueue = new Queue<AnalyticsJobData>('analytics-reporting', {
connection,
defaultJobOptions: {
attempts: 2, // Analytics can be intensive, so fewer retries might be desired.
backoff: {
type: 'exponential',
delay: 60000, // Wait a minute before retrying.
},
// Remove job from queue on completion to save space, as results are in the DB.
removeOnComplete: true,
removeOnFail: 50, // Keep the last 50 failed jobs for inspection.
},
});
export const weeklyAnalyticsQueue = new Queue<WeeklyAnalyticsJobData>(
'weekly-analytics-reporting',
{
connection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 3600000, // 1 hour delay for retries
},
removeOnComplete: true,
removeOnFail: 50,
},
},
);
export const cleanupQueue = new Queue<CleanupJobData>('file-cleanup', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 30000, // Retry cleanup after 30 seconds
},
removeOnComplete: true, // No need to keep successful cleanup jobs
},
});
export const tokenCleanupQueue = new Queue<TokenCleanupJobData>('token-cleanup', {
connection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 3600000, // 1 hour delay
},
removeOnComplete: true,
removeOnFail: 10,
},
});
// --- Job Data Interfaces ---
interface EmailJobData {
to: string;
subject: string;
text: string;
html: string;
}
// We do NOT export workers here anymore to prevent side effects.
// Consumers needing workers must import from './workers.server'.
/**
* Defines the data for an analytics job.
*/
interface AnalyticsJobData {
reportDate: string; // e.g., '2024-10-26'
}
/**
* Defines the data for a weekly analytics job.
*/
interface WeeklyAnalyticsJobData {
reportYear: number;
reportWeek: number; // ISO week number (1-53)
}
interface CleanupJobData {
flyerId: number;
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
paths?: string[];
}
/**
* Defines the data for a token cleanup job.
*/
interface TokenCleanupJobData {
timestamp: string; // ISO string to ensure the job is unique per run
}
// --- Worker Instantiation ---
// Create an adapter for fsPromises to match the IFileSystem interface.
const fsAdapter: IFileSystem = {
readdir: (path: string, options: { withFileTypes: true }) => fsPromises.readdir(path, options),
unlink: (path: string) => fsPromises.unlink(path),
};
// Instantiate the service with its real dependencies
const flyerProcessingService = new FlyerProcessingService(
aiService,
db,
fsAdapter,
execAsync,
cleanupQueue, // Inject the cleanup queue to break the circular dependency
new FlyerDataTransformer(), // Inject the new transformer
);
/**
* A generic function to attach logging event listeners to any worker.
* This centralizes logging for job completion and final failure.
* @param worker The BullMQ worker instance.
*/
const attachWorkerEventListeners = (worker: Worker) => {
worker.on('completed', (job: Job, returnValue: unknown) => {
logger.info({ returnValue }, `[${worker.name}] Job ${job.id} completed successfully.`);
});
worker.on('failed', (job: Job | undefined, error: Error) => {
// This event fires after all retries have failed.
logger.error(
{ err: error, jobData: job?.data },
`[${worker.name}] Job ${job?.id} has ultimately failed after all attempts.`,
);
});
};
export const flyerWorker = new Worker<FlyerJobData>(
'flyer-processing', // Must match the queue name
(job) => {
// The processJob method creates its own job-specific logger internally.
return flyerProcessingService.processJob(job);
},
{
connection,
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '1', 10),
},
);
/**
* A dedicated worker process for sending emails.
*/
export const emailWorker = new Worker<EmailJobData>(
'email-sending',
async (job: Job<EmailJobData>) => {
const { to, subject } = job.data;
// Create a job-specific logger instance
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
jobLogger.info({ to, subject }, `[EmailWorker] Sending email for job ${job.id}`);
try {
await emailService.sendEmail(job.data, jobLogger);
} catch (error: unknown) {
// Standardize error logging to capture the full error object, including the stack trace.
// This provides more context for debugging than just logging the message.
logger.error(
{
// Log the full error object for better diagnostics. // The patch requested this specific error handling.
err: error instanceof Error ? error : new Error(String(error)),
// Also include the job data for context.
jobData: job.data,
},
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
// Re-throw to let BullMQ handle the failure and retry.
throw error;
}
},
{
connection,
concurrency: parseInt(process.env.EMAIL_WORKER_CONCURRENCY || '10', 10),
},
);
/**
* A dedicated worker for generating daily analytics reports.
* This is a placeholder for the actual report generation logic.
*/
export const analyticsWorker = new Worker<AnalyticsJobData>(
'analytics-reporting',
async (job: Job<AnalyticsJobData>) => {
const { reportDate } = job.data;
logger.info({ reportDate }, `[AnalyticsWorker] Starting report generation for job ${job.id}`);
try {
// Special case for testing the retry mechanism
if (reportDate === 'FAIL') {
throw new Error('This is a test failure for the analytics job.');
}
// In a real implementation, you would call a database function here.
// For example: await db.generateDailyAnalyticsReport(reportDate);
await new Promise((resolve) => setTimeout(resolve, 10000)); // Simulate a 10-second task
logger.info(`[AnalyticsWorker] Successfully generated report for ${reportDate}.`);
} catch (error: unknown) {
// Standardize error logging.
logger.error(
{
err: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
},
`[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
connection,
concurrency: parseInt(process.env.ANALYTICS_WORKER_CONCURRENCY || '1', 10),
},
);
/**
* A dedicated worker for cleaning up flyer-related files from the filesystem.
* This is triggered manually by an admin after a flyer has been reviewed.
*/
export const cleanupWorker = new Worker<CleanupJobData>(
// This worker now handles two types of cleanup jobs.
'file-cleanup', // The queue name
async (job: Job<CleanupJobData>) => {
// Destructure the data from the job payload.
const { flyerId, paths } = job.data;
logger.info(
{ paths },
`[CleanupWorker] Starting file cleanup for job ${job.id} (Flyer ID: ${flyerId})`,
);
try {
if (!paths || paths.length === 0) {
logger.warn(
`[CleanupWorker] Job ${job.id} for flyer ${flyerId} received no paths to clean. Skipping.`,
);
return;
}
// Iterate over the file paths provided in the job data and delete each one.
for (const filePath of paths) {
try {
await fsAdapter.unlink(filePath);
logger.info(`[CleanupWorker] Deleted temporary file: ${filePath}`);
} catch (unlinkError: unknown) {
// If the file doesn't exist, it's a success from our perspective.
// We can log it as a warning and continue without failing the job.
if (
unlinkError instanceof Error &&
'code' in unlinkError &&
unlinkError.code === 'ENOENT'
) {
logger.warn(
`[CleanupWorker] File not found during cleanup (already deleted?): ${filePath}`,
);
} else {
throw unlinkError; // For any other error (e.g., permissions), re-throw to fail the job.
}
}
}
logger.info(
`[CleanupWorker] Successfully cleaned up ${paths.length} file(s) for flyer ${flyerId}.`,
);
} catch (error: unknown) {
// Standardize error logging.
logger.error(
{
err: error instanceof Error ? error : new Error(String(error)),
},
`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
connection,
concurrency: parseInt(process.env.CLEANUP_WORKER_CONCURRENCY || '10', 10),
},
);
/**
* A dedicated worker for generating weekly analytics reports.
* This is a placeholder for the actual report generation logic.
*/
export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
'weekly-analytics-reporting',
async (job: Job<WeeklyAnalyticsJobData>) => {
const { reportYear, reportWeek } = job.data;
logger.info(
{ reportYear, reportWeek },
`[WeeklyAnalyticsWorker] Starting weekly report generation for job ${job.id}`,
);
try {
// Simulate a longer-running task for weekly reports
await new Promise((resolve) => setTimeout(resolve, 30000)); // Simulate 30-second task
logger.info(
`[WeeklyAnalyticsWorker] Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`,
);
} catch (error: unknown) {
// Standardize error logging.
logger.error(
{
err: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
},
`[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`,
);
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
connection,
concurrency: parseInt(process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY || '1', 10),
},
);
/**
* A dedicated worker for cleaning up expired password reset tokens.
*/
export const tokenCleanupWorker = new Worker<TokenCleanupJobData>(
'token-cleanup',
async (job: Job<TokenCleanupJobData>) => {
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
jobLogger.info('[TokenCleanupWorker] Starting cleanup of expired password reset tokens.');
try {
const deletedCount = await db.userRepo.deleteExpiredResetTokens(jobLogger);
jobLogger.info(`[TokenCleanupWorker] Successfully deleted ${deletedCount} expired tokens.`);
return { deletedCount };
} catch (error: unknown) {
jobLogger.error({ err: error }, `[TokenCleanupWorker] Job ${job.id} failed.`);
throw error;
}
},
{
connection,
concurrency: 1, // This is a low-priority, non-intensive task.
},
);
// --- Attach Event Listeners to All Workers ---
attachWorkerEventListeners(flyerWorker);
attachWorkerEventListeners(emailWorker);
attachWorkerEventListeners(analyticsWorker);
attachWorkerEventListeners(cleanupWorker);
attachWorkerEventListeners(weeklyAnalyticsWorker);
attachWorkerEventListeners(tokenCleanupWorker);
logger.info('All workers started and listening for jobs.');
/**
* A function to gracefully shut down all queue workers and connections.
* This is essential for preventing jobs from getting stuck in an 'active' state
* when the application process is terminated.
* @param signal The signal that triggered the shutdown (e.g., 'SIGINT').
* A function to gracefully shut down all queues and connections.
* This is for the API process which only uses queues.
* For worker processes, use the gracefulShutdown from workers.server.ts
*/
export const gracefulShutdown = async (signal: string) => {
logger.info(`[Shutdown] Received ${signal}. Closing all workers and queues...`);
logger.info(`[Shutdown] Received ${signal}. Closing all queues...`);
let exitCode = 0; // Default to success
const resources = [
{ name: 'flyerWorker', close: () => flyerWorker.close() },
{ name: 'emailWorker', close: () => emailWorker.close() },
{ name: 'analyticsWorker', close: () => analyticsWorker.close() },
{ name: 'cleanupWorker', close: () => cleanupWorker.close() },
{ name: 'weeklyAnalyticsWorker', close: () => weeklyAnalyticsWorker.close() },
{ name: 'tokenCleanupWorker', close: () => tokenCleanupWorker.close() },
{ name: 'flyerQueue', close: () => flyerQueue.close() },
{ name: 'emailQueue', close: () => emailQueue.close() },
{ name: 'analyticsQueue', close: () => analyticsQueue.close() },
@@ -437,7 +49,7 @@ export const gracefulShutdown = async (signal: string) => {
});
if (exitCode === 0) {
logger.info('[Shutdown] All workers, queues, and connections closed successfully.');
logger.info('[Shutdown] All queues and connections closed successfully.');
} else {
logger.warn('[Shutdown] Graceful shutdown completed with errors.');
}

View File

@@ -0,0 +1,118 @@
// src/services/queueService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
const createMockQueue = (name: string) => ({
name,
close: vi.fn().mockResolvedValue(undefined),
add: vi.fn(),
});
return {
flyerQueue: createMockQueue('flyer-processing'),
emailQueue: createMockQueue('email-sending'),
analyticsQueue: createMockQueue('analytics-reporting'),
weeklyAnalyticsQueue: createMockQueue('weekly-analytics-reporting'),
cleanupQueue: createMockQueue('file-cleanup'),
tokenCleanupQueue: createMockQueue('token-cleanup'),
redisConnection: {
quit: vi.fn().mockResolvedValue('OK'),
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
// --- Mock Modules ---
vi.mock('./queues.server', () => ({
flyerQueue: mocks.flyerQueue,
emailQueue: mocks.emailQueue,
analyticsQueue: mocks.analyticsQueue,
weeklyAnalyticsQueue: mocks.weeklyAnalyticsQueue,
cleanupQueue: mocks.cleanupQueue,
tokenCleanupQueue: mocks.tokenCleanupQueue,
}));
vi.mock('./redis.server', () => ({
connection: mocks.redisConnection,
}));
vi.mock('./logger.server', () => ({
logger: mocks.logger,
}));
// --- Test ---
describe('Queue Service (API Shutdown)', () => {
let gracefulShutdown: (signal: string) => Promise<void>;
let processExitSpy: Mock;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Spy on process.exit and prevent it from actually exiting
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
// Dynamically import the module under test
const queueService = await import('./queueService.server');
gracefulShutdown = queueService.gracefulShutdown;
});
afterEach(() => {
processExitSpy.mockRestore();
});
it('should attempt to close all queues and the redis connection on shutdown', async () => {
await gracefulShutdown('SIGINT');
expect(mocks.flyerQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.emailQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.analyticsQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.cleanupQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.weeklyAnalyticsQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.tokenCleanupQueue.close).toHaveBeenCalledTimes(1);
expect(mocks.redisConnection.quit).toHaveBeenCalledTimes(1);
});
it('should log success and exit with code 0 if all resources close successfully', async () => {
await gracefulShutdown('SIGINT');
expect(mocks.logger.info).toHaveBeenCalledWith(
'[Shutdown] All queues and connections closed successfully.',
);
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it('should log a warning and exit with code 1 if a queue fails to close', async () => {
const closeError = new Error('Queue failed to close');
mocks.emailQueue.close.mockRejectedValue(closeError);
await gracefulShutdown('SIGTERM');
expect(mocks.logger.error).toHaveBeenCalledWith(
{ err: closeError, resource: 'emailQueue' },
'[Shutdown] Error closing resource.',
);
expect(mocks.logger.warn).toHaveBeenCalledWith('[Shutdown] Graceful shutdown completed with errors.');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should log a warning and exit with code 1 if the redis connection fails to close', async () => {
const redisError = new Error('Redis quit failed');
mocks.redisConnection.quit.mockRejectedValue(redisError);
await gracefulShutdown('SIGTERM');
expect(mocks.logger.error).toHaveBeenCalledWith(
{ err: redisError, resource: 'redisConnection' },
'[Shutdown] Error closing resource.',
);
expect(mocks.logger.warn).toHaveBeenCalledWith('[Shutdown] Graceful shutdown completed with errors.');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View File

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

View File

@@ -0,0 +1,119 @@
// src/services/queues.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
return {
// This will be our mock for the BullMQ Queue constructor
MockQueue: vi.fn(),
// This is a mock for the Redis connection object
mockConnection: { id: 'mock-redis-connection' },
};
});
// --- Mock Modules ---
// Mock the 'bullmq' library to replace the real Queue constructor with our mock.
vi.mock('bullmq', () => ({
Queue: mocks.MockQueue,
}));
// Mock our internal redis connection module to export our mock connection object.
vi.mock('./redis.server', () => ({
connection: mocks.mockConnection,
}));
describe('Queue Definitions', () => {
beforeEach(async () => {
// Clear any previous mock calls and reset module cache before each test.
// This is crucial because the queues are instantiated at the module level.
// Resetting modules ensures the `queues.server.ts` file is re-executed.
vi.clearAllMocks();
vi.resetModules();
// Dynamically import the module under test. This will trigger the
// `new Queue(...)` calls, which will be captured by our mock constructor.
await import('./queues.server');
});
it('should create flyerQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('flyer-processing', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
});
});
it('should create emailQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('email-sending', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 10000,
},
},
});
});
it('should create analyticsQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('analytics-reporting', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 60000,
},
removeOnComplete: true,
removeOnFail: 50,
},
});
});
it('should create weeklyAnalyticsQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('weekly-analytics-reporting', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 3600000 },
removeOnComplete: true,
removeOnFail: 50,
},
});
});
it('should create cleanupQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('file-cleanup', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 30000 },
removeOnComplete: true,
},
});
});
it('should create tokenCleanupQueue with the correct name and options', () => {
expect(mocks.MockQueue).toHaveBeenCalledWith('token-cleanup', {
connection: mocks.mockConnection,
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 3600000 },
removeOnComplete: true,
removeOnFail: 10,
},
});
});
it('should create exactly 6 queues', () => {
// This is a good sanity check to ensure no new queues were added without tests.
expect(mocks.MockQueue).toHaveBeenCalledTimes(6);
});
});

View File

@@ -0,0 +1,96 @@
import { Queue } from 'bullmq';
import { connection } from './redis.server';
import type { FlyerJobData } from './flyerProcessingService.server';
// --- Job Data Interfaces ---
export interface EmailJobData {
to: string;
subject: string;
text: string;
html: string;
}
export interface AnalyticsJobData {
reportDate: string; // e.g., '2024-10-26'
}
export interface WeeklyAnalyticsJobData {
reportYear: number;
reportWeek: number; // ISO week number (1-53)
}
export interface CleanupJobData {
flyerId: number;
paths?: string[];
}
export interface TokenCleanupJobData {
timestamp: string;
}
// --- Queues ---
export const flyerQueue = new Queue<FlyerJobData>('flyer-processing', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
});
export const emailQueue = new Queue<EmailJobData>('email-sending', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 10000,
},
},
});
export const analyticsQueue = new Queue<AnalyticsJobData>('analytics-reporting', {
connection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 60000,
},
removeOnComplete: true,
removeOnFail: 50,
},
});
export const weeklyAnalyticsQueue = new Queue<WeeklyAnalyticsJobData>('weekly-analytics-reporting', {
connection,
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 3600000 },
removeOnComplete: true,
removeOnFail: 50,
},
});
export const cleanupQueue = new Queue<CleanupJobData>('file-cleanup', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 30000 },
removeOnComplete: true,
},
});
export const tokenCleanupQueue = new Queue<TokenCleanupJobData>('token-cleanup', {
connection,
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 3600000 },
removeOnComplete: true,
removeOnFail: 10,
},
});

View File

@@ -0,0 +1,16 @@
import IORedis from 'ioredis';
import { logger } from './logger.server';
export const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null, // Important for BullMQ
password: process.env.REDIS_PASSWORD,
});
// --- Redis Connection Event Listeners ---
connection.on('connect', () => {
logger.info('[Redis] Connection established successfully.');
});
connection.on('error', (err) => {
logger.error({ err }, '[Redis] Connection error.');
});

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