Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1c14ce89 | ||
| 6fafc3d089 | |||
|
|
4316866bce | ||
| 356c1a1894 | |||
|
|
2a310648ca | ||
| 8592633c22 | |||
|
|
0a9cdb8709 | ||
| 0d21e098f8 | |||
| b6799ed167 | |||
|
|
be5bda169e | ||
| 4ede403356 | |||
| 5d31605b80 | |||
| ddd4ad024e | |||
|
|
4e927f48bd | ||
| af5644d17a | |||
|
|
016c0a883a | ||
| c6a5f889b4 | |||
|
|
c895ecdb28 | ||
| 05e3f8a61c | |||
|
|
f79a2abc65 | ||
| a726c270bb | |||
|
|
8a4965c45b | ||
| 93497bf7c7 | |||
|
|
20584af729 | ||
| be9f452656 | |||
| ef4b8e58fe | |||
|
|
a42f7d7007 | ||
| 768d02b9ed | |||
|
|
c4742959e4 | ||
| 97c54c0c5c | |||
| 7cc50907d1 | |||
|
|
b4199f7c48 | ||
| dda36f7bc5 | |||
| 27810bbb36 | |||
|
|
7a1421d5c2 | ||
| 1b52478f97 | |||
| fe8b000737 | |||
|
|
d2babbe3b0 | ||
|
|
684d81db2a | ||
| 59ffa65562 | |||
| 0c0dd852ac | |||
|
|
cde766872e | ||
| 604b543c12 | |||
| fd67fe2941 | |||
|
|
582035b60e | ||
| 44e7670a89 | |||
| 2abfb3ed6e | |||
|
|
219de4a25c | ||
| 1540d5051f | |||
| 9c978c26fa | |||
|
|
adb109d8e9 | ||
| c668c8785f | |||
|
|
695bbb61b9 | ||
| 877c971833 | |||
| ed3af07aab | |||
|
|
dd4b34edfa | ||
| 91fa2f0516 | |||
|
|
aefd57e57b | ||
| 2ca4eb47ac | |||
| a4fe30da22 | |||
|
|
abab7fd25e | ||
| 53dd26d2d9 | |||
| ab3da0336c | |||
|
|
ed6d6349a2 | ||
| d4db2a709a | |||
| 508583809b | |||
|
|
6b1f7e7590 | ||
| 07bb31f4fb | |||
| a42fb76da8 | |||
|
|
08c320423c | ||
| d2498065ed | |||
| 56dc96f418 | |||
|
|
4e9aa0efc3 | ||
| e5e4b1316c | |||
| e8d511b4de | |||
|
|
c4bbf5c251 | ||
| 32a9e6732b | |||
| e7c076e2ed | |||
|
|
dbe8e72efe | ||
| 38bd193042 | |||
|
|
57215e2778 | ||
| 2c1de24e9a | |||
| c8baff7aac | |||
| de3f21a7ec | |||
|
|
c6adbf79e7 | ||
| 7399a27600 | |||
|
|
68aadcaa4e | ||
| 971d2c3fa7 | |||
|
|
daaacfde5e | ||
| 7ac8fe1d29 | |||
| a2462dfb6b | |||
|
|
a911224fb4 | ||
|
|
bf4bcef890 | ||
| ac6cd2e0a1 | |||
| eea03880c1 | |||
|
|
7fc263691f | ||
| c0912d36d5 | |||
| 612c2b5943 | |||
|
|
8e787ddcf0 | ||
| 11c52d284c | |||
|
|
b528bd3651 | ||
| 4c5ceb1bd6 | |||
| bcc4ad64dc | |||
|
|
d520980322 | ||
| d79955aaa0 | |||
| e66027dc8e | |||
|
|
027df989a4 | ||
| d4d69caaf7 | |||
| 03b5af39e1 | |||
|
|
8a86333f86 | ||
| f173f805ea | |||
| d3b0996ad5 | |||
|
|
b939262f0c | ||
| 9437f3d6c6 | |||
| f1e028d498 | |||
|
|
5274650aea | ||
| de5a9a565b | |||
| 10a379c5e3 | |||
| a6a484d432 | |||
|
|
4b0a172c35 | ||
| e8c894d5cf | |||
| 6c8fd4b126 | |||
|
|
a1f52544d0 | ||
| 2334359756 | |||
| 406954ca06 | |||
|
|
95d441be98 | ||
| 186ed484b7 | |||
|
|
3669958e9d | ||
| 5f3daf0539 | |||
| ae7afaaf97 | |||
|
|
3ae7b9e0d4 | ||
| 921c48fc57 | |||
|
|
2571864b91 | ||
| 065d0c746a | |||
| 395f6c21a2 |
@@ -47,6 +47,19 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
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
|
- name: Check for Production Database Schema Changes
|
||||||
env:
|
env:
|
||||||
DB_HOST: ${{ secrets.DB_HOST }}
|
DB_HOST: ${{ secrets.DB_HOST }}
|
||||||
@@ -61,9 +74,10 @@ jobs:
|
|||||||
echo "--- Checking for production schema changes ---"
|
echo "--- Checking for production schema changes ---"
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
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"
|
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."
|
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
||||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||||
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
||||||
@@ -79,8 +93,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
GITEA_SERVER_URL="https://gitea.projectium.com"
|
GITEA_SERVER_URL="https://gitea.projectium.com"
|
||||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
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_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||||
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
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
|
cd /var/www/flyer-crawler.projectium.com
|
||||||
npm install --omit=dev
|
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 ---
|
# --- Version Check Logic ---
|
||||||
# Get the version from the newly deployed package.json
|
# Get the version from the newly deployed package.json
|
||||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||||
@@ -139,7 +158,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||||
fi
|
fi
|
||||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
|
||||||
echo "Production backend server reloaded successfully."
|
echo "Production backend server reloaded successfully."
|
||||||
else
|
else
|
||||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||||
@@ -148,7 +167,12 @@ jobs:
|
|||||||
echo "Updating schema hash in production database..."
|
echo "Updating schema hash in production database..."
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
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 \
|
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();"
|
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)
|
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)
|
||||||
|
|||||||
@@ -90,10 +90,11 @@ jobs:
|
|||||||
# integration test suite can launch its own, fresh server instance.
|
# integration test suite can launch its own, fresh server instance.
|
||||||
# '|| true' ensures the workflow doesn't fail if the process isn't running.
|
# '|| true' ensures the workflow doesn't fail if the process isn't running.
|
||||||
run: |
|
run: |
|
||||||
pm2 stop flyer-crawler-api-test || true
|
echo "--- Stopping and deleting all test processes ---"
|
||||||
pm2 stop flyer-crawler-worker-test || true
|
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
|
||||||
pm2 delete flyer-crawler-api-test || true
|
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
|
||||||
pm2 delete flyer-crawler-worker-test || true
|
# It prevents the accumulation of duplicate processes from previous test runs.
|
||||||
|
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
|
||||||
|
|
||||||
- name: Run All Tests and Generate Merged Coverage Report
|
- name: Run All Tests and Generate Merged Coverage Report
|
||||||
# This single step runs both unit and integration tests, then merges their
|
# This single step runs both unit and integration tests, then merges their
|
||||||
@@ -119,6 +120,11 @@ jobs:
|
|||||||
# --- JWT Secret for Passport authentication in tests ---
|
# --- JWT Secret for Passport authentication in tests ---
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
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 ---
|
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
||||||
# This is crucial for memory-intensive tasks like running tests and coverage.
|
# This is crucial for memory-intensive tasks like running tests and coverage.
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
@@ -137,10 +143,39 @@ jobs:
|
|||||||
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
|
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
|
||||||
echo "--- Running Unit Tests ---"
|
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 || 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 ---"
|
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 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.
|
# Re-enable secret masking for subsequent steps.
|
||||||
echo "::secret-masking::"
|
echo "::secret-masking::"
|
||||||
@@ -156,6 +191,7 @@ jobs:
|
|||||||
echo "Checking for source coverage files..."
|
echo "Checking for source coverage files..."
|
||||||
ls -l .coverage/unit/coverage-final.json
|
ls -l .coverage/unit/coverage-final.json
|
||||||
ls -l .coverage/integration/coverage-final.json
|
ls -l .coverage/integration/coverage-final.json
|
||||||
|
ls -l .coverage/e2e/coverage-final.json || echo "E2E coverage file not found"
|
||||||
|
|
||||||
# --- V8 Coverage Processing for Backend Server ---
|
# --- V8 Coverage Processing for Backend Server ---
|
||||||
# The integration tests start the server, which generates raw V8 coverage data.
|
# The integration tests start the server, which generates raw V8 coverage data.
|
||||||
@@ -168,7 +204,7 @@ jobs:
|
|||||||
# Run c8: read raw files from the temp dir, and output an Istanbul JSON report.
|
# 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.
|
# We only generate the 'json' report here because it's all nyc needs for merging.
|
||||||
echo "Server coverage report about to be generated..."
|
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:"
|
echo "Server coverage report generated. Verifying existence:"
|
||||||
ls -l .coverage/integration-server/coverage-final.json
|
ls -l .coverage/integration-server/coverage-final.json
|
||||||
|
|
||||||
@@ -187,6 +223,7 @@ jobs:
|
|||||||
# We give them unique names to be safe, though it's not strictly necessary.
|
# We give them unique names to be safe, though it's not strictly necessary.
|
||||||
cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json"
|
cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json"
|
||||||
cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json"
|
cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json"
|
||||||
|
cp .coverage/e2e/coverage-final.json "$NYC_SOURCE_DIR/e2e-coverage.json" || echo "E2E coverage file not found, skipping."
|
||||||
# This file might not exist if integration tests fail early, so we add `|| true`
|
# This file might not exist if integration tests fail early, so we add `|| true`
|
||||||
cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping."
|
cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping."
|
||||||
echo "Copied coverage files to source directory. Contents:"
|
echo "Copied coverage files to source directory. Contents:"
|
||||||
@@ -206,7 +243,10 @@ jobs:
|
|||||||
--reporter=text \
|
--reporter=text \
|
||||||
--reporter=html \
|
--reporter=html \
|
||||||
--report-dir .coverage/ \
|
--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.
|
# Re-enable secret masking for subsequent steps.
|
||||||
echo "::secret-masking::"
|
echo "::secret-masking::"
|
||||||
@@ -257,18 +297,19 @@ jobs:
|
|||||||
# We normalize line endings to ensure the hash is consistent across different OS environments.
|
# 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 }')
|
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||||
|
|
||||||
# Query the production database to get the hash of the deployed schema.
|
# Query the production database to get the hash of the deployed schema.
|
||||||
# The `psql` command requires PGPASSWORD to be set.
|
# The `psql` command requires PGPASSWORD to be set.
|
||||||
# `\t` sets tuples-only mode and `\A` unaligns output to get just the raw value.
|
# `\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.
|
# 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 "none")
|
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"
|
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).
|
# 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 "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 "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.
|
# We allow the deployment to continue, but a manual schema update is required.
|
||||||
# You could choose to fail here by adding `exit 1`.
|
# You could choose to fail here by adding `exit 1`.
|
||||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||||
@@ -292,8 +333,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
|
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
|
||||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
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_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
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
|
VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build
|
||||||
@@ -348,18 +390,30 @@ jobs:
|
|||||||
|
|
||||||
run: |
|
run: |
|
||||||
# Fail-fast check to ensure secrets are configured in Gitea.
|
# Fail-fast check to ensure secrets are configured in Gitea.
|
||||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
MISSING_SECRETS=""
|
||||||
echo "ERROR: One or more test database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_TEST) are not set in Gitea repository settings."
|
if [ -z "$DB_HOST" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_HOST"; fi
|
||||||
|
if [ -z "$DB_USER" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_USER"; fi
|
||||||
|
if [ -z "$DB_PASSWORD" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_PASSWORD"; fi
|
||||||
|
if [ -z "$DB_NAME" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_NAME"; fi
|
||||||
|
if [ -z "$JWT_SECRET" ]; then MISSING_SECRETS="${MISSING_SECRETS} JWT_SECRET"; fi
|
||||||
|
|
||||||
|
if [ ! -z "$MISSING_SECRETS" ]; then
|
||||||
|
echo "ERROR: The following required secrets are missing in Gitea:${MISSING_SECRETS}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Installing production dependencies and restarting test server..."
|
echo "Installing production dependencies and restarting test server..."
|
||||||
cd /var/www/flyer-crawler-test.projectium.com
|
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.
|
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
||||||
# It will START the process if it's not running, or RELOAD it if it is.
|
# It will START the process if it's not running, or RELOAD it if it is.
|
||||||
# We also add `&& pm2 save` to persist the process list across server reboots.
|
# We also add `&& pm2 save` to persist the process list across server reboots.
|
||||||
pm2 startOrReload ecosystem.config.cjs --env test && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env test --update-env && pm2 save
|
||||||
echo "Test backend server reloaded successfully."
|
echo "Test backend server reloaded successfully."
|
||||||
|
|
||||||
# After a successful deployment, update the schema hash in the database.
|
# After a successful deployment, update the schema hash in the database.
|
||||||
@@ -367,7 +421,12 @@ jobs:
|
|||||||
echo "Updating schema hash in test database..."
|
echo "Updating schema hash in test database..."
|
||||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
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 \
|
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();"
|
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||||
|
|
||||||
# Verify the hash was updated
|
# Verify the hash was updated
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: database-backup
|
name: database-backup
|
||||||
path: ${{ env.backup_filename }}
|
path: ${{ env.backup_filename }}
|
||||||
|
|||||||
@@ -144,4 +144,4 @@ jobs:
|
|||||||
find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete
|
find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete
|
||||||
find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete
|
find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete
|
||||||
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
||||||
echo "✅ Flyer asset directories cleared."
|
echo "✅ Flyer asset directories cleared."
|
||||||
|
|||||||
@@ -130,4 +130,4 @@ jobs:
|
|||||||
find "$APP_PATH/flyer-images" -mindepth 1 -type f -delete
|
find "$APP_PATH/flyer-images" -mindepth 1 -type f -delete
|
||||||
find "$APP_PATH/flyer-images/icons" -mindepth 1 -type f -delete
|
find "$APP_PATH/flyer-images/icons" -mindepth 1 -type f -delete
|
||||||
find "$APP_PATH/flyer-images/archive" -mindepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
find "$APP_PATH/flyer-images/archive" -mindepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
||||||
echo "✅ Test flyer asset directories cleared."
|
echo "✅ Test flyer asset directories cleared."
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
DB_USER: ${{ secrets.DB_USER }}
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||||
BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups
|
BACKUP_DIR: '/var/www/backups' # Define a dedicated directory for backups
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Validate Secrets and Inputs
|
- name: Validate Secrets and Inputs
|
||||||
@@ -92,4 +92,4 @@ jobs:
|
|||||||
echo "Restarting application server..."
|
echo "Restarting application server..."
|
||||||
cd /var/www/flyer-crawler.projectium.com
|
cd /var/www/flyer-crawler.projectium.com
|
||||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
||||||
echo "✅ Application server restarted."
|
echo "✅ Application server restarted."
|
||||||
|
|||||||
185
.gitea/workflows/manual-deploy-major.yml
Normal file
185
.gitea/workflows/manual-deploy-major.yml
Normal 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."
|
||||||
@@ -6,4 +6,4 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"endOfLine": "auto"
|
"endOfLine": "auto"
|
||||||
}
|
}
|
||||||
|
|||||||
130
README.md
130
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Flyer Crawler is a web application that uses the Google Gemini AI to extract, analyze, and manage data from grocery store flyers. Users can upload flyer images or PDFs, and the application will automatically identify items, prices, and sale dates, storing the structured data in a PostgreSQL database for historical analysis, price tracking, and personalized deal alerts.
|
Flyer Crawler is a web application that uses the Google Gemini AI to extract, analyze, and manage data from grocery store flyers. Users can upload flyer images or PDFs, and the application will automatically identify items, prices, and sale dates, storing the structured data in a PostgreSQL database for historical analysis, price tracking, and personalized deal alerts.
|
||||||
|
|
||||||
We are working on an app to help people save money, by finding good deals that are only advertized in store flyers/ads. So, the primary purpose of the site is to make uploading flyers as easy as possible and as accurate as possible, and to store peoples needs, so sales can be matched to needs.
|
We are working on an app to help people save money, by finding good deals that are only advertized in store flyers/ads. So, the primary purpose of the site is to make uploading flyers as easy as possible and as accurate as possible, and to store peoples needs, so sales can be matched to needs.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -45,9 +45,9 @@ This project is configured to run in a CI/CD environment and does not use `.env`
|
|||||||
|
|
||||||
1. **Set up a PostgreSQL database instance.**
|
1. **Set up a PostgreSQL database instance.**
|
||||||
2. **Run the Database Schema**:
|
2. **Run the Database Schema**:
|
||||||
- Connect to your database using a tool like `psql` or DBeaver.
|
- Connect to your database using a tool like `psql` or DBeaver.
|
||||||
- Open `sql/schema.sql.txt`, copy its entire contents, and execute it against your database.
|
- Open `sql/schema.sql.txt`, copy its entire contents, and execute it against your database.
|
||||||
- This will create all necessary tables, functions, and relationships.
|
- This will create all necessary tables, functions, and relationships.
|
||||||
|
|
||||||
### Step 2: Install Dependencies and Run the Application
|
### Step 2: Install Dependencies and Run the Application
|
||||||
|
|
||||||
@@ -79,11 +79,11 @@ sudo nano /etc/nginx/mime.types
|
|||||||
|
|
||||||
change
|
change
|
||||||
|
|
||||||
application/javascript js;
|
application/javascript js;
|
||||||
|
|
||||||
TO
|
TO
|
||||||
|
|
||||||
application/javascript js mjs;
|
application/javascript js mjs;
|
||||||
|
|
||||||
RESTART NGINX
|
RESTART NGINX
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ actually the proper change was to do this in the /etc/nginx/sites-available/flye
|
|||||||
## for OAuth
|
## for OAuth
|
||||||
|
|
||||||
1. Get Google OAuth Credentials
|
1. Get Google OAuth Credentials
|
||||||
This is a crucial step that you must do outside the codebase:
|
This is a crucial step that you must do outside the codebase:
|
||||||
|
|
||||||
Go to the Google Cloud Console.
|
Go to the Google Cloud Console.
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Under Authorized redirect URIs, click ADD URI and enter the URL where Google wil
|
|||||||
Click Create. You will be given a Client ID and a Client Secret.
|
Click Create. You will be given a Client ID and a Client Secret.
|
||||||
|
|
||||||
2. Get GitHub OAuth Credentials
|
2. Get GitHub OAuth Credentials
|
||||||
You'll need to obtain a Client ID and Client Secret from GitHub:
|
You'll need to obtain a Client ID and Client Secret from GitHub:
|
||||||
|
|
||||||
Go to your GitHub profile settings.
|
Go to your GitHub profile settings.
|
||||||
|
|
||||||
@@ -133,21 +133,23 @@ You will be given a Client ID and a Client Secret.
|
|||||||
|
|
||||||
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
||||||
|
|
||||||
|
|
||||||
## postgis
|
## postgis
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT version();
|
flyer-crawler-prod=> SELECT version();
|
||||||
version
|
version
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
---
|
||||||
|
|
||||||
|
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
||||||
(1 row)
|
(1 row)
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
||||||
postgis_full_version
|
postgis_full_version
|
||||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
|
||||||
(1 row)
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
||||||
|
(1 row)
|
||||||
|
|
||||||
## production postgres setup
|
## production postgres setup
|
||||||
|
|
||||||
@@ -201,9 +203,13 @@ Step 4: Seed the Admin Account (If Needed)
|
|||||||
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
||||||
|
|
||||||
bash
|
bash
|
||||||
|
|
||||||
# Set variables for the current session
|
# Set variables for the current session
|
||||||
|
|
||||||
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
||||||
|
|
||||||
# Run the seeding script
|
# Run the seeding script
|
||||||
|
|
||||||
npx tsx src/db/seed_admin_account.ts
|
npx tsx src/db/seed_admin_account.ts
|
||||||
Your production database is now ready!
|
Your production database is now ready!
|
||||||
|
|
||||||
@@ -284,8 +290,6 @@ Test Execution: Your tests run against this clean, isolated schema.
|
|||||||
|
|
||||||
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
||||||
[PM2][Module] Installing NPM pm2-logrotate module
|
[PM2][Module] Installing NPM pm2-logrotate module
|
||||||
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
||||||
@@ -293,7 +297,7 @@ gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
|||||||
added 161 packages in 5s
|
added 161 packages in 5s
|
||||||
|
|
||||||
21 packages are looking for funding
|
21 packages are looking for funding
|
||||||
run `npm fund` for details
|
run `npm fund` for details
|
||||||
npm notice
|
npm notice
|
||||||
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
||||||
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
||||||
@@ -308,23 +312,23 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 \* \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
Modules configuration. Copy/Paste line to edit values.
|
Modules configuration. Copy/Paste line to edit values.
|
||||||
[PM2][Module] Module successfully installed and launched
|
[PM2][Module] Module successfully installed and launched
|
||||||
[PM2][Module] Checkout module options: `$ pm2 conf`
|
[PM2][Module] Checkout module options: `$ pm2 conf`
|
||||||
┌────┬───────────────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
|
┌────┬───────────────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
|
||||||
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
|
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
|
||||||
├────┼───────────────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
|
├────┼───────────────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
|
||||||
│ 2 │ flyer-crawler-analytics-worker │ default │ 0.0.0 │ fork │ 3846981 │ 7m │ 5 │ online │ 0% │ 55.8mb │ git… │ disabled │
|
│ 2 │ flyer-crawler-analytics-worker │ default │ 0.0.0 │ fork │ 3846981 │ 7m │ 5 │ online │ 0% │ 55.8mb │ git… │ disabled │
|
||||||
│ 11 │ flyer-crawler-api │ default │ 0.0.0 │ fork │ 3846987 │ 7m │ 0 │ online │ 0% │ 59.0mb │ git… │ disabled │
|
│ 11 │ flyer-crawler-api │ default │ 0.0.0 │ fork │ 3846987 │ 7m │ 0 │ online │ 0% │ 59.0mb │ git… │ disabled │
|
||||||
│ 12 │ flyer-crawler-worker │ default │ 0.0.0 │ fork │ 3846988 │ 7m │ 0 │ online │ 0% │ 54.2mb │ git… │ disabled │
|
│ 12 │ flyer-crawler-worker │ default │ 0.0.0 │ fork │ 3846988 │ 7m │ 0 │ online │ 0% │ 54.2mb │ git… │ disabled │
|
||||||
└────┴───────────────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
|
└────┴───────────────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
|
||||||
Module
|
Module
|
||||||
┌────┬──────────────────────────────┬───────────────┬──────────┬──────────┬──────┬──────────┬──────────┬──────────┐
|
┌────┬──────────────────────────────┬───────────────┬──────────┬──────────┬──────┬──────────┬──────────┬──────────┐
|
||||||
│ id │ module │ version │ pid │ status │ ↺ │ cpu │ mem │ user │
|
│ id │ module │ version │ pid │ status │ ↺ │ cpu │ mem │ user │
|
||||||
├────┼──────────────────────────────┼───────────────┼──────────┼──────────┼──────┼──────────┼──────────┼──────────┤
|
├────┼──────────────────────────────┼───────────────┼──────────┼──────────┼──────┼──────────┼──────────┼──────────┤
|
||||||
│ 13 │ pm2-logrotate │ 3.0.0 │ 3848878 │ online │ 0 │ 0% │ 20.1mb │ git… │
|
│ 13 │ pm2-logrotate │ 3.0.0 │ 3848878 │ online │ 0 │ 0% │ 20.1mb │ git… │
|
||||||
└────┴──────────────────────────────┴───────────────┴──────────┴──────────┴──────┴──────────┴──────────┴──────────┘
|
└────┴──────────────────────────────┴───────────────┴──────────┴──────────┴──────┴──────────┴──────────┴──────────┘
|
||||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:max_size 10M
|
gitea-runner@projectium:~$ pm2 set pm2-logrotate:max_size 10M
|
||||||
[PM2] Module pm2-logrotate restarted
|
[PM2] Module pm2-logrotate restarted
|
||||||
@@ -335,7 +339,7 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
||||||
[PM2] Module pm2-logrotate restarted
|
[PM2] Module pm2-logrotate restarted
|
||||||
@@ -346,33 +350,31 @@ $ pm2 set pm2-logrotate:retain 14
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* \*
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$
|
gitea-runner@projectium:~$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## dev server setup:
|
## dev server setup:
|
||||||
|
|
||||||
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
||||||
|
|
||||||
1. Install Prerequisites on Windows
|
1. Install Prerequisites on Windows
|
||||||
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
||||||
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
||||||
|
|
||||||
2. Set Up Podman
|
2. Set Up Podman
|
||||||
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||||
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||||
|
|
||||||
3. Set Up the Ubuntu Container
|
3. Set Up the Ubuntu Container
|
||||||
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
|
||||||
podman pull ubuntu:latest
|
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
||||||
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
podman pull ubuntu:latest
|
||||||
podman volume create node_modules_cache
|
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
||||||
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
podman volume create node_modules_cache
|
||||||
- Open a terminal in your project's root directory on Windows.
|
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
||||||
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
- Open a terminal in your project's root directory on Windows.
|
||||||
|
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
||||||
|
|
||||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com:/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com:/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||||
|
|
||||||
@@ -383,46 +385,40 @@ podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-cra
|
|||||||
-v "node_modules_cache:/app/node_modules": Mounts the named volume for node_modules.
|
-v "node_modules_cache:/app/node_modules": Mounts the named volume for node_modules.
|
||||||
|
|
||||||
4. Configure the Ubuntu Environment
|
4. Configure the Ubuntu Environment
|
||||||
You are now inside the Ubuntu container's shell.
|
You are now inside the Ubuntu container's shell.
|
||||||
|
|
||||||
- Update Package Lists:
|
- Update Package Lists:
|
||||||
apt-get update
|
apt-get update
|
||||||
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
||||||
apt-get install -y curl git
|
apt-get install -y curl git
|
||||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
apt-get install -y nodejs
|
apt-get install -y nodejs
|
||||||
- Navigate to Project Directory:
|
- Navigate to Project Directory:
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
- Install Project Dependencies:
|
- Install Project Dependencies:
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
5. Run the Development Server
|
5. Run the Development Server
|
||||||
- Start the Application:
|
- Start the Application:
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
6. Accessing the Application
|
6. Accessing the Application
|
||||||
- Frontend: Open your browser and go to http://localhost:5173.
|
|
||||||
- Backend: The frontend will make API calls to http://localhost:3001.
|
- Frontend: Open your browser and go to http://localhost:5173.
|
||||||
|
- Backend: The frontend will make API calls to http://localhost:3001.
|
||||||
|
|
||||||
Managing the Environment
|
Managing the Environment
|
||||||
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
|
||||||
- Restarting the Container:
|
|
||||||
podman start -a -i flyer-dev
|
|
||||||
|
|
||||||
|
|
||||||
|
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
||||||
|
- Restarting the Container:
|
||||||
|
podman start -a -i flyer-dev
|
||||||
|
|
||||||
## for me:
|
## for me:
|
||||||
|
|
||||||
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
||||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rate limiting
|
rate limiting
|
||||||
|
|
||||||
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ We will adopt a strict, consistent error-handling contract for the service and r
|
|||||||
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
||||||
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
||||||
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
||||||
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the _exact_ error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
||||||
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
|
|||||||
@@ -10,21 +10,19 @@ Following the standardization of error handling in ADR-001, the next most common
|
|||||||
|
|
||||||
This manual approach has several drawbacks:
|
This manual approach has several drawbacks:
|
||||||
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
||||||
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application.
|
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
||||||
3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
||||||
|
|
||||||
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
||||||
|
- Acquiring a client from the database pool.
|
||||||
* Acquiring a client from the database pool.
|
- Starting a transaction (`BEGIN`).
|
||||||
* Starting a transaction (`BEGIN`).
|
- Executing the `callback` function, passing the transactional client to it.
|
||||||
* Executing the `callback` function, passing the transactional client to it.
|
- If the callback succeeds, it will `COMMIT` the transaction.
|
||||||
* If the callback succeeds, it will `COMMIT` the transaction.
|
- If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
||||||
* If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
- In all cases, it will `RELEASE` the client back to the pool.
|
||||||
* In all cases, it will `RELEASE` the client back to the pool.
|
|
||||||
|
|
||||||
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
||||||
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
||||||
@@ -40,7 +38,7 @@ async function registerUserAndCreateDefaultList(userData) {
|
|||||||
const shoppingRepo = new ShoppingRepository(client);
|
const shoppingRepo = new ShoppingRepository(client);
|
||||||
|
|
||||||
const newUser = await userRepo.createUser(userData);
|
const newUser = await userRepo.createUser(userData);
|
||||||
await shoppingRepo.createShoppingList(newUser.user_id, "My First List");
|
await shoppingRepo.createShoppingList(newUser.user_id, 'My First List');
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ We will adopt a schema-based approach for input validation using the `zod` libra
|
|||||||
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
||||||
|
|
||||||
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
||||||
* On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
- On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
||||||
* On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
- On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
||||||
|
|
||||||
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
||||||
|
|
||||||
@@ -46,18 +46,18 @@ const getFlyerSchema = z.object({
|
|||||||
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
||||||
|
|
||||||
// 3. Apply the middleware and use an inline cast for the request
|
// 3. Apply the middleware and use an inline cast for the request
|
||||||
router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => {
|
||||||
// Cast 'req' to the inferred type.
|
// Cast 'req' to the inferred type.
|
||||||
// This provides full type safety for params, query, and body.
|
// This provides full type safety for params, query, and body.
|
||||||
const { params } = req as unknown as GetFlyerRequest;
|
const { params } = req as unknown as GetFlyerRequest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number'
|
const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number'
|
||||||
res.json(flyer);
|
res.json(flyer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}));
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
||||||
|
|
||||||
* Generate a unique `request_id` for each incoming request.
|
- Generate a unique `request_id` for each incoming request.
|
||||||
* Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
||||||
* Attach this child logger to the `req` object (e.g., `req.log`).
|
- Attach this child logger to the `req` object (e.g., `req.log`).
|
||||||
|
|
||||||
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Standardized Logging Practices**:
|
**Standardized Logging Practices**:
|
||||||
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
||||||
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
||||||
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
||||||
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
@@ -59,15 +59,15 @@ export const requestLogger = (req, res, next) => {
|
|||||||
|
|
||||||
// In a route handler:
|
// In a route handler:
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
// Use the request-scoped logger
|
// Use the request-scoped logger
|
||||||
req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID');
|
req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID');
|
||||||
try {
|
try {
|
||||||
// ... business logic ...
|
// ... business logic ...
|
||||||
res.json(flyer);
|
res.json(flyer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The error itself will be logged with full context by the errorHandler
|
// The error itself will be logged with full context by the errorHandler
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize a centralized Role-Based Access Control (RBAC) or Attribute-Ba
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
- **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
||||||
* **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
- **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will establish a formal Design System and Component Library. This will involv
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
||||||
* **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt a dedicated database migration tool, such as **`node-pg-migrate`**
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
- **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
||||||
* **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
- **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will standardize the deployment process by containerizing the application usi
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
- **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
||||||
* **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
- **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ We will implement a multi-layered security approach for the API:
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
- **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
||||||
* **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
- **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize the end-to-end CI/CD process. This ADR will define the project
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
- **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
||||||
* **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
- **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt **OpenAPI (Swagger)** for API documentation. We will use tools (e.
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
- **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
||||||
* **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
- **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will implement a formal data backup and recovery strategy. This will involve
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
||||||
* **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ When the application is containerized (`ADR-014`), the container orchestrator (e
|
|||||||
|
|
||||||
We will implement dedicated health check endpoints in the Express application.
|
We will implement dedicated health check endpoints in the Express application.
|
||||||
|
|
||||||
* A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
- A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
||||||
|
|
||||||
* A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
- A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
- **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
||||||
* **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
- **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ We will adopt a standardized, application-wide structured logging policy for all
|
|||||||
|
|
||||||
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
||||||
|
|
||||||
* `logger.info('A simple message');`
|
- `logger.info('A simple message');`
|
||||||
* `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
- `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||||
|
|
||||||
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe('MyComponent', () => {
|
|||||||
// Assert that the logger was called with the expected structure
|
// Assert that the logger was called with the expected structure
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
||||||
'Failed to fetch component data' // Check for the message
|
'Failed to fetch component data', // Check for the message
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,64 +3,260 @@
|
|||||||
// It allows us to define all the settings for our application in one place.
|
// It allows us to define all the settings for our application in one place.
|
||||||
// The .cjs extension is required because the project's package.json has "type": "module".
|
// The .cjs extension is required because the project's package.json has "type": "module".
|
||||||
|
|
||||||
|
// --- Environment Variable Validation ---
|
||||||
|
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
|
||||||
|
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
|
||||||
|
|
||||||
|
if (missingSecrets.length > 0) {
|
||||||
|
console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:');
|
||||||
|
missingSecrets.forEach(key => console.warn(` - ${key}`));
|
||||||
|
console.warn('[ecosystem.config.cjs] The application may crash if these are required for startup.\n');
|
||||||
|
process.exit(1); // Fail fast so PM2 doesn't attempt to start a broken app
|
||||||
|
} else {
|
||||||
|
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
// --- API Server ---
|
// --- API Server ---
|
||||||
// The name is now dynamically set based on the environment.
|
|
||||||
// This is a common pattern but requires you to call pm2 with the correct name.
|
|
||||||
// The deploy script handles this by using 'flyer-crawler-api' for prod and 'flyer-crawler-api-test' for test.
|
|
||||||
name: 'flyer-crawler-api',
|
name: 'flyer-crawler-api',
|
||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'server.ts', // tsx will execute this file
|
args: 'server.ts',
|
||||||
|
max_memory_restart: '500M',
|
||||||
|
|
||||||
|
// Restart Logic
|
||||||
|
max_restarts: 40,
|
||||||
|
exp_backoff_restart_delay: 100,
|
||||||
|
min_uptime: '10s',
|
||||||
|
|
||||||
// Production Environment Settings
|
// Production Environment Settings
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production', // Set the Node.js environment to production
|
NODE_ENV: 'production',
|
||||||
name: 'flyer-crawler-api',
|
name: 'flyer-crawler-api',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
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
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development', // Use 'development' for test to enable more verbose logging if needed
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-api-test',
|
name: 'flyer-crawler-api-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
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 ---
|
// --- General Worker ---
|
||||||
name: 'flyer-crawler-worker',
|
name: 'flyer-crawler-worker',
|
||||||
script: './node_modules/.bin/tsx',
|
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
|
// Production Environment Settings
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
name: 'flyer-crawler-worker',
|
name: 'flyer-crawler-worker',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
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
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-worker-test',
|
name: 'flyer-crawler-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
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 ---
|
// --- Analytics Worker ---
|
||||||
name: 'flyer-crawler-analytics-worker',
|
name: 'flyer-crawler-analytics-worker',
|
||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'src/services/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
|
// Production Environment Settings
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
name: 'flyer-crawler-analytics-worker',
|
name: 'flyer-crawler-analytics-worker',
|
||||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||||
|
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
|
// Test Environment Settings
|
||||||
env_test: {
|
env_test: {
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-analytics-worker-test',
|
name: 'flyer-crawler-analytics-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import globals from "globals";
|
import globals from 'globals';
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from 'typescript-eslint';
|
||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from 'eslint-plugin-react';
|
||||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||||
import pluginReactRefresh from "eslint-plugin-react-refresh";
|
import pluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
// Global ignores
|
// Global ignores
|
||||||
ignores: ["dist", ".gitea", "node_modules", "*.cjs"],
|
ignores: ['dist', '.gitea', 'node_modules', '*.cjs'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// All files
|
// All files
|
||||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
react: pluginReact,
|
react: pluginReact,
|
||||||
"react-hooks": pluginReactHooks,
|
'react-hooks': pluginReactHooks,
|
||||||
"react-refresh": pluginReactRefresh,
|
'react-refresh': pluginReactRefresh,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
@@ -24,12 +24,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TypeScript files
|
// TypeScript files
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
);
|
);
|
||||||
|
|||||||
4
express.d.ts
vendored
4
express.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
// src/types/express.d.ts
|
// express.d.ts
|
||||||
import { Logger } from 'pino';
|
import { Logger } from 'pino';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,4 +12,4 @@ declare global {
|
|||||||
log: Logger;
|
log: Logger;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
index.html
38
index.html
@@ -1,20 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Grocery Flyer AI Analyzer</title>
|
<title>Grocery Flyer AI Analyzer</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- The stylesheet will be injected here by Vite during the build process -->
|
<!-- The stylesheet will be injected here by Vite during the build process -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Vite will inject the correct <script> tag here during the build process -->
|
<!-- Vite will inject the correct <script> tag here during the build process -->
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Flyer Crawler",
|
"name": "Flyer Crawler",
|
||||||
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
||||||
"requestFramePermissions": [
|
"requestFramePermissions": ["geolocation", "microphone"]
|
||||||
"geolocation",
|
}
|
||||||
"microphone"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
118
notes-to-ai4.txt
Normal file
118
notes-to-ai4.txt
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
RULES:
|
||||||
|
1) if you do not have a file that you need, stop, and request it immediately.
|
||||||
|
2) never remove logging or comments
|
||||||
|
3) you cannot ever use 'any' or 'unknown' to solve possible typescript issues
|
||||||
|
4) when creating new files, output there entire path in your explanation, to make it easier to know where to save those new files and directories to
|
||||||
|
5) add comments when you can, as that will help ensure ideas persist into the app
|
||||||
|
6) Your knowledge of package version, like nodejs, is always old, like a year or more old - ask me for the best version to use, as your knowledge is incomplete
|
||||||
|
7) Stop making predictions and/or guessing at solutions. Focus on adding logging and debugging to issues that are not solved right away.
|
||||||
|
8) Do not make obsequious statements - we're here to do a job, not get patted on the shoulder for insignificant achievements.
|
||||||
|
9) Provide me with the npm command to execute rather than wanting to edit the package.json file. That is not the correct way to handle a package update.
|
||||||
|
10) Provide the code solution in DIFF format for brevity.
|
||||||
|
11) Always add logging and debugging to prove a solution works, and only remove logging and debugging when it is not possible to adapt it to the new conditions.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
UPC SCANNING !
|
||||||
|
|
||||||
|
EXPIRY DATES !
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
are there any other unit test files that should be refactored :
|
||||||
|
|
||||||
|
Extract Shared Utility Functions Several utility functions are duplicated across multiple route files.
|
||||||
|
|
||||||
|
Update Route Files to Use New Utilities route files will be updated to import these new utility functions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Review the `getJobStatus` API client function and add more robust error handling for non-JSON responses.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Review the `src/services/apiClient.test.ts` file. It seems to have a lot of manual data creation for API payloads. Can we refactor it to use factories where appropriate, or create new factories for API payloads if needed?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The `useApi` hook has complex state management. Can you suggest a way to simplify it, perhaps by using a reducer or a state machine?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Are there any opportunities to improve the mock factories themselves, for example, by adding more realistic default data?
|
||||||
|
|
||||||
|
Now that the user creation is centralized, can we add more complex test user scenarios to the helper, like a user with a pre-existing shopping list?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
How can I add a new "Recipes Created" stat to the AdminStatsPage and its tests?
|
||||||
|
|
||||||
|
NO CODE just outline the steps
|
||||||
|
|
||||||
|
|
||||||
|
Gemini
|
||||||
|
Outlining the Implementation Path
|
||||||
|
Gemini 2.5 generated this response due to high traffic on Gemini 3
|
||||||
|
|
||||||
|
Of course! Here is a step-by-step outline of how to add a new "Recipes Created" statistic to your AdminStatsPage.
|
||||||
|
|
||||||
|
Backend Steps
|
||||||
|
|
||||||
|
|
||||||
|
Update Database Query:
|
||||||
|
|
||||||
|
In the database repository file responsible for fetching admin statistics (e.g., admin.db.ts), you'll need to add a query that performs a COUNT(*) on your recipes table.
|
||||||
|
|
||||||
|
|
||||||
|
Update Service Layer:
|
||||||
|
|
||||||
|
The service function that aggregates the statistics (which is called by your API controller) needs to be modified to execute the new recipe count query.
|
||||||
|
The result of this query should be added to the statistics object that is returned.
|
||||||
|
|
||||||
|
|
||||||
|
Update API Endpoint:
|
||||||
|
|
||||||
|
The controller for the /api/admin/stats endpoint must be updated to include the new recipeCount field in the JSON object it sends to the frontend.
|
||||||
|
Frontend Steps
|
||||||
|
|
||||||
|
|
||||||
|
Update AppStats Type:
|
||||||
|
|
||||||
|
In src/services/apiClient.ts, where the AppStats type is defined, add the new property: recipeCount: number;.
|
||||||
|
Update Mock Factory:
|
||||||
|
|
||||||
|
In src/tests/utils/mockFactories.ts, modify the createMockAppStats function. Add recipeCount to the defaultStats object with a default value (e.g., recipeCount: 500). This ensures any test that uses the factory will not break due to the missing property.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Update AdminStatsPage Component (AdminStatsPage.tsx):
|
||||||
|
|
||||||
|
Import a suitable icon for recipes, like BookOpenIcon, at the top of the file.
|
||||||
|
Inside the main div where the other StatCard components are rendered, add a new one for the recipe count:
|
||||||
|
jsx
|
||||||
|
<StatCard title="Total Recipes" value={stats.recipeCount.toLocaleString()} icon={<BookOpenIcon className="w-6 h-6" />} />
|
||||||
|
Update AdminStatsPage Tests (AdminStatsPage.test.tsx):
|
||||||
|
|
||||||
|
In the test 'should display stats cards when data is fetched successfully', update the mockStats object to include a value for recipeCount. Add a new expect assertion to verify that the "Total Recipes" title and its corresponding value are rendered.
|
||||||
|
In the test 'should format large numbers with commas for readability', add a large number for recipeCount in the mock data and assert that it is rendered with commas.
|
||||||
|
In the test 'should correctly display zero values for all stats', add recipeCount: 0 to the mock data and increase the expected count of "0" elements found in the document from 5 to 6.
|
||||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.0.15",
|
"version": "0.2.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.0.15",
|
"version": "0.2.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@types/connect-timeout": "^1.9.0",
|
"@types/connect-timeout": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.65.1",
|
"bullmq": "^5.65.1",
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"zod": "^4.1.13",
|
"zod": "^4.2.1",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -4882,6 +4883,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@testcontainers/postgresql": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.15",
|
"version": "0.2.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@types/connect-timeout": "^1.9.0",
|
"@types/connect-timeout": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.65.1",
|
"bullmq": "^5.65.1",
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"zod": "^4.1.13",
|
"zod": "^4.2.1",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ const tailwindConfigPath = path.resolve(process.cwd(), 'tailwind.config.js');
|
|||||||
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
||||||
|
|
||||||
// Log to prove the imported config object is what we expect
|
// Log to prove the imported config object is what we expect
|
||||||
console.log('[POSTCSS] Imported tailwind.config.js object:', JSON.stringify(tailwindConfig, null, 2));
|
console.log(
|
||||||
|
'[POSTCSS] Imported tailwind.config.js object:',
|
||||||
|
JSON.stringify(tailwindConfig, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {}, // The empty object is correct.
|
'@tailwindcss/postcss': {}, // The empty object is correct.
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1030,11 +1030,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
@@ -1566,4 +1616,3 @@ BEGIN
|
|||||||
bp.price_rank = 1;
|
bp.price_rank = 1;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,23 @@
|
|||||||
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
||||||
|
|
||||||
-- 1. Pre-populate the master grocery items dictionary.
|
-- 1. Pre-populate the master grocery items dictionary.
|
||||||
-- This block links generic items to their respective categories.
|
-- This MUST run after populating categories.
|
||||||
|
-- Renumbered to 2.
|
||||||
|
|
||||||
|
-- 2. Pre-populate the categories table from a predefined list.
|
||||||
|
-- Renumbered to 1. This MUST run before populating master_grocery_items.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.categories (name) VALUES
|
||||||
|
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
||||||
|
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
||||||
|
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
||||||
|
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
||||||
|
('International Foods'), ('Other/Miscellaneous')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Pre-populate the master grocery items dictionary.
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
||||||
@@ -53,18 +69,6 @@ BEGIN
|
|||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- 2. Pre-populate the categories table from a predefined list.
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.categories (name) VALUES
|
|
||||||
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
|
||||||
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
|
||||||
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
|
||||||
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
|
||||||
('International Foods'), ('Other/Miscellaneous')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 3. Pre-populate the brands and products tables.
|
-- 3. Pre-populate the brands and products tables.
|
||||||
-- This block adds common brands and links them to specific products.
|
-- This block adds common brands and links them to specific products.
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
file_name TEXT NOT NULL,
|
file_name TEXT NOT NULL,
|
||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -139,7 +139,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -162,6 +162,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -171,13 +203,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
@@ -294,7 +326,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -359,7 +391,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -379,9 +411,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -409,38 +441,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -496,7 +496,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -780,7 +780,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -844,7 +844,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -865,8 +865,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
file_name TEXT NOT NULL,
|
file_name TEXT NOT NULL,
|
||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -155,7 +155,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -178,6 +178,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -187,13 +219,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
@@ -310,7 +342,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -375,7 +407,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -395,9 +427,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -424,38 +456,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -510,7 +510,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -796,7 +796,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -862,7 +862,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -883,8 +883,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
@@ -2128,11 +2128,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
362
src/App.test.tsx
362
src/App.test.tsx
@@ -20,6 +20,7 @@ import {
|
|||||||
mockUseUserData,
|
mockUseUserData,
|
||||||
mockUseFlyerItems,
|
mockUseFlyerItems,
|
||||||
} from './tests/setup/mockHooks';
|
} from './tests/setup/mockHooks';
|
||||||
|
import { useAppInitialization } from './hooks/useAppInitialization';
|
||||||
|
|
||||||
// Mock top-level components rendered by App's routes
|
// Mock top-level components rendered by App's routes
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ vi.mock('pdfjs-dist', () => ({
|
|||||||
// Mock the new config module
|
// Mock the new config module
|
||||||
vi.mock('./config', () => ({
|
vi.mock('./config', () => ({
|
||||||
default: {
|
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' },
|
google: { mapsEmbedApiKey: 'mock-key' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -52,6 +53,9 @@ vi.mock('./hooks/useFlyerItems', async () => {
|
|||||||
return { useFlyerItems: hooks.mockUseFlyerItems };
|
return { useFlyerItems: hooks.mockUseFlyerItems };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('./hooks/useAppInitialization');
|
||||||
|
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||||
|
|
||||||
vi.mock('./hooks/useAuth', async () => {
|
vi.mock('./hooks/useAuth', async () => {
|
||||||
const hooks = await import('./tests/setup/mockHooks');
|
const hooks = await import('./tests/setup/mockHooks');
|
||||||
return { useAuth: hooks.mockUseAuth };
|
return { useAuth: hooks.mockUseAuth };
|
||||||
@@ -122,7 +126,23 @@ vi.mock('./layouts/MainLayout', async () => {
|
|||||||
return { MainLayout: MockMainLayout };
|
return { MainLayout: MockMainLayout };
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
|
vi.mock('./components/AppGuard', async () => {
|
||||||
|
// We need to use the real useModal hook inside our mock AppGuard
|
||||||
|
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
||||||
|
return {
|
||||||
|
AppGuard: ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isModalOpen } = useModal();
|
||||||
|
return (
|
||||||
|
<div data-testid="app-guard-mock">
|
||||||
|
{children}
|
||||||
|
{isModalOpen('whatsNew') && <div data-testid="whats-new-modal-mock" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedAiApiClient = vi.mocked(aiApiClient);
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
const mockFlyers: Flyer[] = [
|
const mockFlyers: Flyer[] = [
|
||||||
@@ -131,33 +151,6 @@ const mockFlyers: Flyer[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('App Component', () => {
|
describe('App Component', () => {
|
||||||
// Mock localStorage
|
|
||||||
let storage: { [key: string]: string } = {};
|
|
||||||
const localStorageMock = {
|
|
||||||
getItem: vi.fn((key: string) => storage[key] || null),
|
|
||||||
setItem: vi.fn((key: string, value: string) => {
|
|
||||||
storage[key] = value;
|
|
||||||
}),
|
|
||||||
removeItem: vi.fn((key: string) => {
|
|
||||||
delete storage[key];
|
|
||||||
}),
|
|
||||||
clear: vi.fn(() => {
|
|
||||||
storage = {};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock matchMedia
|
|
||||||
const matchMediaMock = vi.fn().mockImplementation((query) => ({
|
|
||||||
matches: false, // Default to light mode
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(), // deprecated
|
|
||||||
removeListener: vi.fn(), // deprecated
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
|
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -205,11 +198,9 @@ describe('App Component', () => {
|
|||||||
mockUseFlyerItems.mockReturnValue({
|
mockUseFlyerItems.mockReturnValue({
|
||||||
flyerItems: [],
|
flyerItems: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
// Clear local storage to prevent state from leaking between tests.
|
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
|
||||||
localStorage.clear();
|
|
||||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
|
|
||||||
Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true });
|
|
||||||
|
|
||||||
// Default mocks for API calls
|
// Default mocks for API calls
|
||||||
// Use mockImplementation to create a new Response object for each call,
|
// Use mockImplementation to create a new Response object for each call,
|
||||||
@@ -261,6 +252,7 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should render the main layout and header', async () => {
|
it('should render the main layout and header', async () => {
|
||||||
// Simulate the auth hook finishing its initial check
|
// Simulate the auth hook finishing its initial check
|
||||||
|
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
authStatus: 'SIGNED_OUT',
|
authStatus: 'SIGNED_OUT',
|
||||||
@@ -272,6 +264,7 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('app-guard-mock')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
|
||||||
// Check that the main layout and home page are rendered for the root path
|
// Check that the main layout and home page are rendered for the root path
|
||||||
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument();
|
||||||
@@ -364,193 +357,6 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Theme and Unit System Synchronization', () => {
|
|
||||||
it('should set dark mode based on user profile preferences', async () => {
|
|
||||||
console.log(
|
|
||||||
'[TEST DEBUG] Test Start: should set dark mode based on user profile preferences',
|
|
||||||
);
|
|
||||||
const profileWithDarkMode: UserProfile = createMockUserProfile({
|
|
||||||
user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }),
|
|
||||||
role: 'user',
|
|
||||||
points: 0,
|
|
||||||
preferences: { darkMode: true },
|
|
||||||
});
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: profileWithDarkMode,
|
|
||||||
authStatus: 'AUTHENTICATED',
|
|
||||||
isLoading: false,
|
|
||||||
login: vi.fn(),
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App');
|
|
||||||
renderApp();
|
|
||||||
// The useEffect that sets the theme is asynchronous. We must wait for the update.
|
|
||||||
await waitFor(() => {
|
|
||||||
console.log(
|
|
||||||
'[TEST DEBUG] Checking for dark class. Current classes:',
|
|
||||||
document.documentElement.className,
|
|
||||||
);
|
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set light mode based on user profile preferences', async () => {
|
|
||||||
const profileWithLightMode: UserProfile = createMockUserProfile({
|
|
||||||
user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }),
|
|
||||||
role: 'user',
|
|
||||||
points: 0,
|
|
||||||
preferences: { darkMode: false },
|
|
||||||
});
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: profileWithLightMode,
|
|
||||||
authStatus: 'AUTHENTICATED',
|
|
||||||
isLoading: false,
|
|
||||||
login: vi.fn(),
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
renderApp();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.documentElement).not.toHaveClass('dark');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set dark mode based on localStorage if profile has no preference', async () => {
|
|
||||||
localStorageMock.setItem('darkMode', 'true');
|
|
||||||
renderApp();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set dark mode based on system preference if no other setting exists', async () => {
|
|
||||||
matchMediaMock.mockImplementationOnce((query) => ({ matches: true, media: query }));
|
|
||||||
renderApp();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set unit system based on user profile preferences', async () => {
|
|
||||||
const profileWithMetric: UserProfile = createMockUserProfile({
|
|
||||||
user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }),
|
|
||||||
role: 'user',
|
|
||||||
points: 0,
|
|
||||||
preferences: { unitSystem: 'metric' },
|
|
||||||
});
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: profileWithMetric,
|
|
||||||
authStatus: 'AUTHENTICATED',
|
|
||||||
isLoading: false,
|
|
||||||
login: vi.fn(),
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
renderApp();
|
|
||||||
// The unit system is passed as a prop to Header, which is mocked.
|
|
||||||
// We can't directly see the result in the DOM easily, so we trust the state is set.
|
|
||||||
// A more integrated test would be needed to verify the Header receives the prop.
|
|
||||||
// For now, this test ensures the useEffect logic runs without crashing.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Token Handling', () => {
|
|
||||||
it('should call login when a googleAuthToken is in the URL', async () => {
|
|
||||||
console.log(
|
|
||||||
'[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL',
|
|
||||||
);
|
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: null,
|
|
||||||
authStatus: 'SIGNED_OUT',
|
|
||||||
isLoading: false,
|
|
||||||
login: mockLogin,
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
|
||||||
renderApp(['/?googleAuthToken=test-google-token']);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
|
||||||
expect(mockLogin).toHaveBeenCalledWith('test-google-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call login when a githubAuthToken is in the URL', async () => {
|
|
||||||
console.log(
|
|
||||||
'[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL',
|
|
||||||
);
|
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: null,
|
|
||||||
authStatus: 'SIGNED_OUT',
|
|
||||||
isLoading: false,
|
|
||||||
login: mockLogin,
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
|
||||||
renderApp(['/?githubAuthToken=test-github-token']);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
|
||||||
expect(mockLogin).toHaveBeenCalledWith('test-github-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error if login with a GitHub token fails', async () => {
|
|
||||||
console.log(
|
|
||||||
'[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails',
|
|
||||||
);
|
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: null,
|
|
||||||
authStatus: 'SIGNED_OUT',
|
|
||||||
isLoading: false,
|
|
||||||
login: mockLogin,
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
|
||||||
renderApp(['/?githubAuthToken=bad-token']);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
|
||||||
expect(mockLogin).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error if login with a token fails', async () => {
|
|
||||||
console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails');
|
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed'));
|
|
||||||
mockUseAuth.mockReturnValue({
|
|
||||||
userProfile: null,
|
|
||||||
authStatus: 'SIGNED_OUT',
|
|
||||||
isLoading: false,
|
|
||||||
login: mockLogin,
|
|
||||||
logout: vi.fn(),
|
|
||||||
updateProfile: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
|
||||||
renderApp(['/?googleAuthToken=bad-token']);
|
|
||||||
await waitFor(() => {
|
|
||||||
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
|
||||||
expect(mockLogin).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Flyer Selection from URL', () => {
|
describe('Flyer Selection from URL', () => {
|
||||||
it('should select a flyer when flyerId is present in the URL', async () => {
|
it('should select a flyer when flyerId is present in the URL', async () => {
|
||||||
renderApp(['/flyers/2']);
|
renderApp(['/flyers/2']);
|
||||||
@@ -583,23 +389,9 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Version and "What\'s New" Modal', () => {
|
|
||||||
it('should show the "What\'s New" modal if the app version is new', async () => {
|
|
||||||
// Mock the config module for this specific test
|
|
||||||
vi.mock('./config', () => ({
|
|
||||||
default: {
|
|
||||||
app: { version: '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', () => {
|
describe('Modal Interactions', () => {
|
||||||
it('should open and close the ProfileManager modal', async () => {
|
it('should open and close the ProfileManager modal', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should open and close the ProfileManager modal');
|
||||||
renderApp();
|
renderApp();
|
||||||
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
|
||||||
|
|
||||||
@@ -607,11 +399,13 @@ describe('App Component', () => {
|
|||||||
fireEvent.click(screen.getByText('Open Profile'));
|
fireEvent.click(screen.getByText('Open Profile'));
|
||||||
expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument();
|
expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument();
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] ProfileManager modal opened. Now closing...');
|
||||||
// Close modal
|
// Close modal
|
||||||
fireEvent.click(screen.getByText('Close Profile'));
|
fireEvent.click(screen.getByText('Close Profile'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
console.log('[TEST DEBUG] ProfileManager modal closed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
||||||
@@ -636,7 +430,7 @@ describe('App Component', () => {
|
|||||||
fireEvent.click(screen.getByText('Open Voice Assistant'));
|
fireEvent.click(screen.getByText('Open Voice Assistant'));
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
|
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
|
||||||
expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument();
|
expect(await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 })).toBeInTheDocument();
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
fireEvent.click(screen.getByText('Close Voice Assistant'));
|
fireEvent.click(screen.getByText('Close Voice Assistant'));
|
||||||
@@ -735,64 +529,6 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Version Display and What's New", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Also mock the config module to reflect this change
|
|
||||||
vi.mock('./config', () => ({
|
|
||||||
default: {
|
|
||||||
app: {
|
|
||||||
version: '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', () => {
|
describe('Profile and Login Handlers', () => {
|
||||||
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -841,12 +577,19 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
updateProfile: vi.fn(),
|
updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
// Mock the login function to simulate a successful login. Signature: (token, profile)
|
||||||
|
const mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => {
|
||||||
|
// Simulate fetching profile after login
|
||||||
|
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
|
||||||
|
const userProfileData: UserProfile = await profileResponse.json();
|
||||||
|
mockUseAuth.mockReturnValue({ ...mockUseAuth(), userProfile: userProfileData, authStatus: 'AUTHENTICATED' });
|
||||||
|
});
|
||||||
|
|
||||||
console.log('[TEST DEBUG] Rendering App');
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
console.log('[TEST DEBUG] Opening Profile');
|
console.log('[TEST DEBUG] Opening Profile');
|
||||||
fireEvent.click(screen.getByText('Open Profile'));
|
fireEvent.click(screen.getByText('Open Profile'));
|
||||||
const loginButton = await screen.findByText('Login');
|
const loginButton = await screen.findByRole('button', { name: 'Login' });
|
||||||
console.log('[TEST DEBUG] Clicking Login');
|
console.log('[TEST DEBUG] Clicking Login');
|
||||||
fireEvent.click(loginButton);
|
fireEvent.click(loginButton);
|
||||||
|
|
||||||
@@ -857,4 +600,33 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Version Display and What's New", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mock('./config', () => ({
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
version: '2.0.0',
|
||||||
|
commitMessage: 'A new version!',
|
||||||
|
commitUrl: 'http://example.com/commit/2.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the version number and commit link', () => {
|
||||||
|
renderApp();
|
||||||
|
const versionLink = screen.getByText(`Version: 2.0.0`);
|
||||||
|
expect(versionLink).toBeInTheDocument();
|
||||||
|
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
||||||
|
renderApp();
|
||||||
|
const openButton = await screen.findByTitle("Show what's new in this version");
|
||||||
|
fireEvent.click(openButton);
|
||||||
|
// The mock AppGuard now renders the modal when it's open
|
||||||
|
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
130
src/App.tsx
130
src/App.tsx
@@ -1,9 +1,9 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, useParams } from 'react-router-dom';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { Footer } from './components/Footer'; // Assuming this is where your Footer component will live
|
import { Footer } from './components/Footer';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { logger } from './services/logger.client';
|
import { logger } from './services/logger.client';
|
||||||
import type { Flyer, Profile, UserProfile } from './types';
|
import type { Flyer, Profile, UserProfile } from './types';
|
||||||
@@ -15,16 +15,17 @@ import { CorrectionsPage } from './pages/admin/CorrectionsPage';
|
|||||||
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
|
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
|
||||||
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
||||||
import { VoiceLabPage } from './pages/VoiceLabPage';
|
import { VoiceLabPage } from './pages/VoiceLabPage';
|
||||||
import { WhatsNewModal } from './components/WhatsNewModal';
|
|
||||||
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
|
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
|
||||||
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
|
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
import { useFlyers } from './hooks/useFlyers'; // Assuming useFlyers fetches all flyers
|
import { useFlyers } from './hooks/useFlyers';
|
||||||
import { useFlyerItems } from './hooks/useFlyerItems'; // Import the new hook for flyer items
|
import { useFlyerItems } from './hooks/useFlyerItems';
|
||||||
import { useModal } from './hooks/useModal';
|
import { useModal } from './hooks/useModal';
|
||||||
import { MainLayout } from './layouts/MainLayout';
|
import { MainLayout } from './layouts/MainLayout';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
|
import { AppGuard } from './components/AppGuard';
|
||||||
|
import { useAppInitialization } from './hooks/useAppInitialization';
|
||||||
|
|
||||||
// pdf.js worker configuration
|
// pdf.js worker configuration
|
||||||
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
|
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
|
||||||
@@ -35,15 +36,20 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|||||||
import.meta.url,
|
import.meta.url,
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
|
// Create a client
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { userProfile, authStatus, login, logout, updateProfile } = useAuth();
|
const { userProfile, authStatus, login, logout, updateProfile } = useAuth();
|
||||||
const { flyers } = useFlyers();
|
const { flyers } = useFlyers();
|
||||||
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
||||||
const { openModal, closeModal, isModalOpen } = useModal();
|
const { openModal, closeModal, isModalOpen } = useModal();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const params = useParams<{ flyerId?: string }>();
|
const params = useParams<{ flyerId?: string }>();
|
||||||
|
|
||||||
|
// This hook now handles initialization effects (OAuth, version check, theme)
|
||||||
|
// and returns the theme/unit state needed by other components.
|
||||||
|
const { isDarkMode, unitSystem } = useAppInitialization();
|
||||||
|
|
||||||
// Debugging: Log renders to identify infinite loops
|
// Debugging: Log renders to identify infinite loops
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
@@ -53,14 +59,11 @@ function App() {
|
|||||||
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
|
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
|
||||||
authStatus,
|
authStatus,
|
||||||
profileId: userProfile?.user.user_id,
|
profileId: userProfile?.user.user_id,
|
||||||
locationSearch: location.search,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
const { flyerItems } = useFlyerItems(selectedFlyer);
|
const { flyerItems } = useFlyerItems(selectedFlyer);
|
||||||
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
|
|
||||||
|
|
||||||
// Define modal handlers with useCallback at the top level to avoid Rules of Hooks violations
|
// Define modal handlers with useCallback at the top level to avoid Rules of Hooks violations
|
||||||
const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]);
|
const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]);
|
||||||
@@ -105,37 +108,6 @@ function App() {
|
|||||||
|
|
||||||
// --- State Synchronization and Error Handling ---
|
// --- State Synchronization and Error Handling ---
|
||||||
|
|
||||||
// Effect to set initial theme based on user profile, local storage, or system preference
|
|
||||||
useEffect(() => {
|
|
||||||
if (process.env.NODE_ENV === 'test')
|
|
||||||
console.log('[App] Effect: Theme Update', { profileId: userProfile?.user.user_id });
|
|
||||||
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
|
|
||||||
// Preference from DB
|
|
||||||
const dbDarkMode = userProfile.preferences.darkMode;
|
|
||||||
setIsDarkMode(dbDarkMode);
|
|
||||||
document.documentElement.classList.toggle('dark', dbDarkMode);
|
|
||||||
} else {
|
|
||||||
// Fallback to local storage or system preference
|
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark;
|
|
||||||
setIsDarkMode(initialDarkMode);
|
|
||||||
document.documentElement.classList.toggle('dark', initialDarkMode);
|
|
||||||
}
|
|
||||||
}, [userProfile?.preferences?.darkMode, userProfile?.user.user_id]);
|
|
||||||
|
|
||||||
// Effect to set initial unit system based on user profile or local storage
|
|
||||||
useEffect(() => {
|
|
||||||
if (userProfile && userProfile.preferences?.unitSystem) {
|
|
||||||
setUnitSystem(userProfile.preferences.unitSystem);
|
|
||||||
} else {
|
|
||||||
const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null;
|
|
||||||
if (savedSystem) {
|
|
||||||
setUnitSystem(savedSystem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
|
|
||||||
|
|
||||||
// This is the login handler that will be passed to the ProfileManager component.
|
// This is the login handler that will be passed to the ProfileManager component.
|
||||||
const handleLoginSuccess = useCallback(
|
const handleLoginSuccess = useCallback(
|
||||||
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
||||||
@@ -153,36 +125,6 @@ function App() {
|
|||||||
[login],
|
[login],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Effect to handle the token from Google OAuth redirect
|
|
||||||
useEffect(() => {
|
|
||||||
const urlParams = new URLSearchParams(location.search);
|
|
||||||
const googleToken = urlParams.get('googleAuthToken');
|
|
||||||
|
|
||||||
if (googleToken) {
|
|
||||||
logger.info('Received Google Auth token from URL. Authenticating...');
|
|
||||||
// The login flow is now handled by the useAuth hook. We just need to trigger it.
|
|
||||||
// We pass only the token; the AuthProvider will fetch the user profile.
|
|
||||||
login(googleToken).catch((err) =>
|
|
||||||
logger.error('Failed to log in with Google token', { error: err }),
|
|
||||||
);
|
|
||||||
// Clean the token from the URL
|
|
||||||
navigate(location.pathname, { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const githubToken = urlParams.get('githubAuthToken');
|
|
||||||
if (githubToken) {
|
|
||||||
logger.info('Received GitHub Auth token from URL. Authenticating...');
|
|
||||||
login(githubToken).catch((err) => {
|
|
||||||
logger.error('Failed to log in with GitHub token', { error: err });
|
|
||||||
// Optionally, redirect to a page with an error message
|
|
||||||
// navigate('/login?error=github_auth_failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean the token from the URL
|
|
||||||
navigate(location.pathname, { replace: true });
|
|
||||||
}
|
|
||||||
}, [login, location.search, navigate, location.pathname]);
|
|
||||||
|
|
||||||
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
|
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
|
||||||
setSelectedFlyer(flyer);
|
setSelectedFlyer(flyer);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -210,31 +152,10 @@ function App() {
|
|||||||
// Read the application version injected at build time.
|
// Read the application version injected at build time.
|
||||||
// This will only be available in the production build, not during local development.
|
// This will only be available in the production build, not during local development.
|
||||||
const appVersion = config.app.version;
|
const appVersion = config.app.version;
|
||||||
const commitMessage = config.app.commitMessage;
|
|
||||||
useEffect(() => {
|
|
||||||
if (appVersion) {
|
|
||||||
logger.info(`Application version: ${appVersion}`);
|
|
||||||
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
|
|
||||||
// If the current version is new, show the "What's New" modal.
|
|
||||||
if (appVersion !== lastSeenVersion) {
|
|
||||||
openModal('whatsNew');
|
|
||||||
localStorage.setItem('lastSeenVersion', appVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [appVersion]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
|
// AppGuard now handles the main page wrapper, theme styles, and "What's New" modal
|
||||||
{/* Toaster component for displaying notifications. It's placed at the top level. */}
|
<AppGuard>
|
||||||
<Toaster position="top-center" reverseOrder={false} />
|
|
||||||
{/* Add CSS variables for toast theming based on dark mode */}
|
|
||||||
<style>{`
|
|
||||||
:root {
|
|
||||||
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
|
|
||||||
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<Header
|
<Header
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
unitSystem={unitSystem}
|
unitSystem={unitSystem}
|
||||||
@@ -261,15 +182,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{appVersion && commitMessage && (
|
|
||||||
<WhatsNewModal
|
|
||||||
isOpen={isModalOpen('whatsNew')}
|
|
||||||
onClose={handleCloseWhatsNew}
|
|
||||||
version={appVersion}
|
|
||||||
commitMessage={commitMessage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFlyer && (
|
{selectedFlyer && (
|
||||||
<FlyerCorrectionTool
|
<FlyerCorrectionTool
|
||||||
isOpen={isModalOpen('correctionTool')}
|
isOpen={isModalOpen('correctionTool')}
|
||||||
@@ -341,8 +253,14 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</AppGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
const WrappedApp = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default WrappedApp;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
||||||
|
|
||||||
// Mock the icon to ensure it is rendered correctly
|
// Mock the icon to ensure it is rendered correctly
|
||||||
vi.mock('../../../components/icons/InformationCircleIcon', () => ({
|
vi.mock('./icons/InformationCircleIcon', () => ({
|
||||||
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
|
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg data-testid="info-icon" {...props} />
|
<svg data-testid="info-icon" {...props} />
|
||||||
),
|
),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/pages/admin/components/AnonymousUserBanner.tsx
|
// src/components/AnonymousUserBanner.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
|
import { InformationCircleIcon } from './icons/InformationCircleIcon';
|
||||||
|
|
||||||
interface AnonymousUserBannerProps {
|
interface AnonymousUserBannerProps {
|
||||||
/**
|
/**
|
||||||
93
src/components/AppGuard.test.tsx
Normal file
93
src/components/AppGuard.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// src/components/AppGuard.test.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { AppGuard } from './AppGuard';
|
||||||
|
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||||
|
import { useModal } from '../hooks/useModal';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../hooks/useAppInitialization');
|
||||||
|
vi.mock('../hooks/useModal');
|
||||||
|
vi.mock('./WhatsNewModal', () => ({
|
||||||
|
WhatsNewModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||||
|
isOpen ? <div data-testid="whats-new-modal-mock" /> : null,
|
||||||
|
}));
|
||||||
|
vi.mock('../config', () => ({
|
||||||
|
default: {
|
||||||
|
app: { version: '1.0.0', commitMessage: 'Test commit' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||||
|
const mockedUseModal = vi.mocked(useModal);
|
||||||
|
|
||||||
|
describe('AppGuard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Default mocks
|
||||||
|
mockedUseAppInitialization.mockReturnValue({
|
||||||
|
isDarkMode: false,
|
||||||
|
unitSystem: 'imperial',
|
||||||
|
});
|
||||||
|
mockedUseModal.mockReturnValue({
|
||||||
|
isModalOpen: vi.fn().mockReturnValue(false),
|
||||||
|
openModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render children', () => {
|
||||||
|
render(
|
||||||
|
<AppGuard>
|
||||||
|
<div>Child Content</div>
|
||||||
|
</AppGuard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Child Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render WhatsNewModal when it is open', () => {
|
||||||
|
mockedUseModal.mockReturnValue({
|
||||||
|
...mockedUseModal(),
|
||||||
|
isModalOpen: (modalId) => modalId === 'whatsNew',
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<AppGuard>
|
||||||
|
<div>Child</div>
|
||||||
|
</AppGuard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('whats-new-modal-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set dark mode styles for toaster', async () => {
|
||||||
|
mockedUseAppInitialization.mockReturnValue({
|
||||||
|
isDarkMode: true,
|
||||||
|
unitSystem: 'imperial',
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<AppGuard>
|
||||||
|
<div>Child</div>
|
||||||
|
</AppGuard>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
const styleTag = document.querySelector('style');
|
||||||
|
expect(styleTag).not.toBeNull();
|
||||||
|
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
|
||||||
|
expect(styleTag!.innerHTML).toContain('--toast-color: #F9FAFB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set light mode styles for toaster', async () => {
|
||||||
|
render(
|
||||||
|
<AppGuard>
|
||||||
|
<div>Child</div>
|
||||||
|
</AppGuard>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
const styleTag = document.querySelector('style');
|
||||||
|
expect(styleTag).not.toBeNull();
|
||||||
|
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
|
||||||
|
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/components/AppGuard.tsx
Normal file
47
src/components/AppGuard.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// src/components/AppGuard.tsx
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||||
|
import { useModal } from '../hooks/useModal';
|
||||||
|
import { WhatsNewModal } from './WhatsNewModal';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
interface AppGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppGuard: React.FC<AppGuardProps> = ({ children }) => {
|
||||||
|
// This hook handles OAuth tokens, version checks, and returns theme state.
|
||||||
|
const { isDarkMode } = useAppInitialization();
|
||||||
|
const { isModalOpen, closeModal } = useModal();
|
||||||
|
|
||||||
|
const handleCloseWhatsNew = useCallback(() => closeModal('whatsNew'), [closeModal]);
|
||||||
|
|
||||||
|
const appVersion = config.app.version;
|
||||||
|
const commitMessage = config.app.commitMessage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
|
||||||
|
{/* Toaster component for displaying notifications. It's placed at the top level. */}
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
{/* Add CSS variables for toast theming based on dark mode */}
|
||||||
|
<style>{`
|
||||||
|
:root {
|
||||||
|
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
|
||||||
|
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{appVersion && commitMessage && (
|
||||||
|
<WhatsNewModal
|
||||||
|
isOpen={isModalOpen('whatsNew')}
|
||||||
|
onClose={handleCloseWhatsNew}
|
||||||
|
version={appVersion}
|
||||||
|
commitMessage={commitMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,7 +44,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { 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.');
|
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.';
|
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
||||||
notifyError(msg);
|
notifyError(msg);
|
||||||
logger.error('Error during rescan:', { error: err });
|
logger.error({ error: err }, 'Error during rescan:');
|
||||||
} finally {
|
} finally {
|
||||||
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/pages/admin/components/PasswordInput.tsx
|
// src/components/PasswordInput.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { EyeIcon } from '../../../components/icons/EyeIcon';
|
import { EyeIcon } from './icons/EyeIcon';
|
||||||
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
|
import { EyeSlashIcon } from './icons/EyeSlashIcon';
|
||||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// src/pages/admin/components/PasswordStrengthIndicator.tsx
|
// src/pages/admin/components/PasswordStrengthIndicator.tsx
|
||||||
|
// src/components/PasswordStrengthIndicator.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
@@ -16,4 +16,4 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
202
src/db/seed.ts
202
src/db/seed.ts
@@ -6,10 +6,11 @@
|
|||||||
* DO NOT run this on a production database.
|
* DO NOT run this on a production database.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { CATEGORIES } from '../types';
|
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
@@ -20,81 +21,55 @@ const pool = new Pool({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Declare client outside the try block so it's accessible in the finally block.
|
let client: PoolClient | undefined;
|
||||||
let client;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client = await pool.connect();
|
client = await pool.connect();
|
||||||
logger.info('Connected to the database for seeding.');
|
logger.info('Connected to the database for seeding.');
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// 1. Clean the database
|
// 1. Clean the database by dropping and recreating the schema
|
||||||
logger.info('--- Wiping existing data... ---');
|
logger.info('--- Wiping and rebuilding schema... ---');
|
||||||
// Using TRUNCATE ... RESTART IDENTITY CASCADE is a powerful way to clean all tables
|
const dropScriptPath = path.resolve(process.cwd(), 'sql/drop_tables.sql');
|
||||||
// and reset auto-incrementing keys, while respecting foreign key relationships.
|
const dropSql = await fs.readFile(dropScriptPath, 'utf-8');
|
||||||
const tablesRes = await client.query(`
|
await client.query(dropSql);
|
||||||
SELECT tablename
|
logger.info('All tables dropped successfully.');
|
||||||
FROM pg_tables
|
|
||||||
WHERE schemaname = 'public'
|
|
||||||
-- Exclude PostGIS system tables from truncation to avoid permission errors.
|
|
||||||
AND tablename NOT IN ('spatial_ref_sys', 'geometry_columns')
|
|
||||||
`);
|
|
||||||
const tables = tablesRes.rows.map((row) => `"${row.tablename}"`).join(', ');
|
|
||||||
if (tables) {
|
|
||||||
await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
|
||||||
logger.info('All tables in public schema have been truncated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Seed Categories
|
const schemaScriptPath = path.resolve(process.cwd(), 'sql/master_schema_rollup.sql');
|
||||||
logger.info('--- Seeding Categories... ---');
|
const schemaSql = await fs.readFile(schemaScriptPath, 'utf-8');
|
||||||
const categoryQuery = `INSERT INTO public.categories (name) VALUES ${CATEGORIES.map((_, i) => `($${i + 1})`).join(', ')} RETURNING category_id, name`;
|
await client.query(schemaSql);
|
||||||
const seededCategories = (
|
logger.info(
|
||||||
await client.query<{ category_id: number; name: string }>(categoryQuery, CATEGORIES)
|
'Schema rebuilt and static data seeded successfully from master_schema_rollup.sql.',
|
||||||
).rows;
|
);
|
||||||
const categoryMap = new Map(seededCategories.map((c) => [c.name, c.category_id]));
|
|
||||||
logger.info(`Seeded ${seededCategories.length} categories.`);
|
|
||||||
|
|
||||||
// 3. Seed Stores
|
// 2. Seed Additional Stores (if any beyond what's in the rollup)
|
||||||
logger.info('--- Seeding Stores... ---');
|
logger.info('--- Seeding Stores... ---');
|
||||||
const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore'];
|
const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore'];
|
||||||
const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} RETURNING store_id, name`;
|
const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING RETURNING store_id, name`;
|
||||||
const seededStores = (
|
await client.query<{ store_id: number; name: string }>(storeQuery, stores);
|
||||||
await client.query<{ store_id: number; name: string }>(storeQuery, stores)
|
const allStores = (
|
||||||
|
await client.query<{ store_id: number; name: string }>(
|
||||||
|
'SELECT store_id, name FROM public.stores',
|
||||||
|
)
|
||||||
).rows;
|
).rows;
|
||||||
const storeMap = new Map(seededStores.map((s) => [s.name, s.store_id]));
|
const storeMap = new Map(
|
||||||
logger.info(`Seeded ${seededStores.length} stores.`);
|
allStores.map((s: { name: string; store_id: number }) => [s.name, s.store_id]),
|
||||||
|
|
||||||
// 4. Seed Master Grocery Items
|
|
||||||
logger.info('--- Seeding Master Grocery Items... ---');
|
|
||||||
const masterItems = [
|
|
||||||
{ name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' },
|
|
||||||
{ name: 'Ground Beef, Lean', category: 'Meat & Seafood' },
|
|
||||||
{ name: 'Avocado', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Bananas', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Broccoli', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Milk, 2%', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Eggs, Large', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Whole Wheat Bread', category: 'Bakery & Bread' },
|
|
||||||
{ name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' },
|
|
||||||
{ name: 'Canned Tomatoes, Diced', category: 'Canned Goods' },
|
|
||||||
{ name: 'Coca-Cola, 12-pack', category: 'Beverages' },
|
|
||||||
{ name: 'Frozen Pizza', category: 'Frozen Foods' },
|
|
||||||
{ name: 'Paper Towels', category: 'Household & Cleaning' },
|
|
||||||
];
|
|
||||||
const masterItemValues = masterItems
|
|
||||||
.map((item) => `('${item.name.replace(/'/g, "''")}', ${categoryMap.get(item.category)})`)
|
|
||||||
.join(', ');
|
|
||||||
const masterItemQuery = `INSERT INTO public.master_grocery_items (name, category_id) VALUES ${masterItemValues} RETURNING master_grocery_item_id, name`;
|
|
||||||
const seededMasterItems = (
|
|
||||||
await client.query<{ master_grocery_item_id: number; name: string }>(masterItemQuery)
|
|
||||||
).rows;
|
|
||||||
const masterItemMap = new Map(
|
|
||||||
seededMasterItems.map((item) => [item.name, item.master_grocery_item_id]),
|
|
||||||
);
|
);
|
||||||
logger.info(`Seeded ${seededMasterItems.length} master grocery items.`);
|
logger.info(`Seeded/verified ${allStores.length} total stores.`);
|
||||||
|
|
||||||
// 5. Seed Users & Profiles
|
// Fetch maps for items seeded by the master rollup script
|
||||||
|
const masterItemMap = new Map(
|
||||||
|
(
|
||||||
|
await client.query<{ master_grocery_item_id: number; name: string }>(
|
||||||
|
'SELECT master_grocery_item_id, name FROM public.master_grocery_items',
|
||||||
|
)
|
||||||
|
).rows.map((item: { name: string; master_grocery_item_id: number }) => [
|
||||||
|
item.name,
|
||||||
|
item.master_grocery_item_id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Seed Users & Profiles
|
||||||
logger.info('--- Seeding Users & Profiles... ---');
|
logger.info('--- Seeding Users & Profiles... ---');
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const adminPassHash = await bcrypt.hash('adminpass', saltRounds);
|
const adminPassHash = await bcrypt.hash('adminpass', saltRounds);
|
||||||
@@ -126,7 +101,7 @@ async function main() {
|
|||||||
const userId = userRes.rows[0].user_id;
|
const userId = userRes.rows[0].user_id;
|
||||||
logger.info('Seeded regular user (user@example.com / userpass)');
|
logger.info('Seeded regular user (user@example.com / userpass)');
|
||||||
|
|
||||||
// 6. Seed a Flyer
|
// 4. Seed a Flyer
|
||||||
logger.info('--- Seeding a Sample Flyer... ---');
|
logger.info('--- Seeding a Sample Flyer... ---');
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const validFrom = new Date(today);
|
const validFrom = new Date(today);
|
||||||
@@ -146,29 +121,29 @@ async function main() {
|
|||||||
const flyerId = flyerRes.rows[0].flyer_id;
|
const flyerId = flyerRes.rows[0].flyer_id;
|
||||||
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
|
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
|
||||||
|
|
||||||
// 7. Seed Flyer Items
|
// 5. Seed Flyer Items
|
||||||
logger.info('--- Seeding Flyer Items... ---');
|
logger.info('--- Seeding Flyer Items... ---');
|
||||||
const flyerItems = [
|
const flyerItems = [
|
||||||
{
|
{
|
||||||
name: 'Chicken Breast, Boneless Skinless',
|
name: 'chicken breast',
|
||||||
price_display: '$3.99 /lb',
|
price_display: '$3.99 /lb',
|
||||||
price_in_cents: 399,
|
price_in_cents: 399,
|
||||||
quantity: 'per lb',
|
quantity: 'per lb',
|
||||||
master_item_id: masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
master_item_id: masterItemMap.get('chicken breast'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Avocado',
|
name: 'avocados',
|
||||||
price_display: '2 for $5.00',
|
price_display: '2 for $5.00',
|
||||||
price_in_cents: 250,
|
price_in_cents: 250,
|
||||||
quantity: 'each',
|
quantity: 'each',
|
||||||
master_item_id: masterItemMap.get('Avocado'),
|
master_item_id: masterItemMap.get('avocados'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Coca-Cola 12-pack',
|
name: 'soda',
|
||||||
price_display: '$6.99',
|
price_display: '$6.99',
|
||||||
price_in_cents: 699,
|
price_in_cents: 699,
|
||||||
quantity: '12x355ml',
|
quantity: '12x355ml',
|
||||||
master_item_id: masterItemMap.get('Coca-Cola, 12-pack'),
|
master_item_id: masterItemMap.get('soda'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Unmatched Sample Item',
|
name: 'Unmatched Sample Item',
|
||||||
@@ -194,12 +169,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`);
|
logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`);
|
||||||
|
|
||||||
// 8. Seed Watched Items for the user
|
// 6. Seed Watched Items for the user
|
||||||
logger.info('--- Seeding Watched Items... ---');
|
logger.info('--- Seeding Watched Items... ---');
|
||||||
const watchedItemIds = [
|
const watchedItemIds = [
|
||||||
masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
masterItemMap.get('chicken breast'),
|
||||||
masterItemMap.get('Avocado'),
|
masterItemMap.get('avocados'),
|
||||||
masterItemMap.get('Ground Beef, Lean'),
|
masterItemMap.get('ground beef'),
|
||||||
];
|
];
|
||||||
for (const itemId of watchedItemIds) {
|
for (const itemId of watchedItemIds) {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
@@ -211,7 +186,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`);
|
logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`);
|
||||||
|
|
||||||
// 9. Seed a Shopping List
|
// 7. Seed a Shopping List
|
||||||
logger.info('--- Seeding a Shopping List... ---');
|
logger.info('--- Seeding a Shopping List... ---');
|
||||||
const listRes = await client.query<{ shopping_list_id: number }>(
|
const listRes = await client.query<{ shopping_list_id: number }>(
|
||||||
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id',
|
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id',
|
||||||
@@ -220,8 +195,8 @@ async function main() {
|
|||||||
const listId = listRes.rows[0].shopping_list_id;
|
const listId = listRes.rows[0].shopping_list_id;
|
||||||
|
|
||||||
const shoppingListItems = [
|
const shoppingListItems = [
|
||||||
{ master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 },
|
{ master_item_id: masterItemMap.get('milk'), quantity: 1 },
|
||||||
{ master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 },
|
{ master_item_id: masterItemMap.get('eggs'), quantity: 1 },
|
||||||
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -235,75 +210,6 @@ async function main() {
|
|||||||
`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`,
|
`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 10. Seed Brands
|
|
||||||
logger.info('--- Seeding Brands... ---');
|
|
||||||
const brands = [
|
|
||||||
'Coca-Cola',
|
|
||||||
'Kraft',
|
|
||||||
'Maple Leaf',
|
|
||||||
"Dempster's",
|
|
||||||
'No Name',
|
|
||||||
"President's Choice",
|
|
||||||
];
|
|
||||||
const brandQuery = `INSERT INTO public.brands (name) VALUES ${brands.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING`;
|
|
||||||
await client.query(brandQuery, brands);
|
|
||||||
logger.info(`Seeded ${brands.length} brands.`);
|
|
||||||
|
|
||||||
// Link store-specific brands
|
|
||||||
const loblawsId = storeMap.get('Loblaws');
|
|
||||||
if (loblawsId) {
|
|
||||||
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [
|
|
||||||
loblawsId,
|
|
||||||
'No Name',
|
|
||||||
"President's Choice",
|
|
||||||
]);
|
|
||||||
logger.info('Linked store brands to Loblaws.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. Seed Recipes
|
|
||||||
logger.info('--- Seeding Recipes... ---');
|
|
||||||
const recipes = [
|
|
||||||
{
|
|
||||||
name: 'Simple Chicken and Rice',
|
|
||||||
description: 'A quick and healthy weeknight meal.',
|
|
||||||
instructions: '1. Cook rice. 2. Cook chicken. 3. Combine.',
|
|
||||||
prep: 10,
|
|
||||||
cook: 20,
|
|
||||||
servings: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Classic Spaghetti Bolognese',
|
|
||||||
description: 'A rich and hearty meat sauce.',
|
|
||||||
instructions: '1. Brown beef. 2. Add sauce. 3. Simmer.',
|
|
||||||
prep: 15,
|
|
||||||
cook: 45,
|
|
||||||
servings: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vegetable Stir-fry',
|
|
||||||
description: 'A fast and flavorful vegetarian meal.',
|
|
||||||
instructions: '1. Chop veggies. 2. Stir-fry. 3. Add sauce.',
|
|
||||||
prep: 10,
|
|
||||||
cook: 10,
|
|
||||||
servings: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const recipe of recipes) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'public') ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING`,
|
|
||||||
[
|
|
||||||
recipe.name,
|
|
||||||
recipe.description,
|
|
||||||
recipe.instructions,
|
|
||||||
recipe.prep,
|
|
||||||
recipe.cook,
|
|
||||||
recipe.servings,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logger.info(`Seeded ${recipes.length} recipes.`);
|
|
||||||
|
|
||||||
// --- SEED SCRIPT DEBUG LOGGING ---
|
// --- SEED SCRIPT DEBUG LOGGING ---
|
||||||
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
||||||
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
||||||
|
|||||||
@@ -38,8 +38,26 @@ vi.mock('recharts', () => ({
|
|||||||
),
|
),
|
||||||
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
||||||
XAxis: () => <div data-testid="x-axis" />,
|
XAxis: () => <div data-testid="x-axis" />,
|
||||||
YAxis: () => <div data-testid="y-axis" />,
|
YAxis: ({ tickFormatter, domain }: any) => {
|
||||||
Tooltip: () => <div data-testid="tooltip" />,
|
// 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" />,
|
Legend: () => <div data-testid="legend" />,
|
||||||
// Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
|
// Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
|
||||||
Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
|
Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
|
||||||
@@ -301,4 +319,66 @@ describe('PriceHistoryChart', () => {
|
|||||||
expect(chartData).toHaveLength(2);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/PriceHistoryChart.tsx
|
// src/features/charts/PriceHistoryChart.tsx
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -142,7 +142,7 @@ export const PriceHistoryChart: React.FC = () => {
|
|||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isLoading || isLoadingUserData) {
|
if (isLoading || isLoadingUserData) {
|
||||||
return (
|
return (
|
||||||
<div role="status" className="flex justify-center items-center h-full min-h-[200px]">
|
<div role="status" className="flex justify-center items-center h-full min-h-50]">
|
||||||
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
|
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -198,7 +198,12 @@ export const PriceHistoryChart: React.FC = () => {
|
|||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: '#F9FAFB' }}
|
labelStyle={{ color: '#F9FAFB' }}
|
||||||
formatter={(value: number) => `$${(value / 100).toFixed(2)}`}
|
formatter={(value: number | undefined) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return [`$${(value / 100).toFixed(2)}`];
|
||||||
|
}
|
||||||
|
return [null];
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||||
{availableItems.map((item, index) => (
|
{availableItems.map((item, index) => (
|
||||||
|
|||||||
@@ -406,6 +406,74 @@ describe('ExtractedDataTable', () => {
|
|||||||
render(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />);
|
render(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />);
|
||||||
expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument();
|
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', () => {
|
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
|
// 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);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FlyerUploader } from './FlyerUploader';
|
|||||||
import * as aiApiClientModule from '../../services/aiApiClient';
|
import * as aiApiClientModule from '../../services/aiApiClient';
|
||||||
import * as checksumModule from '../../utils/checksum';
|
import * as checksumModule from '../../utils/checksum';
|
||||||
import { useNavigate, MemoryRouter } from 'react-router-dom';
|
import { useNavigate, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('../../services/aiApiClient');
|
vi.mock('../../services/aiApiClient');
|
||||||
@@ -39,10 +40,19 @@ const mockedChecksumModule = checksumModule as unknown as {
|
|||||||
|
|
||||||
const renderComponent = (onProcessingComplete = vi.fn()) => {
|
const renderComponent = (onProcessingComplete = vi.fn()) => {
|
||||||
console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.');
|
console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.');
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FlyerUploader onProcessingComplete={onProcessingComplete} />
|
<MemoryRouter>
|
||||||
</MemoryRouter>,
|
<FlyerUploader onProcessingComplete={onProcessingComplete} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,10 +60,11 @@ describe('FlyerUploader', () => {
|
|||||||
const navigateSpy = vi.fn();
|
const navigateSpy = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
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}"`);
|
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.
|
vi.resetAllMocks(); // Resets mock implementations AND call history.
|
||||||
console.log('--- [TEST LOG] ---: Mocks reset.');
|
console.log('--- [TEST LOG] ---: Mocks reset.');
|
||||||
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
|
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
|
||||||
@@ -61,7 +72,6 @@ describe('FlyerUploader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
|
||||||
console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`);
|
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 () => {
|
it('should handle file upload and start polling', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
|
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||||
);
|
state: 'active',
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
progress: { message: 'Checking...' },
|
||||||
new Response(JSON.stringify({ state: 'active', progress: { message: 'Checking...' } })),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.');
|
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.');
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -105,21 +114,18 @@ describe('FlyerUploader', () => {
|
|||||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
||||||
console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');
|
console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');
|
||||||
|
|
||||||
await act(async () => {
|
// With real timers, we now wait for the polling interval to elapse.
|
||||||
console.log('--- [TEST LOG] ---: 8a. vi.advanceTimersByTime(3000) starting...');
|
|
||||||
vi.advanceTimersByTime(3000);
|
|
||||||
console.log('--- [TEST LOG] ---: 8b. vi.advanceTimersByTime(3000) complete.');
|
|
||||||
});
|
|
||||||
console.log(
|
console.log(
|
||||||
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
|
`--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// The polling interval is 3s, so we wait for a bit longer.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
|
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
|
||||||
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
|
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
|
||||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
||||||
});
|
}, { timeout: 4000 });
|
||||||
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
|
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
|
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 () => {
|
it('should handle file upload via drag and drop', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-dnd' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-dnd' }), { status: 200 }),
|
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||||
);
|
state: 'active',
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
progress: { message: 'Dropped...' },
|
||||||
new Response(JSON.stringify({ state: 'active', progress: { message: 'Dropped...' } })),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.');
|
console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.');
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -159,16 +164,10 @@ describe('FlyerUploader', () => {
|
|||||||
it('should poll for status, complete successfully, and redirect', async () => {
|
it('should poll for status, complete successfully, and redirect', async () => {
|
||||||
const onProcessingComplete = vi.fn();
|
const onProcessingComplete = vi.fn();
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 }),
|
|
||||||
);
|
|
||||||
mockedAiApiClient.getJobStatus
|
mockedAiApiClient.getJobStatus
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Analyzing...' } })
|
||||||
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
|
.mockResolvedValueOnce({ state: 'completed', returnValue: { flyerId: 42 } });
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
new Response(JSON.stringify({ state: 'completed', returnValue: { flyerId: 42 } })),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.');
|
console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.');
|
||||||
renderComponent(onProcessingComplete);
|
renderComponent(onProcessingComplete);
|
||||||
@@ -189,24 +188,21 @@ describe('FlyerUploader', () => {
|
|||||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
||||||
console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.');
|
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 {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
|
'--- [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(() => {
|
await waitFor(() => {
|
||||||
console.log(
|
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(
|
expect(
|
||||||
screen.getByText('Processing complete! Redirecting to flyer 42...'),
|
screen.getByText('Processing complete! Redirecting to flyer 42...'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
}, { timeout: 4000 });
|
||||||
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
|
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
|
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
|
||||||
@@ -216,12 +212,9 @@ describe('FlyerUploader', () => {
|
|||||||
}
|
}
|
||||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
await act(async () => {
|
// Wait for the redirect timer (1.5s in component) to fire.
|
||||||
console.log(`--- [TEST LOG] ---: 10. Advancing timers by 2000ms for redirect...`);
|
await act(() => new Promise((r) => setTimeout(r, 2000)));
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
});
|
|
||||||
console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
|
console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
|
||||||
|
|
||||||
expect(onProcessingComplete).toHaveBeenCalled();
|
expect(onProcessingComplete).toHaveBeenCalled();
|
||||||
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
|
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
|
||||||
console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
|
console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
|
||||||
@@ -229,12 +222,15 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
it('should handle a failed job', async () => {
|
it('should handle a failed job', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-fail' }), { status: 200 }),
|
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||||
);
|
state: 'failed',
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
progress: {
|
||||||
new Response(JSON.stringify({ state: 'failed', failedReason: 'AI model exploded' })),
|
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.');
|
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -260,11 +256,80 @@ describe('FlyerUploader', () => {
|
|||||||
console.log('--- [TEST LOG] ---: 6. "Upload Another" button confirmed.');
|
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 () => {
|
it('should handle a duplicate flyer error (409)', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
// The API client now throws a structured error for non-2xx responses.
|
||||||
new Response(JSON.stringify({ flyerId: 99, message: 'Duplicate' }), { status: 409 }),
|
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
|
||||||
);
|
status: 409,
|
||||||
|
body: { flyerId: 99, message: 'Duplicate' },
|
||||||
|
});
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -278,7 +343,7 @@ describe('FlyerUploader', () => {
|
|||||||
try {
|
try {
|
||||||
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
|
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
|
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -295,12 +360,11 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
it('should allow the user to stop watching progress', async () => {
|
it('should allow the user to stop watching progress', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-stop' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-stop' }), { status: 200 }),
|
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||||
);
|
state: 'active',
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
progress: { message: 'Analyzing...' },
|
||||||
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })),
|
} as any);
|
||||||
);
|
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -362,9 +426,11 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
it('should handle a generic network error during upload', async () => {
|
it('should handle a generic network error during upload', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(
|
// Simulate a structured error from the API client
|
||||||
new Error('Network Error During Upload'),
|
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
|
||||||
);
|
status: 500,
|
||||||
|
body: { message: 'Network Error During Upload' },
|
||||||
|
});
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||||
const input = screen.getByLabelText(/click to select a file/i);
|
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 () => {
|
it('should handle a generic network error during polling', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-poll-fail' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-poll-fail' }), { status: 200 }),
|
|
||||||
);
|
|
||||||
mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error'));
|
mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error'));
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -392,17 +456,15 @@ describe('FlyerUploader', () => {
|
|||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
|
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.');
|
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a completed job with a missing flyerId', async () => {
|
it('should handle a completed job with a missing flyerId', async () => {
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-no-flyerid' });
|
||||||
new Response(JSON.stringify({ jobId: 'job-no-flyerid' }), { status: 200 }),
|
|
||||||
);
|
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
||||||
new Response(JSON.stringify({ state: 'completed', returnValue: {} })), // No flyerId
|
{ state: 'completed', returnValue: {} }, // No flyerId
|
||||||
);
|
);
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
@@ -419,6 +481,29 @@ describe('FlyerUploader', () => {
|
|||||||
console.log('--- [TEST LOG] ---: 4. Assertions passed.');
|
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', () => {
|
it('should do nothing if the file input is cancelled', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const input = screen.getByLabelText(/click to select a file/i);
|
const input = screen.getByLabelText(/click to select a file/i);
|
||||||
|
|||||||
@@ -1,213 +1,62 @@
|
|||||||
// src/features/flyer/FlyerUploader.tsx
|
// 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 { useNavigate, Link } from 'react-router-dom';
|
||||||
import { uploadAndProcessFlyer, getJobStatus } from '../../services/aiApiClient';
|
|
||||||
import { generateFileChecksum } from '../../utils/checksum';
|
|
||||||
import { logger } from '../../services/logger.client';
|
import { logger } from '../../services/logger.client';
|
||||||
import { ProcessingStatus } from './ProcessingStatus';
|
import { ProcessingStatus } from './ProcessingStatus';
|
||||||
import type { ProcessingStage } from '../../types';
|
|
||||||
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
|
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
|
||||||
|
import { useFlyerUploader } from '../../hooks/useFlyerUploader';
|
||||||
type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
|
|
||||||
|
|
||||||
interface FlyerUploaderProps {
|
interface FlyerUploaderProps {
|
||||||
onProcessingComplete: () => void;
|
onProcessingComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComplete }) => {
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const pollingTimeoutRef = useRef<number | null>(null);
|
const {
|
||||||
|
processingState,
|
||||||
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
|
statusMessage,
|
||||||
const [estimatedTime, setEstimatedTime] = useState(0);
|
errorMessage,
|
||||||
const [currentFile, setCurrentFile] = useState<string | null>(null);
|
duplicateFlyerId,
|
||||||
|
processingStages,
|
||||||
// DEBUG: Log component mount and unmount
|
estimatedTime,
|
||||||
useEffect(() => {
|
currentFile,
|
||||||
console.debug('[DEBUG] FlyerUploader: Component did mount.');
|
flyerId,
|
||||||
return () => {
|
upload,
|
||||||
console.debug('[DEBUG] FlyerUploader: Component will unmount.');
|
resetUploaderState,
|
||||||
};
|
} = useFlyerUploader();
|
||||||
}, []);
|
|
||||||
|
|
||||||
// DEBUG: Log state changes
|
|
||||||
useEffect(() => {
|
|
||||||
console.debug(`[DEBUG] FlyerUploader: processingState changed to -> ${processingState}`);
|
|
||||||
}, [processingState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
|
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
|
||||||
}, [statusMessage]);
|
}, [statusMessage]);
|
||||||
|
|
||||||
|
// Handle completion and navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.debug(`[DEBUG] Polling Effect Triggered: state=${processingState}, jobId=${jobId}`);
|
if (processingState === 'completed' && flyerId) {
|
||||||
if (processingState !== 'polling' || !jobId) {
|
onProcessingComplete();
|
||||||
if (pollingTimeoutRef.current) {
|
// Small delay to show the "Complete" state before redirecting
|
||||||
console.debug(
|
const timer = setTimeout(() => {
|
||||||
`[DEBUG] Polling Effect: Clearing timeout ID ${pollingTimeoutRef.current} because state is not 'polling' or no jobId exists.`,
|
navigate(`/flyers/${flyerId}`);
|
||||||
);
|
}, 1500);
|
||||||
clearTimeout(pollingTimeoutRef.current);
|
return () => clearTimeout(timer);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [processingState, flyerId, onProcessingComplete, navigate]);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
console.debug('[DEBUG] handleFileChange(): File input changed.');
|
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
processFile(file);
|
upload(file);
|
||||||
}
|
}
|
||||||
event.target.value = '';
|
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(
|
const onFilesDropped = useCallback(
|
||||||
(files: FileList) => {
|
(files: FileList) => {
|
||||||
console.debug('[DEBUG] onFilesDropped(): Files were dropped.');
|
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
processFile(files[0]);
|
upload(files[0]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[processFile],
|
[upload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isProcessing = processingState === 'uploading' || processingState === 'polling';
|
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-brand-light/50 dark:bg-brand-dark/20'
|
||||||
: 'bg-gray-50/50 dark:bg-gray-800/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') {
|
if (isProcessing || processingState === 'completed' || processingState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
@@ -235,19 +79,26 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
|
|||||||
currentFile={currentFile}
|
currentFile={currentFile}
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
{/* Display the current status message to the user and the test runner */}
|
{/* Display status message if not completed (completed has its own redirect logic) */}
|
||||||
{statusMessage && (
|
{statusMessage && processingState !== 'completed' && (
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2 italic animate-pulse">
|
<p className="text-gray-600 dark:text-gray-400 mt-2 italic animate-pulse">
|
||||||
{statusMessage}
|
{statusMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{processingState === 'completed' && (
|
||||||
|
<p className="text-green-600 dark:text-green-400 mt-2 font-bold">
|
||||||
|
Processing complete! Redirecting to flyer {flyerId}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md">
|
<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>
|
<p>{errorMessage}</p>
|
||||||
{duplicateFlyerId && (
|
{duplicateFlyerId && (
|
||||||
<p>
|
<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}
|
Flyer #{duplicateFlyerId}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
174
src/hooks/useAppInitialization.test.tsx
Normal file
174
src/hooks/useAppInitialization.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/hooks/useAppInitialization.ts
Normal file
88
src/hooks/useAppInitialization.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -6,24 +6,28 @@ import { useAuth } from './useAuth';
|
|||||||
import { AuthProvider } from '../providers/AuthProvider';
|
import { AuthProvider } from '../providers/AuthProvider';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
|
import * as tokenStorage from '../services/tokenStorage';
|
||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock('../services/apiClient', () => ({
|
vi.mock('../services/apiClient', () => ({
|
||||||
// Mock other functions if needed
|
// Mock other functions if needed
|
||||||
getAuthenticatedUserProfile: vi.fn(),
|
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', () => ({
|
vi.mock('../services/logger.client', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn((...args) => console.log('[AUTH-INFO]', ...args)),
|
info: vi.fn(),
|
||||||
warn: vi.fn((...args) => console.warn('[AUTH-WARN]', ...args)),
|
warn: vi.fn(),
|
||||||
error: vi.fn((...args) => console.error('[AUTH-ERROR]', ...args)),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
const mockedTokenStorage = vi.mocked(tokenStorage);
|
||||||
|
|
||||||
const mockProfile: UserProfile = createMockUserProfile({
|
const mockProfile: UserProfile = createMockUserProfile({
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
@@ -36,26 +40,9 @@ const mockProfile: UserProfile = createMockUserProfile({
|
|||||||
const wrapper = ({ children }: { children: ReactNode }) => <AuthProvider>{children}</AuthProvider>;
|
const wrapper = ({ children }: { children: ReactNode }) => <AuthProvider>{children}</AuthProvider>;
|
||||||
|
|
||||||
describe('useAuth Hook and 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(() => {
|
beforeEach(() => {
|
||||||
// Reset mocks and storage before each test
|
// Reset mocks and storage before each test
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
storage = {};
|
|
||||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -85,7 +72,8 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Initial Auth Check (useEffect)', () => {
|
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 });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -97,7 +85,7 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sets state to AUTHENTICATED if a valid token is found', async () => {
|
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({
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -121,7 +109,7 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sets state to SIGNED_OUT and removes token if validation fails', async () => {
|
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'));
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid token'));
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
@@ -132,13 +120,40 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
|
|
||||||
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
||||||
expect(result.current.userProfile).toBeNull();
|
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', () => {
|
describe('login function', () => {
|
||||||
// This was the failing test
|
// This was the failing test
|
||||||
it('sets token, fetches profile, and updates state on successful login', async () => {
|
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 ---
|
// --- FIX ---
|
||||||
// The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object,
|
// 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
|
// 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);
|
console.log('[TEST-DEBUG] State immediately after login `act` call:', result.current);
|
||||||
|
|
||||||
// 3. Assertions
|
// 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
|
// 4. We must wait for the state update inside the hook to propagate
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -202,16 +217,44 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should trigger the logout flow
|
// 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.authStatus).toBe('SIGNED_OUT'); // This was a duplicate, fixed.
|
||||||
expect(result.current.userProfile).toBeNull();
|
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', () => {
|
describe('logout function', () => {
|
||||||
it('removes token and resets auth state', async () => {
|
it('removes token and resets auth state', async () => {
|
||||||
// Start in a logged-in state
|
// Start in a logged-in state by mocking the token storage
|
||||||
localStorageMock.setItem('authToken', 'valid-token');
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -227,16 +270,15 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
result.current.logout();
|
result.current.logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('authToken');
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
||||||
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
||||||
expect(result.current.userProfile).toBeNull();
|
expect(result.current.userProfile).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateProfile function', () => {
|
describe('updateProfile function', () => {
|
||||||
it('merges new data into the existing profile state', async () => {
|
it('merges new data into the existing profile state', async () => { // Start in a logged-in state
|
||||||
// Start in a logged-in state
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
||||||
localStorageMock.setItem('authToken', 'valid-token');
|
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -264,6 +306,10 @@ describe('useAuth Hook and AuthProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not update profile if user is not authenticated', async () => {
|
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 });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
// Wait for initial check to complete
|
// Wait for initial check to complete
|
||||||
|
|||||||
136
src/hooks/useFlyerUploader.test.tsx
Normal file
136
src/hooks/useFlyerUploader.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
127
src/hooks/useFlyerUploader.ts
Normal file
127
src/hooks/useFlyerUploader.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
This single directive replaces @tailwind base, components, and utilities.
|
This single directive replaces @tailwind base, components, and utilities.
|
||||||
It is the new entry point for all of Tailwind's generated CSS.
|
It is the new entry point for all of Tailwind's generated CSS.
|
||||||
*/
|
*/
|
||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
This is the new v4 directive that tells the @tailwindcss/postcss plugin
|
This is the new v4 directive that tells the @tailwindcss/postcss plugin
|
||||||
@@ -12,4 +12,3 @@
|
|||||||
Since tailwind.config.js is in the root and this is in src/, the path is '../tailwind.config.js'.
|
Since tailwind.config.js is in the root and this is in src/, the path is '../tailwind.config.js'.
|
||||||
*/
|
*/
|
||||||
@config '../tailwind.config.js';
|
@config '../tailwind.config.js';
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ import './index.css';
|
|||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Could not find root element to mount to");
|
throw new Error('Could not find root element to mount to');
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<App />
|
<App />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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" />,
|
AnonymousUserBanner: () => <div data-testid="anonymous-banner" />,
|
||||||
}));
|
}));
|
||||||
vi.mock('../components/ErrorDisplay', () => ({
|
vi.mock('../components/ErrorDisplay', () => ({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { PriceChart } from '../features/charts/PriceChart';
|
|||||||
import { PriceHistoryChart } from '../features/charts/PriceHistoryChart';
|
import { PriceHistoryChart } from '../features/charts/PriceHistoryChart';
|
||||||
import Leaderboard from '../components/Leaderboard';
|
import Leaderboard from '../components/Leaderboard';
|
||||||
import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog';
|
import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog';
|
||||||
import { AnonymousUserBanner } from '../pages/admin/components/AnonymousUserBanner';
|
import { AnonymousUserBanner } from '../components/AnonymousUserBanner';
|
||||||
import { ErrorDisplay } from '../components/ErrorDisplay';
|
import { ErrorDisplay } from '../components/ErrorDisplay';
|
||||||
|
|
||||||
export interface MainLayoutProps {
|
export interface MainLayoutProps {
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ import toast from 'react-hot-toast';
|
|||||||
// This intermediate file allows us to mock 'src/lib/toast' reliably in tests
|
// This intermediate file allows us to mock 'src/lib/toast' reliably in tests
|
||||||
// without wrestling with the internal structure of the 'react-hot-toast' package.
|
// without wrestling with the internal structure of the 'react-hot-toast' package.
|
||||||
export * from 'react-hot-toast';
|
export * from 'react-hot-toast';
|
||||||
export default toast;
|
export default toast;
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ import type { Logger } from 'pino';
|
|||||||
// Create a mock logger that we can inject into requests and assert against.
|
// 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
|
// 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.
|
// Logger type is satisfied by casting, which is a common and clean testing practice.
|
||||||
const mockLogger = {
|
const { mockLogger } = vi.hoisted(() => {
|
||||||
error: vi.fn(),
|
const mockLogger = {
|
||||||
warn: vi.fn(),
|
error: vi.fn(),
|
||||||
info: vi.fn(),
|
warn: vi.fn(),
|
||||||
debug: vi.fn(),
|
info: vi.fn(),
|
||||||
fatal: vi.fn(),
|
debug: vi.fn(),
|
||||||
trace: vi.fn(),
|
fatal: vi.fn(),
|
||||||
silent: vi.fn(),
|
trace: vi.fn(),
|
||||||
child: vi.fn().mockReturnThis(),
|
silent: vi.fn(),
|
||||||
} as unknown as Logger;
|
child: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
return { mockLogger };
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the global logger as a fallback, though our tests will focus on req.log
|
// Mock the global logger as a fallback, though our tests will focus on req.log
|
||||||
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
|
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
|
||||||
@@ -37,7 +40,7 @@ const app = express();
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
// Add a middleware to inject our mock logger into each request as `req.log`
|
// Add a middleware to inject our mock logger into each request as `req.log`
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
req.log = mockLogger;
|
req.log = mockLogger as unknown as Logger;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +109,10 @@ describe('errorHandler Middleware', () => {
|
|||||||
it('should return a generic 500 error for a standard Error object', async () => {
|
it('should return a generic 500 error for a standard Error object', async () => {
|
||||||
const response = await supertest(app).get('/generic-error');
|
const response = await supertest(app).get('/generic-error');
|
||||||
expect(response.status).toBe(500);
|
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(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
@@ -116,7 +122,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
expect.any(Error),
|
expect.any(Error),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -130,15 +136,11 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
validationErrors: undefined,
|
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
},
|
},
|
||||||
'Client Error on GET /http-error-404: Resource not found',
|
'Client Error on GET /http-error-404: Resource not found',
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
|
||||||
expect.any(Error),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a NotFoundError with a 404 status', async () => {
|
it('should handle a NotFoundError with a 404 status', async () => {
|
||||||
@@ -150,15 +152,11 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
err: expect.any(NotFoundError),
|
err: expect.any(NotFoundError),
|
||||||
validationErrors: undefined,
|
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
},
|
},
|
||||||
'Client Error on GET /not-found-error: Specific resource missing',
|
'Client Error on GET /not-found-error: Specific resource missing',
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
|
||||||
expect.any(NotFoundError),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a ForeignKeyConstraintError with a 400 status and the specific error message', async () => {
|
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(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
err: expect.any(ForeignKeyConstraintError),
|
err: expect.any(ForeignKeyConstraintError),
|
||||||
validationErrors: undefined,
|
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
},
|
},
|
||||||
'Client Error on GET /fk-error: The referenced item does not exist.',
|
'Client Error on GET /fk-error: The referenced item does not exist.',
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
|
||||||
expect.any(ForeignKeyConstraintError),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a UniqueConstraintError with a 409 status and the specific error message', async () => {
|
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(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
err: expect.any(UniqueConstraintError),
|
err: expect.any(UniqueConstraintError),
|
||||||
validationErrors: undefined,
|
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
},
|
},
|
||||||
'Client Error on GET /unique-error: This item already exists.',
|
'Client Error on GET /unique-error: This item already exists.',
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
|
||||||
expect.any(UniqueConstraintError),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a ValidationError with a 400 status and include the validation errors array', async () => {
|
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',
|
'Client Error on GET /validation-error: Input validation failed',
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
|
||||||
expect.any(ValidationError),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
|
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
|
||||||
const response = await supertest(app).get('/db-error-500');
|
const response = await supertest(app).get('/db-error-500');
|
||||||
|
|
||||||
expect(response.status).toBe(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(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(DatabaseError),
|
err: expect.any(DatabaseError),
|
||||||
@@ -239,7 +229,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
expect.any(DatabaseError),
|
expect.any(DatabaseError),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -249,8 +239,14 @@ describe('errorHandler Middleware', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
expect(response.body).toEqual({ message: 'Invalid Token' });
|
||||||
// 4xx errors log as warn
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
expect(mockLogger.warn).toHaveBeenCalled();
|
{
|
||||||
|
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 () => {
|
it('should handle an UnauthorizedError with explicit status', async () => {
|
||||||
@@ -258,6 +254,14 @@ describe('errorHandler Middleware', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
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', () => {
|
it('should call next(err) if headers have already been sent', () => {
|
||||||
@@ -302,6 +306,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect(response.body.message).toMatch(
|
expect(response.body.message).toMatch(
|
||||||
/An unexpected server error occurred. Please reference error ID: \w+/,
|
/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 () => {
|
it('should return the actual error message for client errors (4xx) in production', async () => {
|
||||||
|
|||||||
@@ -1,94 +1,101 @@
|
|||||||
// src/middleware/errorHandler.ts
|
// src/middleware/errorHandler.ts
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
|
||||||
UniqueConstraintError,
|
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
UniqueConstraintError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
ValidationIssue,
|
|
||||||
} from '../services/db/errors.db';
|
} from '../services/db/errors.db';
|
||||||
import crypto from 'crypto';
|
import { logger } from '../services/logger.server';
|
||||||
|
|
||||||
interface HttpError extends Error {
|
/**
|
||||||
status?: number;
|
* 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.
|
||||||
|
*
|
||||||
export const errorHandler = (err: HttpError, req: Request, res: Response, next: NextFunction) => {
|
* It standardizes error responses and ensures consistent logging.
|
||||||
// If the response headers have already been sent, we must delegate to the default Express error handler.
|
*/
|
||||||
|
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) {
|
if (res.headersSent) {
|
||||||
return next(err);
|
return next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pino-http middleware guarantees that `req.log` will be available.
|
// Use the request-scoped logger if available, otherwise fall back to the global logger.
|
||||||
const log = req.log;
|
const log = req.log || logger;
|
||||||
|
|
||||||
// --- 1. Determine Final Status Code and Message ---
|
// --- Handle Zod Validation Errors (from validateRequest middleware) ---
|
||||||
let statusCode = err.status ?? 500;
|
if (err instanceof ZodError) {
|
||||||
const message = err.message;
|
const statusCode = 400;
|
||||||
let validationIssues: ValidationIssue[] | undefined;
|
const message = 'The request data is invalid.';
|
||||||
let errorId: string | undefined;
|
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}`);
|
||||||
// Refine the status code for known error types. Check for most specific types first.
|
return res.status(statusCode).json({ message, errors });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Log Based on Final Status Code ---
|
// --- Handle Custom Operational Errors ---
|
||||||
// Log the full error details for debugging, especially for server errors.
|
if (err instanceof NotFoundError) {
|
||||||
if (statusCode >= 500) {
|
const statusCode = 404;
|
||||||
errorId = crypto.randomBytes(4).toString('hex');
|
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||||
// The request-scoped logger already contains user, IP, and request_id.
|
return res.status(statusCode).json({ message: err.message });
|
||||||
// We add the full error and the request object itself.
|
}
|
||||||
// Pino's `redact` config will automatically sanitize sensitive fields in `req`.
|
|
||||||
log.error(
|
if (err instanceof ValidationError) {
|
||||||
{
|
const statusCode = 400;
|
||||||
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.
|
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{ err, validationErrors: err.validationErrors, statusCode },
|
||||||
err,
|
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
||||||
validationErrors: validationIssues, // Add validation issues to the log object
|
|
||||||
statusCode,
|
|
||||||
},
|
|
||||||
`Client Error on ${req.method} ${req.path}: ${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') {
|
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 to avoid leaking implementation details.
|
||||||
// In production, send a generic message for 5xx errors.
|
if (process.env.NODE_ENV === 'production') {
|
||||||
// In dev/test, send the actual error message for easier debugging.
|
return res.status(500).json({
|
||||||
const responseMessage =
|
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
|
||||||
statusCode >= 500 && process.env.NODE_ENV === 'production'
|
});
|
||||||
? `An unexpected server error occurred. Please reference error ID: ${errorId}`
|
}
|
||||||
: message;
|
|
||||||
|
|
||||||
res.status(statusCode).json({
|
// In non-production environments (dev, test, etc.), send more details for easier debugging.
|
||||||
message: responseMessage,
|
return res.status(500).json({ message: err.message, stack: err.stack, errorId });
|
||||||
...(validationIssues && { errors: validationIssues }), // Conditionally add the 'errors' array if it exists
|
};
|
||||||
});
|
|
||||||
};
|
|
||||||
74
src/middleware/multer.middleware.test.ts
Normal file
74
src/middleware/multer.middleware.test.ts
Normal 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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
src/middleware/multer.middleware.ts
Normal file
123
src/middleware/multer.middleware.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
|
|||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||||
import { PasswordInput } from './admin/components/PasswordInput';
|
import { PasswordInput } from '../components/PasswordInput';
|
||||||
|
|
||||||
export const ResetPasswordPage: React.FC = () => {
|
export const ResetPasswordPage: React.FC = () => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/pages/admin/components/AuthView.test.tsx
|
// src/pages/admin/components/AuthView.test.tsx
|
||||||
import React from 'react';
|
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 { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { AuthView } from './AuthView';
|
import { AuthView } from './AuthView';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
@@ -12,6 +12,11 @@ const mockedApiClient = vi.mocked(apiClient, true);
|
|||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnLoginSuccess = 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 = {
|
const defaultProps = {
|
||||||
onClose: mockOnClose,
|
onClose: mockOnClose,
|
||||||
onLoginSuccess: mockOnLoginSuccess,
|
onLoginSuccess: mockOnLoginSuccess,
|
||||||
@@ -353,4 +358,27 @@ describe('AuthView', () => {
|
|||||||
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { notifySuccess } from '../../../services/notificationService';
|
|||||||
import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
||||||
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
|
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
|
||||||
import { GithubIcon } from '../../../components/icons/GithubIcon';
|
import { GithubIcon } from '../../../components/icons/GithubIcon';
|
||||||
import { PasswordInput } from './PasswordInput';
|
import { PasswordInput } from '../../../components/PasswordInput';
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
userprofile: UserProfile;
|
userprofile: UserProfile;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/pages/admin/components/ProfileManager.test.tsx
|
// src/pages/admin/components/ProfileManager.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/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 { ProfileManager } from './ProfileManager';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./ProfileManager');
|
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);
|
const mockedApiClient = vi.mocked(apiClient, true);
|
||||||
|
|
||||||
vi.mock('../../../services/notificationService');
|
vi.mock('../../../services/notificationService');
|
||||||
@@ -242,6 +247,17 @@ describe('ProfileManager', () => {
|
|||||||
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
|
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 () => {
|
it('should reset state when the modal is closed and reopened', async () => {
|
||||||
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} />);
|
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User'));
|
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 () => {
|
it('should handle unexpected critical error during profile save', async () => {
|
||||||
const loggerSpy = vi.spyOn(logger.logger, 'error');
|
const loggerSpy = vi.spyOn(logger.logger, 'error');
|
||||||
mockedApiClient.updateUserProfile.mockRejectedValue(new Error('Catastrophic failure'));
|
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 () => {
|
it('should show map view when address has coordinates', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
await waitFor(() => {
|
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 };
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||||
mockedApiClient.getUserAddress.mockResolvedValue(
|
mockedApiClient.getUserAddress.mockResolvedValue(
|
||||||
new Response(JSON.stringify(addressWithoutCoords)),
|
new Response(JSON.stringify(addressWithoutCoords)),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[TEST LOG] Rendering for automatic geocode test (Real Timers + Wait)');
|
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
console.log('[TEST LOG] Waiting for initial address load...');
|
// Wait for initial async address load to complete by flushing promises.
|
||||||
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
|
await act(async () => {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
console.log('[TEST LOG] Initial address loaded. Changing city...');
|
});
|
||||||
|
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
|
||||||
|
|
||||||
// Change address, geocode should not be called immediately
|
// Change address, geocode should not be called immediately
|
||||||
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
||||||
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
|
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
|
||||||
|
|
||||||
console.log('[TEST LOG] Waiting 1600ms for debounce...');
|
// Advance timers to fire the debounce and resolve the subsequent geocode promise.
|
||||||
// Wait for debounce (1500ms) + buffer using real timers to avoid freeze
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
await vi.runAllTimersAsync();
|
||||||
});
|
});
|
||||||
console.log('[TEST LOG] Wait complete. Checking results.');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
// Now check the final result.
|
||||||
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
|
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('NewCity'),
|
expect.stringContaining('NewCity'),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not geocode if address already has coordinates', async () => {
|
it('should not geocode if address already has coordinates (using fake timers)', async () => {
|
||||||
console.log('[TEST LOG] Rendering for no-geocode test (Real Timers + Wait)');
|
// Use real timers for the initial async render and data fetch
|
||||||
|
vi.useRealTimers();
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
console.log('[TEST LOG] Waiting for initial address load...');
|
console.log('[TEST LOG] Waiting for initial address load...');
|
||||||
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
|
||||||
|
|
||||||
console.log(
|
// Switch to fake timers to control the debounce check
|
||||||
'[TEST LOG] Initial address loaded. Waiting 1600ms to ensure no geocode triggers...',
|
vi.useFakeTimers();
|
||||||
);
|
|
||||||
await act(async () => {
|
// Advance timers past the debounce threshold. Nothing should happen.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1600);
|
||||||
});
|
});
|
||||||
console.log('[TEST LOG] Wait complete. Verifying no geocode call.');
|
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 () => {
|
it('should show an error if password is too short', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||||
@@ -442,7 +542,7 @@ describe('ProfileManager', () => {
|
|||||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||||
target: { value: 'short' },
|
target: { value: 'short' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByTestId('update-password-form'));
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(notifyError).toHaveBeenCalledWith('Password must be at least 6 characters long.');
|
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: /data & privacy/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /delete my account/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' },
|
target: { value: 'password' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
||||||
@@ -593,7 +693,7 @@ describe('ProfileManager', () => {
|
|||||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||||
target: { value: 'newpassword123' },
|
target: { value: 'newpassword123' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByTestId('update-password-form'));
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith(
|
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith(
|
||||||
@@ -614,7 +714,7 @@ describe('ProfileManager', () => {
|
|||||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||||
target: { value: 'mismatch' },
|
target: { value: 'mismatch' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByTestId('update-password-form'));
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(notifyError).toHaveBeenCalledWith('Passwords do not match.');
|
expect(notifyError).toHaveBeenCalledWith('Passwords do not match.');
|
||||||
@@ -641,9 +741,10 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle account deletion flow', async () => {
|
it('should handle account deletion flow', async () => {
|
||||||
// Use spy instead of fake timers to avoid blocking waitFor during async API calls
|
// Use fake timers to control the setTimeout call for the entire test.
|
||||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
|
vi.useFakeTimers();
|
||||||
const { unmount } = render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
||||||
|
|
||||||
@@ -654,39 +755,28 @@ describe('ProfileManager', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Fill password and submit to open modal
|
// Fill password and submit to open modal
|
||||||
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), {
|
fireEvent.change(screen.getByTestId('password-input'), {
|
||||||
target: { value: 'correctpassword' },
|
target: { value: 'correctpassword' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
||||||
|
|
||||||
// Confirm in the modal
|
// 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);
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
// The async deleteAccount call is now pending. We need to flush promises
|
||||||
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalledWith(
|
// and then advance the timers to run the subsequent setTimeout.
|
||||||
'correctpassword',
|
// `runAllTimersAsync` will resolve pending promises and run timers recursively.
|
||||||
expect.objectContaining({ signal: expect.anything() }),
|
await act(async () => {
|
||||||
);
|
await vi.runAllTimersAsync();
|
||||||
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)();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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(mockOnClose).toHaveBeenCalled();
|
||||||
expect(mockOnSignOut).toHaveBeenCalled();
|
expect(mockOnSignOut).toHaveBeenCalled();
|
||||||
|
|
||||||
unmount();
|
|
||||||
setTimeoutSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow toggling dark mode', async () => {
|
it('should allow toggling dark mode', async () => {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
|||||||
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
|
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
|
||||||
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
|
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
|
||||||
import { GithubIcon } from '../../../components/icons/GithubIcon';
|
import { GithubIcon } from '../../../components/icons/GithubIcon';
|
||||||
import { ConfirmationModal } from '../../../components/ConfirmationModal';
|
import { ConfirmationModal } from '../../../components/ConfirmationModal'; // This path is correct
|
||||||
import { PasswordInput } from './PasswordInput';
|
import { PasswordInput } from '../../../components/PasswordInput';
|
||||||
import { MapView } from '../../../components/MapView';
|
import { MapView } from '../../../components/MapView';
|
||||||
import type { AuthStatus } from '../../../hooks/useAuth';
|
import type { AuthStatus } from '../../../hooks/useAuth';
|
||||||
import { AuthView } from './AuthView';
|
import { AuthView } from './AuthView';
|
||||||
|
|||||||
55
src/providers/ApiProvider.test.tsx
Normal file
55
src/providers/ApiProvider.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { AuthContext, AuthContextType } from '../contexts/AuthContext';
|
|||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { useApi } from '../hooks/useApi';
|
import { useApi } from '../hooks/useApi';
|
||||||
|
import { getToken, setToken, removeToken } from '../services/tokenStorage';
|
||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
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.');
|
logger.info('[AuthProvider-Effect] Starting initial authentication check.');
|
||||||
|
|
||||||
const checkAuthToken = async () => {
|
const checkAuthToken = async () => {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
logger.info('[AuthProvider-Effect] Found auth token. Validating...');
|
logger.info('[AuthProvider-Effect] Found auth token. Validating...');
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +42,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
'[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.',
|
'[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.',
|
||||||
);
|
);
|
||||||
localStorage.removeItem('authToken');
|
removeToken();
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
setAuthStatus('SIGNED_OUT');
|
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.
|
// This catch block is now primarily for unexpected errors, as useApi handles API errors.
|
||||||
logger.warn('Auth token validation failed. Clearing token.', { error: e });
|
logger.warn('Auth token validation failed. Clearing token.', { error: e });
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
localStorage.removeItem('authToken');
|
removeToken();
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
setAuthStatus('SIGNED_OUT');
|
setAuthStatus('SIGNED_OUT');
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
logger.info('[AuthProvider-Logout] Clearing user data and auth token.');
|
logger.info('[AuthProvider-Logout] Clearing user data and auth token.');
|
||||||
localStorage.removeItem('authToken');
|
removeToken();
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
setAuthStatus('SIGNED_OUT');
|
setAuthStatus('SIGNED_OUT');
|
||||||
}, []);
|
}, []);
|
||||||
@@ -87,7 +88,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (token: string, profileData?: UserProfile) => {
|
async (token: string, profileData?: UserProfile) => {
|
||||||
logger.info(`[AuthProvider-Login] Attempting login.`);
|
logger.info(`[AuthProvider-Login] Attempting login.`);
|
||||||
localStorage.setItem('authToken', token);
|
setToken(token);
|
||||||
|
|
||||||
if (profileData) {
|
if (profileData) {
|
||||||
// If profile is provided (e.g., from credential login), use it directly.
|
// If profile is provided (e.g., from credential login), use it directly.
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ import {
|
|||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
|
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 { 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 { 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', () => ({
|
||||||
|
requireFileUpload: () => (req: Request, res: Response, next: NextFunction) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/queue', () => ({
|
vi.mock('../lib/queue', () => ({
|
||||||
serverAdapter: {
|
serverAdapter: {
|
||||||
@@ -91,8 +96,9 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
@@ -125,12 +131,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -244,7 +244,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.body.message).toBe('Brand logo updated successfully.');
|
expect(response.body.message).toBe('Brand logo updated successfully.');
|
||||||
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
|
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
|
||||||
brandId,
|
brandId,
|
||||||
expect.stringContaining('/assets/'),
|
expect.stringContaining('/flyer-images/'),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -262,10 +262,26 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
const response = await supertest(app).post('/api/admin/brands/55/logo');
|
const response = await supertest(app).post('/api/admin/brands/55/logo');
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect(response.body.message).toMatch(
|
expect(response.body.message).toMatch(
|
||||||
/Logo image file is required|The request data is invalid/,
|
/Logo image file is required|The request data is invalid|Logo image file is missing./,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/admin/brands/abc/logo')
|
.post('/api/admin/brands/abc/logo')
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
// Mock the background job service to control its methods.
|
// Mock the background job service to control its methods.
|
||||||
vi.mock('../services/backgroundJobService', () => ({
|
vi.mock('../services/backgroundJobService', () => ({
|
||||||
@@ -66,8 +65,9 @@ import {
|
|||||||
} from '../services/queueService.server';
|
} from '../services/queueService.server';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
@@ -97,12 +97,6 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -248,6 +242,17 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 404 if the queue name is valid but not in the retry map', async () => {
|
||||||
|
const queueName = 'weekly-analytics-reporting'; // This is in the Zod enum but not the queueMap
|
||||||
|
const jobId = 'some-job-id';
|
||||||
|
|
||||||
|
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||||
|
|
||||||
|
// The route throws a NotFoundError, which the error handler should convert to a 404.
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.message).toBe(`Queue 'weekly-analytics-reporting' not found.`);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 404 if the job ID is not found in the queue', async () => {
|
it('should return 404 if the job ID is not found in the queue', async () => {
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
||||||
const response = await supertest(app).post(
|
const response = await supertest(app).post(
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import type { Request, Response, NextFunction } from 'express';
|
|||||||
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
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', () => ({
|
vi.mock('../lib/queue', () => ({
|
||||||
serverAdapter: {
|
serverAdapter: {
|
||||||
@@ -27,19 +36,22 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
notificationRepo: {},
|
notificationRepo: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the queue service to control worker statuses
|
// Mock the queue service for queue status checks
|
||||||
vi.mock('../services/queueService.server', () => ({
|
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() },
|
flyerWorker: { name: 'flyer-processing', isRunning: vi.fn() },
|
||||||
emailWorker: { name: 'email-sending', isRunning: vi.fn() },
|
emailWorker: { name: 'email-sending', isRunning: vi.fn() },
|
||||||
analyticsWorker: { name: 'analytics-reporting', isRunning: vi.fn() },
|
analyticsWorker: { name: 'analytics-reporting', isRunning: vi.fn() },
|
||||||
cleanupWorker: { name: 'file-cleanup', isRunning: vi.fn() },
|
cleanupWorker: { name: 'file-cleanup', isRunning: vi.fn() },
|
||||||
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', 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
|
// 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 the mocked modules to control them
|
||||||
import * as queueService from '../services/queueService.server';
|
import * as queueService from '../services/queueService.server';
|
||||||
|
import * as workerService from '../services/workers.server';
|
||||||
import { adminRepo } from '../services/db/index.db';
|
import { adminRepo } from '../services/db/index.db';
|
||||||
const mockedQueueService = queueService as Mocked<typeof queueService>;
|
const mockedQueueService = queueService as Mocked<typeof queueService>;
|
||||||
|
const mockedWorkerService = workerService as Mocked<typeof workerService>;
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', () => ({
|
||||||
@@ -102,12 +116,6 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -143,11 +151,11 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
describe('GET /workers/status', () => {
|
describe('GET /workers/status', () => {
|
||||||
it('should return the status of all registered workers', async () => {
|
it('should return the status of all registered workers', async () => {
|
||||||
// Arrange: Set the mock status for each worker
|
// Arrange: Set the mock status for each worker
|
||||||
vi.mocked(mockedQueueService.flyerWorker.isRunning).mockReturnValue(true);
|
vi.mocked(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
|
||||||
vi.mocked(mockedQueueService.emailWorker.isRunning).mockReturnValue(true);
|
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
|
||||||
vi.mocked(mockedQueueService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
||||||
vi.mocked(mockedQueueService.cleanupWorker.isRunning).mockReturnValue(true);
|
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
|
||||||
vi.mocked(mockedQueueService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
|
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/admin/workers/status');
|
const response = await supertest(app).get('/api/admin/workers/status');
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
import { Router, NextFunction, Request, Response } from 'express';
|
import { Router, NextFunction, Request, Response } from 'express';
|
||||||
import passport from './passport.routes';
|
import passport from './passport.routes';
|
||||||
import { isAdmin } from './passport.routes'; // Correctly imported
|
import { isAdmin } from './passport.routes'; // Correctly imported
|
||||||
import multer from 'multer'; // --- Zod Schemas for Admin Routes (as per ADR-003) ---
|
import multer from 'multer';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { logger } from '../services/logger.server';
|
import type { UserProfile } from '../types';
|
||||||
import { UserProfile } from '../types';
|
|
||||||
import { geocodingService } from '../services/geocodingService.server';
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
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 { NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
|
||||||
@@ -26,52 +29,50 @@ import {
|
|||||||
analyticsQueue,
|
analyticsQueue,
|
||||||
cleanupQueue,
|
cleanupQueue,
|
||||||
weeklyAnalyticsQueue,
|
weeklyAnalyticsQueue,
|
||||||
flyerWorker,
|
} from '../services/queueService.server'; // Import your queues
|
||||||
emailWorker,
|
import {
|
||||||
analyticsWorker,
|
analyticsWorker,
|
||||||
cleanupWorker,
|
cleanupWorker,
|
||||||
|
emailWorker,
|
||||||
|
flyerWorker,
|
||||||
weeklyAnalyticsWorker,
|
weeklyAnalyticsWorker,
|
||||||
} from '../services/queueService.server'; // Import your queues
|
} from '../services/workers.server';
|
||||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||||
|
import {
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
requiredString,
|
||||||
const requiredString = (message: string) =>
|
numericIdParam,
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
uuidParamSchema,
|
||||||
|
optionalNumeric,
|
||||||
|
} from '../utils/zodUtils';
|
||||||
|
import { logger } from '../services/logger.server';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A factory for creating a Zod schema that validates a UUID in the request parameters.
|
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
|
||||||
* @param key The name of the parameter key (e.g., 'userId').
|
* @param file The multer file object to delete.
|
||||||
* @param message A custom error message for invalid UUIDs.
|
|
||||||
*/
|
*/
|
||||||
const uuidParamSchema = (key: string, message = `Invalid UUID for parameter '${key}'.`) =>
|
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
|
||||||
z.object({
|
if (!file) return;
|
||||||
params: z.object({ [key]: z.string().uuid({ message }) }),
|
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({
|
||||||
* A factory for creating a Zod schema that validates a numeric ID in the request parameters.
|
|
||||||
*/
|
|
||||||
const numericIdParamSchema = (
|
|
||||||
key: string,
|
|
||||||
message = `Invalid ID for parameter '${key}'. Must be a positive integer.`,
|
|
||||||
) =>
|
|
||||||
z.object({
|
|
||||||
params: z.object({ [key]: z.coerce.number().int({ message }).positive({ message }) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCorrectionSchema = numericIdParamSchema('id').extend({
|
|
||||||
body: z.object({
|
body: z.object({
|
||||||
suggested_value: requiredString('A new suggested_value is required.'),
|
suggested_value: requiredString('A new suggested_value is required.'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRecipeStatusSchema = numericIdParamSchema('id').extend({
|
const updateRecipeStatusSchema = numericIdParam('id').extend({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
|
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCommentStatusSchema = numericIdParamSchema('id').extend({
|
const updateCommentStatusSchema = numericIdParam('id').extend({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
status: z.enum(['visible', 'hidden', 'reported']),
|
status: z.enum(['visible', 'hidden', 'reported']),
|
||||||
}),
|
}),
|
||||||
@@ -85,8 +86,8 @@ const updateUserRoleSchema = uuidParamSchema('id', 'A valid user ID is required.
|
|||||||
|
|
||||||
const activityLogSchema = z.object({
|
const activityLogSchema = z.object({
|
||||||
query: z.object({
|
query: z.object({
|
||||||
limit: z.coerce.number().int().positive().optional().default(50),
|
limit: optionalNumeric({ default: 50, integer: true, positive: true }),
|
||||||
offset: z.coerce.number().int().nonnegative().optional().default(0),
|
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,19 +106,7 @@ const jobRetrySchema = z.object({
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// --- Multer Configuration for File Uploads ---
|
const upload = createUploadMiddleware({ storageType: 'flyer' });
|
||||||
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 });
|
|
||||||
|
|
||||||
// --- Bull Board (Job Queue UI) Setup ---
|
// --- Bull Board (Job Queue UI) Setup ---
|
||||||
const serverAdapter = new ExpressAdapter();
|
const serverAdapter = new ExpressAdapter();
|
||||||
@@ -154,6 +143,7 @@ router.get('/corrections', async (req, res, next: NextFunction) => {
|
|||||||
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
|
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
|
||||||
res.json(corrections);
|
res.json(corrections);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching suggested corrections');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -163,6 +153,7 @@ router.get('/brands', async (req, res, next: NextFunction) => {
|
|||||||
const brands = await db.flyerRepo.getAllBrands(req.log);
|
const brands = await db.flyerRepo.getAllBrands(req.log);
|
||||||
res.json(brands);
|
res.json(brands);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching brands');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -172,6 +163,7 @@ router.get('/stats', async (req, res, next: NextFunction) => {
|
|||||||
const stats = await db.adminRepo.getApplicationStats(req.log);
|
const stats = await db.adminRepo.getApplicationStats(req.log);
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching application stats');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,20 +173,22 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
|||||||
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
|
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
|
||||||
res.json(dailyStats);
|
res.json(dailyStats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching daily stats');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/corrections/:id/approve',
|
'/corrections/:id/approve',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
|
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error approving correction');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -202,14 +196,15 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/corrections/:id/reject',
|
'/corrections/:id/reject',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
|
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error rejecting correction');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -229,6 +224,7 @@ router.put(
|
|||||||
);
|
);
|
||||||
res.status(200).json(updatedCorrection);
|
res.status(200).json(updatedCorrection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error updating suggested correction');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -244,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
|
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);
|
res.status(200).json(updatedRecipe);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error updating recipe status');
|
||||||
next(error); // Pass all errors to the central error handler
|
next(error); // Pass all errors to the central error handler
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -251,24 +248,29 @@ router.put(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/brands/:id/logo',
|
'/brands/:id/logo',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
upload.single('logoImage'),
|
upload.single('logoImage'),
|
||||||
requireFileUpload('logoImage'),
|
requireFileUpload('logoImage'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
// Although requireFileUpload middleware should ensure the file exists,
|
// Although requireFileUpload middleware should ensure the file exists,
|
||||||
// this check satisfies TypeScript and adds robustness.
|
// this check satisfies TypeScript and adds robustness.
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
throw new ValidationError([], 'Logo image file is missing.');
|
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);
|
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
|
||||||
|
|
||||||
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
|
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
|
||||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||||
} catch (error) {
|
} 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);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -279,6 +281,7 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
|||||||
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching unmatched items');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -288,16 +291,17 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/recipes/:recipeId',
|
'/recipes/:recipeId',
|
||||||
validateRequest(numericIdParamSchema('recipeId')),
|
validateRequest(numericIdParam('recipeId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
|
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
|
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
logger.error({ error }, 'Error deleting recipe');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -308,14 +312,15 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/flyers/:flyerId',
|
'/flyers/:flyerId',
|
||||||
validateRequest(numericIdParamSchema('flyerId')),
|
validateRequest(numericIdParam('flyerId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Infer the type directly from the schema generator function.
|
// Infer the type directly from the schema generator function.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
logger.error({ error }, 'Error deleting flyer');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -335,6 +340,7 @@ router.put(
|
|||||||
); // This is still a standalone function in admin.db.ts
|
); // This is still a standalone function in admin.db.ts
|
||||||
res.status(200).json(updatedComment);
|
res.status(200).json(updatedComment);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
logger.error({ error }, 'Error updating comment status');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -345,6 +351,7 @@ router.get('/users', async (req, res, next: NextFunction) => {
|
|||||||
const users = await db.adminRepo.getAllUsers(req.log);
|
const users = await db.adminRepo.getAllUsers(req.log);
|
||||||
res.json(users);
|
res.json(users);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching users');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -364,6 +371,7 @@ router.get(
|
|||||||
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
|
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching activity log');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -379,6 +387,7 @@ router.get(
|
|||||||
const user = await db.userRepo.findUserProfileById(params.id, req.log);
|
const user = await db.userRepo.findUserProfileById(params.id, req.log);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching user profile');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -414,6 +423,7 @@ router.delete(
|
|||||||
await db.userRepo.deleteUserById(params.id, req.log);
|
await db.userRepo.deleteUserById(params.id, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error deleting user');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -435,12 +445,10 @@ router.post(
|
|||||||
// We call the function but don't wait for it to finish (no `await`).
|
// We call the function but don't wait for it to finish (no `await`).
|
||||||
// This is a "fire-and-forget" operation from the client's perspective.
|
// This is a "fire-and-forget" operation from the client's perspective.
|
||||||
backgroundJobService.runDailyDealCheck();
|
backgroundJobService.runDailyDealCheck();
|
||||||
res
|
res.status(202).json({
|
||||||
.status(202)
|
message:
|
||||||
.json({
|
'Daily deal check job has been triggered successfully. It will run in the background.',
|
||||||
message:
|
});
|
||||||
'Daily deal check job has been triggered successfully. It will run in the background.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
||||||
next(error);
|
next(error);
|
||||||
@@ -467,11 +475,9 @@ router.post(
|
|||||||
|
|
||||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||||
|
|
||||||
res
|
res.status(202).json({
|
||||||
.status(202)
|
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`,
|
||||||
.json({
|
});
|
||||||
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
||||||
next(error);
|
next(error);
|
||||||
@@ -485,11 +491,11 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/flyers/:flyerId/cleanup',
|
'/flyers/:flyerId/cleanup',
|
||||||
validateRequest(numericIdParamSchema('flyerId')),
|
validateRequest(numericIdParam('flyerId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
// Infer type from the schema generator for type safety, as per ADR-003.
|
// Infer type from the schema generator for type safety, as per ADR-003.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; // This was a duplicate, fixed.
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>; // This was a duplicate, fixed.
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`,
|
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`,
|
||||||
);
|
);
|
||||||
@@ -501,6 +507,7 @@ router.post(
|
|||||||
.status(202)
|
.status(202)
|
||||||
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
|
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error enqueuing cleanup job');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -523,6 +530,7 @@ router.post('/trigger/failing-job', async (req: Request, res: Response, next: Ne
|
|||||||
.status(202)
|
.status(202)
|
||||||
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
|
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error enqueuing failing job');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -541,11 +549,9 @@ router.post(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
||||||
res
|
res.status(200).json({
|
||||||
.status(200)
|
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
||||||
.json({
|
});
|
||||||
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
|
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
|
||||||
next(error);
|
next(error);
|
||||||
@@ -597,6 +603,7 @@ router.get('/queues/status', async (req: Request, res: Response, next: NextFunct
|
|||||||
);
|
);
|
||||||
res.json(queueStatuses);
|
res.json(queueStatuses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching queue statuses');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -645,6 +652,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error retrying job');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -676,9 +684,13 @@ router.post(
|
|||||||
.status(202)
|
.status(202)
|
||||||
.json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
|
.json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error enqueuing weekly analytics job');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Catches errors from multer (e.g., file size, file filter) */
|
||||||
|
router.use(handleMulterError);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { Request, Response, NextFunction } from 'express';
|
|||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
vi.mock('../services/db/index.db', () => ({
|
vi.mock('../services/db/index.db', () => ({
|
||||||
adminRepo: {
|
adminRepo: {
|
||||||
@@ -45,8 +44,9 @@ import adminRouter from './admin.routes';
|
|||||||
import { adminRepo } from '../services/db/index.db';
|
import { adminRepo } from '../services/db/index.db';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
@@ -73,12 +73,6 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import supertest from 'supertest';
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('../services/geocodingService.server', () => ({
|
vi.mock('../services/geocodingService.server', () => ({
|
||||||
@@ -50,8 +49,9 @@ import adminRouter from './admin.routes';
|
|||||||
import { geocodingService } from '../services/geocodingService.server';
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
@@ -79,12 +79,6 @@ describe('Admin System Routes (/api/admin/system)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/m
|
|||||||
import type { UserProfile, Profile } from '../types';
|
import type { UserProfile, Profile } from '../types';
|
||||||
import { NotFoundError } from '../services/db/errors.db';
|
import { NotFoundError } from '../services/db/errors.db';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
vi.mock('../services/db/index.db', () => ({
|
vi.mock('../services/db/index.db', () => ({
|
||||||
adminRepo: {
|
adminRepo: {
|
||||||
@@ -44,8 +43,9 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import the router AFTER all mocks are defined.
|
// Import the router AFTER all mocks are defined.
|
||||||
@@ -83,12 +83,6 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
|||||||
authenticatedUser: adminUser,
|
authenticatedUser: adminUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ import aiRouter from './ai.routes';
|
|||||||
import { flyerQueue } from '../services/queueService.server';
|
import { flyerQueue } from '../services/queueService.server';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// 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.
|
// Mock the passport module to control authentication for different tests.
|
||||||
@@ -78,40 +79,59 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
vi.mocked(mockLogger.info).mockImplementation(() => {});
|
vi.mocked(mockLogger.info).mockImplementation(() => {});
|
||||||
vi.mocked(mockLogger.error).mockImplementation(() => {});
|
vi.mocked(mockLogger.error).mockImplementation(() => {});
|
||||||
vi.mocked(mockLogger.warn).mockImplementation(() => {});
|
vi.mocked(mockLogger.warn).mockImplementation(() => {});
|
||||||
|
vi.mocked(mockLogger.debug).mockImplementation(() => {}); // Ensure debug is also mocked
|
||||||
});
|
});
|
||||||
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
||||||
|
|
||||||
describe('Module-level error handling', () => {
|
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
|
||||||
it('should log an error if storage path creation fails', async () => {
|
describe('Diagnostic Middleware Error Handling', () => {
|
||||||
// Arrange
|
it('should log an error if logger.debug throws an object with a message property', async () => {
|
||||||
const mkdirError = new Error('EACCES: permission denied');
|
const mockErrorObject = { message: 'Mock debug error' };
|
||||||
vi.resetModules(); // Reset modules to re-run top-level code
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
||||||
vi.doMock('node:fs', () => ({
|
throw mockErrorObject;
|
||||||
...fs,
|
});
|
||||||
mkdirSync: vi.fn().mockImplementation(() => {
|
|
||||||
throw mkdirError;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { logger } = await import('../services/logger.server');
|
|
||||||
|
|
||||||
// Act: Dynamically import the router to trigger the mkdirSync call
|
// Make any request to trigger the middleware
|
||||||
await import('./ai.routes');
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||||
|
|
||||||
// Assert
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
const storagePath =
|
{ error: mockErrorObject.message }, // errMsg should extract the message
|
||||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
'Failed to log incoming AI request headers',
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
{ error: 'EACCES: permission denied' },
|
|
||||||
`Failed to create storage path (${storagePath}). File uploads may fail.`,
|
|
||||||
);
|
);
|
||||||
vi.doUnmock('node:fs'); // Cleanup
|
// The request should still proceed, but might fail later if the original flow was interrupted.
|
||||||
|
// Here, it will likely hit the 404 for job not found.
|
||||||
|
expect(response.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
it('should log an error if logger.debug throws a primitive string', async () => {
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
const mockErrorString = 'Mock debug error string';
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
throw mockErrorString;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make any request to trigger the middleware
|
||||||
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: mockErrorString }, // errMsg should convert to string
|
||||||
|
'Failed to log incoming AI request headers',
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error if logger.debug throws null/undefined', async () => {
|
||||||
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
||||||
|
throw null; // Simulate throwing null
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: 'An unknown error occurred.' }, // errMsg should handle null/undefined
|
||||||
|
'Failed to log incoming AI request headers',
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /upload-and-process', () => {
|
describe('POST /upload-and-process', () => {
|
||||||
@@ -235,6 +255,21 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
'123 Pacific St, Anytown, BC, V8T 1A1, CA',
|
'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', () => {
|
describe('GET /jobs/:jobId/status', () => {
|
||||||
@@ -304,10 +339,11 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(response.status).toBe(400);
|
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
|
// Arrange
|
||||||
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 });
|
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 });
|
||||||
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found
|
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found
|
||||||
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
@@ -319,6 +355,10 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(response.status).toBe(409);
|
expect(response.status).toBe(409);
|
||||||
expect(response.body.message).toBe('This flyer has already been processed.');
|
expect(response.body.message).toBe('This flyer has already been processed.');
|
||||||
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled();
|
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 () => {
|
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
|
||||||
@@ -420,6 +460,52 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle payload where extractedData is null', async () => {
|
||||||
|
const payloadWithNullExtractedData = {
|
||||||
|
checksum: 'null-extracted-data-checksum',
|
||||||
|
originalFileName: 'flyer-null.jpg',
|
||||||
|
extractedData: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/ai/flyers/process')
|
||||||
|
.field('data', JSON.stringify(payloadWithNullExtractedData))
|
||||||
|
.attach('flyerImage', imagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify that extractedData was correctly defaulted to an empty object
|
||||||
|
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
||||||
|
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
{ bodyData: expect.any(Object) },
|
||||||
|
'Missing extractedData in /api/ai/flyers/process payload.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle payload where extractedData is a string', async () => {
|
||||||
|
const payloadWithStringExtractedData = {
|
||||||
|
checksum: 'string-extracted-data-checksum',
|
||||||
|
originalFileName: 'flyer-string.jpg',
|
||||||
|
extractedData: 'not-an-object',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/ai/flyers/process')
|
||||||
|
.field('data', JSON.stringify(payloadWithStringExtractedData))
|
||||||
|
.attach('flyerImage', imagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify that extractedData was correctly defaulted to an empty object
|
||||||
|
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
||||||
|
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
{ bodyData: expect.any(Object) },
|
||||||
|
'Missing extractedData in /api/ai/flyers/process payload.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle payload where extractedData is at the root of the body', async () => {
|
it('should handle payload where extractedData is at the root of the body', async () => {
|
||||||
// This simulates a client sending multipart fields for each property of extractedData
|
// This simulates a client sending multipart fields for each property of extractedData
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
@@ -435,6 +521,72 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
||||||
expect(flyerDataArg.store_name).toBe('Root Store');
|
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', () => {
|
describe('POST /check-flyer', () => {
|
||||||
@@ -554,10 +706,11 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const mockUser = createMockUserProfile({
|
const mockUser = createMockUserProfile({
|
||||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||||
});
|
});
|
||||||
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Inject an authenticated user for this test block
|
// Inject an authenticated user for this test block
|
||||||
app.use((req, res, next) => {
|
authenticatedApp.use((req, res, next) => {
|
||||||
req.user = mockUser;
|
req.user = mockUser;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -572,7 +725,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||||
.field('extractionType', 'item_details')
|
.field('extractionType', 'item_details')
|
||||||
.attach('image', imagePath);
|
.attach('image', imagePath);
|
||||||
|
// Use the authenticatedApp instance for requests in this block
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockResult);
|
expect(response.body).toEqual(mockResult);
|
||||||
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
||||||
@@ -583,7 +736,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
new Error('AI API is down'),
|
new Error('AI API is down'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await supertest(app)
|
const response = await supertest(authenticatedApp)
|
||||||
.post('/api/ai/rescan-area')
|
.post('/api/ai/rescan-area')
|
||||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||||
.field('extractionType', 'item_details')
|
.field('extractionType', 'item_details')
|
||||||
@@ -599,15 +752,12 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const mockUserProfile = createMockUserProfile({
|
const mockUserProfile = createMockUserProfile({
|
||||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||||
});
|
});
|
||||||
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserProfile });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// For this block, simulate an authenticated request by attaching the user.
|
// The authenticatedApp instance is already set up with mockUserProfile
|
||||||
app.use((req, res, next) => {
|
|
||||||
req.user = mockUserProfile;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /quick-insights should return the stubbed response', async () => {
|
it('POST /quick-insights should return the stubbed response', async () => {
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/ai/quick-insights')
|
.post('/api/ai/quick-insights')
|
||||||
@@ -617,6 +767,14 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(response.body.text).toContain('server-generated quick insight');
|
expect(response.body.text).toContain('server-generated quick insight');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/ai/quick-insights')
|
||||||
|
.send({ items: [{ item: 'test item' }] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /quick-insights should return 500 on a generic error', async () => {
|
it('POST /quick-insights should return 500 on a generic error', async () => {
|
||||||
// To hit the catch block, we can simulate an error by making the logger throw.
|
// To hit the catch block, we can simulate an error by making the logger throw.
|
||||||
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
||||||
@@ -700,6 +858,39 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(response.body.message).toBe('Maps API key invalid');
|
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 () => {
|
it('POST /quick-insights should return 400 if items are missing', async () => {
|
||||||
const response = await supertest(app).post('/api/ai/quick-insights').send({});
|
const response = await supertest(app).post('/api/ai/quick-insights').send({});
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
// src/routes/ai.routes.ts
|
// src/routes/ai.routes.ts
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import multer from 'multer';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import passport from './passport.routes';
|
import passport from './passport.routes';
|
||||||
import { optionalAuth } from './passport.routes';
|
import { optionalAuth } from './passport.routes';
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { createFlyerAndItems } from '../services/db/flyer.db';
|
import { createFlyerAndItems } from '../services/db/flyer.db';
|
||||||
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
|
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 { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
import { sanitizeFilename } from '../utils/stringUtils';
|
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
|
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
|
||||||
import { flyerQueue } from '../services/queueService.server';
|
import { flyerQueue } from '../services/queueService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -26,9 +29,6 @@ interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Zod Schemas for AI Routes (as per ADR-003) ---
|
// --- Zod Schemas for AI Routes (as per ADR-003) ---
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
const uploadAndProcessSchema = z.object({
|
const uploadAndProcessSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
@@ -52,6 +52,22 @@ const errMsg = (e: unknown) => {
|
|||||||
return String(e || 'An unknown error occurred.');
|
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({
|
const cropAreaObjectSchema = z.object({
|
||||||
x: z.number(),
|
x: z.number(),
|
||||||
y: z.number(),
|
y: z.number(),
|
||||||
@@ -80,7 +96,6 @@ const rescanAreaSchema = z.object({
|
|||||||
})
|
})
|
||||||
.pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
|
.pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
|
||||||
extractionType: z.enum(['store_name', 'dates', 'item_details'], {
|
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'.",
|
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -88,10 +103,17 @@ const rescanAreaSchema = z.object({
|
|||||||
|
|
||||||
const flyerItemForAnalysisSchema = z
|
const flyerItemForAnalysisSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: requiredString('Item name is required.'),
|
item: z.string().nullish(),
|
||||||
// Allow other properties to pass through without validation
|
name: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough()
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(data.item && data.item.trim().length > 0) || (data.name && data.name.trim().length > 0),
|
||||||
|
{
|
||||||
|
message: "Item identifier is required (either 'item' or 'name').",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const insightsSchema = z.object({
|
const insightsSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
@@ -134,40 +156,7 @@ const searchWebSchema = z.object({
|
|||||||
body: z.object({ query: requiredString('A search query is required.') }),
|
body: z.object({ query: requiredString('A search query is required.') }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Multer Configuration for File Uploads ---
|
const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
|
||||||
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 });
|
|
||||||
|
|
||||||
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
|
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
|
||||||
router.use((req: Request, res: Response, next: NextFunction) => {
|
router.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -180,7 +169,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
|
|||||||
'[API /ai] Incoming request',
|
'[API /ai] Incoming request',
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} 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();
|
next();
|
||||||
});
|
});
|
||||||
@@ -193,15 +182,19 @@ router.post(
|
|||||||
'/upload-and-process',
|
'/upload-and-process',
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.single('flyerFile'),
|
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) => {
|
async (req, res, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
// Manually validate the request body. This will throw if validation fails.
|
||||||
|
uploadAndProcessSchema.parse({ body: req.body });
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
|
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
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',
|
'Handling /upload-and-process',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,6 +246,9 @@ router.post(
|
|||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -311,7 +307,7 @@ router.post(
|
|||||||
|
|
||||||
// Try several ways to obtain the payload so we are tolerant to client variations.
|
// Try several ways to obtain the payload so we are tolerant to client variations.
|
||||||
let parsed: FlyerProcessPayload = {};
|
let parsed: FlyerProcessPayload = {};
|
||||||
let extractedData: Partial<ExtractedCoreData> = {};
|
let extractedData: Partial<ExtractedCoreData> | null | undefined = {};
|
||||||
try {
|
try {
|
||||||
// If the client sent a top-level `data` field (stringified JSON), parse it.
|
// If the client sent a top-level `data` field (stringified JSON), parse it.
|
||||||
if (req.body && (req.body.data || req.body.extractedData)) {
|
if (req.body && (req.body.data || req.body.extractedData)) {
|
||||||
@@ -332,7 +328,7 @@ router.post(
|
|||||||
) as FlyerProcessPayload;
|
) as FlyerProcessPayload;
|
||||||
}
|
}
|
||||||
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
|
// 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 {
|
} else {
|
||||||
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
|
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
|
||||||
try {
|
try {
|
||||||
@@ -378,6 +374,12 @@ router.post(
|
|||||||
|
|
||||||
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
|
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
|
||||||
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
||||||
|
|
||||||
|
if (!checksum) {
|
||||||
|
await cleanupUploadedFile(req.file);
|
||||||
|
return res.status(400).json({ message: 'Checksum is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
const originalFileName =
|
const originalFileName =
|
||||||
parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
|
parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
|
||||||
const userProfile = req.user as UserProfile | undefined;
|
const userProfile = req.user as UserProfile | undefined;
|
||||||
@@ -404,6 +406,7 @@ router.post(
|
|||||||
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
|
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
|
||||||
...item,
|
...item,
|
||||||
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
|
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,
|
view_count: 0,
|
||||||
click_count: 0,
|
click_count: 0,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
@@ -424,6 +427,7 @@ router.post(
|
|||||||
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
|
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
|
||||||
if (existingFlyer) {
|
if (existingFlyer) {
|
||||||
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
|
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.' });
|
return res.status(409).json({ message: 'This flyer has already been processed.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +475,7 @@ router.post(
|
|||||||
|
|
||||||
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
|
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await cleanupUploadedFile(req.file);
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -493,6 +498,8 @@ router.post(
|
|||||||
res.status(200).json({ is_flyer: true }); // Stubbed response
|
res.status(200).json({ is_flyer: true }); // Stubbed response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
await cleanupUploadedFile(req.file);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -510,6 +517,8 @@ router.post(
|
|||||||
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
|
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
await cleanupUploadedFile(req.file);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -527,6 +536,8 @@ router.post(
|
|||||||
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
|
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
await cleanupUploadedFiles(req.files as Express.Multer.File[]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -674,8 +685,13 @@ router.post(
|
|||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
await cleanupUploadedFile(req.file);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Catches errors from multer (e.g., file size, file filter) */
|
||||||
|
router.use(handleMulterError);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
createMockUserProfile,
|
createMockUserProfile,
|
||||||
createMockUserWithPasswordHash,
|
createMockUserWithPasswordHash,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
// --- FIX: Hoist passport mocks to be available for vi.mock ---
|
// --- FIX: Hoist passport mocks to be available for vi.mock ---
|
||||||
const passportMocks = vi.hoisted(() => {
|
const passportMocks = vi.hoisted(() => {
|
||||||
@@ -111,8 +110,9 @@ vi.mock('../services/db/connection.db', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the email service
|
// Mock the email service
|
||||||
@@ -144,6 +144,8 @@ import { UniqueConstraintError } from '../services/db/errors.db'; // Import actu
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
|
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
|
||||||
|
|
||||||
|
const { mockLogger } = await import('../tests/utils/mockLogger');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser()); // Mount BEFORE router
|
app.use(cookieParser()); // Mount BEFORE router
|
||||||
@@ -297,7 +299,6 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
// The API now returns a nested UserProfile object
|
// The API now returns a nested UserProfile object
|
||||||
expect(response.body.userprofile).toEqual(
|
expect(response.body.userprofile).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
user_id: 'user-123',
|
|
||||||
user: expect.objectContaining({
|
user: expect.objectContaining({
|
||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
email: loginCredentials.email,
|
email: loginCredentials.email,
|
||||||
@@ -618,7 +619,9 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
const setCookieHeader = response.headers['set-cookie'];
|
const setCookieHeader = response.headers['set-cookie'];
|
||||||
expect(setCookieHeader).toBeDefined();
|
expect(setCookieHeader).toBeDefined();
|
||||||
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
||||||
expect(setCookieHeader[0]).toContain('Expires=Thu, 01 Jan 1970');
|
// Check for Max-Age=0, which is the modern way to expire a cookie.
|
||||||
|
// The 'Expires' attribute is a fallback and its exact value can be inconsistent.
|
||||||
|
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// src/routes/auth.routes.ts
|
// src/routes/auth.routes.ts
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import zxcvbn from 'zxcvbn';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
import passport from './passport.routes'; // Corrected import path
|
import passport from './passport.routes';
|
||||||
import { userRepo, adminRepo } from '../services/db/index.db';
|
import { userRepo, adminRepo } from '../services/db/index.db';
|
||||||
import { UniqueConstraintError } from '../services/db/errors.db';
|
import { UniqueConstraintError } from '../services/db/errors.db';
|
||||||
import { getPool } from '../services/db/connection.db';
|
import { getPool } from '../services/db/connection.db';
|
||||||
@@ -15,38 +14,13 @@ import { logger } from '../services/logger.server';
|
|||||||
import { sendPasswordResetEmail } from '../services/emailService.server';
|
import { sendPasswordResetEmail } from '../services/emailService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
|
import { validatePasswordStrength } from '../utils/authUtils';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the strength of a password using zxcvbn.
|
|
||||||
* @param password The password to check.
|
|
||||||
* @returns An object with `isValid` and an optional `feedback` message.
|
|
||||||
*/
|
|
||||||
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
|
|
||||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
|
||||||
const strength = zxcvbn(password);
|
|
||||||
|
|
||||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
|
||||||
const feedbackMessage =
|
|
||||||
strength.feedback.warning ||
|
|
||||||
(strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
feedback:
|
|
||||||
`Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// Conditionally disable rate limiting for the test environment
|
// Conditionally disable rate limiting for the test environment
|
||||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||||
|
|
||||||
@@ -69,8 +43,6 @@ const resetPasswordLimiter = rateLimit({
|
|||||||
skip: () => isTestEnv, // Skip this middleware if in test environment
|
skip: () => isTestEnv, // Skip this middleware if in test environment
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Zod Schemas for Auth Routes (as per ADR-003) ---
|
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
email: z.string().email('A valid email is required.'),
|
email: z.string().email('A valid email is required.'),
|
||||||
@@ -162,8 +134,8 @@ router.post(
|
|||||||
// If the email is a duplicate, return a 409 Conflict status.
|
// If the email is a duplicate, return a 409 Conflict status.
|
||||||
return res.status(409).json({ message: error.message });
|
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}.`);
|
logger.error({ error }, `User registration route failed for email: ${email}.`);
|
||||||
|
// Pass the error to the centralized handler
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -213,7 +185,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
|||||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
|
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||||
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
|
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
|
||||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||||
|
|
||||||
@@ -381,7 +353,7 @@ router.post('/logout', async (req: Request, res: Response) => {
|
|||||||
// Instruct the browser to clear the cookie by setting its expiration to the past.
|
// Instruct the browser to clear the cookie by setting its expiration to the past.
|
||||||
res.cookie('refreshToken', '', {
|
res.cookie('refreshToken', '', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
expires: new Date(0),
|
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
});
|
});
|
||||||
res.status(200).json({ message: 'Logged out successfully.' });
|
res.status(200).json({ message: 'Logged out successfully.' });
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
createMockBudget,
|
createMockBudget,
|
||||||
createMockSpendingByCategory,
|
createMockSpendingByCategory,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
||||||
// 1. Mock the Service Layer directly.
|
// 1. Mock the Service Layer directly.
|
||||||
@@ -26,8 +25,9 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// 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.
|
// Import the router and mocked DB AFTER all mocks are defined.
|
||||||
@@ -69,17 +69,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
|||||||
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createTestApp({
|
const app = createTestApp({ router: budgetRouter, basePath: '/api/budgets', authenticatedUser: mockUserProfile });
|
||||||
router: budgetRouter,
|
|
||||||
basePath: '/api/budgets',
|
|
||||||
authenticatedUser: mockUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('should return a list of budgets for the user', async () => {
|
it('should return a list of budgets for the user', async () => {
|
||||||
|
|||||||
@@ -5,20 +5,12 @@ import passport from './passport.routes';
|
|||||||
import { budgetRepo } from '../services/db/index.db';
|
import { budgetRepo } from '../services/db/index.db';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 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 Budget Routes (as per ADR-003) ---
|
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
|
||||||
|
const budgetIdParamSchema = numericIdParam('id', "Invalid ID for parameter 'id'. Must be a number.");
|
||||||
const budgetIdParamSchema = z.object({
|
|
||||||
params: z.object({
|
|
||||||
id: z.coerce.number().int().positive("Invalid ID for parameter 'id'. Must be a number."),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createBudgetSchema = z.object({
|
const createBudgetSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import supertest from 'supertest';
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||||
import type { WatchedItemDeal } from '../types';
|
import type { WatchedItemDeal } from '../types';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
|
||||||
// 1. Mock the Service Layer directly.
|
// 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 the router and mocked repo AFTER all mocks are defined.
|
||||||
import dealsRouter from './deals.routes';
|
import dealsRouter from './deals.routes';
|
||||||
import { dealsRepo } from '../services/db/deals.db';
|
import { dealsRepo } from '../services/db/deals.db';
|
||||||
|
import { mockLogger } from '../tests/utils/mockLogger';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
@@ -54,13 +55,6 @@ describe('Deals Routes (/api/users/deals)', () => {
|
|||||||
authenticatedUser: mockUser,
|
authenticatedUser: mockUser,
|
||||||
});
|
});
|
||||||
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
|
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
|
||||||
const errorHandler = (err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply the handler to both app instances
|
|
||||||
authenticatedApp.use(errorHandler);
|
|
||||||
unauthenticatedApp.use(errorHandler);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import * as db from '../services/db/index.db';
|
|||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
import { mockLogger } from '../tests/utils/mockLogger';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Define a reusable matcher for the logger object.
|
// Define a reusable matcher for the logger object.
|
||||||
@@ -40,12 +41,6 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
|
|
||||||
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
|
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('should return a list of flyers on success', async () => {
|
it('should return a list of flyers on success', async () => {
|
||||||
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
|
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Router } from 'express';
|
|||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { optionalNumeric } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,8 +11,8 @@ const router = Router();
|
|||||||
|
|
||||||
const getFlyersSchema = z.object({
|
const getFlyersSchema = z.object({
|
||||||
query: z.object({
|
query: z.object({
|
||||||
limit: z.coerce.number().int().positive().optional().default(20),
|
limit: optionalNumeric({ default: 20, integer: true, positive: true }),
|
||||||
offset: z.coerce.number().int().nonnegative().optional().default(0),
|
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ router.post(
|
|||||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
req.log.error({ error }, 'Error fetching batch flyer items');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,6 +128,7 @@ router.post(
|
|||||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
|
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
|
||||||
res.json({ count });
|
res.json({ count });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
req.log.error({ error }, 'Error counting batch flyer items');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ import gamificationRouter from './gamification.routes';
|
|||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// 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.
|
// Use vi.hoisted to create mutable mock function references.
|
||||||
@@ -86,12 +87,6 @@ describe('Gamification Routes (/api/achievements)', () => {
|
|||||||
basePath,
|
basePath,
|
||||||
authenticatedUser: mockAdminProfile,
|
authenticatedUser: mockAdminProfile,
|
||||||
});
|
});
|
||||||
const errorHandler = (err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
};
|
|
||||||
unauthenticatedApp.use(errorHandler);
|
|
||||||
authenticatedApp.use(errorHandler);
|
|
||||||
adminApp.use(errorHandler);
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('should return a list of all achievements (public endpoint)', async () => {
|
it('should return a list of all achievements (public endpoint)', async () => {
|
||||||
|
|||||||
@@ -7,19 +7,16 @@ import { logger } from '../services/logger.server';
|
|||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString, optionalNumeric } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
||||||
|
|
||||||
// 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 Gamification Routes (as per ADR-003) ---
|
// --- Zod Schemas for Gamification Routes (as per ADR-003) ---
|
||||||
|
|
||||||
const leaderboardSchema = z.object({
|
const leaderboardSchema = z.object({
|
||||||
query: z.object({
|
query: z.object({
|
||||||
limit: z.coerce.number().int().positive().max(50).optional().default(10),
|
limit: optionalNumeric({ default: 10, integer: true, positive: true, max: 50 }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ import healthRouter from './health.routes';
|
|||||||
import * as dbConnection from '../services/db/connection.db';
|
import * as dbConnection from '../services/db/connection.db';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean.
|
// Mock the logger to keep test output clean.
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// 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.
|
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
|
||||||
@@ -46,12 +47,6 @@ const { logger } = await import('../services/logger.server');
|
|||||||
// 2. Create a minimal Express app to host the router for testing.
|
// 2. Create a minimal Express app to host the router for testing.
|
||||||
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Health Routes (/api/health)', () => {
|
describe('Health Routes (/api/health)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear mock history before each test to ensure isolation.
|
// Clear mock history before each test to ensure isolation.
|
||||||
@@ -166,10 +161,14 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
const response = await supertest(app).get('/api/health/db-schema');
|
const response = await supertest(app).get('/api/health/db-schema');
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('DB connection failed');
|
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(response.body.stack).toBeDefined();
|
||||||
{ error: 'DB connection failed' },
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
'Error during DB schema check:',
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
err: expect.any(Error),
|
||||||
|
}),
|
||||||
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,10 +180,13 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
const response = await supertest(app).get('/api/health/db-schema');
|
const response = await supertest(app).get('/api/health/db-schema');
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('DB connection failed');
|
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
{ error: dbError },
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
'Error during DB schema check:',
|
expect.objectContaining({
|
||||||
|
err: expect.objectContaining({ message: 'DB connection failed' }),
|
||||||
|
}),
|
||||||
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -214,9 +216,11 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toContain('Storage check failed.');
|
expect(response.body.message).toContain('Storage check failed.');
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ error: 'EACCES: permission denied' },
|
expect.objectContaining({
|
||||||
expect.stringContaining('Storage check failed for path:'),
|
err: expect.any(Error),
|
||||||
|
}),
|
||||||
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,9 +235,11 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toContain('Storage check failed.');
|
expect(response.body.message).toContain('Storage check failed.');
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ error: accessError },
|
expect.objectContaining({
|
||||||
expect.stringContaining('Storage check failed for path:'),
|
err: expect.any(Error),
|
||||||
|
}),
|
||||||
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -288,10 +294,13 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
const response = await supertest(app).get('/api/health/db-pool');
|
const response = await supertest(app).get('/api/health/db-pool');
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('Pool is not initialized');
|
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
{ error: 'Pool is not initialized' },
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
'Error during DB pool health check:',
|
expect.objectContaining({
|
||||||
|
err: expect.any(Error),
|
||||||
|
}),
|
||||||
|
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,10 +314,51 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
const response = await supertest(app).get('/api/health/db-pool');
|
const response = await supertest(app).get('/api/health/db-pool');
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('Pool is not initialized');
|
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(response.body.stack).toBeDefined();
|
||||||
{ error: poolError },
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
'Error during DB pool health check:',
|
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+\)/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.' });
|
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(
|
if (error instanceof Error) {
|
||||||
{ error: error instanceof Error ? error.message : error },
|
return next(error);
|
||||||
'Error during DB schema check:',
|
}
|
||||||
);
|
const message =
|
||||||
next(error);
|
(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.
|
* This is important for features like file uploads.
|
||||||
*/
|
*/
|
||||||
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
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 {
|
try {
|
||||||
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
|
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
|
||||||
return res
|
return res
|
||||||
@@ -62,10 +63,6 @@ router.get('/storage', validateRequest(emptySchema), async (req, res, next: Next
|
|||||||
message: `Storage directory '${storagePath}' is accessible and writable.`,
|
message: `Storage directory '${storagePath}' is accessible and writable.`,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(
|
|
||||||
{ error: error instanceof Error ? error.message : error },
|
|
||||||
`Storage check failed for path: ${storagePath}`,
|
|
||||||
);
|
|
||||||
next(
|
next(
|
||||||
new Error(
|
new Error(
|
||||||
`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`,
|
`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}` });
|
.json({ success: false, message: `Pool may be under stress. ${message}` });
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(
|
if (error instanceof Error) {
|
||||||
{ error: error instanceof Error ? error.message : error },
|
return next(error);
|
||||||
'Error during DB pool health check:',
|
}
|
||||||
);
|
const message =
|
||||||
next(error);
|
(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
|
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
|
||||||
} catch (error: unknown) {
|
} 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));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import {
|
|||||||
createMockUserProfile,
|
createMockUserProfile,
|
||||||
createMockUserWithPasswordHash,
|
createMockUserWithPasswordHash,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
|
|
||||||
// Mock dependencies before importing the passport configuration
|
// Mock dependencies before importing the passport configuration
|
||||||
vi.mock('../services/db/index.db', () => ({
|
vi.mock('../services/db/index.db', () => ({
|
||||||
@@ -74,9 +73,10 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
|
|
||||||
const mockedDb = db as Mocked<typeof db>;
|
const mockedDb = db as Mocked<typeof db>;
|
||||||
|
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
// This mock is used by the module under test and can be imported in the test file.
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
logger: mockLogger,
|
// Note: We need to await the import inside the factory
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock bcrypt for password comparisons
|
// Mock bcrypt for password comparisons
|
||||||
|
|||||||
@@ -260,6 +260,13 @@ const jwtOptions = {
|
|||||||
secretOrKey: JWT_SECRET,
|
secretOrKey: JWT_SECRET,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- DEBUG LOGGING FOR JWT SECRET ---
|
||||||
|
if (!JWT_SECRET) {
|
||||||
|
logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.');
|
||||||
|
} else {
|
||||||
|
logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
|
||||||
|
}
|
||||||
|
|
||||||
passport.use(
|
passport.use(
|
||||||
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
createMockDietaryRestriction,
|
createMockDietaryRestriction,
|
||||||
createMockAppliance,
|
createMockAppliance,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
|
||||||
// 1. Mock the Service Layer directly.
|
// 1. Mock the Service Layer directly.
|
||||||
@@ -21,21 +20,17 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
// Import the router and mocked DB AFTER all mocks are defined.
|
// Import the router and mocked DB AFTER all mocks are defined.
|
||||||
import personalizationRouter from './personalization.routes';
|
import personalizationRouter from './personalization.routes';
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
|
import { mockLogger } from '../tests/utils/mockLogger';
|
||||||
|
|
||||||
// Mock the logger to keep test output clean
|
// Mock the logger to keep test output clean
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', async () => ({
|
||||||
logger: mockLogger,
|
// Use async import to avoid hoisting issues with mockLogger
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Personalization Routes (/api/personalization)', () => {
|
describe('Personalization Routes (/api/personalization)', () => {
|
||||||
const app = createTestApp({ router: personalizationRouter, basePath: '/api/personalization' });
|
const app = createTestApp({ router: personalizationRouter, basePath: '/api/personalization' });
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user