Compare commits
235 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0478e176d5 | ||
| 47f7f97cd9 | |||
|
|
b0719d1e39 | ||
| 0039ac3752 | |||
|
|
3c8316f4f7 | ||
| 2564df1c64 | |||
|
|
696c547238 | ||
| 38165bdb9a | |||
|
|
6139dca072 | ||
| 68bfaa50e6 | |||
|
|
9c42621f74 | ||
| 1b98282202 | |||
|
|
b6731b220c | ||
| 3507d455e8 | |||
|
|
92b2adf8e8 | ||
| d6c7452256 | |||
|
|
d812b681dd | ||
| b4306a6092 | |||
|
|
57fdd159d5 | ||
| 4a747ca042 | |||
|
|
e0bf96824c | ||
| e86e09703e | |||
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a | |||
|
|
e4acab77c8 | ||
| 4e20b1b430 | |||
|
|
15747ac942 | ||
| e5fa89ef17 | |||
|
|
2c65da31e9 | ||
| eeec6af905 | |||
|
|
e7d03951b9 | ||
| af8816e0af | |||
|
|
64f6427e1a | ||
| c9b7a75429 | |||
|
|
0490f6922e | ||
| 057c4c9174 | |||
|
|
a9e56bc707 | ||
| e5d09c73b7 | |||
|
|
6e1298b825 | ||
| fc8e43437a | |||
|
|
cb453aa949 | ||
| 2651bd16ae | |||
|
|
91e0f0c46f | ||
| e6986d512b | |||
|
|
8f9c21675c | ||
| 7fb22cdd20 | |||
|
|
780291303d | ||
| 4f607f7d2f | |||
|
|
208227b3ed | ||
| bf1c7d4adf | |||
|
|
a7a30cf983 | ||
| 0bc0676b33 | |||
|
|
73484d3eb4 | ||
| b3253d5bbc | |||
|
|
54f3769e90 | ||
| bad6f74ee6 | |||
|
|
bcf16168b6 | ||
| 498fbd9e0e | |||
|
|
007ff8e538 | ||
| 1fc70e3915 | |||
|
|
d891e47e02 | ||
| 08c39afde4 | |||
|
|
c579543b8a | ||
| 0d84137786 | |||
|
|
20ee30c4b4 | ||
| 93612137e3 | |||
|
|
6e70f08e3c | ||
| 459f5f7976 | |||
|
|
a2e6331ddd | ||
| 13cd30bec9 | |||
|
|
baeb9488c6 | ||
| 0cba0f987e | |||
|
|
958a79997d | ||
| 8fb1c96f93 | |||
| 6e6fe80c7f | |||
|
|
d1554050bd | ||
|
|
b1fae270bb | ||
|
|
c852483e18 | ||
| 2e01ad5bc9 | |||
|
|
26763c7183 | ||
| f0c5c2c45b | |||
|
|
034bb60fd5 | ||
| d4b389cb79 | |||
|
|
a71fb81468 | ||
| 9bee0a013b | |||
|
|
8bcb4311b3 | ||
| 9fd15f3a50 | |||
|
|
e3c876c7be | ||
| 32dcf3b89e | |||
| 7066b937f6 | |||
|
|
8553ea8811 | ||
| 19885a50f7 | |||
|
|
ce82034b9d | ||
| 4528da2934 | |||
|
|
146d4c1351 | ||
| 88625706f4 | |||
|
|
e395faed30 | ||
| e8f8399896 | |||
|
|
ac0115af2b | ||
| f24b15f19b | |||
|
|
e64426bd84 | ||
| 0ec4cd68d2 | |||
|
|
840516d2a3 | ||
| 59355c3eef | |||
| d024935fe9 | |||
|
|
5a5470634e | ||
| 392231ad63 | |||
|
|
4b1c896621 | ||
| 720920a51c | |||
|
|
460adb9506 | ||
| 7aa1f756a9 | |||
|
|
c484a8ca9b | ||
| 28d2c9f4ec | |||
|
|
ee253e9449 | ||
| b6c15e53d0 | |||
|
|
722162c2c3 | ||
| 02a76fe996 | |||
|
|
0ebb03a7ab | ||
| 748ac9e049 | |||
|
|
495edd621c | ||
| 4ffca19db6 | |||
|
|
717427c5d7 | ||
| cc438a0e36 | |||
|
|
a32a0b62fc | ||
| 342f72b713 | |||
|
|
91254d18f3 | ||
| 40580dbf15 | |||
| 7f1d74c047 | |||
|
|
ecec686347 | ||
| 86de680080 | |||
|
|
0371947065 | ||
| 296698758c | |||
|
|
18c1161587 | ||
| 0010396780 | |||
|
|
d4557e13fb | ||
| 3e41130c69 | |||
|
|
d9034563d6 | ||
| 5836a75157 | |||
|
|
790008ae0d | ||
|
|
b5b91eb968 | ||
| 38eb810e7a | |||
|
|
458588a6e7 | ||
| 0b4113417f | |||
|
|
b59d2a9533 | ||
| 6740b35f8a | |||
|
|
92ad82a012 | ||
| 672e4ca597 | |||
|
|
e4d70a9b37 | ||
| c30f1c4162 | |||
|
|
44062a9f5b | ||
| 17fac8cf86 | |||
|
|
9fa8553486 | ||
|
|
f5b0b3b543 | ||
| e3ed5c7e63 | |||
|
|
ae0040e092 | ||
| 1f3f99d430 | |||
|
|
7be72f1758 | ||
| 0967c7a33d | |||
| 1f1c0fa6f3 | |||
|
|
728b1a20d3 | ||
| f248f7cbd0 | |||
|
|
0ad9bb16c2 | ||
| 510787bc5b | |||
|
|
9f696e7676 | ||
|
|
a77105316f | ||
| cadacb63f5 | |||
|
|
62592f707e | ||
| 023e48d99a | |||
|
|
99efca0371 | ||
| 1448950b81 | |||
|
|
a811fdac63 | ||
| 1201fe4d3c | |||
|
|
ba9228c9cb | ||
| b392b82c25 | |||
|
|
87825d13d6 | ||
| 21a6a796cf | |||
|
|
ecd0a73bc8 | ||
|
|
39d61dc7ad | ||
|
|
43491359d9 | ||
| 5ed2cea7e9 | |||
|
|
cbb16a8d52 | ||
| 70e94a6ce0 | |||
|
|
b61a00003a | ||
| 52dba6f890 | |||
| 4242678aab | |||
|
|
b2e086d5ba | ||
| 07a9787570 | |||
|
|
4bf5dc3d58 | ||
| be3d269928 | |||
|
|
80a53fae94 | ||
| e15d2b6c2f | |||
|
|
7a52bf499e | ||
| 2489ec8d2d | |||
|
|
4a4f349805 | ||
| 517a268307 | |||
|
|
a94b2a97b1 | ||
| 542cdfbb82 | |||
|
|
262062f468 | ||
| 0a14193371 | |||
|
|
7f665f5117 | ||
| 2782a8fb3b | |||
|
|
c182ef6d30 | ||
| fdb3b76cbd | |||
|
|
01e7c843cb | ||
| a0dbefbfa0 | |||
|
|
ab3fc318a0 | ||
| e658b35e43 | |||
|
|
67e106162a | ||
| b7f3182fd6 | |||
|
|
ac60072d88 | ||
| 9390f38bf6 | |||
|
|
236d5518c9 | ||
| fd52a79a72 | |||
|
|
f72819e343 | ||
| 1af8be3f15 | |||
|
|
28d03f4e21 | ||
| 2e72ee81dd | |||
|
|
ba67ace190 | ||
|
|
50782c30e5 | ||
| 4a2ff8afc5 | |||
|
|
7a1c14ce89 | ||
| 6fafc3d089 | |||
|
|
4316866bce | ||
| 356c1a1894 | |||
|
|
2a310648ca | ||
| 8592633c22 | |||
|
|
0a9cdb8709 | ||
| 0d21e098f8 | |||
| b6799ed167 | |||
|
|
be5bda169e | ||
| 4ede403356 | |||
| 5d31605b80 | |||
| ddd4ad024e |
@@ -158,7 +158,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||||
fi
|
fi
|
||||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
|
||||||
echo "Production backend server reloaded successfully."
|
echo "Production backend server reloaded successfully."
|
||||||
else
|
else
|
||||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||||
@@ -185,7 +185,17 @@ jobs:
|
|||||||
- name: Show PM2 Environment for Production
|
- name: Show PM2 Environment for Production
|
||||||
run: |
|
run: |
|
||||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
||||||
sleep 5
|
sleep 5 # Wait a few seconds for the app to start and log its output.
|
||||||
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."
|
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||||
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."
|
PM2_ID=$(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.pm_id : ''); } catch(e) { console.log(''); }")
|
||||||
|
|
||||||
|
if [ -n "$PM2_ID" ]; then
|
||||||
|
echo "Found process ID: $PM2_ID"
|
||||||
|
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||||
|
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||||
|
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||||
|
else
|
||||||
|
echo "Could not find process 'flyer-crawler-api' in pm2 list."
|
||||||
|
pm2 list # Fallback to listing everything to help debug
|
||||||
|
fi
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||||
|
|
||||||
# --- Integration test specific variables ---
|
# --- Integration test specific variables ---
|
||||||
FRONTEND_URL: 'http://localhost:3000'
|
FRONTEND_URL: 'https://example.com'
|
||||||
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
||||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ jobs:
|
|||||||
|
|
||||||
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
||||||
# This is crucial for memory-intensive tasks like running tests and coverage.
|
# This is crucial for memory-intensive tasks like running tests and coverage.
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192 --trace-warnings --unhandled-rejections=strict'
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
# Fail-fast check to ensure secrets are configured in Gitea for testing.
|
# Fail-fast check to ensure secrets are configured in Gitea for testing.
|
||||||
@@ -151,6 +151,9 @@ jobs:
|
|||||||
--coverage.exclude='src/db/**' \
|
--coverage.exclude='src/db/**' \
|
||||||
--coverage.exclude='src/lib/**' \
|
--coverage.exclude='src/lib/**' \
|
||||||
--coverage.exclude='src/types/**' \
|
--coverage.exclude='src/types/**' \
|
||||||
|
--coverage.exclude='**/index.tsx' \
|
||||||
|
--coverage.exclude='**/vite-env.d.ts' \
|
||||||
|
--coverage.exclude='**/vitest.setup.ts' \
|
||||||
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
|
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
|
||||||
|
|
||||||
echo "--- Running Integration Tests ---"
|
echo "--- Running Integration Tests ---"
|
||||||
@@ -162,6 +165,9 @@ jobs:
|
|||||||
--coverage.exclude='src/db/**' \
|
--coverage.exclude='src/db/**' \
|
||||||
--coverage.exclude='src/lib/**' \
|
--coverage.exclude='src/lib/**' \
|
||||||
--coverage.exclude='src/types/**' \
|
--coverage.exclude='src/types/**' \
|
||||||
|
--coverage.exclude='**/index.tsx' \
|
||||||
|
--coverage.exclude='**/vite-env.d.ts' \
|
||||||
|
--coverage.exclude='**/vitest.setup.ts' \
|
||||||
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||||
|
|
||||||
echo "--- Running E2E Tests ---"
|
echo "--- Running E2E Tests ---"
|
||||||
@@ -175,6 +181,9 @@ jobs:
|
|||||||
--coverage.exclude='src/db/**' \
|
--coverage.exclude='src/db/**' \
|
||||||
--coverage.exclude='src/lib/**' \
|
--coverage.exclude='src/lib/**' \
|
||||||
--coverage.exclude='src/types/**' \
|
--coverage.exclude='src/types/**' \
|
||||||
|
--coverage.exclude='**/index.tsx' \
|
||||||
|
--coverage.exclude='**/vite-env.d.ts' \
|
||||||
|
--coverage.exclude='**/vitest.setup.ts' \
|
||||||
--reporter=verbose --no-file-parallelism || true
|
--reporter=verbose --no-file-parallelism || true
|
||||||
|
|
||||||
# Re-enable secret masking for subsequent steps.
|
# Re-enable secret masking for subsequent steps.
|
||||||
@@ -246,7 +255,10 @@ jobs:
|
|||||||
--temp-dir "$NYC_SOURCE_DIR" \
|
--temp-dir "$NYC_SOURCE_DIR" \
|
||||||
--exclude "**/*.test.ts" \
|
--exclude "**/*.test.ts" \
|
||||||
--exclude "**/tests/**" \
|
--exclude "**/tests/**" \
|
||||||
--exclude "**/mocks/**"
|
--exclude "**/mocks/**" \
|
||||||
|
--exclude "**/index.tsx" \
|
||||||
|
--exclude "**/vite-env.d.ts" \
|
||||||
|
--exclude "**/vitest.setup.ts"
|
||||||
|
|
||||||
# Re-enable secret masking for subsequent steps.
|
# Re-enable secret masking for subsequent steps.
|
||||||
echo "::secret-masking::"
|
echo "::secret-masking::"
|
||||||
@@ -259,16 +271,6 @@ jobs:
|
|||||||
if: always() # This step runs even if the previous test or coverage steps failed.
|
if: always() # This step runs even if the previous test or coverage steps failed.
|
||||||
run: echo "Skipping test artifact cleanup on runner; this is handled on the server."
|
run: echo "Skipping test artifact cleanup on runner; this is handled on the server."
|
||||||
|
|
||||||
- name: Deploy Coverage Report to Public URL
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
TARGET_DIR="/var/www/flyer-crawler-test.projectium.com/coverage"
|
|
||||||
echo "Deploying HTML coverage report to $TARGET_DIR..."
|
|
||||||
mkdir -p "$TARGET_DIR"
|
|
||||||
rm -rf "$TARGET_DIR"/*
|
|
||||||
cp -r .coverage/* "$TARGET_DIR/"
|
|
||||||
echo "✅ Coverage report deployed to https://flyer-crawler-test.projectium.com/coverage"
|
|
||||||
|
|
||||||
- name: Archive Code Coverage Report
|
- name: Archive Code Coverage Report
|
||||||
# This action saves the generated HTML coverage report as a downloadable artifact.
|
# This action saves the generated HTML coverage report as a downloadable artifact.
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -333,7 +335,8 @@ 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 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
# Sanitize commit message to prevent shell injection or build breaks (removes quotes, backticks, backslashes, $)
|
||||||
|
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s | tr -d '"`\\$')
|
||||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
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_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 }}" \
|
||||||
@@ -358,6 +361,17 @@ jobs:
|
|||||||
rsync -avz dist/ "$APP_PATH"
|
rsync -avz dist/ "$APP_PATH"
|
||||||
echo "Application deployment complete."
|
echo "Application deployment complete."
|
||||||
|
|
||||||
|
- name: Deploy Coverage Report to Public URL
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
TARGET_DIR="/var/www/flyer-crawler-test.projectium.com/coverage"
|
||||||
|
echo "Deploying HTML coverage report to $TARGET_DIR..."
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
rm -rf "$TARGET_DIR"/*
|
||||||
|
# The merged nyc report is generated in the .coverage directory. We copy its contents.
|
||||||
|
cp -r .coverage/* "$TARGET_DIR/"
|
||||||
|
echo "✅ Coverage report deployed to https://flyer-crawler-test.projectium.com/coverage"
|
||||||
|
|
||||||
- name: Install Backend Dependencies and Restart Test Server
|
- name: Install Backend Dependencies and Restart Test Server
|
||||||
env:
|
env:
|
||||||
# --- Test Secrets Injection ---
|
# --- Test Secrets Injection ---
|
||||||
@@ -375,8 +389,8 @@ jobs:
|
|||||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||||
|
|
||||||
# Application Secrets
|
# Application Secrets
|
||||||
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
|
FRONTEND_URL: 'https://example.com'
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }}
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
|
|
||||||
@@ -390,8 +404,15 @@ jobs:
|
|||||||
|
|
||||||
run: |
|
run: |
|
||||||
# Fail-fast check to ensure secrets are configured in Gitea.
|
# Fail-fast check to ensure secrets are configured in Gitea.
|
||||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
MISSING_SECRETS=""
|
||||||
echo "ERROR: One or more test database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_TEST) are not set in Gitea repository settings."
|
if [ -z "$DB_HOST" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_HOST"; fi
|
||||||
|
if [ -z "$DB_USER" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_USER"; fi
|
||||||
|
if [ -z "$DB_PASSWORD" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_PASSWORD"; fi
|
||||||
|
if [ -z "$DB_NAME" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_NAME"; fi
|
||||||
|
if [ -z "$JWT_SECRET" ]; then MISSING_SECRETS="${MISSING_SECRETS} JWT_SECRET"; fi
|
||||||
|
|
||||||
|
if [ ! -z "$MISSING_SECRETS" ]; then
|
||||||
|
echo "ERROR: The following required secrets are missing in Gitea:${MISSING_SECRETS}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -406,7 +427,7 @@ jobs:
|
|||||||
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
||||||
# It will START the process if it's not running, or RELOAD it if it is.
|
# It will START the process if it's not running, or RELOAD it if it is.
|
||||||
# We also add `&& pm2 save` to persist the process list across server reboots.
|
# We also add `&& pm2 save` to persist the process list across server reboots.
|
||||||
pm2 startOrReload ecosystem.config.cjs --env test && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env test --update-env && pm2 save
|
||||||
echo "Test backend server reloaded successfully."
|
echo "Test backend server reloaded successfully."
|
||||||
|
|
||||||
# After a successful deployment, update the schema hash in the database.
|
# After a successful deployment, update the schema hash in the database.
|
||||||
@@ -441,7 +462,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api-test ---"
|
echo "--- Displaying recent PM2 logs for flyer-crawler-api-test ---"
|
||||||
# After a reload, the server restarts. We'll show the last 20 lines of the log to see the startup messages.
|
# After a reload, the server restarts. We'll show the last 20 lines of the log to see the startup messages.
|
||||||
sleep 5 # Wait a few seconds for the app to start and log its output.
|
sleep 5
|
||||||
pm2 describe flyer-crawler-api-test || echo "Could not find test pm2 process."
|
|
||||||
pm2 logs flyer-crawler-api-test --lines 20 --nostream || echo "Could not find test pm2 process."
|
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||||
pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process."
|
PM2_ID=$(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-test'); console.log(app ? app.pm2_env.pm_id : ''); } catch(e) { console.log(''); }")
|
||||||
|
|
||||||
|
if [ -n "$PM2_ID" ]; then
|
||||||
|
echo "Found process ID: $PM2_ID"
|
||||||
|
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||||
|
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||||
|
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||||
|
else
|
||||||
|
echo "Could not find process 'flyer-crawler-api-test' in pm2 list."
|
||||||
|
pm2 list # Fallback to listing everything to help debug
|
||||||
|
fi
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||||
fi
|
fi
|
||||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
|
||||||
echo "Production backend server reloaded successfully."
|
echo "Production backend server reloaded successfully."
|
||||||
else
|
else
|
||||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ADR-027: Standardized Naming Convention for AI and Database Types
|
||||||
|
|
||||||
|
**Date**: 2026-01-05
|
||||||
|
|
||||||
|
**Status**: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The application codebase primarily follows the standard TypeScript convention of `camelCase` for variable and property names. However, the PostgreSQL database uses `snake_case` for column names. Additionally, the AI prompts are designed to extract data that maps directly to these database columns.
|
||||||
|
|
||||||
|
Attempting to enforce `camelCase` strictly across the entire stack created friction and ambiguity, particularly in the background processing pipeline where data moves from the AI model directly to the database. Developers were unsure whether to transform keys immediately upon receipt (adding overhead) or keep them as-is.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will adopt a hybrid naming convention strategy to explicitly distinguish between internal application state and external/persisted data formats.
|
||||||
|
|
||||||
|
1. **Database and AI Types (`snake_case`)**:
|
||||||
|
Interfaces, Type definitions, and Zod schemas that represent raw database rows or direct AI responses **MUST** use `snake_case`.
|
||||||
|
- *Examples*: `AiFlyerDataSchema`, `ExtractedFlyerItemSchema`, `FlyerInsert`.
|
||||||
|
- *Reasoning*: This avoids unnecessary mapping layers when inserting data into the database or parsing AI output. It serves as a visual cue that the data is "raw", "external", or destined for persistence.
|
||||||
|
|
||||||
|
2. **Internal Application Logic (`camelCase`)**:
|
||||||
|
Variables, function arguments, and processed data structures used within the application logic (Service layer, UI components, utility functions) **MUST** use `camelCase`.
|
||||||
|
- *Reasoning*: This adheres to standard JavaScript/TypeScript practices and maintains consistency with the rest of the ecosystem (React, etc.).
|
||||||
|
|
||||||
|
3. **Boundary Handling**:
|
||||||
|
- For background jobs that primarily move data from AI to DB, preserving `snake_case` is preferred to minimize transformation logic.
|
||||||
|
- For API responses sent to the frontend, data should generally be transformed to `camelCase` unless it is a direct dump of a database entity for a specific administrative view.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Visual Distinction**: It is immediately obvious whether a variable holds raw data (`price_in_cents`) or processed application state (`priceInCents`).
|
||||||
|
- **Efficiency**: Reduces boilerplate code for mapping keys (e.g., `price_in_cents: data.priceInCents`) when performing bulk inserts or updates.
|
||||||
|
- **Simplicity**: AI prompts can request JSON keys that match the database schema 1:1, reducing the risk of mapping errors.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **Context Switching**: Developers must be mindful of the casing context.
|
||||||
|
- **Linter Configuration**: May require specific overrides or `// eslint-disable-next-line` comments if the linter is configured to strictly enforce `camelCase` everywhere.
|
||||||
@@ -3,14 +3,54 @@
|
|||||||
// 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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared Environment Variables ---
|
||||||
|
// Define common variables to reduce duplication and ensure consistency across apps.
|
||||||
|
const sharedEnv = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
// --- API Server ---
|
// --- API Server ---
|
||||||
name: 'flyer-crawler-api',
|
name: 'flyer-crawler-api',
|
||||||
|
// Note: The process names below are referenced in .gitea/workflows/ for status checks.
|
||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'server.ts',
|
args: 'server.ts',
|
||||||
max_memory_restart: '500M',
|
max_memory_restart: '500M',
|
||||||
|
// Production Optimization: Run in cluster mode to utilize all CPU cores
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
kill_timeout: 5000, // Allow 5s for graceful shutdown of API requests
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
|
||||||
// Restart Logic
|
// Restart Logic
|
||||||
max_restarts: 40,
|
max_restarts: 40,
|
||||||
@@ -22,44 +62,16 @@ module.exports = {
|
|||||||
NODE_ENV: '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,
|
WORKER_LOCK_DURATION: '120000',
|
||||||
DB_USER: process.env.DB_USER,
|
...sharedEnv,
|
||||||
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: 'test',
|
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,
|
WORKER_LOCK_DURATION: '120000',
|
||||||
DB_USER: process.env.DB_USER,
|
...sharedEnv,
|
||||||
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
|
// Development Environment Settings
|
||||||
env_development: {
|
env_development: {
|
||||||
@@ -67,22 +79,8 @@ module.exports = {
|
|||||||
name: 'flyer-crawler-api-dev',
|
name: 'flyer-crawler-api-dev',
|
||||||
watch: true,
|
watch: true,
|
||||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
DB_HOST: process.env.DB_HOST,
|
WORKER_LOCK_DURATION: '120000',
|
||||||
DB_USER: process.env.DB_USER,
|
...sharedEnv,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,6 +89,8 @@ module.exports = {
|
|||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'src/services/worker.ts',
|
args: 'src/services/worker.ts',
|
||||||
max_memory_restart: '1G',
|
max_memory_restart: '1G',
|
||||||
|
kill_timeout: 10000, // Workers may need more time to complete a job
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
|
||||||
// Restart Logic
|
// Restart Logic
|
||||||
max_restarts: 40,
|
max_restarts: 40,
|
||||||
@@ -102,44 +102,14 @@ module.exports = {
|
|||||||
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,
|
...sharedEnv,
|
||||||
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: 'test',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-worker-test',
|
name: 'flyer-crawler-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
DB_HOST: process.env.DB_HOST,
|
...sharedEnv,
|
||||||
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
|
// Development Environment Settings
|
||||||
env_development: {
|
env_development: {
|
||||||
@@ -147,22 +117,7 @@ module.exports = {
|
|||||||
name: 'flyer-crawler-worker-dev',
|
name: 'flyer-crawler-worker-dev',
|
||||||
watch: true,
|
watch: true,
|
||||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
DB_HOST: process.env.DB_HOST,
|
...sharedEnv,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -171,6 +126,8 @@ module.exports = {
|
|||||||
script: './node_modules/.bin/tsx',
|
script: './node_modules/.bin/tsx',
|
||||||
args: 'src/services/worker.ts',
|
args: 'src/services/worker.ts',
|
||||||
max_memory_restart: '1G',
|
max_memory_restart: '1G',
|
||||||
|
kill_timeout: 10000,
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
|
||||||
// Restart Logic
|
// Restart Logic
|
||||||
max_restarts: 40,
|
max_restarts: 40,
|
||||||
@@ -182,44 +139,14 @@ module.exports = {
|
|||||||
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,
|
...sharedEnv,
|
||||||
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: 'test',
|
NODE_ENV: 'test',
|
||||||
name: 'flyer-crawler-analytics-worker-test',
|
name: 'flyer-crawler-analytics-worker-test',
|
||||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||||
DB_HOST: process.env.DB_HOST,
|
...sharedEnv,
|
||||||
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
|
// Development Environment Settings
|
||||||
env_development: {
|
env_development: {
|
||||||
@@ -227,22 +154,7 @@ module.exports = {
|
|||||||
name: 'flyer-crawler-analytics-worker-dev',
|
name: 'flyer-crawler-analytics-worker-dev',
|
||||||
watch: true,
|
watch: true,
|
||||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||||
DB_HOST: process.env.DB_HOST,
|
...sharedEnv,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ RULES:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
latest refacter
|
||||||
|
|
||||||
|
Refactor `RecipeSuggester.test.tsx` to use `renderWithProviders`.
|
||||||
|
Create a new test file for `StatCard.tsx` to verify its props and rendering.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
while assuming that master_schema_rollup.sql is the "ultimate source of truth", issues can happen and it may not have been properly
|
||||||
|
updated - look for differences between these files
|
||||||
|
|
||||||
|
|
||||||
UPC SCANNING !
|
UPC SCANNING !
|
||||||
|
|||||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.2.10",
|
"version": "0.9.38",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.2.10",
|
"version": "0.9.38",
|
||||||
"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",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"connect-timeout": "^1.9.1",
|
"connect-timeout": "^1.9.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exif-parser": "^0.1.12",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-list-endpoints": "^7.1.1",
|
"express-list-endpoints": "^7.1.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pdfjs-dist": "^5.4.394",
|
"pdfjs-dist": "^5.4.394",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"piexifjs": "^1.0.6",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/piexifjs": "^1.0.0",
|
||||||
"@types/pino": "^7.0.4",
|
"@types/pino": "^7.0.4",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -5435,6 +5438,13 @@
|
|||||||
"pg-types": "^2.2.0"
|
"pg-types": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/piexifjs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/piexifjs/-/piexifjs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-PPiGeCkmkZQgYjvqtjD3kp4OkbCox2vEFVuK4DaLVOIazJLAXk+/ujbizkIPH5CN4AnN9Clo5ckzUlaj3+SzCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/pino": {
|
"node_modules/@types/pino": {
|
||||||
"version": "7.0.4",
|
"version": "7.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
|
||||||
@@ -8965,6 +8975,11 @@
|
|||||||
"bare-events": "^2.7.0"
|
"bare-events": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exif-parser": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -13363,6 +13378,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/piexifjs": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/piexifjs/-/piexifjs-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.10",
|
"version": "0.9.38",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"connect-timeout": "^1.9.1",
|
"connect-timeout": "^1.9.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exif-parser": "^0.1.12",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-list-endpoints": "^7.1.1",
|
"express-list-endpoints": "^7.1.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pdfjs-dist": "^5.4.394",
|
"pdfjs-dist": "^5.4.394",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"piexifjs": "^1.0.6",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/piexifjs": "^1.0.0",
|
||||||
"@types/pino": "^7.0.4",
|
"@types/pino": "^7.0.4",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,477 +1,8 @@
|
|||||||
-- sql/Initial_triggers_and_functions.sql
|
-- sql/Initial_triggers_and_functions.sql
|
||||||
-- This file contains all trigger functions and trigger definitions for the database.
|
-- This file contains all trigger functions and trigger definitions for the database.
|
||||||
|
|
||||||
-- 1. Set up the trigger to automatically create a profile when a new user signs up.
|
|
||||||
-- This function is called by a trigger on the `public.users` table.
|
|
||||||
DROP FUNCTION IF EXISTS public.handle_new_user();
|
|
||||||
|
|
||||||
-- It creates a corresponding profile and a default shopping list for the new user.
|
|
||||||
-- It now accepts full_name and avatar_url from the user's metadata.
|
|
||||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
new_profile_id UUID;
|
|
||||||
user_meta_data JSONB;
|
|
||||||
BEGIN
|
|
||||||
-- The user's metadata (full_name, avatar_url) is passed via a temporary session variable.
|
|
||||||
user_meta_data := current_setting('my_app.user_metadata', true)::JSONB;
|
|
||||||
|
|
||||||
INSERT INTO public.profiles (user_id, role, full_name, avatar_url)
|
|
||||||
VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url')
|
|
||||||
RETURNING user_id INTO new_profile_id;
|
|
||||||
|
|
||||||
-- Also create a default shopping list for the new user.
|
|
||||||
INSERT INTO public.shopping_lists (user_id, name)
|
|
||||||
VALUES (new.user_id, 'Main Shopping List');
|
|
||||||
|
|
||||||
-- Log the new user event
|
|
||||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
||||||
VALUES (new.user_id, 'user_registered',
|
|
||||||
COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.',
|
|
||||||
'user-plus',
|
|
||||||
jsonb_build_object('email', new.email)
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN new;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- This trigger calls the function after a new user is created.
|
|
||||||
DROP TRIGGER IF EXISTS on_auth_user_created ON public.users;
|
|
||||||
CREATE TRIGGER on_auth_user_created
|
|
||||||
AFTER INSERT ON public.users
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
|
||||||
|
|
||||||
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
|
||||||
DROP FUNCTION IF EXISTS public.handle_updated_at();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = now();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Dynamically apply the 'handle_updated_at' trigger to all tables in the public schema
|
|
||||||
-- that have an 'updated_at' column. This is more maintainable than creating a separate
|
|
||||||
-- trigger for each table.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
t_name TEXT;
|
|
||||||
BEGIN
|
|
||||||
FOR t_name IN
|
|
||||||
SELECT table_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND column_name = 'updated_at'
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('DROP TRIGGER IF EXISTS on_%s_updated ON public.%I;
|
|
||||||
CREATE TRIGGER on_%s_updated
|
|
||||||
BEFORE UPDATE ON public.%I
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();',
|
|
||||||
t_name, t_name, t_name, t_name);
|
|
||||||
END LOOP;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- 3. Create a trigger function to populate the item_price_history table on insert.
|
|
||||||
DROP FUNCTION IF EXISTS public.update_price_history_on_flyer_item_insert();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.update_price_history_on_flyer_item_insert()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
flyer_valid_from DATE;
|
|
||||||
flyer_valid_to DATE;
|
|
||||||
current_summary_date DATE;
|
|
||||||
flyer_location_id BIGINT;
|
|
||||||
BEGIN
|
|
||||||
-- If the item could not be matched, add it to the unmatched queue for review.
|
|
||||||
IF NEW.master_item_id IS NULL THEN
|
|
||||||
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
|
|
||||||
VALUES (NEW.flyer_item_id)
|
|
||||||
ON CONFLICT (flyer_item_id) DO NOTHING;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Only run if the new flyer item is linked to a master item and has a price.
|
|
||||||
IF NEW.master_item_id IS NULL OR NEW.price_in_cents IS NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Get the validity dates of the flyer and the store_id.
|
|
||||||
SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to
|
|
||||||
FROM public.flyers
|
|
||||||
WHERE flyer_id = NEW.flyer_id;
|
|
||||||
|
|
||||||
-- This single, set-based query is much more performant than looping.
|
|
||||||
-- It generates all date/location pairs and inserts/updates them in one operation.
|
|
||||||
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
|
|
||||||
SELECT
|
|
||||||
NEW.master_item_id,
|
|
||||||
d.day,
|
|
||||||
fl.store_location_id,
|
|
||||||
NEW.price_in_cents,
|
|
||||||
NEW.price_in_cents,
|
|
||||||
NEW.price_in_cents,
|
|
||||||
1
|
|
||||||
FROM public.flyer_locations fl
|
|
||||||
CROSS JOIN generate_series(flyer_valid_from, flyer_valid_to, '1 day'::interval) AS d(day)
|
|
||||||
WHERE fl.flyer_id = NEW.flyer_id
|
|
||||||
ON CONFLICT (master_item_id, summary_date, store_location_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
min_price_in_cents = LEAST(item_price_history.min_price_in_cents, EXCLUDED.min_price_in_cents),
|
|
||||||
max_price_in_cents = GREATEST(item_price_history.max_price_in_cents, EXCLUDED.max_price_in_cents),
|
|
||||||
avg_price_in_cents = ROUND(((item_price_history.avg_price_in_cents * item_price_history.data_points_count) + EXCLUDED.avg_price_in_cents) / (item_price_history.data_points_count + 1.0)),
|
|
||||||
data_points_count = item_price_history.data_points_count + 1;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Create the trigger on the flyer_items table for insert.
|
|
||||||
DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items;
|
|
||||||
CREATE TRIGGER trigger_update_price_history
|
|
||||||
AFTER INSERT ON public.flyer_items
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.update_price_history_on_flyer_item_insert();
|
|
||||||
|
|
||||||
-- 4. Create a trigger function to recalculate price history when a flyer item is deleted.
|
|
||||||
DROP FUNCTION IF EXISTS public.recalculate_price_history_on_flyer_item_delete();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.recalculate_price_history_on_flyer_item_delete()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
affected_dates RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Only run if the deleted item was linked to a master item and had a price.
|
|
||||||
IF OLD.master_item_id IS NULL OR OLD.price_in_cents IS NULL THEN
|
|
||||||
RETURN OLD;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- This single, set-based query is much more performant than looping.
|
|
||||||
-- It recalculates aggregates for all affected dates and locations at once.
|
|
||||||
WITH affected_days_and_locations AS (
|
|
||||||
-- 1. Get all date/location pairs affected by the deleted item's flyer.
|
|
||||||
SELECT DISTINCT
|
|
||||||
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
|
|
||||||
fl.store_location_id
|
|
||||||
FROM public.flyers f
|
|
||||||
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
|
|
||||||
WHERE f.flyer_id = OLD.flyer_id
|
|
||||||
),
|
|
||||||
new_aggregates AS (
|
|
||||||
-- 2. For each affected date/location, recalculate the aggregates from all other relevant flyer items.
|
|
||||||
SELECT
|
|
||||||
adl.summary_date,
|
|
||||||
adl.store_location_id,
|
|
||||||
MIN(fi.price_in_cents) AS min_price,
|
|
||||||
MAX(fi.price_in_cents) AS max_price,
|
|
||||||
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
|
|
||||||
COUNT(fi.flyer_item_id)::int AS data_points
|
|
||||||
FROM affected_days_and_locations adl
|
|
||||||
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
|
|
||||||
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
|
|
||||||
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
|
|
||||||
WHERE fl.flyer_id IS NOT NULL -- Ensure the join was successful
|
|
||||||
GROUP BY adl.summary_date, adl.store_location_id
|
|
||||||
)
|
|
||||||
-- 3. Update the history table with the new aggregates.
|
|
||||||
UPDATE public.item_price_history iph
|
|
||||||
SET
|
|
||||||
min_price_in_cents = na.min_price,
|
|
||||||
max_price_in_cents = na.max_price,
|
|
||||||
avg_price_in_cents = na.avg_price,
|
|
||||||
data_points_count = na.data_points
|
|
||||||
FROM new_aggregates na
|
|
||||||
WHERE iph.master_item_id = OLD.master_item_id
|
|
||||||
AND iph.summary_date = na.summary_date
|
|
||||||
AND iph.store_location_id = na.store_location_id;
|
|
||||||
|
|
||||||
-- 4. Delete any history records that no longer have any data points.
|
|
||||||
DELETE FROM public.item_price_history iph
|
|
||||||
WHERE iph.master_item_id = OLD.master_item_id
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM new_aggregates na
|
|
||||||
WHERE na.summary_date = iph.summary_date AND na.store_location_id = iph.store_location_id
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN OLD;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Create the trigger on the flyer_items table for DELETE operations.
|
|
||||||
DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items;
|
|
||||||
CREATE TRIGGER trigger_recalculate_price_history_on_delete
|
|
||||||
AFTER DELETE ON public.flyer_items
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.recalculate_price_history_on_flyer_item_delete();
|
|
||||||
|
|
||||||
-- 5. Trigger function to update the average rating on the recipes table.
|
|
||||||
DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE public.recipes
|
|
||||||
SET
|
|
||||||
avg_rating = (
|
|
||||||
SELECT AVG(rating)
|
|
||||||
FROM public.recipe_ratings
|
|
||||||
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
|
|
||||||
),
|
|
||||||
rating_count = (
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM public.recipe_ratings
|
|
||||||
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
|
|
||||||
)
|
|
||||||
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
|
|
||||||
|
|
||||||
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after any change to recipe_ratings.
|
|
||||||
DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings;
|
|
||||||
CREATE TRIGGER on_recipe_rating_change
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON public.recipe_ratings
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.update_recipe_rating_aggregates();
|
|
||||||
|
|
||||||
-- 6. Trigger function to log the creation of a new recipe.
|
|
||||||
DROP FUNCTION IF EXISTS public.log_new_recipe();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.log_new_recipe()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
||||||
VALUES (
|
|
||||||
NEW.user_id,
|
|
||||||
'recipe_created',
|
|
||||||
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
|
|
||||||
'chef-hat',
|
|
||||||
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Award 'First Recipe' achievement if it's their first one.
|
|
||||||
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after a new recipe is inserted.
|
|
||||||
DROP TRIGGER IF EXISTS on_new_recipe_created ON public.recipes;
|
|
||||||
CREATE TRIGGER on_new_recipe_created
|
|
||||||
AFTER INSERT ON public.recipes
|
|
||||||
FOR EACH ROW
|
|
||||||
WHEN (NEW.user_id IS NOT NULL) -- Only log activity for user-created recipes.
|
|
||||||
EXECUTE FUNCTION public.log_new_recipe();
|
|
||||||
|
|
||||||
-- 7a. Trigger function to update the item_count on the flyers table.
|
|
||||||
DROP FUNCTION IF EXISTS public.update_flyer_item_count();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF (TG_OP = 'INSERT') THEN
|
|
||||||
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
|
|
||||||
ELSIF (TG_OP = 'DELETE') THEN
|
|
||||||
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
|
|
||||||
END IF;
|
|
||||||
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after any change to flyer_items.
|
|
||||||
-- This ensures the item_count on the parent flyer is always accurate.
|
|
||||||
DROP TRIGGER IF EXISTS on_flyer_item_change ON public.flyer_items;
|
|
||||||
CREATE TRIGGER on_flyer_item_change
|
|
||||||
AFTER INSERT OR DELETE ON public.flyer_items
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.update_flyer_item_count();
|
|
||||||
|
|
||||||
-- 7. Trigger function to log the creation of a new flyer.
|
|
||||||
DROP FUNCTION IF EXISTS public.log_new_flyer();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.log_new_flyer()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.activity_log (action, display_text, icon, details)
|
|
||||||
VALUES (
|
|
||||||
'flyer_uploaded',
|
|
||||||
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
|
|
||||||
'file-text',
|
|
||||||
jsonb_build_object(
|
|
||||||
'flyer_id', NEW.flyer_id,
|
|
||||||
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
|
|
||||||
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
|
|
||||||
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after a new flyer is inserted.
|
|
||||||
DROP TRIGGER IF EXISTS on_new_flyer_created ON public.flyers;
|
|
||||||
CREATE TRIGGER on_new_flyer_created
|
|
||||||
AFTER INSERT ON public.flyers
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_flyer();
|
|
||||||
|
|
||||||
-- 8. Trigger function to log when a user favorites a recipe.
|
|
||||||
DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
||||||
VALUES (
|
|
||||||
NEW.user_id,
|
|
||||||
'recipe_favorited',
|
|
||||||
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
|
|
||||||
'heart',
|
|
||||||
jsonb_build_object(
|
|
||||||
'recipe_id', NEW.recipe_id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Award 'First Favorite' achievement.
|
|
||||||
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after a recipe is favorited.
|
|
||||||
DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes;
|
|
||||||
CREATE TRIGGER on_new_favorite_recipe
|
|
||||||
AFTER INSERT ON public.favorite_recipes
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe();
|
|
||||||
|
|
||||||
-- 9. Trigger function to log when a user shares a shopping list.
|
|
||||||
DROP FUNCTION IF EXISTS public.log_new_list_share();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.log_new_list_share()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
||||||
VALUES (
|
|
||||||
NEW.shared_by_user_id,
|
|
||||||
'list_shared',
|
|
||||||
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
|
|
||||||
'share-2',
|
|
||||||
jsonb_build_object(
|
|
||||||
'shopping_list_id', NEW.shopping_list_id,
|
|
||||||
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
|
|
||||||
'shared_with_user_id', NEW.shared_with_user_id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Award 'List Sharer' achievement.
|
|
||||||
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the function after a shopping list is shared.
|
|
||||||
DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists;
|
|
||||||
CREATE TRIGGER on_new_list_share
|
|
||||||
AFTER INSERT ON public.shared_shopping_lists
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share();
|
|
||||||
|
|
||||||
-- 9a. Trigger function to log when a user shares a recipe collection.
|
|
||||||
DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Log the activity
|
|
||||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
||||||
VALUES (
|
|
||||||
NEW.shared_by_user_id, 'recipe_collection_shared',
|
|
||||||
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
|
|
||||||
'book',
|
|
||||||
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Award 'Recipe Sharer' achievement.
|
|
||||||
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS on_new_recipe_collection_share ON public.shared_recipe_collections;
|
|
||||||
CREATE TRIGGER on_new_recipe_collection_share
|
|
||||||
AFTER INSERT ON public.shared_recipe_collections
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
|
|
||||||
|
|
||||||
-- 10. Trigger function to geocode a store location's address.
|
|
||||||
-- This function is designed to be extensible. In a production environment,
|
|
||||||
-- you would replace the placeholder with a call to an external geocoding service
|
|
||||||
-- (e.g., using the `http` extension or a `plpythonu` function) to convert
|
|
||||||
-- the address into geographic coordinates.
|
|
||||||
DROP FUNCTION IF EXISTS public.geocode_store_location();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.geocode_store_location()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
full_address TEXT;
|
|
||||||
BEGIN
|
|
||||||
-- Only proceed if the address has actually changed.
|
|
||||||
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.address IS DISTINCT FROM OLD.address) THEN
|
|
||||||
-- Concatenate address parts into a single string for the geocoder.
|
|
||||||
full_address := CONCAT_WS(', ', NEW.address, NEW.city, NEW.province_state, NEW.postal_code);
|
|
||||||
|
|
||||||
-- ======================================================================
|
|
||||||
-- Placeholder for Geocoding API Call
|
|
||||||
-- ======================================================================
|
|
||||||
-- In a real application, you would call a geocoding service here.
|
|
||||||
-- For example, using the `http` extension:
|
|
||||||
--
|
|
||||||
-- DECLARE
|
|
||||||
-- response http_get;
|
|
||||||
-- lat NUMERIC;
|
|
||||||
-- lon NUMERIC;
|
|
||||||
-- BEGIN
|
|
||||||
-- SELECT * INTO response FROM http_get('https://api.geocodingservice.com/geocode?address=' || url_encode(full_address));
|
|
||||||
-- lat := (response.content::jsonb)->'results'->0->'geometry'->'location'->'lat';
|
|
||||||
-- lon := (response.content::jsonb)->'results'->0->'geometry'->'location'->'lng';
|
|
||||||
-- NEW.location := ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography;
|
|
||||||
-- END;
|
|
||||||
--
|
|
||||||
-- For now, this function does nothing, but the trigger is in place.
|
|
||||||
-- If you manually provide lat/lon, you could parse them here.
|
|
||||||
-- For this example, we will assume the `location` might be set manually
|
|
||||||
-- or by a separate batch process.
|
|
||||||
-- ======================================================================
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger to call the geocoding function.
|
|
||||||
DROP TRIGGER IF EXISTS on_store_location_address_change ON public.store_locations;
|
|
||||||
CREATE TRIGGER on_store_location_address_change
|
|
||||||
BEFORE INSERT OR UPDATE ON public.store_locations
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.geocode_store_location();
|
|
||||||
|
|
||||||
-- 11. Trigger function to increment the fork_count on the original recipe.
|
|
||||||
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Only run if the recipe is a fork (original_recipe_id is not null).
|
|
||||||
IF NEW.original_recipe_id IS NOT NULL THEN
|
|
||||||
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
|
|
||||||
-- Award 'First Fork' achievement.
|
|
||||||
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
|
|
||||||
CREATE TRIGGER on_recipe_fork
|
|
||||||
AFTER INSERT ON public.recipes
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- PART 6: DATABASE FUNCTIONS
|
-- PART 3: DATABASE FUNCTIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Function to find the best current sale price for a user's watched items.
|
-- Function to find the best current sale price for a user's watched items.
|
||||||
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_user(UUID);
|
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_user(UUID);
|
||||||
@@ -1336,8 +867,7 @@ AS $$
|
|||||||
'list_shared'
|
'list_shared'
|
||||||
-- 'new_recipe_rating' could be added here later
|
-- 'new_recipe_rating' could be added here later
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY al.created_at DESC, al.display_text, al.icon
|
||||||
al.created_at DESC
|
|
||||||
LIMIT p_limit
|
LIMIT p_limit
|
||||||
OFFSET p_offset;
|
OFFSET p_offset;
|
||||||
$$;
|
$$;
|
||||||
@@ -1549,16 +1079,18 @@ $$;
|
|||||||
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
|
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
|
||||||
-- Returns: TABLE(...) - A set of records including user details and deal information.
|
-- Returns: TABLE(...) - A set of records including user details and deal information.
|
||||||
-- =================================================================
|
-- =================================================================
|
||||||
|
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
|
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
|
||||||
RETURNS TABLE(
|
RETURNS TABLE(
|
||||||
user_id uuid,
|
user_id uuid,
|
||||||
email text,
|
email text,
|
||||||
full_name text,
|
full_name text,
|
||||||
master_item_id integer,
|
master_item_id bigint,
|
||||||
item_name text,
|
item_name text,
|
||||||
best_price_in_cents integer,
|
best_price_in_cents integer,
|
||||||
store_name text,
|
store_name text,
|
||||||
flyer_id integer,
|
flyer_id bigint,
|
||||||
valid_to date
|
valid_to date
|
||||||
) AS $$
|
) AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -1569,11 +1101,12 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
fi.master_item_id,
|
fi.master_item_id,
|
||||||
fi.price_in_cents,
|
fi.price_in_cents,
|
||||||
f.store_name,
|
s.name as store_name,
|
||||||
f.flyer_id,
|
f.flyer_id,
|
||||||
f.valid_to
|
f.valid_to
|
||||||
FROM public.flyer_items fi
|
FROM public.flyer_items fi
|
||||||
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
||||||
|
JOIN public.stores s ON f.store_id = s.store_id
|
||||||
WHERE
|
WHERE
|
||||||
fi.master_item_id IS NOT NULL
|
fi.master_item_id IS NOT NULL
|
||||||
AND fi.price_in_cents IS NOT NULL
|
AND fi.price_in_cents IS NOT NULL
|
||||||
@@ -1616,3 +1149,472 @@ BEGIN
|
|||||||
bp.price_rank = 1;
|
bp.price_rank = 1;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PART 4: TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. Trigger to automatically create a profile when a new user signs up.
|
||||||
|
-- This function is called by a trigger on the `public.users` table.
|
||||||
|
DROP FUNCTION IF EXISTS public.handle_new_user();
|
||||||
|
|
||||||
|
-- It creates a corresponding profile and a default shopping list for the new user.
|
||||||
|
-- It now accepts full_name and avatar_url from the user's metadata.
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
new_profile_id UUID;
|
||||||
|
user_meta_data JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- The user's metadata (full_name, avatar_url) is passed via a temporary session variable.
|
||||||
|
user_meta_data := current_setting('my_app.user_metadata', true)::JSONB;
|
||||||
|
|
||||||
|
INSERT INTO public.profiles (user_id, role, full_name, avatar_url)
|
||||||
|
VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url')
|
||||||
|
RETURNING user_id INTO new_profile_id;
|
||||||
|
|
||||||
|
-- Also create a default shopping list for the new user.
|
||||||
|
INSERT INTO public.shopping_lists (user_id, name)
|
||||||
|
VALUES (new.user_id, 'Main Shopping List');
|
||||||
|
|
||||||
|
-- Log the new user event
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (new.user_id, 'user_registered',
|
||||||
|
COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.',
|
||||||
|
'user-plus',
|
||||||
|
jsonb_build_object('email', new.email)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN new;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- This trigger calls the function after a new user is created.
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON public.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
||||||
|
DROP FUNCTION IF EXISTS public.handle_updated_at();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Dynamically apply the 'handle_updated_at' trigger to all tables in the public schema
|
||||||
|
-- that have an 'updated_at' column. This is more maintainable than creating a separate
|
||||||
|
-- trigger for each table.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR t_name IN
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND column_name = 'updated_at'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS on_%s_updated ON public.%I;
|
||||||
|
CREATE TRIGGER on_%s_updated
|
||||||
|
BEFORE UPDATE ON public.%I
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();',
|
||||||
|
t_name, t_name, t_name, t_name);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 3. Create a trigger function to populate the item_price_history table on insert.
|
||||||
|
DROP FUNCTION IF EXISTS public.update_price_history_on_flyer_item_insert();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_price_history_on_flyer_item_insert()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
flyer_valid_from DATE;
|
||||||
|
flyer_valid_to DATE;
|
||||||
|
current_summary_date DATE;
|
||||||
|
flyer_location_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- If the item could not be matched, add it to the unmatched queue for review.
|
||||||
|
IF NEW.master_item_id IS NULL THEN
|
||||||
|
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
|
||||||
|
VALUES (NEW.flyer_item_id)
|
||||||
|
ON CONFLICT (flyer_item_id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Only run if the new flyer item is linked to a master item and has a price.
|
||||||
|
IF NEW.master_item_id IS NULL OR NEW.price_in_cents IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get the validity dates of the flyer and the store_id.
|
||||||
|
SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to
|
||||||
|
FROM public.flyers
|
||||||
|
WHERE flyer_id = NEW.flyer_id;
|
||||||
|
|
||||||
|
-- This single, set-based query is much more performant than looping.
|
||||||
|
-- It generates all date/location pairs and inserts/updates them in one operation.
|
||||||
|
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
|
||||||
|
SELECT
|
||||||
|
NEW.master_item_id,
|
||||||
|
d.day,
|
||||||
|
fl.store_location_id,
|
||||||
|
NEW.price_in_cents,
|
||||||
|
NEW.price_in_cents,
|
||||||
|
NEW.price_in_cents,
|
||||||
|
1
|
||||||
|
FROM public.flyer_locations fl
|
||||||
|
CROSS JOIN generate_series(flyer_valid_from, flyer_valid_to, '1 day'::interval) AS d(day)
|
||||||
|
WHERE fl.flyer_id = NEW.flyer_id
|
||||||
|
ON CONFLICT (master_item_id, summary_date, store_location_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
min_price_in_cents = LEAST(item_price_history.min_price_in_cents, EXCLUDED.min_price_in_cents),
|
||||||
|
max_price_in_cents = GREATEST(item_price_history.max_price_in_cents, EXCLUDED.max_price_in_cents),
|
||||||
|
avg_price_in_cents = ROUND(((item_price_history.avg_price_in_cents * item_price_history.data_points_count) + EXCLUDED.avg_price_in_cents) / (item_price_history.data_points_count + 1.0)),
|
||||||
|
data_points_count = item_price_history.data_points_count + 1;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create the trigger on the flyer_items table for insert.
|
||||||
|
DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items;
|
||||||
|
CREATE TRIGGER trigger_update_price_history
|
||||||
|
AFTER INSERT ON public.flyer_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_price_history_on_flyer_item_insert();
|
||||||
|
|
||||||
|
-- 4. Create a trigger function to recalculate price history when a flyer item is deleted.
|
||||||
|
DROP FUNCTION IF EXISTS public.recalculate_price_history_on_flyer_item_delete();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.recalculate_price_history_on_flyer_item_delete()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
affected_dates RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Only run if the deleted item was linked to a master item and had a price.
|
||||||
|
IF OLD.master_item_id IS NULL OR OLD.price_in_cents IS NULL THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- This single, set-based query is much more performant than looping.
|
||||||
|
-- It recalculates aggregates for all affected dates and locations at once.
|
||||||
|
WITH affected_days_and_locations AS (
|
||||||
|
-- 1. Get all date/location pairs affected by the deleted item's flyer.
|
||||||
|
SELECT DISTINCT
|
||||||
|
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
|
||||||
|
fl.store_location_id
|
||||||
|
FROM public.flyers f
|
||||||
|
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
|
||||||
|
WHERE f.flyer_id = OLD.flyer_id
|
||||||
|
),
|
||||||
|
new_aggregates AS (
|
||||||
|
-- 2. For each affected date/location, recalculate the aggregates from all other relevant flyer items.
|
||||||
|
SELECT
|
||||||
|
adl.summary_date,
|
||||||
|
adl.store_location_id,
|
||||||
|
MIN(fi.price_in_cents) AS min_price,
|
||||||
|
MAX(fi.price_in_cents) AS max_price,
|
||||||
|
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
|
||||||
|
COUNT(fi.flyer_item_id)::int AS data_points
|
||||||
|
FROM affected_days_and_locations adl
|
||||||
|
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
|
||||||
|
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
|
||||||
|
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
|
||||||
|
WHERE fl.flyer_id IS NOT NULL -- Ensure the join was successful
|
||||||
|
GROUP BY adl.summary_date, adl.store_location_id
|
||||||
|
)
|
||||||
|
-- 3. Update the history table with the new aggregates.
|
||||||
|
UPDATE public.item_price_history iph
|
||||||
|
SET
|
||||||
|
min_price_in_cents = na.min_price,
|
||||||
|
max_price_in_cents = na.max_price,
|
||||||
|
avg_price_in_cents = na.avg_price,
|
||||||
|
data_points_count = na.data_points
|
||||||
|
FROM new_aggregates na
|
||||||
|
WHERE iph.master_item_id = OLD.master_item_id
|
||||||
|
AND iph.summary_date = na.summary_date
|
||||||
|
AND iph.store_location_id = na.store_location_id;
|
||||||
|
|
||||||
|
-- 4. Delete any history records that no longer have any data points.
|
||||||
|
DELETE FROM public.item_price_history iph
|
||||||
|
WHERE iph.master_item_id = OLD.master_item_id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM new_aggregates na
|
||||||
|
WHERE na.summary_date = iph.summary_date AND na.store_location_id = iph.store_location_id
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create the trigger on the flyer_items table for DELETE operations.
|
||||||
|
DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items;
|
||||||
|
CREATE TRIGGER trigger_recalculate_price_history_on_delete
|
||||||
|
AFTER DELETE ON public.flyer_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.recalculate_price_history_on_flyer_item_delete();
|
||||||
|
|
||||||
|
-- 5. Trigger function to update the average rating on the recipes table.
|
||||||
|
DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.recipes
|
||||||
|
SET
|
||||||
|
avg_rating = (
|
||||||
|
SELECT AVG(rating)
|
||||||
|
FROM public.recipe_ratings
|
||||||
|
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
|
||||||
|
),
|
||||||
|
rating_count = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM public.recipe_ratings
|
||||||
|
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
|
||||||
|
)
|
||||||
|
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
|
||||||
|
|
||||||
|
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after any change to recipe_ratings.
|
||||||
|
DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings;
|
||||||
|
CREATE TRIGGER on_recipe_rating_change
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON public.recipe_ratings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_recipe_rating_aggregates();
|
||||||
|
|
||||||
|
-- 6. Trigger function to log the creation of a new recipe.
|
||||||
|
DROP FUNCTION IF EXISTS public.log_new_recipe();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_new_recipe()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (
|
||||||
|
NEW.user_id,
|
||||||
|
'recipe_created',
|
||||||
|
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
|
||||||
|
'chef-hat',
|
||||||
|
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Award 'First Recipe' achievement if it's their first one.
|
||||||
|
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after a new recipe is inserted.
|
||||||
|
DROP TRIGGER IF EXISTS on_new_recipe_created ON public.recipes;
|
||||||
|
CREATE TRIGGER on_new_recipe_created
|
||||||
|
AFTER INSERT ON public.recipes
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.user_id IS NOT NULL) -- Only log activity for user-created recipes.
|
||||||
|
EXECUTE FUNCTION public.log_new_recipe();
|
||||||
|
|
||||||
|
-- 7a. Trigger function to update the item_count on the flyers table.
|
||||||
|
DROP FUNCTION IF EXISTS public.update_flyer_item_count();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after any change to flyer_items.
|
||||||
|
-- This ensures the item_count on the parent flyer is always accurate.
|
||||||
|
DROP TRIGGER IF EXISTS on_flyer_item_change ON public.flyer_items;
|
||||||
|
CREATE TRIGGER on_flyer_item_change
|
||||||
|
AFTER INSERT OR DELETE ON public.flyer_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_flyer_item_count();
|
||||||
|
|
||||||
|
-- 7. Trigger function to log the creation of a new flyer.
|
||||||
|
DROP FUNCTION IF EXISTS public.log_new_flyer();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_new_flyer()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
|
||||||
|
-- The award_achievement function handles checking if the user already has it.
|
||||||
|
IF NEW.uploaded_by IS NOT NULL THEN
|
||||||
|
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (
|
||||||
|
NEW.uploaded_by, -- Log the user who uploaded it
|
||||||
|
'flyer_uploaded',
|
||||||
|
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
|
||||||
|
'file-text',
|
||||||
|
jsonb_build_object(
|
||||||
|
'flyer_id', NEW.flyer_id,
|
||||||
|
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
|
||||||
|
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
|
||||||
|
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after a new flyer is inserted.
|
||||||
|
DROP TRIGGER IF EXISTS on_new_flyer_created ON public.flyers;
|
||||||
|
CREATE TRIGGER on_new_flyer_created
|
||||||
|
AFTER INSERT ON public.flyers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.log_new_flyer();
|
||||||
|
|
||||||
|
-- 8. Trigger function to log when a user favorites a recipe.
|
||||||
|
DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (
|
||||||
|
NEW.user_id,
|
||||||
|
'recipe_favorited',
|
||||||
|
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
|
||||||
|
'heart',
|
||||||
|
jsonb_build_object(
|
||||||
|
'recipe_id', NEW.recipe_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Award 'First Favorite' achievement.
|
||||||
|
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after a recipe is favorited.
|
||||||
|
DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes;
|
||||||
|
CREATE TRIGGER on_new_favorite_recipe
|
||||||
|
AFTER INSERT ON public.favorite_recipes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe();
|
||||||
|
|
||||||
|
-- 9. Trigger function to log when a user shares a shopping list.
|
||||||
|
DROP FUNCTION IF EXISTS public.log_new_list_share();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_new_list_share()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (
|
||||||
|
NEW.shared_by_user_id,
|
||||||
|
'list_shared',
|
||||||
|
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
|
||||||
|
'share-2',
|
||||||
|
jsonb_build_object(
|
||||||
|
'shopping_list_id', NEW.shopping_list_id,
|
||||||
|
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
|
||||||
|
'shared_with_user_id', NEW.shared_with_user_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Award 'List Sharer' achievement.
|
||||||
|
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to call the function after a shopping list is shared.
|
||||||
|
DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists;
|
||||||
|
CREATE TRIGGER on_new_list_share
|
||||||
|
AFTER INSERT ON public.shared_shopping_lists
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share();
|
||||||
|
|
||||||
|
-- 9a. Trigger function to log when a user shares a recipe collection.
|
||||||
|
DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Log the activity
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
|
VALUES (
|
||||||
|
NEW.shared_by_user_id, 'recipe_collection_shared',
|
||||||
|
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
|
||||||
|
'book',
|
||||||
|
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Award 'Recipe Sharer' achievement.
|
||||||
|
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_new_recipe_collection_share ON public.shared_recipe_collections;
|
||||||
|
CREATE TRIGGER on_new_recipe_collection_share
|
||||||
|
AFTER INSERT ON public.shared_recipe_collections
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
|
||||||
|
|
||||||
|
-- 10. Trigger function to geocode a store location's address.
|
||||||
|
-- This function is triggered when an address is inserted or updated, and is
|
||||||
|
-- designed to be extensible for external geocoding services to populate the
|
||||||
|
-- latitude, longitude, and location fields.
|
||||||
|
DROP FUNCTION IF EXISTS public.geocode_address();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.geocode_address()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
full_address TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Only proceed if an address component has actually changed.
|
||||||
|
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (
|
||||||
|
NEW.address_line_1 IS DISTINCT FROM OLD.address_line_1 OR
|
||||||
|
NEW.address_line_2 IS DISTINCT FROM OLD.address_line_2 OR
|
||||||
|
NEW.city IS DISTINCT FROM OLD.city OR
|
||||||
|
NEW.province_state IS DISTINCT FROM OLD.province_state OR
|
||||||
|
NEW.postal_code IS DISTINCT FROM OLD.postal_code OR
|
||||||
|
NEW.country IS DISTINCT FROM OLD.country
|
||||||
|
)) THEN
|
||||||
|
-- Concatenate address parts into a single string for the geocoder.
|
||||||
|
full_address := CONCAT_WS(', ', NEW.address_line_1, NEW.address_line_2, NEW.city, NEW.province_state, NEW.postal_code, NEW.country);
|
||||||
|
|
||||||
|
-- Placeholder for Geocoding API Call
|
||||||
|
-- In a real application, you would call a service here and update NEW.latitude, NEW.longitude, and NEW.location.
|
||||||
|
-- e.g., NEW.latitude := result.lat; NEW.longitude := result.lon;
|
||||||
|
-- NEW.location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- This trigger calls the geocoding function when an address changes.
|
||||||
|
DROP TRIGGER IF EXISTS on_address_change_geocode ON public.addresses;
|
||||||
|
CREATE TRIGGER on_address_change_geocode
|
||||||
|
BEFORE INSERT OR UPDATE ON public.addresses
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.geocode_address();
|
||||||
|
|
||||||
|
-- 11. Trigger function to increment the fork_count on the original recipe.
|
||||||
|
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Only run if the recipe is a fork (original_recipe_id is not null).
|
||||||
|
IF NEW.original_recipe_id IS NOT NULL THEN
|
||||||
|
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
|
||||||
|
-- Award 'First Fork' achievement.
|
||||||
|
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
|
||||||
|
CREATE TRIGGER on_recipe_fork
|
||||||
|
AFTER INSERT ON public.recipes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
|
||||||
|
|||||||
@@ -265,5 +265,6 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES
|
|||||||
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
|
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
|
||||||
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
|
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
|
||||||
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
|
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
|
||||||
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15)
|
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15),
|
||||||
|
('First-Upload', 'Upload your first flyer.', 'upload-cloud', 25)
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|||||||
@@ -8,16 +8,23 @@
|
|||||||
CREATE TABLE IF NOT EXISTS public.addresses (
|
CREATE TABLE IF NOT EXISTS public.addresses (
|
||||||
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
address_line_1 TEXT NOT NULL UNIQUE,
|
address_line_1 TEXT NOT NULL UNIQUE,
|
||||||
address_line_2 TEXT,
|
|
||||||
city TEXT NOT NULL,
|
city TEXT NOT NULL,
|
||||||
province_state TEXT NOT NULL,
|
province_state TEXT NOT NULL,
|
||||||
postal_code TEXT NOT NULL,
|
postal_code TEXT NOT NULL,
|
||||||
country TEXT NOT NULL,
|
country TEXT NOT NULL,
|
||||||
|
address_line_2 TEXT,
|
||||||
latitude NUMERIC(9, 6),
|
latitude NUMERIC(9, 6),
|
||||||
longitude NUMERIC(9, 6),
|
longitude NUMERIC(9, 6),
|
||||||
location GEOGRAPHY(Point, 4326),
|
location GEOGRAPHY(Point, 4326),
|
||||||
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,
|
||||||
|
CONSTRAINT addresses_address_line_1_check CHECK (TRIM(address_line_1) <> ''),
|
||||||
|
CONSTRAINT addresses_city_check CHECK (TRIM(city) <> ''),
|
||||||
|
CONSTRAINT addresses_province_state_check CHECK (TRIM(province_state) <> ''),
|
||||||
|
CONSTRAINT addresses_postal_code_check CHECK (TRIM(postal_code) <> ''),
|
||||||
|
CONSTRAINT addresses_country_check CHECK (TRIM(country) <> ''),
|
||||||
|
CONSTRAINT addresses_latitude_check CHECK (latitude >= -90 AND latitude <= 90),
|
||||||
|
CONSTRAINT addresses_longitude_check CHECK (longitude >= -180 AND longitude <= 180)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
|
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
|
||||||
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
|
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
|
||||||
@@ -31,12 +38,14 @@ CREATE TABLE IF NOT EXISTS public.users (
|
|||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
refresh_token TEXT,
|
refresh_token TEXT,
|
||||||
failed_login_attempts INTEGER DEFAULT 0,
|
failed_login_attempts INTEGER DEFAULT 0 CHECK (failed_login_attempts >= 0),
|
||||||
last_failed_login TIMESTAMPTZ,
|
last_failed_login TIMESTAMPTZ,
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
last_login_ip TEXT,
|
last_login_ip TEXT,
|
||||||
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,
|
||||||
|
CONSTRAINT users_email_check CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||||
|
CONSTRAINT users_password_hash_check CHECK (password_hash IS NULL OR TRIM(password_hash) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.users IS 'Stores user authentication information.';
|
COMMENT ON TABLE public.users IS 'Stores user authentication information.';
|
||||||
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
|
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
|
||||||
@@ -59,10 +68,13 @@ CREATE TABLE IF NOT EXISTS public.activity_log (
|
|||||||
icon TEXT,
|
icon TEXT,
|
||||||
details JSONB,
|
details JSONB,
|
||||||
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,
|
||||||
|
CONSTRAINT activity_log_action_check CHECK (TRIM(action) <> ''),
|
||||||
|
CONSTRAINT activity_log_display_text_check CHECK (TRIM(display_text) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.activity_log IS 'Logs key user and system actions for auditing and display in an activity feed.';
|
COMMENT ON TABLE public.activity_log IS 'Logs key user and system actions for auditing and display in an activity feed.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_id);
|
-- This composite index is more efficient for user-specific activity feeds ordered by date.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_user_id_created_at ON public.activity_log(user_id, created_at DESC);
|
||||||
|
|
||||||
-- 3. for public user profiles.
|
-- 3. for public user profiles.
|
||||||
-- This table is linked to the users table and stores non-sensitive user data.
|
-- This table is linked to the users table and stores non-sensitive user data.
|
||||||
@@ -72,16 +84,20 @@ CREATE TABLE IF NOT EXISTS public.profiles (
|
|||||||
full_name TEXT,
|
full_name TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
|
address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
|
||||||
|
points INTEGER DEFAULT 0 NOT NULL CHECK (points >= 0),
|
||||||
preferences JSONB,
|
preferences JSONB,
|
||||||
role TEXT CHECK (role IN ('admin', 'user')),
|
role TEXT CHECK (role IN ('admin', 'user')),
|
||||||
points INTEGER DEFAULT 0 NOT 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,
|
||||||
|
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
||||||
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
||||||
|
-- This index is crucial for the gamification leaderboard feature.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_points_leaderboard ON public.profiles (points DESC, full_name ASC);
|
||||||
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
|
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
|
||||||
|
|
||||||
-- 4. The 'stores' table for normalized store data.
|
-- 4. The 'stores' table for normalized store data.
|
||||||
@@ -91,8 +107,10 @@ CREATE TABLE IF NOT EXISTS public.stores (
|
|||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
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,
|
||||||
|
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
||||||
|
|
||||||
-- 5. The 'categories' table for normalized category data.
|
-- 5. The 'categories' table for normalized category data.
|
||||||
@@ -100,7 +118,8 @@ CREATE TABLE IF NOT EXISTS public.categories (
|
|||||||
category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT categories_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').';
|
COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').';
|
||||||
|
|
||||||
@@ -115,11 +134,17 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
item_count INTEGER DEFAULT 0 NOT NULL,
|
status TEXT DEFAULT 'processed' NOT NULL CHECK (status IN ('processed', 'needs_review', 'archived')),
|
||||||
|
item_count INTEGER DEFAULT 0 NOT NULL CHECK (item_count >= 0),
|
||||||
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
||||||
|
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
||||||
|
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https://?.*'),
|
||||||
|
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
||||||
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
||||||
@@ -130,9 +155,11 @@ COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a
|
|||||||
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
|
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
|
||||||
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
|
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
|
||||||
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
|
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
|
||||||
|
COMMENT ON COLUMN public.flyers.status IS 'The processing status of the flyer, e.g., if it needs manual review.';
|
||||||
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
|
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
|
||||||
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
|
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flyers_status ON public.flyers(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
|
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
|
||||||
-- 7. The 'master_grocery_items' table. This is the master dictionary.
|
-- 7. The 'master_grocery_items' table. This is the master dictionary.
|
||||||
@@ -144,7 +171,8 @@ CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
|||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
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,
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT master_grocery_items_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.';
|
COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id);
|
CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id);
|
||||||
@@ -169,8 +197,10 @@ CREATE TABLE IF NOT EXISTS public.brands (
|
|||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
store_id BIGINT REFERENCES public.stores(store_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,
|
||||||
|
CONSTRAINT brands_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
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.';
|
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.';
|
||||||
|
|
||||||
@@ -184,7 +214,9 @@ CREATE TABLE IF NOT EXISTS public.products (
|
|||||||
size TEXT,
|
size TEXT,
|
||||||
upc_code TEXT UNIQUE,
|
upc_code TEXT UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT products_name_check CHECK (TRIM(name) <> ''),
|
||||||
|
CONSTRAINT products_upc_code_check CHECK (upc_code IS NULL OR upc_code ~ '^[0-9]{8,14}$')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
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.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
@@ -200,18 +232,22 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
item TEXT NOT NULL,
|
item TEXT NOT NULL,
|
||||||
price_display TEXT NOT NULL,
|
price_display TEXT NOT NULL,
|
||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER CHECK (price_in_cents IS NULL OR price_in_cents >= 0),
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
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 CHECK (view_count >= 0),
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL CHECK (click_count >= 0),
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
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) ON DELETE SET NULL,
|
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,
|
||||||
|
CONSTRAINT flyer_items_item_check CHECK (TRIM(item) <> ''),
|
||||||
|
CONSTRAINT flyer_items_price_display_check CHECK (TRIM(price_display) <> ''),
|
||||||
|
CONSTRAINT flyer_items_quantity_check CHECK (TRIM(quantity) <> ''),
|
||||||
|
CONSTRAINT flyer_items_category_name_check CHECK (category_name IS NULL OR TRIM(category_name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.';
|
COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.';
|
||||||
COMMENT ON COLUMN public.flyer_items.flyer_id IS 'Foreign key linking this item to its parent flyer in the `flyers` table.';
|
COMMENT ON COLUMN public.flyer_items.flyer_id IS 'Foreign key linking this item to its parent flyer in the `flyers` table.';
|
||||||
@@ -230,6 +266,8 @@ CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_id ON public.flyer_items(
|
|||||||
CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id);
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id);
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id);
|
||||||
-- Add a GIN index to the 'item' column for fast fuzzy text searching.
|
-- Add a GIN index to the 'item' column for fast fuzzy text searching.
|
||||||
|
-- This partial index is optimized for queries that find the best price for an item.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_price ON public.flyer_items (master_item_id, price_in_cents ASC) WHERE price_in_cents IS NOT NULL;
|
||||||
-- This requires the pg_trgm extension.
|
-- This requires the pg_trgm extension.
|
||||||
CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops);
|
CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops);
|
||||||
|
|
||||||
@@ -238,7 +276,7 @@ CREATE TABLE IF NOT EXISTS public.user_alerts (
|
|||||||
user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE,
|
user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE,
|
||||||
alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')),
|
alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')),
|
||||||
threshold_value NUMERIC NOT NULL,
|
threshold_value NUMERIC NOT NULL CHECK (threshold_value > 0),
|
||||||
is_active BOOLEAN DEFAULT true NOT NULL,
|
is_active BOOLEAN DEFAULT true NOT 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
|
||||||
@@ -256,7 +294,8 @@ CREATE TABLE IF NOT EXISTS public.notifications (
|
|||||||
link_url TEXT,
|
link_url TEXT,
|
||||||
is_read BOOLEAN DEFAULT false NOT NULL,
|
is_read BOOLEAN DEFAULT false NOT 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,
|
||||||
|
CONSTRAINT notifications_content_check CHECK (TRIM(content) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.';
|
COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.';
|
||||||
COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.';
|
COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.';
|
||||||
@@ -269,8 +308,8 @@ CREATE TABLE IF NOT EXISTS public.store_locations (
|
|||||||
store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
address_id BIGINT NOT NULL REFERENCES public.addresses(address_id) ON DELETE CASCADE,
|
address_id BIGINT NOT NULL REFERENCES public.addresses(address_id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
UNIQUE(store_id, address_id),
|
UNIQUE(store_id, address_id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.';
|
COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.';
|
||||||
@@ -282,13 +321,14 @@ CREATE TABLE IF NOT EXISTS public.item_price_history (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
summary_date DATE NOT NULL,
|
summary_date DATE NOT NULL,
|
||||||
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
min_price_in_cents INTEGER,
|
min_price_in_cents INTEGER CHECK (min_price_in_cents IS NULL OR min_price_in_cents >= 0),
|
||||||
max_price_in_cents INTEGER,
|
max_price_in_cents INTEGER CHECK (max_price_in_cents IS NULL OR max_price_in_cents >= 0),
|
||||||
avg_price_in_cents INTEGER,
|
avg_price_in_cents INTEGER CHECK (avg_price_in_cents IS NULL OR avg_price_in_cents >= 0),
|
||||||
data_points_count INTEGER DEFAULT 0 NOT NULL,
|
data_points_count INTEGER DEFAULT 0 NOT NULL CHECK (data_points_count >= 0),
|
||||||
UNIQUE(master_item_id, summary_date, store_location_id),
|
UNIQUE(master_item_id, summary_date, store_location_id),
|
||||||
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,
|
||||||
|
CONSTRAINT item_price_history_price_order_check CHECK (min_price_in_cents <= max_price_in_cents)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.';
|
COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.';
|
||||||
COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.';
|
COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.';
|
||||||
@@ -305,7 +345,8 @@ CREATE TABLE IF NOT EXISTS public.master_item_aliases (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
alias TEXT NOT NULL UNIQUE,
|
alias TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT master_item_aliases_alias_check CHECK (TRIM(alias) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.';
|
COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.';
|
||||||
COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".';
|
COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".';
|
||||||
@@ -317,7 +358,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_lists (
|
|||||||
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,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT 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,
|
||||||
|
CONSTRAINT shopping_lists_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".';
|
COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".';
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id);
|
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id);
|
||||||
@@ -328,12 +370,13 @@ CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
|||||||
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) ON DELETE CASCADE,
|
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 CHECK (quantity > 0),
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
added_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
added_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL),
|
||||||
|
CONSTRAINT shopping_list_items_custom_item_name_check CHECK (custom_item_name IS NULL OR TRIM(custom_item_name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.';
|
COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.';
|
||||||
COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".';
|
COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".';
|
||||||
@@ -341,7 +384,6 @@ COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check i
|
|||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_shopping_list_id ON public.shopping_list_items(shopping_list_id);
|
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_shopping_list_id ON public.shopping_list_items(shopping_list_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shopping_list_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_master_item_id ON public.shopping_list_items(master_item_id);
|
||||||
|
|
||||||
-- 17. Manage shared access to shopping lists.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.shared_shopping_lists (
|
CREATE TABLE IF NOT EXISTS public.shared_shopping_lists (
|
||||||
shared_shopping_list_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shared_shopping_list_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,
|
||||||
@@ -366,6 +408,7 @@ CREATE TABLE IF NOT EXISTS public.menu_plans (
|
|||||||
end_date DATE NOT NULL,
|
end_date DATE NOT 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,
|
||||||
|
CONSTRAINT menu_plans_name_check CHECK (TRIM(name) <> ''),
|
||||||
CONSTRAINT date_range_check CHECK (end_date >= start_date)
|
CONSTRAINT date_range_check CHECK (end_date >= start_date)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".';
|
COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".';
|
||||||
@@ -394,11 +437,13 @@ CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
|||||||
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,
|
||||||
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 CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
reviewed_notes TEXT,
|
reviewed_notes TEXT,
|
||||||
reviewed_at TIMESTAMPTZ,
|
reviewed_at TIMESTAMPTZ,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT suggested_corrections_correction_type_check CHECK (TRIM(correction_type) <> ''),
|
||||||
|
CONSTRAINT suggested_corrections_suggested_value_check CHECK (TRIM(suggested_value) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.';
|
COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.';
|
||||||
COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.';
|
COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.';
|
||||||
@@ -414,13 +459,14 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
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,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
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) ON DELETE CASCADE,
|
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 CHECK (price_in_cents > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
downvotes INTEGER DEFAULT 0 NOT NULL,
|
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
||||||
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
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
@@ -461,21 +507,23 @@ CREATE TABLE IF NOT EXISTS public.recipes (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
instructions TEXT,
|
instructions TEXT,
|
||||||
prep_time_minutes INTEGER,
|
prep_time_minutes INTEGER CHECK (prep_time_minutes IS NULL OR prep_time_minutes >= 0),
|
||||||
cook_time_minutes INTEGER,
|
cook_time_minutes INTEGER CHECK (cook_time_minutes IS NULL OR cook_time_minutes >= 0),
|
||||||
servings INTEGER,
|
servings INTEGER CHECK (servings IS NULL OR servings > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
calories_per_serving INTEGER,
|
calories_per_serving INTEGER,
|
||||||
protein_grams NUMERIC,
|
protein_grams NUMERIC,
|
||||||
fat_grams NUMERIC,
|
fat_grams NUMERIC,
|
||||||
carb_grams NUMERIC,
|
carb_grams NUMERIC,
|
||||||
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL,
|
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL CHECK (avg_rating >= 0.0 AND avg_rating <= 5.0),
|
||||||
status TEXT DEFAULT 'private' NOT NULL CHECK (status IN ('private', 'pending_review', 'public', 'rejected')),
|
status TEXT DEFAULT 'private' NOT NULL CHECK (status IN ('private', 'pending_review', 'public', 'rejected')),
|
||||||
rating_count INTEGER DEFAULT 0 NOT NULL,
|
rating_count INTEGER DEFAULT 0 NOT NULL CHECK (rating_count >= 0),
|
||||||
fork_count INTEGER DEFAULT 0 NOT NULL,
|
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
||||||
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,
|
||||||
|
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
||||||
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
||||||
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
||||||
@@ -485,11 +533,11 @@ COMMENT ON COLUMN public.recipes.calories_per_serving IS 'Optional nutritional i
|
|||||||
COMMENT ON COLUMN public.recipes.protein_grams IS 'Optional nutritional information.';
|
COMMENT ON COLUMN public.recipes.protein_grams IS 'Optional nutritional information.';
|
||||||
COMMENT ON COLUMN public.recipes.fat_grams IS 'Optional nutritional information.';
|
COMMENT ON COLUMN public.recipes.fat_grams IS 'Optional nutritional information.';
|
||||||
COMMENT ON COLUMN public.recipes.carb_grams IS 'Optional nutritional information.';
|
COMMENT ON COLUMN public.recipes.carb_grams IS 'Optional nutritional information.';
|
||||||
COMMENT ON COLUMN public.recipes.fork_count IS 'To track how many times a public recipe has been "forked" or copied by other users.';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON public.recipes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON public.recipes(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id);
|
CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id);
|
||||||
-- Add a partial unique index to ensure system-wide recipes (user_id IS NULL) have unique names.
|
-- Add a partial unique index to ensure system-wide recipes (user_id IS NULL) have unique names.
|
||||||
-- This allows different users to have recipes with the same name.
|
-- This index helps speed up sorting for recipe recommendations.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_rating_sort ON public.recipes (avg_rating DESC, rating_count DESC);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON public.recipes(name) WHERE user_id IS NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON public.recipes(name) WHERE user_id IS NULL;
|
||||||
|
|
||||||
-- 27. For ingredients required for each recipe.
|
-- 27. For ingredients required for each recipe.
|
||||||
@@ -497,10 +545,11 @@ 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) ON DELETE CASCADE,
|
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 CHECK (quantity > 0),
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT 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,
|
||||||
|
CONSTRAINT recipe_ingredients_unit_check CHECK (TRIM(unit) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.';
|
COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.';
|
||||||
COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".';
|
COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".';
|
||||||
@@ -526,7 +575,8 @@ CREATE TABLE IF NOT EXISTS public.tags (
|
|||||||
tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT tags_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".';
|
COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".';
|
||||||
|
|
||||||
@@ -540,6 +590,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_tags (
|
|||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.';
|
COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_tags_recipe_id ON public.recipe_tags(recipe_id);
|
CREATE INDEX IF NOT EXISTS idx_recipe_tags_recipe_id ON public.recipe_tags(recipe_id);
|
||||||
|
-- This index is crucial for functions that find recipes based on tags.
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id);
|
CREATE INDEX IF NOT EXISTS idx_recipe_tags_tag_id ON public.recipe_tags(tag_id);
|
||||||
|
|
||||||
-- 31. Store a predefined list of kitchen appliances.
|
-- 31. Store a predefined list of kitchen appliances.
|
||||||
@@ -547,7 +598,8 @@ CREATE TABLE IF NOT EXISTS public.appliances (
|
|||||||
appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT appliances_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances (e.g., Air Fryer, Instant Pot).';
|
COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances (e.g., Air Fryer, Instant Pot).';
|
||||||
|
|
||||||
@@ -587,7 +639,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_comments (
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')),
|
status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')),
|
||||||
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,
|
||||||
|
CONSTRAINT recipe_comments_content_check CHECK (TRIM(content) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.';
|
COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.';
|
||||||
COMMENT ON COLUMN public.recipe_comments.parent_comment_id IS 'For threaded comments.';
|
COMMENT ON COLUMN public.recipe_comments.parent_comment_id IS 'For threaded comments.';
|
||||||
@@ -602,6 +655,7 @@ CREATE TABLE IF NOT EXISTS public.pantry_locations (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT 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,
|
||||||
|
CONSTRAINT pantry_locations_name_check CHECK (TRIM(name) <> ''),
|
||||||
UNIQUE(user_id, name)
|
UNIQUE(user_id, name)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").';
|
COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").';
|
||||||
@@ -615,8 +669,9 @@ CREATE TABLE IF NOT EXISTS public.planned_meals (
|
|||||||
plan_date DATE NOT NULL,
|
plan_date DATE NOT NULL,
|
||||||
meal_type TEXT NOT NULL,
|
meal_type TEXT NOT NULL,
|
||||||
servings_to_cook INTEGER,
|
servings_to_cook INTEGER,
|
||||||
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,
|
||||||
|
CONSTRAINT planned_meals_meal_type_check CHECK (TRIM(meal_type) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.';
|
COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.';
|
||||||
COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
|
COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
|
||||||
@@ -628,7 +683,7 @@ CREATE TABLE IF NOT EXISTS public.pantry_items (
|
|||||||
pantry_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
pantry_item_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,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
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 CHECK (quantity >= 0),
|
||||||
unit TEXT,
|
unit TEXT,
|
||||||
best_before_date DATE,
|
best_before_date DATE,
|
||||||
pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL,
|
pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL,
|
||||||
@@ -637,7 +692,6 @@ CREATE TABLE IF NOT EXISTS public.pantry_items (
|
|||||||
UNIQUE(user_id, master_item_id, unit)
|
UNIQUE(user_id, master_item_id, unit)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.pantry_items IS 'Tracks a user''s personal inventory of grocery items to enable smart shopping lists.';
|
COMMENT ON TABLE public.pantry_items IS 'Tracks a user''s personal inventory of grocery items to enable smart shopping lists.';
|
||||||
COMMENT ON COLUMN public.pantry_items.quantity IS 'The current amount of the item. Convention: use grams for weight, mL for volume where applicable.';
|
|
||||||
COMMENT ON COLUMN public.pantry_items.pantry_location_id IS 'Links the item to a user-defined location like "Fridge" or "Freezer".';
|
COMMENT ON COLUMN public.pantry_items.pantry_location_id IS 'Links the item to a user-defined location like "Fridge" or "Freezer".';
|
||||||
COMMENT ON COLUMN public.pantry_items.unit IS 'e.g., ''g'', ''ml'', ''items''. Should align with recipe_ingredients.unit and quantity convention.';
|
COMMENT ON COLUMN public.pantry_items.unit IS 'e.g., ''g'', ''ml'', ''items''. Should align with recipe_ingredients.unit and quantity convention.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_pantry_items_user_id ON public.pantry_items(user_id);
|
CREATE INDEX IF NOT EXISTS idx_pantry_items_user_id ON public.pantry_items(user_id);
|
||||||
@@ -651,7 +705,8 @@ CREATE TABLE IF NOT EXISTS public.password_reset_tokens (
|
|||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT 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,
|
||||||
|
CONSTRAINT password_reset_tokens_token_hash_check CHECK (TRIM(token_hash) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.';
|
COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.';
|
||||||
COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.';
|
COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.';
|
||||||
@@ -666,10 +721,13 @@ CREATE TABLE IF NOT EXISTS public.unit_conversions (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
from_unit TEXT NOT NULL,
|
from_unit TEXT NOT NULL,
|
||||||
to_unit TEXT NOT NULL,
|
to_unit TEXT NOT NULL,
|
||||||
factor NUMERIC NOT NULL,
|
factor NUMERIC NOT NULL CHECK (factor > 0),
|
||||||
UNIQUE(master_item_id, from_unit, to_unit),
|
UNIQUE(master_item_id, from_unit, to_unit),
|
||||||
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,
|
||||||
|
CONSTRAINT unit_conversions_from_unit_check CHECK (TRIM(from_unit) <> ''),
|
||||||
|
CONSTRAINT unit_conversions_to_unit_check CHECK (TRIM(to_unit) <> ''),
|
||||||
|
CONSTRAINT unit_conversions_units_check CHECK (from_unit <> to_unit)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).';
|
COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).';
|
||||||
COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.';
|
COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.';
|
||||||
@@ -683,7 +741,8 @@ CREATE TABLE IF NOT EXISTS public.user_item_aliases (
|
|||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
UNIQUE(user_id, alias),
|
UNIQUE(user_id, alias),
|
||||||
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,
|
||||||
|
CONSTRAINT user_item_aliases_alias_check CHECK (TRIM(alias) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").';
|
COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id);
|
||||||
@@ -720,7 +779,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_collections (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
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,
|
||||||
|
CONSTRAINT recipe_collections_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").';
|
COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").';
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id);
|
||||||
@@ -745,8 +805,11 @@ CREATE TABLE IF NOT EXISTS public.shared_recipe_collections (
|
|||||||
shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')),
|
permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
UNIQUE(recipe_collection_id, shared_with_user_id)
|
UNIQUE(recipe_collection_id, shared_with_user_id)
|
||||||
);
|
);
|
||||||
|
-- This index is crucial for efficiently finding all collections shared with a specific user.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shared_recipe_collections_shared_with ON public.shared_recipe_collections(shared_with_user_id);
|
||||||
|
|
||||||
-- 45. Log user search queries for analysis.
|
-- 45. Log user search queries for analysis.
|
||||||
CREATE TABLE IF NOT EXISTS public.search_queries (
|
CREATE TABLE IF NOT EXISTS public.search_queries (
|
||||||
@@ -756,7 +819,8 @@ CREATE TABLE IF NOT EXISTS public.search_queries (
|
|||||||
result_count INTEGER,
|
result_count INTEGER,
|
||||||
was_successful BOOLEAN,
|
was_successful BOOLEAN,
|
||||||
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,
|
||||||
|
CONSTRAINT search_queries_query_text_check CHECK (TRIM(query_text) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.';
|
COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.';
|
||||||
COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.';
|
COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.';
|
||||||
@@ -782,10 +846,11 @@ CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
|||||||
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) ON DELETE SET NULL,
|
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 CHECK (quantity > 0),
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
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,
|
||||||
|
CONSTRAINT shopping_trip_items_custom_item_name_check CHECK (custom_item_name IS NULL OR TRIM(custom_item_name) <> ''),
|
||||||
CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.';
|
COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.';
|
||||||
@@ -799,7 +864,8 @@ CREATE TABLE IF NOT EXISTS public.dietary_restrictions (
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')),
|
type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')),
|
||||||
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,
|
||||||
|
CONSTRAINT dietary_restrictions_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common diets (e.g., Vegan) and allergies (e.g., Nut Allergy).';
|
COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common diets (e.g., Vegan) and allergies (e.g., Nut Allergy).';
|
||||||
|
|
||||||
@@ -812,6 +878,7 @@ CREATE TABLE IF NOT EXISTS public.user_dietary_restrictions (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.';
|
COMMENT ON TABLE public.user_dietary_restrictions IS 'Connects users to their selected dietary needs and allergies.';
|
||||||
|
-- This index is crucial for functions that filter recipes based on user diets/allergies.
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_user_id ON public.user_dietary_restrictions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id);
|
CREATE INDEX IF NOT EXISTS idx_user_dietary_restrictions_restriction_id ON public.user_dietary_restrictions(restriction_id);
|
||||||
|
|
||||||
@@ -837,6 +904,7 @@ CREATE TABLE IF NOT EXISTS public.user_follows (
|
|||||||
CONSTRAINT cant_follow_self CHECK (follower_id <> following_id)
|
CONSTRAINT cant_follow_self CHECK (follower_id <> following_id)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to build a social graph.';
|
COMMENT ON TABLE public.user_follows IS 'Stores user following relationships to build a social graph.';
|
||||||
|
-- This index is crucial for efficiently generating a user's activity feed.
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id);
|
CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id ON public.user_follows(follower_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id);
|
CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(following_id);
|
||||||
|
|
||||||
@@ -847,13 +915,14 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
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 CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
||||||
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||||
raw_text TEXT,
|
raw_text TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||||
@@ -863,13 +932,14 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE,
|
receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE,
|
||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL CHECK (quantity > 0),
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL CHECK (price_paid_cents >= 0),
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
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) ON DELETE SET NULL,
|
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,
|
||||||
|
CONSTRAINT receipt_items_raw_item_description_check CHECK (TRIM(raw_item_description) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.';
|
COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id);
|
CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id);
|
||||||
@@ -882,7 +952,6 @@ CREATE TABLE IF NOT EXISTS public.schema_info (
|
|||||||
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.schema_info IS 'Stores metadata about the deployed schema, such as a hash of the schema file, to detect changes.';
|
COMMENT ON TABLE public.schema_info IS 'Stores metadata about the deployed schema, such as a hash of the schema file, to detect changes.';
|
||||||
COMMENT ON COLUMN public.schema_info.environment IS 'The deployment environment (e.g., ''development'', ''test'', ''production'').';
|
|
||||||
COMMENT ON COLUMN public.schema_info.schema_hash IS 'A SHA-256 hash of the master_schema_rollup.sql file at the time of deployment.';
|
COMMENT ON COLUMN public.schema_info.schema_hash IS 'A SHA-256 hash of the master_schema_rollup.sql file at the time of deployment.';
|
||||||
|
|
||||||
-- 55. Store user reactions to various entities (e.g., recipes, comments).
|
-- 55. Store user reactions to various entities (e.g., recipes, comments).
|
||||||
@@ -903,14 +972,31 @@ COMMENT ON COLUMN public.user_reactions.reaction_type IS 'The type of reaction (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_reactions_user_id ON public.user_reactions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_reactions_user_id ON public.user_reactions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_reactions_entity ON public.user_reactions(entity_type, entity_id);
|
CREATE INDEX IF NOT EXISTS idx_user_reactions_entity ON public.user_reactions(entity_type, entity_id);
|
||||||
|
|
||||||
|
-- 56. Store user-defined budgets for spending analysis.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.budgets (
|
||||||
|
budget_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
|
||||||
|
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT budgets_name_check CHECK (TRIM(name) <> '')
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);
|
||||||
|
|
||||||
-- 57. Static table defining available achievements for gamification.
|
-- 57. Static table defining available achievements for gamification.
|
||||||
CREATE TABLE IF NOT EXISTS public.achievements (
|
CREATE TABLE IF NOT EXISTS public.achievements (
|
||||||
achievement_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
achievement_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
points_value INTEGER NOT NULL DEFAULT 0,
|
points_value INTEGER NOT NULL DEFAULT 0 CHECK (points_value >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT achievements_name_check CHECK (TRIM(name) <> ''),
|
||||||
|
CONSTRAINT achievements_description_check CHECK (TRIM(description) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.achievements IS 'A static table defining the available achievements users can earn.';
|
COMMENT ON TABLE public.achievements IS 'A static table defining the available achievements users can earn.';
|
||||||
|
|
||||||
@@ -926,16 +1012,3 @@ CREATE INDEX IF NOT EXISTS idx_user_achievements_user_id ON public.user_achievem
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON public.user_achievements(achievement_id);
|
CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON public.user_achievements(achievement_id);
|
||||||
|
|
||||||
|
|
||||||
-- 56. Store user-defined budgets for spending analysis.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.budgets (
|
|
||||||
budget_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
amount_cents INTEGER NOT NULL,
|
|
||||||
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);
|
|
||||||
|
|||||||
@@ -23,16 +23,23 @@
|
|||||||
CREATE TABLE IF NOT EXISTS public.addresses (
|
CREATE TABLE IF NOT EXISTS public.addresses (
|
||||||
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
address_line_1 TEXT NOT NULL UNIQUE,
|
address_line_1 TEXT NOT NULL UNIQUE,
|
||||||
address_line_2 TEXT,
|
|
||||||
city TEXT NOT NULL,
|
city TEXT NOT NULL,
|
||||||
province_state TEXT NOT NULL,
|
province_state TEXT NOT NULL,
|
||||||
postal_code TEXT NOT NULL,
|
postal_code TEXT NOT NULL,
|
||||||
country TEXT NOT NULL,
|
country TEXT NOT NULL,
|
||||||
|
address_line_2 TEXT,
|
||||||
latitude NUMERIC(9, 6),
|
latitude NUMERIC(9, 6),
|
||||||
longitude NUMERIC(9, 6),
|
longitude NUMERIC(9, 6),
|
||||||
location GEOGRAPHY(Point, 4326),
|
location GEOGRAPHY(Point, 4326),
|
||||||
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,
|
||||||
|
CONSTRAINT addresses_address_line_1_check CHECK (TRIM(address_line_1) <> ''),
|
||||||
|
CONSTRAINT addresses_city_check CHECK (TRIM(city) <> ''),
|
||||||
|
CONSTRAINT addresses_province_state_check CHECK (TRIM(province_state) <> ''),
|
||||||
|
CONSTRAINT addresses_postal_code_check CHECK (TRIM(postal_code) <> ''),
|
||||||
|
CONSTRAINT addresses_country_check CHECK (TRIM(country) <> ''),
|
||||||
|
CONSTRAINT addresses_latitude_check CHECK (latitude >= -90 AND latitude <= 90),
|
||||||
|
CONSTRAINT addresses_longitude_check CHECK (longitude >= -180 AND longitude <= 180)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
|
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
|
||||||
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
|
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
|
||||||
@@ -45,14 +52,16 @@ CREATE INDEX IF NOT EXISTS addresses_location_idx ON public.addresses USING GIST
|
|||||||
CREATE TABLE IF NOT EXISTS public.users (
|
CREATE TABLE IF NOT EXISTS public.users (
|
||||||
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
refresh_token TEXT,
|
refresh_token TEXT,
|
||||||
failed_login_attempts INTEGER DEFAULT 0,
|
failed_login_attempts INTEGER DEFAULT 0 CHECK (failed_login_attempts >= 0),
|
||||||
last_failed_login TIMESTAMPTZ,
|
last_failed_login TIMESTAMPTZ,
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
last_login_ip TEXT,
|
last_login_ip TEXT,
|
||||||
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,
|
||||||
|
CONSTRAINT users_email_check CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||||
|
CONSTRAINT users_password_hash_check CHECK (password_hash IS NULL OR TRIM(password_hash) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.users IS 'Stores user authentication information.';
|
COMMENT ON TABLE public.users IS 'Stores user authentication information.';
|
||||||
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
|
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
|
||||||
@@ -74,11 +83,14 @@ CREATE TABLE IF NOT EXISTS public.activity_log (
|
|||||||
display_text TEXT NOT NULL,
|
display_text TEXT NOT NULL,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
details JSONB,
|
details JSONB,
|
||||||
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,
|
||||||
|
CONSTRAINT activity_log_action_check CHECK (TRIM(action) <> ''),
|
||||||
|
CONSTRAINT activity_log_display_text_check CHECK (TRIM(display_text) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.activity_log IS 'Logs key user and system actions for auditing and display in an activity feed.';
|
COMMENT ON TABLE public.activity_log IS 'Logs key user and system actions for auditing and display in an activity feed.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_id);
|
-- This composite index is more efficient for user-specific activity feeds ordered by date.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_user_id_created_at ON public.activity_log(user_id, created_at DESC);
|
||||||
|
|
||||||
-- 3. for public user profiles.
|
-- 3. for public user profiles.
|
||||||
-- This table is linked to the users table and stores non-sensitive user data.
|
-- This table is linked to the users table and stores non-sensitive user data.
|
||||||
@@ -88,16 +100,20 @@ CREATE TABLE IF NOT EXISTS public.profiles (
|
|||||||
full_name TEXT,
|
full_name TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
|
address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
|
||||||
points INTEGER DEFAULT 0 NOT NULL,
|
points INTEGER DEFAULT 0 NOT NULL CHECK (points >= 0),
|
||||||
preferences JSONB,
|
preferences JSONB,
|
||||||
role TEXT CHECK (role IN ('admin', 'user')),
|
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
||||||
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,
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
||||||
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
||||||
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
||||||
|
-- This index is crucial for the gamification leaderboard feature.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_points_leaderboard ON public.profiles (points DESC, full_name ASC);
|
||||||
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
|
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
|
||||||
|
|
||||||
-- 4. The 'stores' table for normalized store data.
|
-- 4. The 'stores' table for normalized store data.
|
||||||
@@ -107,8 +123,10 @@ CREATE TABLE IF NOT EXISTS public.stores (
|
|||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
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,
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
||||||
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
||||||
|
|
||||||
-- 5. The 'categories' table for normalized category data.
|
-- 5. The 'categories' table for normalized category data.
|
||||||
@@ -116,7 +134,8 @@ CREATE TABLE IF NOT EXISTS public.categories (
|
|||||||
category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT categories_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').';
|
COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').';
|
||||||
|
|
||||||
@@ -125,17 +144,23 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
flyer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
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 NOT NULL,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
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,
|
||||||
item_count INTEGER DEFAULT 0 NOT NULL,
|
status TEXT DEFAULT 'processed' NOT NULL CHECK (status IN ('processed', 'needs_review', 'archived')),
|
||||||
|
item_count INTEGER DEFAULT 0 NOT NULL CHECK (item_count >= 0),
|
||||||
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
uploaded_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
||||||
|
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
||||||
|
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https?://.*'),
|
||||||
|
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
||||||
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
||||||
@@ -146,9 +171,11 @@ COMMENT ON COLUMN public.flyers.store_id IS 'Foreign key linking this flyer to a
|
|||||||
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
|
COMMENT ON COLUMN public.flyers.valid_from IS 'The start date of the sale period for this flyer, extracted by the AI.';
|
||||||
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
|
COMMENT ON COLUMN public.flyers.valid_to IS 'The end date of the sale period for this flyer, extracted by the AI.';
|
||||||
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
|
COMMENT ON COLUMN public.flyers.store_address IS 'The physical store address if it was successfully extracted from the flyer image.';
|
||||||
|
COMMENT ON COLUMN public.flyers.status IS 'The processing status of the flyer, e.g., if it needs manual review.';
|
||||||
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
|
COMMENT ON COLUMN public.flyers.item_count IS 'A cached count of the number of items in this flyer, maintained by a trigger.';
|
||||||
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
|
COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer. Can be null for anonymous or system uploads.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flyers_status ON public.flyers(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_flyers_created_at ON public.flyers (created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
|
CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid_to DESC, file_name ASC);
|
||||||
-- 7. The 'master_grocery_items' table. This is the master dictionary.
|
-- 7. The 'master_grocery_items' table. This is the master dictionary.
|
||||||
@@ -160,7 +187,8 @@ CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
|||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
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,
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT master_grocery_items_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.';
|
COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id);
|
CREATE INDEX IF NOT EXISTS idx_master_grocery_items_category_id ON public.master_grocery_items(category_id);
|
||||||
@@ -185,8 +213,10 @@ CREATE TABLE IF NOT EXISTS public.brands (
|
|||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
store_id BIGINT REFERENCES public.stores(store_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,
|
||||||
|
CONSTRAINT brands_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
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.';
|
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.';
|
||||||
|
|
||||||
@@ -200,7 +230,9 @@ CREATE TABLE IF NOT EXISTS public.products (
|
|||||||
size TEXT,
|
size TEXT,
|
||||||
upc_code TEXT UNIQUE,
|
upc_code TEXT UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT products_name_check CHECK (TRIM(name) <> ''),
|
||||||
|
CONSTRAINT products_upc_code_check CHECK (upc_code IS NULL OR upc_code ~ '^[0-9]{8,14}$')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
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.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
@@ -216,18 +248,22 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
item TEXT NOT NULL,
|
item TEXT NOT NULL,
|
||||||
price_display TEXT NOT NULL,
|
price_display TEXT NOT NULL,
|
||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER CHECK (price_in_cents IS NULL OR price_in_cents >= 0),
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
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 CHECK (view_count >= 0),
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL CHECK (click_count >= 0),
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
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) ON DELETE SET NULL,
|
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,
|
||||||
|
CONSTRAINT flyer_items_item_check CHECK (TRIM(item) <> ''),
|
||||||
|
CONSTRAINT flyer_items_price_display_check CHECK (TRIM(price_display) <> ''),
|
||||||
|
CONSTRAINT flyer_items_quantity_check CHECK (TRIM(quantity) <> ''),
|
||||||
|
CONSTRAINT flyer_items_category_name_check CHECK (category_name IS NULL OR TRIM(category_name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.';
|
COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.';
|
||||||
COMMENT ON COLUMN public.flyer_items.flyer_id IS 'Foreign key linking this item to its parent flyer in the `flyers` table.';
|
COMMENT ON COLUMN public.flyer_items.flyer_id IS 'Foreign key linking this item to its parent flyer in the `flyers` table.';
|
||||||
@@ -246,6 +282,8 @@ CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_id ON public.flyer_items(
|
|||||||
CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id);
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_category_id ON public.flyer_items(category_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id);
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_product_id ON public.flyer_items(product_id);
|
||||||
-- Add a GIN index to the 'item' column for fast fuzzy text searching.
|
-- Add a GIN index to the 'item' column for fast fuzzy text searching.
|
||||||
|
-- This partial index is optimized for queries that find the best price for an item.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flyer_items_master_item_price ON public.flyer_items (master_item_id, price_in_cents ASC) WHERE price_in_cents IS NOT NULL;
|
||||||
-- This requires the pg_trgm extension.
|
-- This requires the pg_trgm extension.
|
||||||
CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops);
|
CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops);
|
||||||
|
|
||||||
@@ -254,7 +292,7 @@ CREATE TABLE IF NOT EXISTS public.user_alerts (
|
|||||||
user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE,
|
user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(user_watched_item_id) ON DELETE CASCADE,
|
||||||
alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')),
|
alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')),
|
||||||
threshold_value NUMERIC NOT NULL,
|
threshold_value NUMERIC NOT NULL CHECK (threshold_value > 0),
|
||||||
is_active BOOLEAN DEFAULT true NOT NULL,
|
is_active BOOLEAN DEFAULT true NOT 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
|
||||||
@@ -272,7 +310,8 @@ CREATE TABLE IF NOT EXISTS public.notifications (
|
|||||||
link_url TEXT,
|
link_url TEXT,
|
||||||
is_read BOOLEAN DEFAULT false NOT NULL,
|
is_read BOOLEAN DEFAULT false NOT 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,
|
||||||
|
CONSTRAINT notifications_content_check CHECK (TRIM(content) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.';
|
COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.';
|
||||||
COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.';
|
COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.';
|
||||||
@@ -298,13 +337,14 @@ CREATE TABLE IF NOT EXISTS public.item_price_history (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
summary_date DATE NOT NULL,
|
summary_date DATE NOT NULL,
|
||||||
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
min_price_in_cents INTEGER,
|
min_price_in_cents INTEGER CHECK (min_price_in_cents IS NULL OR min_price_in_cents >= 0),
|
||||||
max_price_in_cents INTEGER,
|
max_price_in_cents INTEGER CHECK (max_price_in_cents IS NULL OR max_price_in_cents >= 0),
|
||||||
avg_price_in_cents INTEGER,
|
avg_price_in_cents INTEGER CHECK (avg_price_in_cents IS NULL OR avg_price_in_cents >= 0),
|
||||||
data_points_count INTEGER DEFAULT 0 NOT NULL,
|
data_points_count INTEGER DEFAULT 0 NOT NULL CHECK (data_points_count >= 0),
|
||||||
UNIQUE(master_item_id, summary_date, store_location_id),
|
UNIQUE(master_item_id, summary_date, store_location_id),
|
||||||
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,
|
||||||
|
CONSTRAINT item_price_history_price_order_check CHECK (min_price_in_cents <= max_price_in_cents)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.';
|
COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.';
|
||||||
COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.';
|
COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.';
|
||||||
@@ -321,7 +361,8 @@ CREATE TABLE IF NOT EXISTS public.master_item_aliases (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
alias TEXT NOT NULL UNIQUE,
|
alias TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT master_item_aliases_alias_check CHECK (TRIM(alias) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.';
|
COMMENT ON TABLE public.master_item_aliases IS 'Stores synonyms or alternative names for master items to improve matching.';
|
||||||
COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".';
|
COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g., "Ground Chuck" for the master item "Ground Beef".';
|
||||||
@@ -333,7 +374,8 @@ CREATE TABLE IF NOT EXISTS public.shopping_lists (
|
|||||||
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,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT 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,
|
||||||
|
CONSTRAINT shopping_lists_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".';
|
COMMENT ON TABLE public.shopping_lists IS 'Stores user-created shopping lists, e.g., "Weekly Groceries".';
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id);
|
CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(user_id);
|
||||||
@@ -344,12 +386,13 @@ CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
|||||||
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) ON DELETE CASCADE,
|
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 CHECK (quantity > 0),
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
added_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
added_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL),
|
||||||
|
CONSTRAINT shopping_list_items_custom_item_name_check CHECK (custom_item_name IS NULL OR TRIM(custom_item_name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.';
|
COMMENT ON TABLE public.shopping_list_items IS 'Contains individual items for a specific shopping list.';
|
||||||
COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".';
|
COMMENT ON COLUMN public.shopping_list_items.custom_item_name IS 'For items not in the master list, e.g., "Grandma''s special spice mix".';
|
||||||
@@ -381,7 +424,8 @@ CREATE TABLE IF NOT EXISTS public.menu_plans (
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE NOT NULL,
|
end_date DATE NOT 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,
|
||||||
|
CONSTRAINT menu_plans_name_check CHECK (TRIM(name) <> ''),
|
||||||
CONSTRAINT date_range_check CHECK (end_date >= start_date)
|
CONSTRAINT date_range_check CHECK (end_date >= start_date)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".';
|
COMMENT ON TABLE public.menu_plans IS 'Represents a user''s meal plan for a specific period, e.g., "Week of Oct 23".';
|
||||||
@@ -410,11 +454,13 @@ CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
|||||||
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,
|
||||||
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 CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
reviewed_notes TEXT,
|
reviewed_notes TEXT,
|
||||||
reviewed_at TIMESTAMPTZ,
|
reviewed_at TIMESTAMPTZ,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT suggested_corrections_correction_type_check CHECK (TRIM(correction_type) <> ''),
|
||||||
|
CONSTRAINT suggested_corrections_suggested_value_check CHECK (TRIM(suggested_value) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.';
|
COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.';
|
||||||
COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.';
|
COMMENT ON COLUMN public.suggested_corrections.correction_type IS 'The type of error the user is reporting.';
|
||||||
@@ -430,13 +476,14 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
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,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
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) ON DELETE CASCADE,
|
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 CHECK (price_in_cents > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
downvotes INTEGER DEFAULT 0 NOT NULL,
|
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
||||||
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
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
@@ -446,7 +493,8 @@ CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.us
|
|||||||
-- 22. Log flyer items that could not be automatically matched to a master item.
|
-- 22. Log flyer items that could not be automatically matched to a master item.
|
||||||
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
||||||
unmatched_flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
unmatched_flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'resolved', 'ignored')),
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
|
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'resolved', 'ignored')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
reviewed_at TIMESTAMPTZ,
|
reviewed_at TIMESTAMPTZ,
|
||||||
UNIQUE(flyer_item_id),
|
UNIQUE(flyer_item_id),
|
||||||
@@ -476,21 +524,23 @@ CREATE TABLE IF NOT EXISTS public.recipes (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
instructions TEXT,
|
instructions TEXT,
|
||||||
prep_time_minutes INTEGER,
|
prep_time_minutes INTEGER CHECK (prep_time_minutes IS NULL OR prep_time_minutes >= 0),
|
||||||
cook_time_minutes INTEGER,
|
cook_time_minutes INTEGER CHECK (cook_time_minutes IS NULL OR cook_time_minutes >= 0),
|
||||||
servings INTEGER,
|
servings INTEGER CHECK (servings IS NULL OR servings > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
calories_per_serving INTEGER,
|
calories_per_serving INTEGER,
|
||||||
protein_grams NUMERIC,
|
protein_grams NUMERIC,
|
||||||
fat_grams NUMERIC,
|
fat_grams NUMERIC,
|
||||||
carb_grams NUMERIC,
|
carb_grams NUMERIC,
|
||||||
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL,
|
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL CHECK (avg_rating >= 0.0 AND avg_rating <= 5.0),
|
||||||
status TEXT DEFAULT 'private' NOT NULL CHECK (status IN ('private', 'pending_review', 'public', 'rejected')),
|
status TEXT DEFAULT 'private' NOT NULL CHECK (status IN ('private', 'pending_review', 'public', 'rejected')),
|
||||||
rating_count INTEGER DEFAULT 0 NOT NULL,
|
rating_count INTEGER DEFAULT 0 NOT NULL CHECK (rating_count >= 0),
|
||||||
fork_count INTEGER DEFAULT 0 NOT NULL,
|
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
||||||
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,
|
||||||
|
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
||||||
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
||||||
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
||||||
@@ -504,6 +554,8 @@ CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON public.recipes(user_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id);
|
CREATE INDEX IF NOT EXISTS idx_recipes_original_recipe_id ON public.recipes(original_recipe_id);
|
||||||
-- Add a partial unique index to ensure system-wide recipes (user_id IS NULL) have unique names.
|
-- Add a partial unique index to ensure system-wide recipes (user_id IS NULL) have unique names.
|
||||||
-- This allows different users to have recipes with the same name.
|
-- This allows different users to have recipes with the same name.
|
||||||
|
-- This index helps speed up sorting for recipe recommendations.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_rating_sort ON public.recipes (avg_rating DESC, rating_count DESC);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON public.recipes(name) WHERE user_id IS NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON public.recipes(name) WHERE user_id IS NULL;
|
||||||
|
|
||||||
-- 27. For ingredients required for each recipe.
|
-- 27. For ingredients required for each recipe.
|
||||||
@@ -511,10 +563,11 @@ 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) ON DELETE CASCADE,
|
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 CHECK (quantity > 0),
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT 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,
|
||||||
|
CONSTRAINT recipe_ingredients_unit_check CHECK (TRIM(unit) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.';
|
COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.';
|
||||||
COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".';
|
COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".';
|
||||||
@@ -541,7 +594,8 @@ CREATE TABLE IF NOT EXISTS public.tags (
|
|||||||
tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT tags_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".';
|
COMMENT ON TABLE public.tags IS 'Stores tags for categorizing recipes, e.g., "Vegetarian", "Quick & Easy".';
|
||||||
|
|
||||||
@@ -563,7 +617,8 @@ CREATE TABLE IF NOT EXISTS public.appliances (
|
|||||||
appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
appliance_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
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,
|
||||||
|
CONSTRAINT appliances_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances (e.g., Air Fryer, Instant Pot).';
|
COMMENT ON TABLE public.appliances IS 'A predefined list of kitchen appliances (e.g., Air Fryer, Instant Pot).';
|
||||||
|
|
||||||
@@ -603,7 +658,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_comments (
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')),
|
status TEXT DEFAULT 'visible' NOT NULL CHECK (status IN ('visible', 'hidden', 'reported')),
|
||||||
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,
|
||||||
|
CONSTRAINT recipe_comments_content_check CHECK (TRIM(content) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.';
|
COMMENT ON TABLE public.recipe_comments IS 'Allows for threaded discussions and comments on recipes.';
|
||||||
COMMENT ON COLUMN public.recipe_comments.parent_comment_id IS 'For threaded comments.';
|
COMMENT ON COLUMN public.recipe_comments.parent_comment_id IS 'For threaded comments.';
|
||||||
@@ -617,7 +673,8 @@ CREATE TABLE IF NOT EXISTS public.pantry_locations (
|
|||||||
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,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT 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,
|
||||||
|
CONSTRAINT pantry_locations_name_check CHECK (TRIM(name) <> ''),
|
||||||
UNIQUE(user_id, name)
|
UNIQUE(user_id, name)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").';
|
COMMENT ON TABLE public.pantry_locations IS 'User-defined locations for organizing pantry items (e.g., "Fridge", "Freezer", "Spice Rack").';
|
||||||
@@ -631,8 +688,9 @@ CREATE TABLE IF NOT EXISTS public.planned_meals (
|
|||||||
plan_date DATE NOT NULL,
|
plan_date DATE NOT NULL,
|
||||||
meal_type TEXT NOT NULL,
|
meal_type TEXT NOT NULL,
|
||||||
servings_to_cook INTEGER,
|
servings_to_cook INTEGER,
|
||||||
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,
|
||||||
|
CONSTRAINT planned_meals_meal_type_check CHECK (TRIM(meal_type) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.';
|
COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.';
|
||||||
COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
|
COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
|
||||||
@@ -644,7 +702,7 @@ CREATE TABLE IF NOT EXISTS public.pantry_items (
|
|||||||
pantry_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
pantry_item_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,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
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 CHECK (quantity >= 0),
|
||||||
unit TEXT,
|
unit TEXT,
|
||||||
best_before_date DATE,
|
best_before_date DATE,
|
||||||
pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL,
|
pantry_location_id BIGINT REFERENCES public.pantry_locations(pantry_location_id) ON DELETE SET NULL,
|
||||||
@@ -667,7 +725,8 @@ CREATE TABLE IF NOT EXISTS public.password_reset_tokens (
|
|||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT 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,
|
||||||
|
CONSTRAINT password_reset_tokens_token_hash_check CHECK (TRIM(token_hash) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.';
|
COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.';
|
||||||
COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.';
|
COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.';
|
||||||
@@ -682,10 +741,13 @@ CREATE TABLE IF NOT EXISTS public.unit_conversions (
|
|||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
from_unit TEXT NOT NULL,
|
from_unit TEXT NOT NULL,
|
||||||
to_unit TEXT NOT NULL,
|
to_unit TEXT NOT NULL,
|
||||||
factor NUMERIC NOT NULL,
|
factor NUMERIC NOT NULL CHECK (factor > 0),
|
||||||
UNIQUE(master_item_id, from_unit, to_unit),
|
|
||||||
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,
|
||||||
|
UNIQUE(master_item_id, from_unit, to_unit),
|
||||||
|
CONSTRAINT unit_conversions_from_unit_check CHECK (TRIM(from_unit) <> ''),
|
||||||
|
CONSTRAINT unit_conversions_to_unit_check CHECK (TRIM(to_unit) <> ''),
|
||||||
|
CONSTRAINT unit_conversions_units_check CHECK (from_unit <> to_unit)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).';
|
COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).';
|
||||||
COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.';
|
COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.';
|
||||||
@@ -697,9 +759,10 @@ CREATE TABLE IF NOT EXISTS public.user_item_aliases (
|
|||||||
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,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
UNIQUE(user_id, alias),
|
|
||||||
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,
|
||||||
|
UNIQUE(user_id, alias),
|
||||||
|
CONSTRAINT user_item_aliases_alias_check CHECK (TRIM(alias) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").';
|
COMMENT ON TABLE public.user_item_aliases IS 'Allows users to create personal aliases for grocery items (e.g., "Dad''s Cereal").';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_item_aliases_user_id ON public.user_item_aliases(user_id);
|
||||||
@@ -736,7 +799,8 @@ CREATE TABLE IF NOT EXISTS public.recipe_collections (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
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,
|
||||||
|
CONSTRAINT recipe_collections_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").';
|
COMMENT ON TABLE public.recipe_collections IS 'Allows users to create personal collections of recipes (e.g., "Holiday Baking").';
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_recipe_collections_user_id ON public.recipe_collections(user_id);
|
||||||
@@ -761,8 +825,11 @@ CREATE TABLE IF NOT EXISTS public.shared_recipe_collections (
|
|||||||
shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
shared_with_user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')),
|
permission_level TEXT NOT NULL CHECK (permission_level IN ('view', 'edit')),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
UNIQUE(recipe_collection_id, shared_with_user_id)
|
UNIQUE(recipe_collection_id, shared_with_user_id)
|
||||||
);
|
);
|
||||||
|
-- This index is crucial for efficiently finding all collections shared with a specific user.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shared_recipe_collections_shared_with ON public.shared_recipe_collections(shared_with_user_id);
|
||||||
|
|
||||||
-- 45. Log user search queries for analysis.
|
-- 45. Log user search queries for analysis.
|
||||||
CREATE TABLE IF NOT EXISTS public.search_queries (
|
CREATE TABLE IF NOT EXISTS public.search_queries (
|
||||||
@@ -772,7 +839,8 @@ CREATE TABLE IF NOT EXISTS public.search_queries (
|
|||||||
result_count INTEGER,
|
result_count INTEGER,
|
||||||
was_successful BOOLEAN,
|
was_successful BOOLEAN,
|
||||||
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,
|
||||||
|
CONSTRAINT search_queries_query_text_check CHECK (TRIM(query_text) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.';
|
COMMENT ON TABLE public.search_queries IS 'Logs user search queries to analyze search effectiveness and identify gaps in data.';
|
||||||
COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.';
|
COMMENT ON COLUMN public.search_queries.was_successful IS 'Indicates if the user interacted with a search result.';
|
||||||
@@ -798,10 +866,11 @@ CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
|||||||
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) ON DELETE SET NULL,
|
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 CHECK (quantity > 0),
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
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,
|
||||||
|
CONSTRAINT shopping_trip_items_custom_item_name_check CHECK (custom_item_name IS NULL OR TRIM(custom_item_name) <> ''),
|
||||||
CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
CONSTRAINT trip_must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.';
|
COMMENT ON TABLE public.shopping_trip_items IS 'A historical log of items purchased during a shopping trip.';
|
||||||
@@ -815,7 +884,8 @@ CREATE TABLE IF NOT EXISTS public.dietary_restrictions (
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')),
|
type TEXT NOT NULL CHECK (type IN ('diet', 'allergy')),
|
||||||
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,
|
||||||
|
CONSTRAINT dietary_restrictions_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common diets (e.g., Vegan) and allergies (e.g., Nut Allergy).';
|
COMMENT ON TABLE public.dietary_restrictions IS 'A predefined list of common diets (e.g., Vegan) and allergies (e.g., Nut Allergy).';
|
||||||
|
|
||||||
@@ -865,13 +935,14 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
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 CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
||||||
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||||
raw_text TEXT,
|
raw_text TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||||
@@ -881,13 +952,14 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE,
|
receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE,
|
||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL CHECK (quantity > 0),
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL CHECK (price_paid_cents >= 0),
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
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) ON DELETE SET NULL,
|
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,
|
||||||
|
CONSTRAINT receipt_items_raw_item_description_check CHECK (TRIM(raw_item_description) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.';
|
COMMENT ON TABLE public.receipt_items IS 'Stores individual line items extracted from a user receipt.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id);
|
CREATE INDEX IF NOT EXISTS idx_receipt_items_receipt_id ON public.receipt_items(receipt_id);
|
||||||
@@ -926,11 +998,12 @@ CREATE TABLE IF NOT EXISTS public.budgets (
|
|||||||
budget_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
budget_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,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
amount_cents INTEGER NOT NULL,
|
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
|
||||||
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
|
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
|
||||||
start_date DATE NOT NULL,
|
start_date DATE NOT 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,
|
||||||
|
CONSTRAINT budgets_name_check CHECK (TRIM(name) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
|
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);
|
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);
|
||||||
@@ -941,8 +1014,10 @@ CREATE TABLE IF NOT EXISTS public.achievements (
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
points_value INTEGER NOT NULL DEFAULT 0,
|
points_value INTEGER NOT NULL DEFAULT 0 CHECK (points_value >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT achievements_name_check CHECK (TRIM(name) <> ''),
|
||||||
|
CONSTRAINT achievements_description_check CHECK (TRIM(description) <> '')
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE public.achievements IS 'A static table defining the available achievements users can earn.';
|
COMMENT ON TABLE public.achievements IS 'A static table defining the available achievements users can earn.';
|
||||||
|
|
||||||
@@ -1038,6 +1113,7 @@ DECLARE
|
|||||||
ground_beef_id BIGINT; pasta_item_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT;
|
ground_beef_id BIGINT; pasta_item_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT;
|
||||||
bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT;
|
bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT;
|
||||||
soda_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT;
|
soda_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT;
|
||||||
|
chicken_thighs_id BIGINT; paper_towels_id BIGINT; toilet_paper_id BIGINT;
|
||||||
|
|
||||||
-- Tag IDs
|
-- Tag IDs
|
||||||
quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT;
|
quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT;
|
||||||
@@ -1089,6 +1165,9 @@ BEGIN
|
|||||||
SELECT mgi.master_grocery_item_id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey';
|
SELECT mgi.master_grocery_item_id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey';
|
||||||
SELECT mgi.master_grocery_item_id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread';
|
SELECT mgi.master_grocery_item_id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread';
|
||||||
SELECT mgi.master_grocery_item_id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese';
|
SELECT mgi.master_grocery_item_id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese';
|
||||||
|
SELECT mgi.master_grocery_item_id INTO chicken_thighs_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken thighs';
|
||||||
|
SELECT mgi.master_grocery_item_id INTO paper_towels_id FROM public.master_grocery_items mgi WHERE mgi.name = 'paper towels';
|
||||||
|
SELECT mgi.master_grocery_item_id INTO toilet_paper_id FROM public.master_grocery_items mgi WHERE mgi.name = 'toilet paper';
|
||||||
|
|
||||||
-- Insert ingredients for each recipe
|
-- Insert ingredients for each recipe
|
||||||
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES
|
||||||
@@ -1125,6 +1204,17 @@ BEGIN
|
|||||||
(bolognese_recipe_id, family_tag), (bolognese_recipe_id, beef_tag), (bolognese_recipe_id, weeknight_tag),
|
(bolognese_recipe_id, family_tag), (bolognese_recipe_id, beef_tag), (bolognese_recipe_id, weeknight_tag),
|
||||||
(stir_fry_recipe_id, quick_easy_tag), (stir_fry_recipe_id, healthy_tag), (stir_fry_recipe_id, vegetarian_tag)
|
(stir_fry_recipe_id, quick_easy_tag), (stir_fry_recipe_id, healthy_tag), (stir_fry_recipe_id, vegetarian_tag)
|
||||||
ON CONFLICT (recipe_id, tag_id) DO NOTHING;
|
ON CONFLICT (recipe_id, tag_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO public.master_item_aliases (master_item_id, alias) VALUES
|
||||||
|
(ground_beef_id, 'ground chuck'), (ground_beef_id, 'lean ground beef'),
|
||||||
|
(ground_beef_id, 'extra lean ground beef'), (ground_beef_id, 'hamburger meat'),
|
||||||
|
(chicken_breast_id, 'boneless skinless chicken breast'), (chicken_breast_id, 'chicken cutlets'),
|
||||||
|
(chicken_thighs_id, 'boneless skinless chicken thighs'), (chicken_thighs_id, 'bone-in chicken thighs'),
|
||||||
|
(bell_peppers_id, 'red pepper'), (bell_peppers_id, 'green pepper'), (bell_peppers_id, 'yellow pepper'), (bell_peppers_id, 'orange pepper'),
|
||||||
|
(soda_item_id, 'pop'), (soda_item_id, 'soft drink'), (soda_item_id, 'coke'), (soda_item_id, 'pepsi'),
|
||||||
|
(paper_towels_id, 'paper towel'),
|
||||||
|
(toilet_paper_id, 'bathroom tissue'), (toilet_paper_id, 'toilet tissue')
|
||||||
|
ON CONFLICT (alias) DO NOTHING;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Pre-populate the unit_conversions table with common cooking conversions.
|
-- Pre-populate the unit_conversions table with common cooking conversions.
|
||||||
@@ -1173,7 +1263,8 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES
|
|||||||
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
|
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
|
||||||
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
|
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
|
||||||
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
|
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
|
||||||
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15)
|
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15),
|
||||||
|
('First-Upload', 'Upload your first flyer.', 'upload-cloud', 25)
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -2039,6 +2130,61 @@ AS $$
|
|||||||
ORDER BY potential_savings_cents DESC;
|
ORDER BY potential_savings_cents DESC;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
-- Function to get a user's spending breakdown by category for a given date range.
|
||||||
|
DROP FUNCTION IF EXISTS public.get_spending_by_category(UUID, DATE, DATE);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_spending_by_category(p_user_id UUID, p_start_date DATE, p_end_date DATE)
|
||||||
|
RETURNS TABLE (
|
||||||
|
category_id BIGINT,
|
||||||
|
category_name TEXT,
|
||||||
|
total_spent_cents BIGINT
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY INVOKER
|
||||||
|
AS $$
|
||||||
|
WITH all_purchases AS (
|
||||||
|
-- CTE 1: Combine purchases from completed shopping trips.
|
||||||
|
-- We only consider items that have a price paid.
|
||||||
|
SELECT
|
||||||
|
sti.master_item_id,
|
||||||
|
sti.price_paid_cents
|
||||||
|
FROM public.shopping_trip_items sti
|
||||||
|
JOIN public.shopping_trips st ON sti.shopping_trip_id = st.shopping_trip_id
|
||||||
|
WHERE st.user_id = p_user_id
|
||||||
|
AND st.completed_at::date BETWEEN p_start_date AND p_end_date
|
||||||
|
AND sti.price_paid_cents IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- CTE 2: Combine purchases from processed receipts.
|
||||||
|
SELECT
|
||||||
|
ri.master_item_id,
|
||||||
|
ri.price_paid_cents
|
||||||
|
FROM public.receipt_items ri
|
||||||
|
JOIN public.receipts r ON ri.receipt_id = r.receipt_id
|
||||||
|
WHERE r.user_id = p_user_id
|
||||||
|
AND r.transaction_date::date BETWEEN p_start_date AND p_end_date
|
||||||
|
AND ri.master_item_id IS NOT NULL -- Only include items matched to a master item
|
||||||
|
)
|
||||||
|
-- Final Aggregation: Group all combined purchases by category and sum the spending.
|
||||||
|
SELECT
|
||||||
|
c.category_id,
|
||||||
|
c.name AS category_name,
|
||||||
|
SUM(ap.price_paid_cents)::BIGINT AS total_spent_cents
|
||||||
|
FROM all_purchases ap
|
||||||
|
-- Join with master_grocery_items to get the category_id for each purchase.
|
||||||
|
JOIN public.master_grocery_items mgi ON ap.master_item_id = mgi.master_grocery_item_id
|
||||||
|
-- Join with categories to get the category name for display.
|
||||||
|
JOIN public.categories c ON mgi.category_id = c.category_id
|
||||||
|
GROUP BY
|
||||||
|
c.category_id, c.name
|
||||||
|
HAVING
|
||||||
|
SUM(ap.price_paid_cents) > 0
|
||||||
|
ORDER BY
|
||||||
|
total_spent_cents DESC;
|
||||||
|
$$;
|
||||||
|
|
||||||
-- Function to approve a suggested correction and apply it.
|
-- Function to approve a suggested correction and apply it.
|
||||||
DROP FUNCTION IF EXISTS public.approve_correction(BIGINT);
|
DROP FUNCTION IF EXISTS public.approve_correction(BIGINT);
|
||||||
|
|
||||||
@@ -2482,8 +2628,15 @@ DROP FUNCTION IF EXISTS public.log_new_flyer();
|
|||||||
CREATE OR REPLACE FUNCTION public.log_new_flyer()
|
CREATE OR REPLACE FUNCTION public.log_new_flyer()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO public.activity_log (action, display_text, icon, details)
|
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
|
||||||
|
-- The award_achievement function handles checking if the user already has it.
|
||||||
|
IF NEW.uploaded_by IS NOT NULL THEN
|
||||||
|
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
NEW.uploaded_by, -- Log the user who uploaded it
|
||||||
'flyer_uploaded',
|
'flyer_uploaded',
|
||||||
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
|
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
|
||||||
'file-text',
|
'file-text',
|
||||||
@@ -2541,6 +2694,7 @@ BEGIN
|
|||||||
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
|
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
|
||||||
'share-2',
|
'share-2',
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
|
'shopping_list_id', NEW.shopping_list_id,
|
||||||
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
|
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
|
||||||
'shared_with_user_id', NEW.shared_with_user_id
|
'shared_with_user_id', NEW.shared_with_user_id
|
||||||
)
|
)
|
||||||
@@ -2588,6 +2742,66 @@ CREATE TRIGGER on_new_recipe_collection_share
|
|||||||
AFTER INSERT ON public.shared_recipe_collections
|
AFTER INSERT ON public.shared_recipe_collections
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
|
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
|
||||||
|
|
||||||
|
-- 10. Trigger function to geocode a store location's address.
|
||||||
|
-- This function is triggered when an address is inserted or updated, and is
|
||||||
|
-- designed to be extensible for external geocoding services to populate the
|
||||||
|
-- latitude, longitude, and location fields.
|
||||||
|
DROP FUNCTION IF EXISTS public.geocode_address();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.geocode_address()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
full_address TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Only proceed if an address component has actually changed.
|
||||||
|
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (
|
||||||
|
NEW.address_line_1 IS DISTINCT FROM OLD.address_line_1 OR
|
||||||
|
NEW.address_line_2 IS DISTINCT FROM OLD.address_line_2 OR
|
||||||
|
NEW.city IS DISTINCT FROM OLD.city OR
|
||||||
|
NEW.province_state IS DISTINCT FROM OLD.province_state OR
|
||||||
|
NEW.postal_code IS DISTINCT FROM OLD.postal_code OR
|
||||||
|
NEW.country IS DISTINCT FROM OLD.country
|
||||||
|
)) THEN
|
||||||
|
-- Concatenate address parts into a single string for the geocoder.
|
||||||
|
full_address := CONCAT_WS(', ', NEW.address_line_1, NEW.address_line_2, NEW.city, NEW.province_state, NEW.postal_code, NEW.country);
|
||||||
|
|
||||||
|
-- Placeholder for Geocoding API Call.
|
||||||
|
-- In a real application, you would call a service here and update NEW.latitude, NEW.longitude, and NEW.location.
|
||||||
|
-- e.g., NEW.latitude := result.lat; NEW.longitude := result.lon;
|
||||||
|
-- NEW.location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- This trigger calls the geocoding function when an address changes.
|
||||||
|
DROP TRIGGER IF EXISTS on_address_change_geocode ON public.addresses;
|
||||||
|
CREATE TRIGGER on_address_change_geocode
|
||||||
|
BEFORE INSERT OR UPDATE ON public.addresses
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.geocode_address();
|
||||||
|
|
||||||
|
-- 11. Trigger function to increment the fork_count on the original recipe.
|
||||||
|
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Only run if the recipe is a fork (original_recipe_id is not null).
|
||||||
|
IF NEW.original_recipe_id IS NOT NULL THEN
|
||||||
|
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
|
||||||
|
-- Award 'First Fork' achievement.
|
||||||
|
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
|
||||||
|
CREATE TRIGGER on_recipe_fork
|
||||||
|
AFTER INSERT ON public.recipes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
|
||||||
|
|
||||||
-- =================================================================
|
-- =================================================================
|
||||||
-- Function: get_best_sale_prices_for_all_users()
|
-- Function: get_best_sale_prices_for_all_users()
|
||||||
-- Description: Retrieves the best sale price for every item on every user's watchlist.
|
-- Description: Retrieves the best sale price for every item on every user's watchlist.
|
||||||
@@ -2595,16 +2809,19 @@ CREATE TRIGGER on_new_recipe_collection_share
|
|||||||
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
|
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
|
||||||
-- Returns: TABLE(...) - A set of records including user details and deal information.
|
-- Returns: TABLE(...) - A set of records including user details and deal information.
|
||||||
-- =================================================================
|
-- =================================================================
|
||||||
|
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
|
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
|
||||||
RETURNS TABLE(
|
RETURNS TABLE(
|
||||||
user_id uuid,
|
user_id uuid,
|
||||||
|
|
||||||
email text,
|
email text,
|
||||||
full_name text,
|
full_name text,
|
||||||
master_item_id integer,
|
master_item_id bigint,
|
||||||
item_name text,
|
item_name text,
|
||||||
best_price_in_cents integer,
|
best_price_in_cents integer,
|
||||||
store_name text,
|
store_name text,
|
||||||
flyer_id integer,
|
flyer_id bigint,
|
||||||
valid_to date
|
valid_to date
|
||||||
) AS $$
|
) AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -2612,22 +2829,27 @@ BEGIN
|
|||||||
WITH
|
WITH
|
||||||
-- Step 1: Find all flyer items that are currently on sale and have a valid price.
|
-- Step 1: Find all flyer items that are currently on sale and have a valid price.
|
||||||
current_sales AS (
|
current_sales AS (
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
fi.master_item_id,
|
fi.master_item_id,
|
||||||
fi.price_in_cents,
|
fi.price_in_cents,
|
||||||
f.store_name,
|
s.name as store_name,
|
||||||
f.flyer_id,
|
f.flyer_id,
|
||||||
f.valid_to
|
f.valid_to
|
||||||
FROM public.flyer_items fi
|
FROM public.flyer_items fi
|
||||||
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
||||||
|
JOIN public.stores s ON f.store_id = s.store_id
|
||||||
WHERE
|
WHERE
|
||||||
|
|
||||||
fi.master_item_id IS NOT NULL
|
fi.master_item_id IS NOT NULL
|
||||||
AND fi.price_in_cents IS NOT NULL
|
AND fi.price_in_cents IS NOT NULL
|
||||||
AND f.valid_to >= CURRENT_DATE
|
AND f.valid_to >= CURRENT_DATE
|
||||||
),
|
),
|
||||||
-- Step 2: For each master item, find its absolute best (lowest) price across all current sales.
|
-- Step 2: For each master item, find its absolute best (lowest) price across all current sales.
|
||||||
-- We use a window function to rank the sales for each item by price.
|
-- We use a window function to rank the sales for each item by price.
|
||||||
|
|
||||||
best_prices AS (
|
best_prices AS (
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
cs.master_item_id,
|
cs.master_item_id,
|
||||||
cs.price_in_cents AS best_price_in_cents,
|
cs.price_in_cents AS best_price_in_cents,
|
||||||
@@ -2640,6 +2862,7 @@ BEGIN
|
|||||||
)
|
)
|
||||||
-- Step 3: Join the best-priced items with the user watchlist and user details.
|
-- Step 3: Join the best-priced items with the user watchlist and user details.
|
||||||
SELECT
|
SELECT
|
||||||
|
|
||||||
u.user_id,
|
u.user_id,
|
||||||
u.email,
|
u.email,
|
||||||
p.full_name,
|
p.full_name,
|
||||||
@@ -2659,6 +2882,7 @@ BEGIN
|
|||||||
JOIN public.master_grocery_items mgi ON bp.master_item_id = mgi.master_grocery_item_id
|
JOIN public.master_grocery_items mgi ON bp.master_item_id = mgi.master_grocery_item_id
|
||||||
WHERE
|
WHERE
|
||||||
-- Only include the items that are at their absolute best price (rank = 1).
|
-- Only include the items that are at their absolute best price (rank = 1).
|
||||||
|
|
||||||
bp.price_rank = 1;
|
bp.price_rank = 1;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
164
src/App.test.tsx
164
src/App.test.tsx
@@ -20,10 +20,98 @@ import {
|
|||||||
mockUseUserData,
|
mockUseUserData,
|
||||||
mockUseFlyerItems,
|
mockUseFlyerItems,
|
||||||
} from './tests/setup/mockHooks';
|
} from './tests/setup/mockHooks';
|
||||||
|
import './tests/setup/mockUI';
|
||||||
import { useAppInitialization } from './hooks/useAppInitialization';
|
import { useAppInitialization } from './hooks/useAppInitialization';
|
||||||
|
|
||||||
// Mock top-level components rendered by App's routes
|
// Mock top-level components rendered by App's routes
|
||||||
|
|
||||||
|
vi.mock('./components/Header', () => ({
|
||||||
|
Header: ({ onOpenProfile, onOpenVoiceAssistant }: any) => (
|
||||||
|
<div data-testid="header-mock">
|
||||||
|
<button onClick={onOpenProfile}>Open Profile</button>
|
||||||
|
<button onClick={onOpenVoiceAssistant}>Open Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/Footer', () => ({
|
||||||
|
Footer: () => <div data-testid="footer-mock">Mock Footer</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./layouts/MainLayout', async () => {
|
||||||
|
const { Outlet } = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||||
|
return {
|
||||||
|
MainLayout: () => (
|
||||||
|
<div data-testid="main-layout-mock">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./pages/HomePage', () => ({
|
||||||
|
HomePage: ({ selectedFlyer, onOpenCorrectionTool }: any) => (
|
||||||
|
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
|
||||||
|
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminPage', () => ({
|
||||||
|
AdminPage: () => <div data-testid="admin-page-mock">AdminPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/CorrectionsPage', () => ({
|
||||||
|
CorrectionsPage: () => <div data-testid="corrections-page-mock">CorrectionsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminStatsPage', () => ({
|
||||||
|
AdminStatsPage: () => <div data-testid="admin-stats-page-mock">AdminStatsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/FlyerReviewPage', () => ({
|
||||||
|
FlyerReviewPage: () => <div data-testid="flyer-review-page-mock">FlyerReviewPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/VoiceLabPage', () => ({
|
||||||
|
VoiceLabPage: () => <div data-testid="voice-lab-page-mock">VoiceLabPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/ResetPasswordPage', () => ({
|
||||||
|
ResetPasswordPage: () => <div data-testid="reset-password-page-mock">ResetPasswordPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/components/ProfileManager', () => ({
|
||||||
|
ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="profile-manager-mock">
|
||||||
|
<button onClick={onClose}>Close Profile</button>
|
||||||
|
<button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button>
|
||||||
|
<button onClick={() => onLoginSuccess({}, 'token', false)}>Login</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({
|
||||||
|
VoiceAssistant: ({ isOpen, onClose }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="voice-assistant-mock">
|
||||||
|
<button onClick={onClose}>Close Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/FlyerCorrectionTool', () => ({
|
||||||
|
FlyerCorrectionTool: ({ isOpen, onClose, onDataExtracted }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="flyer-correction-tool-mock">
|
||||||
|
<button onClick={onClose}>Close Correction</button>
|
||||||
|
<button onClick={() => onDataExtracted('store_name', 'New Store')}>Extract Store</button>
|
||||||
|
<button onClick={() => onDataExtracted('dates', 'New Dates')}>Extract Dates</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
||||||
// This must be done in any test file that imports App.tsx.
|
// This must be done in any test file that imports App.tsx.
|
||||||
vi.mock('pdfjs-dist', () => ({
|
vi.mock('pdfjs-dist', () => ({
|
||||||
@@ -61,71 +149,6 @@ vi.mock('./hooks/useAuth', async () => {
|
|||||||
return { useAuth: hooks.mockUseAuth };
|
return { useAuth: hooks.mockUseAuth };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./components/Footer', async () => {
|
|
||||||
const { MockFooter } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Footer: MockFooter };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/Header', async () => {
|
|
||||||
const { MockHeader } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Header: MockHeader };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/HomePage', async () => {
|
|
||||||
const { MockHomePage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { HomePage: MockHomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminPage', async () => {
|
|
||||||
const { MockAdminPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminPage: MockAdminPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/CorrectionsPage', async () => {
|
|
||||||
const { MockCorrectionsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { CorrectionsPage: MockCorrectionsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminStatsPage', async () => {
|
|
||||||
const { MockAdminStatsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminStatsPage: MockAdminStatsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/VoiceLabPage', async () => {
|
|
||||||
const { MockVoiceLabPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceLabPage: MockVoiceLabPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/ResetPasswordPage', async () => {
|
|
||||||
const { MockResetPasswordPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ResetPasswordPage: MockResetPasswordPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/components/ProfileManager', async () => {
|
|
||||||
const { MockProfileManager } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ProfileManager: MockProfileManager };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./features/voice-assistant/VoiceAssistant', async () => {
|
|
||||||
const { MockVoiceAssistant } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceAssistant: MockVoiceAssistant };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/FlyerCorrectionTool', async () => {
|
|
||||||
const { MockFlyerCorrectionTool } = await import('./tests/utils/componentMocks');
|
|
||||||
return { FlyerCorrectionTool: MockFlyerCorrectionTool };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/WhatsNewModal', async () => {
|
|
||||||
const { MockWhatsNewModal } = await import('./tests/utils/componentMocks');
|
|
||||||
return { WhatsNewModal: MockWhatsNewModal };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./layouts/MainLayout', async () => {
|
|
||||||
const { MockMainLayout } = await import('./tests/utils/componentMocks');
|
|
||||||
return { MainLayout: MockMainLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/AppGuard', async () => {
|
vi.mock('./components/AppGuard', async () => {
|
||||||
// We need to use the real useModal hook inside our mock AppGuard
|
// We need to use the real useModal hook inside our mock AppGuard
|
||||||
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
||||||
@@ -192,6 +215,7 @@ describe('App Component', () => {
|
|||||||
mockUseUserData.mockReturnValue({
|
mockUseUserData.mockReturnValue({
|
||||||
watchedItems: [],
|
watchedItems: [],
|
||||||
shoppingLists: [],
|
shoppingLists: [],
|
||||||
|
isLoadingShoppingLists: false,
|
||||||
setWatchedItems: vi.fn(),
|
setWatchedItems: vi.fn(),
|
||||||
setShoppingLists: vi.fn(),
|
setShoppingLists: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -361,12 +385,8 @@ describe('App Component', () => {
|
|||||||
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']);
|
||||||
|
|
||||||
// The HomePage mock will be rendered. The important part is that the selection logic
|
|
||||||
// in App.tsx runs and passes the correct `selectedFlyer` prop down.
|
|
||||||
// Since HomePage is mocked, we can't see the direct result, but we can
|
|
||||||
// infer that the logic ran without crashing and the correct route was matched.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('home-page-mock')).toHaveAttribute('data-selected-flyer-id', '2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,7 +628,7 @@ describe('App Component', () => {
|
|||||||
app: {
|
app: {
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
commitMessage: 'A new version!',
|
commitMessage: 'A new version!',
|
||||||
commitUrl: 'http://example.com/commit/2.0.0',
|
commitUrl: 'https://example.com/commit/2.0.0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -618,7 +638,7 @@ describe('App Component', () => {
|
|||||||
renderApp();
|
renderApp();
|
||||||
const versionLink = screen.getByText(`Version: 2.0.0`);
|
const versionLink = screen.getByText(`Version: 2.0.0`);
|
||||||
expect(versionLink).toBeInTheDocument();
|
expect(versionLink).toBeInTheDocument();
|
||||||
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
|
expect(versionLink).toHaveAttribute('href', 'https://example.com/commit/2.0.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -1,6 +1,6 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Routes, Route, useParams } from 'react-router-dom';
|
import { Routes, Route, useLocation, matchPath } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
@@ -13,6 +13,7 @@ import { AdminPage } from './pages/admin/AdminPage';
|
|||||||
import { AdminRoute } from './components/AdminRoute';
|
import { AdminRoute } from './components/AdminRoute';
|
||||||
import { CorrectionsPage } from './pages/admin/CorrectionsPage';
|
import { CorrectionsPage } from './pages/admin/CorrectionsPage';
|
||||||
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
|
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
|
||||||
|
import { FlyerReviewPage } from './pages/admin/FlyerReviewPage';
|
||||||
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
||||||
import { VoiceLabPage } from './pages/VoiceLabPage';
|
import { VoiceLabPage } from './pages/VoiceLabPage';
|
||||||
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
|
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
|
||||||
@@ -44,7 +45,9 @@ function App() {
|
|||||||
const { flyers } = useFlyers();
|
const { flyers } = useFlyers();
|
||||||
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
||||||
const { openModal, closeModal, isModalOpen } = useModal();
|
const { openModal, closeModal, isModalOpen } = useModal();
|
||||||
const params = useParams<{ flyerId?: string }>();
|
const location = useLocation();
|
||||||
|
const match = matchPath('/flyers/:flyerId', location.pathname);
|
||||||
|
const flyerIdFromUrl = match?.params.flyerId;
|
||||||
|
|
||||||
// This hook now handles initialization effects (OAuth, version check, theme)
|
// This hook now handles initialization effects (OAuth, version check, theme)
|
||||||
// and returns the theme/unit state needed by other components.
|
// and returns the theme/unit state needed by other components.
|
||||||
@@ -56,7 +59,7 @@ function App() {
|
|||||||
console.log('[App] Render:', {
|
console.log('[App] Render:', {
|
||||||
flyersCount: flyers.length,
|
flyersCount: flyers.length,
|
||||||
selectedFlyerId: selectedFlyer?.flyer_id,
|
selectedFlyerId: selectedFlyer?.flyer_id,
|
||||||
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
|
flyerIdFromUrl,
|
||||||
authStatus,
|
authStatus,
|
||||||
profileId: userProfile?.user.user_id,
|
profileId: userProfile?.user.user_id,
|
||||||
});
|
});
|
||||||
@@ -138,8 +141,6 @@ function App() {
|
|||||||
|
|
||||||
// New effect to handle routing to a specific flyer ID from the URL
|
// New effect to handle routing to a specific flyer ID from the URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const flyerIdFromUrl = params.flyerId;
|
|
||||||
|
|
||||||
if (flyerIdFromUrl && flyers.length > 0) {
|
if (flyerIdFromUrl && flyers.length > 0) {
|
||||||
const flyerId = parseInt(flyerIdFromUrl, 10);
|
const flyerId = parseInt(flyerIdFromUrl, 10);
|
||||||
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
|
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
|
||||||
@@ -147,7 +148,7 @@ function App() {
|
|||||||
handleFlyerSelect(flyerToSelect);
|
handleFlyerSelect(flyerToSelect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]);
|
}, [flyers, handleFlyerSelect, selectedFlyer, flyerIdFromUrl]);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -228,6 +229,7 @@ function App() {
|
|||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/admin/corrections" element={<CorrectionsPage />} />
|
<Route path="/admin/corrections" element={<CorrectionsPage />} />
|
||||||
<Route path="/admin/stats" element={<AdminStatsPage />} />
|
<Route path="/admin/stats" element={<AdminStatsPage />} />
|
||||||
|
<Route path="/admin/flyer-review" element={<FlyerReviewPage />} />
|
||||||
<Route path="/admin/voice-lab" element={<VoiceLabPage />} />
|
<Route path="/admin/voice-lab" element={<VoiceLabPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// src/components/AchievementsList.test.tsx
|
// src/components/AchievementsList.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { AchievementsList } from './AchievementsList';
|
import { AchievementsList } from './AchievementsList';
|
||||||
import { createMockUserAchievement } from '../tests/utils/mockFactories';
|
import { createMockUserAchievement } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('AchievementsList', () => {
|
describe('AchievementsList', () => {
|
||||||
it('should render the list of achievements with correct details', () => {
|
it('should render the list of achievements with correct details', () => {
|
||||||
@@ -22,9 +23,10 @@ describe('AchievementsList', () => {
|
|||||||
points_value: 15,
|
points_value: 15,
|
||||||
}),
|
}),
|
||||||
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
||||||
|
createMockUserAchievement({ achievement_id: 4, name: 'No Icon Achievement', icon: '' }), // Triggers the fallback for missing name
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<AchievementsList achievements={mockAchievements} />);
|
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -40,11 +42,19 @@ describe('AchievementsList', () => {
|
|||||||
|
|
||||||
// Check achievement with default icon
|
// Check achievement with default icon
|
||||||
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
||||||
expect(screen.getByText('🏆')).toBeInTheDocument(); // Default icon
|
// We expect at least one trophy (for unknown achievement).
|
||||||
|
// Since we added another one that produces a trophy (No Icon), we use getAllByText.
|
||||||
|
expect(screen.getAllByText('🏆').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check achievement with missing icon (empty string)
|
||||||
|
expect(screen.getByText('No Icon Achievement')).toBeInTheDocument();
|
||||||
|
// Verify the specific placeholder class is rendered, ensuring the early return in Icon component is hit
|
||||||
|
const noIconCard = screen.getByText('No Icon Achievement').closest('.bg-white');
|
||||||
|
expect(noIconCard?.querySelector('.icon-placeholder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a message when there are no achievements', () => {
|
it('should render a message when there are no achievements', () => {
|
||||||
render(<AchievementsList achievements={[]} />);
|
renderWithProviders(<AchievementsList achievements={[]} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
|
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/components/AdminRoute.test.tsx
|
// src/components/AdminRoute.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { AdminRoute } from './AdminRoute';
|
import { AdminRoute } from './AdminRoute';
|
||||||
import type { Profile } from '../types';
|
import type { Profile } from '../types';
|
||||||
import { createMockProfile } from '../tests/utils/mockFactories';
|
import { createMockProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./AdminRoute');
|
vi.unmock('./AdminRoute');
|
||||||
@@ -14,15 +15,14 @@ const AdminContent = () => <div>Admin Page Content</div>;
|
|||||||
const HomePage = () => <div>Home Page</div>;
|
const HomePage = () => <div>Home Page</div>;
|
||||||
|
|
||||||
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<MemoryRouter initialEntries={[initialPath]}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/admin" element={<AdminRoute profile={profile} />}>
|
||||||
<Route path="/admin" element={<AdminRoute profile={profile} />}>
|
<Route index element={<AdminContent />} />
|
||||||
<Route index element={<AdminContent />} />
|
</Route>
|
||||||
</Route>
|
</Routes>,
|
||||||
</Routes>
|
{ initialEntries: [initialPath] },
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/AnonymousUserBanner.test.tsx
|
// src/components/AnonymousUserBanner.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the icon to ensure it is rendered correctly
|
// Mock the icon to ensure it is rendered correctly
|
||||||
vi.mock('./icons/InformationCircleIcon', () => ({
|
vi.mock('./icons/InformationCircleIcon', () => ({
|
||||||
@@ -14,7 +15,7 @@ vi.mock('./icons/InformationCircleIcon', () => ({
|
|||||||
describe('AnonymousUserBanner', () => {
|
describe('AnonymousUserBanner', () => {
|
||||||
it('should render the banner with the correct text content and accessibility role', () => {
|
it('should render the banner with the correct text content and accessibility role', () => {
|
||||||
const mockOnOpenProfile = vi.fn();
|
const mockOnOpenProfile = vi.fn();
|
||||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||||
|
|
||||||
// Check for accessibility role
|
// Check for accessibility role
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
@@ -30,7 +31,7 @@ describe('AnonymousUserBanner', () => {
|
|||||||
|
|
||||||
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
||||||
const mockOnOpenProfile = vi.fn();
|
const mockOnOpenProfile = vi.fn();
|
||||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||||
|
|
||||||
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
||||||
fireEvent.click(loginButton);
|
fireEvent.click(loginButton);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// src/components/AppGuard.test.tsx
|
// src/components/AppGuard.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AppGuard } from './AppGuard';
|
import { AppGuard } from './AppGuard';
|
||||||
import { useAppInitialization } from '../hooks/useAppInitialization';
|
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
import { useModal } from '../hooks/useModal';
|
import { useModal } from '../hooks/useModal';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
vi.mock('../hooks/useAppInitialization');
|
vi.mock('../hooks/useAppInitialization');
|
||||||
vi.mock('../hooks/useModal');
|
vi.mock('../hooks/useModal');
|
||||||
vi.mock('./WhatsNewModal', () => ({
|
vi.mock('./WhatsNewModal', () => ({
|
||||||
@@ -19,6 +22,7 @@ vi.mock('../config', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||||
const mockedUseModal = vi.mocked(useModal);
|
const mockedUseModal = vi.mocked(useModal);
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ describe('AppGuard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render children', () => {
|
it('should render children', () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child Content</div>
|
<div>Child Content</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -51,7 +55,7 @@ describe('AppGuard', () => {
|
|||||||
...mockedUseModal(),
|
...mockedUseModal(),
|
||||||
isModalOpen: (modalId) => modalId === 'whatsNew',
|
isModalOpen: (modalId) => modalId === 'whatsNew',
|
||||||
});
|
});
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -64,7 +68,7 @@ describe('AppGuard', () => {
|
|||||||
isDarkMode: true,
|
isDarkMode: true,
|
||||||
unitSystem: 'imperial',
|
unitSystem: 'imperial',
|
||||||
});
|
});
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -78,7 +82,7 @@ describe('AppGuard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set light mode styles for toaster', async () => {
|
it('should set light mode styles for toaster', async () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/ConfirmationModal.test.tsx
|
// src/components/ConfirmationModal.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('ConfirmationModal (in components)', () => {
|
describe('ConfirmationModal (in components)', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
@@ -21,12 +22,12 @@ describe('ConfirmationModal (in components)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
it('should not render when isOpen is false', () => {
|
||||||
const { container } = render(<ConfirmationModal {...defaultProps} isOpen={false} />);
|
const { container } = renderWithProviders(<ConfirmationModal {...defaultProps} isOpen={false} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly when isOpen is true', () => {
|
it('should render correctly when isOpen is true', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
|
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
||||||
@@ -34,38 +35,38 @@ describe('ConfirmationModal (in components)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onConfirm when the confirm button is clicked', () => {
|
it('should call onConfirm when the confirm button is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the cancel button is clicked', () => {
|
it('should call onClose when the cancel button is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the close icon is clicked', () => {
|
it('should call onClose when the close icon is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
|
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the overlay is clicked', () => {
|
it('should call onClose when the overlay is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
// The overlay is the parent of the modal content div
|
// The overlay is the parent of the modal content div
|
||||||
fireEvent.click(screen.getByRole('dialog'));
|
fireEvent.click(screen.getByRole('dialog'));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call onClose when clicking inside the modal content', () => {
|
it('should not call onClose when clicking inside the modal content', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByText('Are you sure you want to do this?'));
|
fireEvent.click(screen.getByText('Are you sure you want to do this?'));
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render custom button text and classes', () => {
|
it('should render custom button text and classes', () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
confirmButtonText="Yes, Delete"
|
confirmButtonText="Yes, Delete"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/DarkModeToggle.test.tsx
|
// src/components/DarkModeToggle.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { DarkModeToggle } from './DarkModeToggle';
|
import { DarkModeToggle } from './DarkModeToggle';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the icon components to isolate the toggle's logic
|
// Mock the icon components to isolate the toggle's logic
|
||||||
vi.mock('./icons/SunIcon', () => ({
|
vi.mock('./icons/SunIcon', () => ({
|
||||||
@@ -20,7 +21,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render in light mode state', () => {
|
it('should render in light mode state', () => {
|
||||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
@@ -29,7 +30,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render in dark mode state', () => {
|
it('should render in dark mode state', () => {
|
||||||
render(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).toBeChecked();
|
expect(checkbox).toBeChecked();
|
||||||
@@ -38,7 +39,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onToggle when the label is clicked', () => {
|
it('should call onToggle when the label is clicked', () => {
|
||||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
// Clicking the label triggers the checkbox change
|
// Clicking the label triggers the checkbox change
|
||||||
const label = screen.getByTitle('Switch to Dark Mode');
|
const label = screen.getByTitle('Switch to Dark Mode');
|
||||||
|
|||||||
67
src/components/Dashboard.test.tsx
Normal file
67
src/components/Dashboard.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// src/components/Dashboard.test.tsx
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { Dashboard } from './Dashboard';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
|
// Mock child components to isolate Dashboard logic
|
||||||
|
// Note: The Dashboard component imports these using '../components/RecipeSuggester'
|
||||||
|
// which resolves to the same file as './RecipeSuggester' when inside src/components.
|
||||||
|
vi.mock('./RecipeSuggester', () => ({
|
||||||
|
RecipeSuggester: () => <div data-testid="recipe-suggester-mock">Recipe Suggester</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./FlyerCountDisplay', () => ({
|
||||||
|
FlyerCountDisplay: () => <div data-testid="flyer-count-display-mock">Flyer Count Display</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./Leaderboard', () => ({
|
||||||
|
Leaderboard: () => <div data-testid="leaderboard-mock">Leaderboard</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Dashboard Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the dashboard title', () => {
|
||||||
|
console.log('TEST: Verifying dashboard title render');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByRole('heading', { name: /dashboard/i, level: 1 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the RecipeSuggester widget', () => {
|
||||||
|
console.log('TEST: Verifying RecipeSuggester presence');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByTestId('recipe-suggester-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the FlyerCountDisplay widget within the "Your Flyers" section', () => {
|
||||||
|
console.log('TEST: Verifying FlyerCountDisplay presence and section title');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
|
||||||
|
// Check for the section heading
|
||||||
|
expect(screen.getByRole('heading', { name: /your flyers/i, level: 2 })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the component
|
||||||
|
expect(screen.getByTestId('flyer-count-display-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Leaderboard widget in the sidebar area', () => {
|
||||||
|
console.log('TEST: Verifying Leaderboard presence');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByTestId('leaderboard-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with the correct grid layout classes', () => {
|
||||||
|
console.log('TEST: Verifying layout classes');
|
||||||
|
const { container } = renderWithProviders(<Dashboard />);
|
||||||
|
|
||||||
|
// The main grid container
|
||||||
|
const gridContainer = container.querySelector('.grid');
|
||||||
|
expect(gridContainer).toBeInTheDocument();
|
||||||
|
expect(gridContainer).toHaveClass('grid-cols-1');
|
||||||
|
expect(gridContainer).toHaveClass('lg:grid-cols-3');
|
||||||
|
expect(gridContainer).toHaveClass('gap-6');
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/components/Dashboard.tsx
Normal file
33
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RecipeSuggester } from '../components/RecipeSuggester';
|
||||||
|
import { FlyerCountDisplay } from '../components/FlyerCountDisplay';
|
||||||
|
import { Leaderboard } from '../components/Leaderboard';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Recipe Suggester Section */}
|
||||||
|
<RecipeSuggester />
|
||||||
|
|
||||||
|
{/* Other Dashboard Widgets */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Your Flyers</h2>
|
||||||
|
<FlyerCountDisplay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Area */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Leaderboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
// src/components/ErrorDisplay.test.tsx
|
// src/components/ErrorDisplay.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ErrorDisplay } from './ErrorDisplay';
|
import { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('ErrorDisplay (in components)', () => {
|
describe('ErrorDisplay (in components)', () => {
|
||||||
it('should not render when the message is empty', () => {
|
it('should not render when the message is empty', () => {
|
||||||
const { container } = render(<ErrorDisplay message="" />);
|
const { container } = renderWithProviders(<ErrorDisplay message="" />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when the message is null', () => {
|
it('should not render when the message is null', () => {
|
||||||
// The component expects a string, but we test for nullish values as a safeguard.
|
// The component expects a string, but we test for nullish values as a safeguard.
|
||||||
const { container } = render(<ErrorDisplay message={null as unknown as string} />);
|
const { container } = renderWithProviders(<ErrorDisplay message={null as unknown as string} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the error message when provided', () => {
|
it('should render the error message when provided', () => {
|
||||||
const errorMessage = 'Something went terribly wrong.';
|
const errorMessage = 'Something went terribly wrong.';
|
||||||
render(<ErrorDisplay message={errorMessage} />);
|
renderWithProviders(<ErrorDisplay message={errorMessage} />);
|
||||||
|
|
||||||
const alert = screen.getByRole('alert');
|
const alert = screen.getByRole('alert');
|
||||||
expect(alert).toBeInTheDocument();
|
expect(alert).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
// src/components/FlyerCorrectionTool.test.tsx
|
// src/components/FlyerCorrectionTool.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
import { screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||||
import { FlyerCorrectionTool } from './FlyerCorrectionTool';
|
import { FlyerCorrectionTool } from './FlyerCorrectionTool';
|
||||||
import * as aiApiClient from '../services/aiApiClient';
|
import * as aiApiClient from '../services/aiApiClient';
|
||||||
import { notifyError, notifySuccess } from '../services/notificationService';
|
import { notifyError, notifySuccess } from '../services/notificationService';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./FlyerCorrectionTool');
|
vi.unmock('./FlyerCorrectionTool');
|
||||||
|
|
||||||
// Mock dependencies
|
// The aiApiClient, notificationService, and logger are mocked globally.
|
||||||
vi.mock('../services/aiApiClient');
|
// We can get a typed reference to the aiApiClient for individual test overrides.
|
||||||
vi.mock('../services/notificationService');
|
const mockedAiApiClient = vi.mocked(aiApiClient);
|
||||||
vi.mock('../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockedAiApiClient = aiApiClient as Mocked<typeof aiApiClient>;
|
|
||||||
const mockedNotifySuccess = notifySuccess as Mocked<typeof notifySuccess>;
|
const mockedNotifySuccess = notifySuccess as Mocked<typeof notifySuccess>;
|
||||||
const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
|
const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
imageUrl: 'http://example.com/flyer.jpg',
|
imageUrl: 'https://example.com/flyer.jpg',
|
||||||
onDataExtracted: vi.fn(),
|
onDataExtracted: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,12 +48,12 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
it('should not render when isOpen is false', () => {
|
||||||
const { container } = render(<FlyerCorrectionTool {...defaultProps} isOpen={false} />);
|
const { container } = renderWithProviders(<FlyerCorrectionTool {...defaultProps} isOpen={false} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly when isOpen is true', () => {
|
it('should render correctly when isOpen is true', () => {
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
expect(screen.getByRole('heading', { name: /flyer correction tool/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /flyer correction tool/i })).toBeInTheDocument();
|
||||||
expect(screen.getByAltText('Flyer for correction')).toBeInTheDocument();
|
expect(screen.getByAltText('Flyer for correction')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /extract store name/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /extract store name/i })).toBeInTheDocument();
|
||||||
@@ -67,7 +61,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the close button is clicked', () => {
|
it('should call onClose when the close button is clicked', () => {
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
// Use the specific aria-label defined in the component to find the close button
|
// Use the specific aria-label defined in the component to find the close button
|
||||||
const closeButton = screen.getByLabelText(/close correction tool/i);
|
const closeButton = screen.getByLabelText(/close correction tool/i);
|
||||||
fireEvent.click(closeButton);
|
fireEvent.click(closeButton);
|
||||||
@@ -75,13 +69,13 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have disabled extraction buttons initially', () => {
|
it('should have disabled extraction buttons initially', () => {
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: /extract store name/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /extract store name/i })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable extraction buttons after a selection is made', () => {
|
it('should enable extraction buttons after a selection is made', () => {
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
|
||||||
// Simulate drawing a rectangle
|
// Simulate drawing a rectangle
|
||||||
@@ -94,7 +88,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should stop drawing when the mouse leaves the canvas', () => {
|
it('should stop drawing when the mouse leaves the canvas', () => {
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
|
||||||
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||||
@@ -114,7 +108,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
|
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
|
||||||
|
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
// Wait for the image fetch to complete to ensure 'imageFile' state is populated
|
// Wait for the image fetch to complete to ensure 'imageFile' state is populated
|
||||||
console.log('--- [TEST LOG] ---: Awaiting image fetch inside component...');
|
console.log('--- [TEST LOG] ---: Awaiting image fetch inside component...');
|
||||||
@@ -192,7 +186,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
// Mock fetch to reject
|
// Mock fetch to reject
|
||||||
global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as Mocked<typeof fetch>;
|
global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as Mocked<typeof fetch>;
|
||||||
|
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedNotifyError).toHaveBeenCalledWith('Could not load the image for correction.');
|
expect(mockedNotifyError).toHaveBeenCalledWith('Could not load the image for correction.');
|
||||||
@@ -211,7 +205,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
return new Promise(() => {});
|
return new Promise(() => {});
|
||||||
}) as Mocked<typeof fetch>;
|
}) as Mocked<typeof fetch>;
|
||||||
|
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
|
||||||
@@ -238,7 +232,7 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
it('should handle non-standard API errors during rescan', async () => {
|
it('should handle non-standard API errors during rescan', async () => {
|
||||||
console.log('TEST: Starting "should handle non-standard API errors during rescan"');
|
console.log('TEST: Starting "should handle non-standard API errors during rescan"');
|
||||||
mockedAiApiClient.rescanImageArea.mockRejectedValue('A plain string error');
|
mockedAiApiClient.rescanImageArea.mockRejectedValue('A plain string error');
|
||||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
// Wait for image fetch to ensure imageFile is set before we interact
|
// Wait for image fetch to ensure imageFile is set before we interact
|
||||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||||
@@ -258,4 +252,54 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle API failure response (ok: false) correctly', async () => {
|
||||||
|
console.log('TEST: Starting "should handle API failure response (ok: false) correctly"');
|
||||||
|
mockedAiApiClient.rescanImageArea.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Custom API Error' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
|
// Wait for image fetch
|
||||||
|
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Draw selection
|
||||||
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||||
|
fireEvent.mouseMove(canvas, { clientX: 50, clientY: 50 });
|
||||||
|
fireEvent.mouseUp(canvas);
|
||||||
|
|
||||||
|
// Click extract
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedNotifyError).toHaveBeenCalledWith('Custom API Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redraw the canvas when the image loads', () => {
|
||||||
|
console.log('TEST: Starting "should redraw the canvas when the image loads"');
|
||||||
|
const clearRectSpy = vi.fn();
|
||||||
|
// Override the getContext mock for this test to capture the spy
|
||||||
|
window.HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
||||||
|
clearRect: clearRectSpy,
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
setLineDash: vi.fn(),
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 0,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
const image = screen.getByAltText('Flyer for correction');
|
||||||
|
|
||||||
|
// The draw function is called on mount via useEffect, so we clear that call.
|
||||||
|
clearRectSpy.mockClear();
|
||||||
|
|
||||||
|
// Simulate image load event which triggers onLoad={draw}
|
||||||
|
fireEvent.load(image);
|
||||||
|
|
||||||
|
expect(clearRectSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/components/FlyerCountDisplay.test.tsx
|
// src/components/FlyerCountDisplay.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { FlyerCountDisplay } from './FlyerCountDisplay';
|
import { FlyerCountDisplay } from './FlyerCountDisplay';
|
||||||
import { useFlyers } from '../hooks/useFlyers';
|
import { useFlyers } from '../hooks/useFlyers';
|
||||||
import type { Flyer } from '../types';
|
import type { Flyer } from '../types';
|
||||||
import { createMockFlyer } from '../tests/utils/mockFactories';
|
import { createMockFlyer } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock('../hooks/useFlyers');
|
vi.mock('../hooks/useFlyers');
|
||||||
@@ -32,7 +33,7 @@ describe('FlyerCountDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the component.
|
// Act: Render the component.
|
||||||
render(<FlyerCountDisplay />);
|
renderWithProviders(<FlyerCountDisplay />);
|
||||||
|
|
||||||
// Assert: Check that the loading spinner is visible.
|
// Assert: Check that the loading spinner is visible.
|
||||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
||||||
@@ -53,7 +54,7 @@ describe('FlyerCountDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
render(<FlyerCountDisplay />);
|
renderWithProviders(<FlyerCountDisplay />);
|
||||||
|
|
||||||
// Assert: Check that the error message is displayed.
|
// Assert: Check that the error message is displayed.
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent(errorMessage);
|
expect(screen.getByRole('alert')).toHaveTextContent(errorMessage);
|
||||||
@@ -73,7 +74,7 @@ describe('FlyerCountDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
render(<FlyerCountDisplay />);
|
renderWithProviders(<FlyerCountDisplay />);
|
||||||
|
|
||||||
// Assert: Check that the correct count is displayed.
|
// Assert: Check that the correct count is displayed.
|
||||||
const countDisplay = screen.getByTestId('flyer-count');
|
const countDisplay = screen.getByTestId('flyer-count');
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/Footer.test.tsx
|
// src/components/Footer.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { Footer } from './Footer';
|
import { Footer } from './Footer';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('Footer', () => {
|
describe('Footer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -21,7 +22,7 @@ describe('Footer', () => {
|
|||||||
vi.setSystemTime(mockDate);
|
vi.setSystemTime(mockDate);
|
||||||
|
|
||||||
// Act: Render the component
|
// Act: Render the component
|
||||||
render(<Footer />);
|
renderWithProviders(<Footer />);
|
||||||
|
|
||||||
// Assert: Check that the rendered text includes the mocked year
|
// Assert: Check that the rendered text includes the mocked year
|
||||||
expect(screen.getByText('Copyright 2025-2025')).toBeInTheDocument();
|
expect(screen.getByText('Copyright 2025-2025')).toBeInTheDocument();
|
||||||
@@ -29,7 +30,7 @@ describe('Footer', () => {
|
|||||||
|
|
||||||
it('should display the correct year when it changes', () => {
|
it('should display the correct year when it changes', () => {
|
||||||
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'));
|
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'));
|
||||||
render(<Footer />);
|
renderWithProviders(<Footer />);
|
||||||
expect(screen.getByText('Copyright 2025-2030')).toBeInTheDocument();
|
expect(screen.getByText('Copyright 2025-2030')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// src/components/Header.test.tsx
|
// src/components/Header.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./Header');
|
vi.unmock('./Header');
|
||||||
@@ -34,12 +34,8 @@ const defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper to render with router context
|
// Helper to render with router context
|
||||||
const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) => {
|
const renderHeader = (props: Partial<React.ComponentProps<typeof Header>>) => {
|
||||||
return render(
|
return renderWithProviders(<Header {...defaultProps} {...props} />);
|
||||||
<MemoryRouter>
|
|
||||||
<Header {...defaultProps} {...props} />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
@@ -48,30 +44,30 @@ describe('Header', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the application title', () => {
|
it('should render the application title', () => {
|
||||||
renderWithRouter({});
|
renderHeader({});
|
||||||
expect(screen.getByRole('heading', { name: /flyer crawler/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /flyer crawler/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display unit system and theme mode', () => {
|
it('should display unit system and theme mode', () => {
|
||||||
renderWithRouter({ isDarkMode: true, unitSystem: 'metric' });
|
renderHeader({ isDarkMode: true, unitSystem: 'metric' });
|
||||||
expect(screen.getByText(/metric/i)).toBeInTheDocument();
|
expect(screen.getByText(/metric/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/dark mode/i)).toBeInTheDocument();
|
expect(screen.getByText(/dark mode/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When user is logged out', () => {
|
describe('When user is logged out', () => {
|
||||||
it('should show a Login button', () => {
|
it('should show a Login button', () => {
|
||||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
renderHeader({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onOpenProfile when Login button is clicked', () => {
|
it('should call onOpenProfile when Login button is clicked', () => {
|
||||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
renderHeader({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show user-specific buttons', () => {
|
it('should not show user-specific buttons', () => {
|
||||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
renderHeader({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||||
expect(screen.queryByLabelText(/open voice assistant/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/open voice assistant/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/open my account settings/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/open my account settings/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
|
||||||
@@ -80,29 +76,29 @@ describe('Header', () => {
|
|||||||
|
|
||||||
describe('When user is authenticated', () => {
|
describe('When user is authenticated', () => {
|
||||||
it('should display the user email', () => {
|
it('should display the user email', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||||
expect(screen.getByText(mockUserProfile.user.email)).toBeInTheDocument();
|
expect(screen.getByText(mockUserProfile.user.email)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display "Guest" for anonymous users', () => {
|
it('should display "Guest" for anonymous users', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'SIGNED_OUT' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'SIGNED_OUT' });
|
||||||
expect(screen.getByText(/guest/i)).toBeInTheDocument();
|
expect(screen.getByText(/guest/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onOpenVoiceAssistant when microphone icon is clicked', () => {
|
it('should call onOpenVoiceAssistant when microphone icon is clicked', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||||
fireEvent.click(screen.getByLabelText(/open voice assistant/i));
|
fireEvent.click(screen.getByLabelText(/open voice assistant/i));
|
||||||
expect(mockOnOpenVoiceAssistant).toHaveBeenCalledTimes(1);
|
expect(mockOnOpenVoiceAssistant).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onOpenProfile when cog icon is clicked', () => {
|
it('should call onOpenProfile when cog icon is clicked', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||||
fireEvent.click(screen.getByLabelText(/open my account settings/i));
|
fireEvent.click(screen.getByLabelText(/open my account settings/i));
|
||||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onSignOut when Logout button is clicked', () => {
|
it('should call onSignOut when Logout button is clicked', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /logout/i }));
|
fireEvent.click(screen.getByRole('button', { name: /logout/i }));
|
||||||
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
|
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -110,14 +106,14 @@ describe('Header', () => {
|
|||||||
|
|
||||||
describe('Admin user', () => {
|
describe('Admin user', () => {
|
||||||
it('should show the Admin Area link for admin users', () => {
|
it('should show the Admin Area link for admin users', () => {
|
||||||
renderWithRouter({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED' });
|
||||||
const adminLink = screen.getByTitle(/admin area/i);
|
const adminLink = screen.getByTitle(/admin area/i);
|
||||||
expect(adminLink).toBeInTheDocument();
|
expect(adminLink).toBeInTheDocument();
|
||||||
expect(adminLink.closest('a')).toHaveAttribute('href', '/admin');
|
expect(adminLink.closest('a')).toHaveAttribute('href', '/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show the Admin Area link for non-admin users', () => {
|
it('should not show the Admin Area link for non-admin users', () => {
|
||||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||||
expect(screen.queryByTitle(/admin area/i)).not.toBeInTheDocument();
|
expect(screen.queryByTitle(/admin area/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
// src/components/Leaderboard.test.tsx
|
// src/components/Leaderboard.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||||
import Leaderboard from './Leaderboard';
|
import Leaderboard from './Leaderboard';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { LeaderboardUser } from '../types';
|
import { LeaderboardUser } from '../types';
|
||||||
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
|
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
|
||||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the apiClient
|
// The apiClient and logger are mocked globally.
|
||||||
vi.mock('../services/apiClient'); // This was correct
|
// We can get a typed reference to the apiClient for individual test overrides.
|
||||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../services/logger', () => ({
|
|
||||||
logger: createMockLogger(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock lucide-react icons to prevent rendering errors in the test environment
|
// Mock lucide-react icons to prevent rendering errors in the test environment
|
||||||
vi.mock('lucide-react', () => ({
|
vi.mock('lucide-react', () => ({
|
||||||
@@ -29,7 +25,7 @@ const mockLeaderboardData: LeaderboardUser[] = [
|
|||||||
createMockLeaderboardUser({
|
createMockLeaderboardUser({
|
||||||
user_id: 'user-2',
|
user_id: 'user-2',
|
||||||
full_name: 'Bob',
|
full_name: 'Bob',
|
||||||
avatar_url: 'http://example.com/bob.jpg',
|
avatar_url: 'https://example.com/bob.jpg',
|
||||||
points: 950,
|
points: 950,
|
||||||
rank: '2',
|
rank: '2',
|
||||||
}),
|
}),
|
||||||
@@ -45,13 +41,13 @@ describe('Leaderboard', () => {
|
|||||||
it('should display a loading message initially', () => {
|
it('should display a loading message initially', () => {
|
||||||
// Mock a pending promise that never resolves to keep it in the loading state
|
// Mock a pending promise that never resolves to keep it in the loading state
|
||||||
mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {}));
|
mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {}));
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument();
|
expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message if the API call fails', async () => {
|
it('should display an error message if the API call fails', async () => {
|
||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 }));
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 }));
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
@@ -62,7 +58,7 @@ describe('Leaderboard', () => {
|
|||||||
it('should display a generic error for unknown error types', async () => {
|
it('should display a generic error for unknown error types', async () => {
|
||||||
const unknownError = 'A string error';
|
const unknownError = 'A string error';
|
||||||
mockedApiClient.fetchLeaderboard.mockRejectedValue(unknownError);
|
mockedApiClient.fetchLeaderboard.mockRejectedValue(unknownError);
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
@@ -72,7 +68,7 @@ describe('Leaderboard', () => {
|
|||||||
|
|
||||||
it('should display a message when the leaderboard is empty', async () => {
|
it('should display a message when the leaderboard is empty', async () => {
|
||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([])));
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -85,7 +81,7 @@ describe('Leaderboard', () => {
|
|||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
new Response(JSON.stringify(mockLeaderboardData)),
|
new Response(JSON.stringify(mockLeaderboardData)),
|
||||||
);
|
);
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('heading', { name: 'Top Users' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Top Users' })).toBeInTheDocument();
|
||||||
@@ -99,7 +95,7 @@ describe('Leaderboard', () => {
|
|||||||
|
|
||||||
// Check for correct avatar URLs
|
// Check for correct avatar URLs
|
||||||
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
|
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
|
||||||
expect(bobAvatar.src).toBe('http://example.com/bob.jpg');
|
expect(bobAvatar.src).toBe('https://example.com/bob.jpg');
|
||||||
|
|
||||||
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
|
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
|
||||||
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar
|
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar
|
||||||
@@ -110,7 +106,7 @@ describe('Leaderboard', () => {
|
|||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
new Response(JSON.stringify(mockLeaderboardData)),
|
new Response(JSON.stringify(mockLeaderboardData)),
|
||||||
);
|
);
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Rank 1, 2, and 3 should have a crown icon
|
// Rank 1, 2, and 3 should have a crown icon
|
||||||
@@ -129,7 +125,7 @@ describe('Leaderboard', () => {
|
|||||||
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
mockedApiClient.fetchLeaderboard.mockResolvedValue(
|
||||||
new Response(JSON.stringify(dataWithMissingNames)),
|
new Response(JSON.stringify(dataWithMissingNames)),
|
||||||
);
|
);
|
||||||
render(<Leaderboard />);
|
renderWithProviders(<Leaderboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check for fallback name
|
// Check for fallback name
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
// src/components/LoadingSpinner.test.tsx
|
// src/components/LoadingSpinner.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { LoadingSpinner } from './LoadingSpinner';
|
import { LoadingSpinner } from './LoadingSpinner';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('LoadingSpinner (in components)', () => {
|
describe('LoadingSpinner (in components)', () => {
|
||||||
it('should render the SVG with animation classes', () => {
|
it('should render the SVG with animation classes', () => {
|
||||||
const { container } = render(<LoadingSpinner />);
|
const { container } = renderWithProviders(<LoadingSpinner />);
|
||||||
const svgElement = container.querySelector('svg');
|
const svgElement = container.querySelector('svg');
|
||||||
expect(svgElement).toBeInTheDocument();
|
expect(svgElement).toBeInTheDocument();
|
||||||
expect(svgElement).toHaveClass('animate-spin');
|
expect(svgElement).toHaveClass('animate-spin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain the correct SVG paths for the spinner graphic', () => {
|
it('should contain the correct SVG paths for the spinner graphic', () => {
|
||||||
const { container } = render(<LoadingSpinner />);
|
const { container } = renderWithProviders(<LoadingSpinner />);
|
||||||
const circle = container.querySelector('circle');
|
const circle = container.querySelector('circle');
|
||||||
const path = container.querySelector('path');
|
const path = container.querySelector('path');
|
||||||
expect(circle).toBeInTheDocument();
|
expect(circle).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// src/components/MapView.test.tsx
|
// src/components/MapView.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { MapView } from './MapView';
|
import { MapView } from './MapView';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Create a type-safe mocked version of the config for easier manipulation
|
// Create a type-safe mocked version of the config for easier manipulation
|
||||||
const mockedConfig = vi.mocked(config);
|
const mockedConfig = vi.mocked(config);
|
||||||
@@ -40,14 +41,14 @@ describe('MapView', () => {
|
|||||||
|
|
||||||
describe('when API key is not configured', () => {
|
describe('when API key is not configured', () => {
|
||||||
it('should render a disabled message', () => {
|
it('should render a disabled message', () => {
|
||||||
render(<MapView {...defaultProps} />);
|
renderWithProviders(<MapView {...defaultProps} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Map view is disabled: API key is not configured.'),
|
screen.getByText('Map view is disabled: API key is not configured.'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render the iframe', () => {
|
it('should not render the iframe', () => {
|
||||||
render(<MapView {...defaultProps} />);
|
renderWithProviders(<MapView {...defaultProps} />);
|
||||||
// Use queryByTitle because iframes don't have a default "iframe" role
|
// Use queryByTitle because iframes don't have a default "iframe" role
|
||||||
expect(screen.queryByTitle('Map view')).not.toBeInTheDocument();
|
expect(screen.queryByTitle('Map view')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -62,7 +63,7 @@ describe('MapView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the iframe with the correct src URL', () => {
|
it('should render the iframe with the correct src URL', () => {
|
||||||
render(<MapView {...defaultProps} />);
|
renderWithProviders(<MapView {...defaultProps} />);
|
||||||
|
|
||||||
// Use getByTitle to access the iframe
|
// Use getByTitle to access the iframe
|
||||||
const iframe = screen.getByTitle('Map view');
|
const iframe = screen.getByTitle('Map view');
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/PasswordInput.test.tsx
|
// src/components/PasswordInput.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { PasswordInput } from './PasswordInput';
|
import { PasswordInput } from './PasswordInput';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
// Mock the child PasswordStrengthIndicator component to isolate the test (relative to new location)
|
// Mock the child PasswordStrengthIndicator component to isolate the test (relative to new location)
|
||||||
vi.mock('./PasswordStrengthIndicator', () => ({
|
vi.mock('./PasswordStrengthIndicator', () => ({
|
||||||
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
|
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
|
||||||
@@ -12,13 +13,13 @@ vi.mock('./PasswordStrengthIndicator', () => ({
|
|||||||
|
|
||||||
describe('PasswordInput (in auth feature)', () => {
|
describe('PasswordInput (in auth feature)', () => {
|
||||||
it('should render as a password input by default', () => {
|
it('should render as a password input by default', () => {
|
||||||
render(<PasswordInput placeholder="Enter password" />);
|
renderWithProviders(<PasswordInput placeholder="Enter password" />);
|
||||||
const input = screen.getByPlaceholderText('Enter password');
|
const input = screen.getByPlaceholderText('Enter password');
|
||||||
expect(input).toHaveAttribute('type', 'password');
|
expect(input).toHaveAttribute('type', 'password');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should toggle input type between password and text when the eye icon is clicked', () => {
|
it('should toggle input type between password and text when the eye icon is clicked', () => {
|
||||||
render(<PasswordInput placeholder="Enter password" />);
|
renderWithProviders(<PasswordInput placeholder="Enter password" />);
|
||||||
const input = screen.getByPlaceholderText('Enter password');
|
const input = screen.getByPlaceholderText('Enter password');
|
||||||
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ describe('PasswordInput (in auth feature)', () => {
|
|||||||
|
|
||||||
it('should pass through standard input attributes', () => {
|
it('should pass through standard input attributes', () => {
|
||||||
const handleChange = vi.fn();
|
const handleChange = vi.fn();
|
||||||
render(
|
renderWithProviders(
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
value="test"
|
value="test"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -56,38 +57,38 @@ describe('PasswordInput (in auth feature)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not show strength indicator by default', () => {
|
it('should not show strength indicator by default', () => {
|
||||||
render(<PasswordInput value="some-password" onChange={() => {}} />);
|
renderWithProviders(<PasswordInput value="some-password" onChange={() => {}} />);
|
||||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show strength indicator when showStrength is true and there is a value', () => {
|
it('should show strength indicator when showStrength is true and there is a value', () => {
|
||||||
render(<PasswordInput value="some-password" showStrength onChange={() => {}} />);
|
renderWithProviders(<PasswordInput value="some-password" showStrength onChange={() => {}} />);
|
||||||
const indicator = screen.getByTestId('strength-indicator');
|
const indicator = screen.getByTestId('strength-indicator');
|
||||||
expect(indicator).toBeInTheDocument();
|
expect(indicator).toBeInTheDocument();
|
||||||
expect(indicator).toHaveTextContent('Strength for: some-password');
|
expect(indicator).toHaveTextContent('Strength for: some-password');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show strength indicator when showStrength is true but value is empty', () => {
|
it('should not show strength indicator when showStrength is true but value is empty', () => {
|
||||||
render(<PasswordInput value="" showStrength onChange={() => {}} />);
|
renderWithProviders(<PasswordInput value="" showStrength onChange={() => {}} />);
|
||||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle undefined className gracefully', () => {
|
it('should handle undefined className gracefully', () => {
|
||||||
render(<PasswordInput placeholder="No class" />);
|
renderWithProviders(<PasswordInput placeholder="No class" />);
|
||||||
const input = screen.getByPlaceholderText('No class');
|
const input = screen.getByPlaceholderText('No class');
|
||||||
expect(input.className).not.toContain('undefined');
|
expect(input.className).not.toContain('undefined');
|
||||||
expect(input.className).toContain('block w-full');
|
expect(input.className).toContain('block w-full');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show strength indicator if value is undefined', () => {
|
it('should not show strength indicator if value is undefined', () => {
|
||||||
render(<PasswordInput showStrength onChange={() => {}} />);
|
renderWithProviders(<PasswordInput showStrength onChange={() => {}} />);
|
||||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show strength indicator if value is not a string', () => {
|
it('should not show strength indicator if value is not a string', () => {
|
||||||
// Force a non-string value to test the typeof check
|
// Force a non-string value to test the typeof check
|
||||||
const props = { value: 12345, showStrength: true, onChange: () => {} } as any;
|
const props = { value: 12345, showStrength: true, onChange: () => {} } as any;
|
||||||
render(<PasswordInput {...props} />);
|
renderWithProviders(<PasswordInput {...props} />);
|
||||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/pages/admin/components/PasswordStrengthIndicator.test.tsx
|
// src/pages/admin/components/PasswordStrengthIndicator.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
// Mock the zxcvbn library to control its output for testing
|
// Mock the zxcvbn library to control its output for testing
|
||||||
@@ -11,7 +12,7 @@ vi.mock('zxcvbn');
|
|||||||
describe('PasswordStrengthIndicator', () => {
|
describe('PasswordStrengthIndicator', () => {
|
||||||
it('should render 5 gray bars when no password is provided', () => {
|
it('should render 5 gray bars when no password is provided', () => {
|
||||||
(zxcvbn as Mock).mockReturnValue({ score: -1, feedback: { warning: '', suggestions: [] } });
|
(zxcvbn as Mock).mockReturnValue({ score: -1, feedback: { warning: '', suggestions: [] } });
|
||||||
const { container } = render(<PasswordStrengthIndicator password="" />);
|
const { container } = renderWithProviders(<PasswordStrengthIndicator password="" />);
|
||||||
const bars = container.querySelectorAll('.h-1\\.5');
|
const bars = container.querySelectorAll('.h-1\\.5');
|
||||||
expect(bars).toHaveLength(5);
|
expect(bars).toHaveLength(5);
|
||||||
bars.forEach((bar) => {
|
bars.forEach((bar) => {
|
||||||
@@ -28,7 +29,7 @@ describe('PasswordStrengthIndicator', () => {
|
|||||||
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
|
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
|
||||||
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
|
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
|
||||||
(zxcvbn as Mock).mockReturnValue({ score, feedback: { warning: '', suggestions: [] } });
|
(zxcvbn as Mock).mockReturnValue({ score, feedback: { warning: '', suggestions: [] } });
|
||||||
const { container } = render(<PasswordStrengthIndicator password="some-password" />);
|
const { container } = renderWithProviders(<PasswordStrengthIndicator password="some-password" />);
|
||||||
|
|
||||||
// Check the label
|
// Check the label
|
||||||
expect(screen.getByText(label)).toBeInTheDocument();
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
@@ -54,7 +55,7 @@ describe('PasswordStrengthIndicator', () => {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
render(<PasswordStrengthIndicator password="password" />);
|
renderWithProviders(<PasswordStrengthIndicator password="password" />);
|
||||||
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
|
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ describe('PasswordStrengthIndicator', () => {
|
|||||||
suggestions: ['Add another word or two'],
|
suggestions: ['Add another word or two'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
render(<PasswordStrengthIndicator password="pass" />);
|
renderWithProviders(<PasswordStrengthIndicator password="pass" />);
|
||||||
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
|
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,14 +76,14 @@ describe('PasswordStrengthIndicator', () => {
|
|||||||
score: 1,
|
score: 1,
|
||||||
feedback: { warning: 'A warning here', suggestions: ['A suggestion here'] },
|
feedback: { warning: 'A warning here', suggestions: ['A suggestion here'] },
|
||||||
});
|
});
|
||||||
render(<PasswordStrengthIndicator password="password" />);
|
renderWithProviders(<PasswordStrengthIndicator password="password" />);
|
||||||
expect(screen.getByText(/a warning here/i)).toBeInTheDocument();
|
expect(screen.getByText(/a warning here/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default empty string if password prop is undefined', () => {
|
it('should use default empty string if password prop is undefined', () => {
|
||||||
(zxcvbn as Mock).mockReturnValue({ score: 0, feedback: { warning: '', suggestions: [] } });
|
(zxcvbn as Mock).mockReturnValue({ score: 0, feedback: { warning: '', suggestions: [] } });
|
||||||
const { container } = render(<PasswordStrengthIndicator />);
|
const { container } = renderWithProviders(<PasswordStrengthIndicator />);
|
||||||
const bars = container.querySelectorAll('.h-1\\.5');
|
const bars = container.querySelectorAll('.h-1\\.5');
|
||||||
expect(bars).toHaveLength(5);
|
expect(bars).toHaveLength(5);
|
||||||
bars.forEach((bar) => {
|
bars.forEach((bar) => {
|
||||||
@@ -94,7 +95,7 @@ describe('PasswordStrengthIndicator', () => {
|
|||||||
it('should handle out-of-range scores gracefully (defensive)', () => {
|
it('should handle out-of-range scores gracefully (defensive)', () => {
|
||||||
// Mock a score that isn't 0-4 to hit default switch cases
|
// Mock a score that isn't 0-4 to hit default switch cases
|
||||||
(zxcvbn as Mock).mockReturnValue({ score: 99, feedback: { warning: '', suggestions: [] } });
|
(zxcvbn as Mock).mockReturnValue({ score: 99, feedback: { warning: '', suggestions: [] } });
|
||||||
const { container } = render(<PasswordStrengthIndicator password="test" />);
|
const { container } = renderWithProviders(<PasswordStrengthIndicator password="test" />);
|
||||||
|
|
||||||
// Check bars - should hit default case in getBarColor which returns gray
|
// Check bars - should hit default case in getBarColor which returns gray
|
||||||
const bars = container.querySelectorAll('.h-1\\.5');
|
const bars = container.querySelectorAll('.h-1\\.5');
|
||||||
|
|||||||
202
src/components/RecipeSuggester.test.tsx
Normal file
202
src/components/RecipeSuggester.test.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// src/components/RecipeSuggester.test.tsx
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { RecipeSuggester } from './RecipeSuggester'; // This should be after mocks
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
|
// We can get a typed reference to it for individual test overrides.
|
||||||
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
|
describe('RecipeSuggester Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset console logs if needed, or just keep them for debug visibility
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly with initial state', () => {
|
||||||
|
console.log('TEST: Verifying initial render state');
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Get a Recipe Suggestion')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Ingredients:/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Suggest a Recipe/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Getting suggestion...')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation error if no ingredients are entered', async () => {
|
||||||
|
console.log('TEST: Verifying validation for empty input');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Please enter at least one ingredient.')).toBeInTheDocument();
|
||||||
|
expect(mockedApiClient.suggestRecipe).not.toHaveBeenCalled();
|
||||||
|
console.log('TEST: Validation error displayed correctly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls suggestRecipe and displays suggestion on success', async () => {
|
||||||
|
console.log('TEST: Verifying successful recipe suggestion flow');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'chicken, rice');
|
||||||
|
|
||||||
|
// Mock successful API response
|
||||||
|
const mockSuggestion = 'Here is a nice Chicken and Rice recipe...';
|
||||||
|
// Add a delay to ensure the loading state is visible during the test
|
||||||
|
mockedApiClient.suggestRecipe.mockImplementation(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
return { ok: true, json: async () => ({ suggestion: mockSuggestion }) } as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
// Check loading state
|
||||||
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
|
expect(screen.getByText('Getting suggestion...')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(mockSuggestion)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedApiClient.suggestRecipe).toHaveBeenCalledWith(['chicken', 'rice']);
|
||||||
|
console.log('TEST: Suggestion displayed and API called with correct args');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API errors (non-200 response) gracefully', async () => {
|
||||||
|
console.log('TEST: Verifying API error handling (400/500 responses)');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'rocks');
|
||||||
|
|
||||||
|
// Mock API failure response
|
||||||
|
const errorMessage = 'Invalid ingredients provided.';
|
||||||
|
mockedApiClient.suggestRecipe.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: errorMessage }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure loading state is reset
|
||||||
|
expect(screen.getByRole('button', { name: /Suggest a Recipe/i })).toBeEnabled();
|
||||||
|
console.log('TEST: API error message displayed to user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles network exceptions and logs them', async () => {
|
||||||
|
console.log('TEST: Verifying network exception handling');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'beef');
|
||||||
|
|
||||||
|
// Mock network error
|
||||||
|
const networkError = new Error('Network Error');
|
||||||
|
mockedApiClient.suggestRecipe.mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network Error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: networkError },
|
||||||
|
'Failed to fetch recipe suggestion.'
|
||||||
|
);
|
||||||
|
console.log('TEST: Network error caught and logged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears previous errors when submitting again', async () => {
|
||||||
|
console.log('TEST: Verifying error clearing on re-submit');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
// Trigger validation error first
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
expect(screen.getByText('Please enter at least one ingredient.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Now type something to clear it (state change doesn't clear it, submit does)
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'tofu');
|
||||||
|
|
||||||
|
// Mock success for the second click
|
||||||
|
mockedApiClient.suggestRecipe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ suggestion: 'Tofu Stir Fry' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Please enter at least one ingredient.')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tofu Stir Fry')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
console.log('TEST: Previous error cleared successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default error message when API error response has no message', async () => {
|
||||||
|
console.log('TEST: Verifying default error message for API failure');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'mystery');
|
||||||
|
|
||||||
|
// Mock API failure response without a message property
|
||||||
|
mockedApiClient.suggestRecipe.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({}), // Empty object
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to get suggestion.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-Error objects thrown during fetch', async () => {
|
||||||
|
console.log('TEST: Verifying handling of non-Error exceptions');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'chaos');
|
||||||
|
|
||||||
|
// Mock a rejection that is NOT an Error object
|
||||||
|
mockedApiClient.suggestRecipe.mockRejectedValue('Something weird happened');
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: 'Something weird happened' },
|
||||||
|
'Failed to fetch recipe suggestion.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
src/components/RecipeSuggester.tsx
Normal file
80
src/components/RecipeSuggester.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// src/components/RecipeSuggester.tsx
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { suggestRecipe } from '../services/apiClient';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
|
export const RecipeSuggester: React.FC = () => {
|
||||||
|
const [ingredients, setIngredients] = useState<string>('');
|
||||||
|
const [suggestion, setSuggestion] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuggestion(null);
|
||||||
|
|
||||||
|
const ingredientList = ingredients.split(',').map(item => item.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (ingredientList.length === 0) {
|
||||||
|
setError('Please enter at least one ingredient.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await suggestRecipe(ingredientList);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Failed to get suggestion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggestion(data.suggestion);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error: err }, 'Failed to fetch recipe suggestion.');
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [ingredients]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Get a Recipe Suggestion</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">Enter some ingredients you have, separated by commas.</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="ingredients-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ingredients:</label>
|
||||||
|
<input
|
||||||
|
id="ingredients-input"
|
||||||
|
type="text"
|
||||||
|
value={ingredients}
|
||||||
|
onChange={(e) => setIngredients(e.target.value)}
|
||||||
|
placeholder="e.g., chicken, rice, broccoli"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm p-2 border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isLoading} className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors">
|
||||||
|
{isLoading ? 'Getting suggestion...' : 'Suggest a Recipe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/50 text-red-700 dark:text-red-200 rounded-md text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestion && (
|
||||||
|
<div className="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||||||
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
<h5 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Recipe Suggestion</h5>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{suggestion}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/components/StatCard.test.tsx
Normal file
34
src/components/StatCard.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/components/StatCard.test.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { StatCard } from './StatCard';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
describe('StatCard', () => {
|
||||||
|
it('renders title and value correctly', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<StatCard
|
||||||
|
title="Total Users"
|
||||||
|
value="1,234"
|
||||||
|
icon={<div data-testid="mock-icon">Icon</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1,234')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the icon', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<StatCard
|
||||||
|
title="Total Users"
|
||||||
|
value="1,234"
|
||||||
|
icon={<div data-testid="mock-icon">Icon</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/components/StatCard.tsx
Normal file
32
src/components/StatCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// src/components/StatCard.tsx
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatCard: React.FC<StatCardProps> = ({ title, value, icon }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">{title}</dt>
|
||||||
|
<dd>
|
||||||
|
<div className="text-lg font-medium text-gray-900 dark:text-white">{value}</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/UnitSystemToggle.test.tsx
|
// src/components/UnitSystemToggle.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { UnitSystemToggle } from './UnitSystemToggle';
|
import { UnitSystemToggle } from './UnitSystemToggle';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('UnitSystemToggle', () => {
|
describe('UnitSystemToggle', () => {
|
||||||
const mockOnToggle = vi.fn();
|
const mockOnToggle = vi.fn();
|
||||||
@@ -12,7 +13,7 @@ describe('UnitSystemToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly for imperial system', () => {
|
it('should render correctly for imperial system', () => {
|
||||||
render(<UnitSystemToggle currentSystem="imperial" onToggle={mockOnToggle} />);
|
renderWithProviders(<UnitSystemToggle currentSystem="imperial" onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).toBeChecked();
|
expect(checkbox).toBeChecked();
|
||||||
@@ -23,7 +24,7 @@ describe('UnitSystemToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly for metric system', () => {
|
it('should render correctly for metric system', () => {
|
||||||
render(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
renderWithProviders(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
@@ -34,7 +35,7 @@ describe('UnitSystemToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onToggle when the toggle is clicked', () => {
|
it('should call onToggle when the toggle is clicked', () => {
|
||||||
render(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
renderWithProviders(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
||||||
fireEvent.click(screen.getByRole('checkbox'));
|
fireEvent.click(screen.getByRole('checkbox'));
|
||||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
// src/components/UserMenuSkeleton.test.tsx
|
// src/components/UserMenuSkeleton.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { UserMenuSkeleton } from './UserMenuSkeleton';
|
import { UserMenuSkeleton } from './UserMenuSkeleton';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('UserMenuSkeleton', () => {
|
describe('UserMenuSkeleton', () => {
|
||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = renderWithProviders(<UserMenuSkeleton />);
|
||||||
expect(container.firstChild).toBeInTheDocument();
|
expect(container.firstChild).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have the main container with pulse animation', () => {
|
it('should have the main container with pulse animation', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = renderWithProviders(<UserMenuSkeleton />);
|
||||||
expect(container.firstChild).toHaveClass('animate-pulse');
|
expect(container.firstChild).toHaveClass('animate-pulse');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render two child placeholder elements', () => {
|
it('should render two child placeholder elements', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = renderWithProviders(<UserMenuSkeleton />);
|
||||||
expect(container.firstChild?.childNodes.length).toBe(2);
|
expect(container.firstChild?.childNodes.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a rectangular placeholder with correct styles', () => {
|
it('should render a rectangular placeholder with correct styles', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = renderWithProviders(<UserMenuSkeleton />);
|
||||||
expect(container.querySelector('.rounded-md')).toHaveClass(
|
expect(container.querySelector('.rounded-md')).toHaveClass(
|
||||||
'h-8 w-24 bg-gray-200 dark:bg-gray-700',
|
'h-8 w-24 bg-gray-200 dark:bg-gray-700',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a circular placeholder with correct styles', () => {
|
it('should render a circular placeholder with correct styles', () => {
|
||||||
const { container } = render(<UserMenuSkeleton />);
|
const { container } = renderWithProviders(<UserMenuSkeleton />);
|
||||||
expect(container.querySelector('.rounded-full')).toHaveClass(
|
expect(container.querySelector('.rounded-full')).toHaveClass(
|
||||||
'h-10 w-10 bg-gray-200 dark:bg-gray-700',
|
'h-10 w-10 bg-gray-200 dark:bg-gray-700',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/WhatsNewModal.test.tsx
|
// src/components/WhatsNewModal.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { WhatsNewModal } from './WhatsNewModal';
|
import { WhatsNewModal } from './WhatsNewModal';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./WhatsNewModal');
|
vi.unmock('./WhatsNewModal');
|
||||||
@@ -21,13 +22,13 @@ describe('WhatsNewModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
it('should not render when isOpen is false', () => {
|
||||||
const { container } = render(<WhatsNewModal {...defaultProps} isOpen={false} />);
|
const { container } = renderWithProviders(<WhatsNewModal {...defaultProps} isOpen={false} />);
|
||||||
// The component returns null, so the container should be empty.
|
// The component returns null, so the container should be empty.
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly when isOpen is true', () => {
|
it('should render correctly when isOpen is true', () => {
|
||||||
render(<WhatsNewModal {...defaultProps} />);
|
renderWithProviders(<WhatsNewModal {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /what's new/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /what's new/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(`Version: ${defaultProps.version}`)).toBeInTheDocument();
|
expect(screen.getByText(`Version: ${defaultProps.version}`)).toBeInTheDocument();
|
||||||
@@ -36,13 +37,13 @@ describe('WhatsNewModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the "Got it!" button is clicked', () => {
|
it('should call onClose when the "Got it!" button is clicked', () => {
|
||||||
render(<WhatsNewModal {...defaultProps} />);
|
renderWithProviders(<WhatsNewModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
|
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the close icon button is clicked', () => {
|
it('should call onClose when the close icon button is clicked', () => {
|
||||||
render(<WhatsNewModal {...defaultProps} />);
|
renderWithProviders(<WhatsNewModal {...defaultProps} />);
|
||||||
// The close button is an SVG icon inside a button, best queried by its aria-label.
|
// The close button is an SVG icon inside a button, best queried by its aria-label.
|
||||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||||
fireEvent.click(closeButton);
|
fireEvent.click(closeButton);
|
||||||
@@ -50,7 +51,7 @@ describe('WhatsNewModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when clicking on the overlay', () => {
|
it('should call onClose when clicking on the overlay', () => {
|
||||||
render(<WhatsNewModal {...defaultProps} />);
|
renderWithProviders(<WhatsNewModal {...defaultProps} />);
|
||||||
// The overlay is the root div with the background color.
|
// The overlay is the root div with the background color.
|
||||||
const overlay = screen.getByRole('dialog').parentElement;
|
const overlay = screen.getByRole('dialog').parentElement;
|
||||||
fireEvent.click(overlay!);
|
fireEvent.click(overlay!);
|
||||||
@@ -58,7 +59,7 @@ describe('WhatsNewModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not call onClose when clicking inside the modal content', () => {
|
it('should not call onClose when clicking inside the modal content', () => {
|
||||||
render(<WhatsNewModal {...defaultProps} />);
|
renderWithProviders(<WhatsNewModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByText(defaultProps.commitMessage));
|
fireEvent.click(screen.getByText(defaultProps.commitMessage));
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
18
src/components/icons/DocumentMagnifyingGlassIcon.tsx
Normal file
18
src/components/icons/DocumentMagnifyingGlassIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const DocumentMagnifyingGlassIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5 4.5L6.75 21.75m0 0L2.25 17.25m4.5 4.5v-4.5m13.5-3V9A2.25 2.25 0 0 0 16.5 6.75h-9A2.25 2.25 0 0 0 5.25 9v9.75m14.25-10.5a2.25 2.25 0 0 0-2.25-2.25H5.25a2.25 2.25 0 0 0-2.25 2.25v10.5a2.25 2.25 0 0 0 2.25 225h5.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
147
src/config/rateLimiters.ts
Normal file
147
src/config/rateLimiters.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// src/config/rateLimiters.ts
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { shouldSkipRateLimit } from '../utils/rateLimit';
|
||||||
|
|
||||||
|
const standardConfig = {
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: shouldSkipRateLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- AUTHENTICATION ---
|
||||||
|
export const loginLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many login attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many accounts created from this IP, please try again after an hour.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forgotPasswordLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetPasswordLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshTokenLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many token refresh attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many logout attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- GENERAL PUBLIC & USER ---
|
||||||
|
export const publicReadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userReadLimiter = publicReadLimiter; // Alias for consistency
|
||||||
|
|
||||||
|
export const userUpdateLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many update requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reactionToggleLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 150,
|
||||||
|
message: 'Too many reaction requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const trackingLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200,
|
||||||
|
message: 'Too many tracking requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- SENSITIVE / COSTLY ---
|
||||||
|
export const userSensitiveUpdateLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many sensitive requests from this IP, please try again after an hour.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminTriggerLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 30,
|
||||||
|
message: 'Too many administrative triggers from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiGenerationLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many AI generation requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const suggestionLimiter = aiGenerationLimiter; // Alias
|
||||||
|
|
||||||
|
export const geocodeLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many geocoding requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const priceHistoryLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 50,
|
||||||
|
message: 'Too many price history requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UPLOADS / BATCH ---
|
||||||
|
export const adminUploadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userUploadLimiter = adminUploadLimiter; // Alias
|
||||||
|
|
||||||
|
export const aiUploadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const batchLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 50,
|
||||||
|
message: 'Too many batch requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const budgetUpdateLimiter = batchLimiter; // Alias
|
||||||
@@ -110,8 +110,8 @@ async function main() {
|
|||||||
validTo.setDate(today.getDate() + 5);
|
validTo.setDate(today.getDate() + 5);
|
||||||
|
|
||||||
const flyerQuery = `
|
const flyerQuery = `
|
||||||
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to)
|
INSERT INTO public.flyers (file_name, image_url, icon_url, checksum, store_id, valid_from, valid_to)
|
||||||
VALUES ('safeway-flyer.jpg', '/sample-assets/safeway-flyer.jpg', 'sample-checksum-123', ${storeMap.get('Safeway')}, $1, $2)
|
VALUES ('safeway-flyer.jpg', 'https://example.com/flyer-images/safeway-flyer.jpg', 'https://example.com/flyer-images/icons/safeway-flyer.jpg', 'a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', ${storeMap.get('Safeway')}, $1, $2)
|
||||||
RETURNING flyer_id;
|
RETURNING flyer_id;
|
||||||
`;
|
`;
|
||||||
const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [
|
const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ describe('PriceChart', () => {
|
|||||||
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render an error message when an error occurs', () => {
|
||||||
|
mockedUseActiveDeals.mockReturnValue({
|
||||||
|
...mockedUseActiveDeals(),
|
||||||
|
activeDeals: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to fetch deals.',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Failed to fetch deals.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the table with deal items when data is provided', () => {
|
it('should render the table with deal items when data is provided', () => {
|
||||||
render(<PriceChart {...defaultProps} />);
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ interface TopDealsProps {
|
|||||||
|
|
||||||
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
||||||
const topDeals = useMemo(() => {
|
const topDeals = useMemo(() => {
|
||||||
|
// Use a type guard in the filter to inform TypeScript that price_in_cents is non-null
|
||||||
|
// in subsequent operations. This allows removing the redundant nullish coalescing in sort.
|
||||||
return [...items]
|
return [...items]
|
||||||
.filter((item) => item.price_in_cents !== null) // Only include items with a parseable price
|
.filter(
|
||||||
.sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity))
|
(item): item is FlyerItem & { price_in_cents: number } => item.price_in_cents !== null,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.price_in_cents - b.price_in_cents)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ describe('AnalysisPanel', () => {
|
|||||||
results: { WEB_SEARCH: 'Search results text.' },
|
results: { WEB_SEARCH: 'Search results text.' },
|
||||||
sources: {
|
sources: {
|
||||||
WEB_SEARCH: [
|
WEB_SEARCH: [
|
||||||
{ title: 'Valid Source', uri: 'http://example.com/source1' },
|
{ title: 'Valid Source', uri: 'https://example.com/source1' },
|
||||||
{ title: 'Source without URI', uri: null },
|
{ title: 'Source without URI', uri: null },
|
||||||
{ title: 'Another Valid Source', uri: 'http://example.com/source2' },
|
{ title: 'Another Valid Source', uri: 'https://example.com/source2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
loadingAnalysis: null,
|
loadingAnalysis: null,
|
||||||
@@ -178,7 +178,7 @@ describe('AnalysisPanel', () => {
|
|||||||
expect(screen.getByText('Sources:')).toBeInTheDocument();
|
expect(screen.getByText('Sources:')).toBeInTheDocument();
|
||||||
const source1 = screen.getByText('Valid Source');
|
const source1 = screen.getByText('Valid Source');
|
||||||
expect(source1).toBeInTheDocument();
|
expect(source1).toBeInTheDocument();
|
||||||
expect(source1.closest('a')).toHaveAttribute('href', 'http://example.com/source1');
|
expect(source1.closest('a')).toHaveAttribute('href', 'https://example.com/source1');
|
||||||
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
|
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
|
expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -278,13 +278,13 @@ describe('AnalysisPanel', () => {
|
|||||||
loadingAnalysis: null,
|
loadingAnalysis: null,
|
||||||
error: null,
|
error: null,
|
||||||
runAnalysis: mockRunAnalysis,
|
runAnalysis: mockRunAnalysis,
|
||||||
generatedImageUrl: 'http://example.com/meal.jpg',
|
generatedImageUrl: 'https://example.com/meal.jpg',
|
||||||
generateImage: mockGenerateImage,
|
generateImage: mockGenerateImage,
|
||||||
});
|
});
|
||||||
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
||||||
const image = screen.getByAltText('AI generated meal plan');
|
const image = screen.getByAltText('AI generated meal plan');
|
||||||
expect(image).toBeInTheDocument();
|
expect(image).toBeInTheDocument();
|
||||||
expect(image).toHaveAttribute('src', 'http://example.com/meal.jpg');
|
expect(image).toHaveAttribute('src', 'https://example.com/meal.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show sources for non-search analysis types', () => {
|
it('should not show sources for non-search analysis types', () => {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { createMockStore } from '../../tests/utils/mockFactories';
|
|||||||
const mockStore = createMockStore({
|
const mockStore = createMockStore({
|
||||||
store_id: 1,
|
store_id: 1,
|
||||||
name: 'SuperMart',
|
name: 'SuperMart',
|
||||||
logo_url: 'http://example.com/logo.png',
|
logo_url: 'https://example.com/logo.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockOnOpenCorrectionTool = vi.fn();
|
const mockOnOpenCorrectionTool = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
imageUrl: 'http://example.com/flyer.jpg',
|
imageUrl: 'https://example.com/flyer.jpg',
|
||||||
store: mockStore,
|
store: mockStore,
|
||||||
validFrom: '2023-10-26',
|
validFrom: '2023-10-26',
|
||||||
validTo: '2023-11-01',
|
validTo: '2023-11-01',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/features/flyer/FlyerDisplay.tsx
|
// src/features/flyer/FlyerDisplay.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ScanIcon } from '../../components/icons/ScanIcon';
|
import { formatDateRange } from '../../utils/dateUtils';
|
||||||
import type { Store } from '../../types';
|
import type { Store } from '../../types';
|
||||||
import { formatDateRange } from './dateUtils';
|
import { ScanIcon } from '../../components/icons/ScanIcon';
|
||||||
|
|
||||||
export interface FlyerDisplayProps {
|
export interface FlyerDisplayProps {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// src/features/flyer/FlyerList.test.tsx
|
// src/features/flyer/FlyerList.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||||
import { FlyerList } from './FlyerList';
|
import { FlyerList } from './FlyerList';
|
||||||
import { formatShortDate } from './dateUtils';
|
import { formatShortDate } from '../../utils/dateUtils';
|
||||||
import type { Flyer, UserProfile } from '../../types';
|
import type { Flyer, UserProfile } from '../../types';
|
||||||
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||||
import { createMockFlyer } from '../../tests/utils/mockFactories';
|
import { createMockFlyer } from '../../tests/utils/mockFactories';
|
||||||
@@ -19,7 +19,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
file_name: 'metro_flyer_oct_1.pdf',
|
file_name: 'metro_flyer_oct_1.pdf',
|
||||||
item_count: 50,
|
item_count: 50,
|
||||||
image_url: 'http://example.com/flyer1.jpg',
|
image_url: 'https://example.com/flyer1.jpg',
|
||||||
store: { store_id: 101, name: 'Metro' },
|
store: { store_id: 101, name: 'Metro' },
|
||||||
valid_from: '2023-10-05',
|
valid_from: '2023-10-05',
|
||||||
valid_to: '2023-10-11',
|
valid_to: '2023-10-11',
|
||||||
@@ -29,7 +29,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 2,
|
flyer_id: 2,
|
||||||
file_name: 'walmart_flyer.pdf',
|
file_name: 'walmart_flyer.pdf',
|
||||||
item_count: 75,
|
item_count: 75,
|
||||||
image_url: 'http://example.com/flyer2.jpg',
|
image_url: 'https://example.com/flyer2.jpg',
|
||||||
store: { store_id: 102, name: 'Walmart' },
|
store: { store_id: 102, name: 'Walmart' },
|
||||||
valid_from: '2023-10-06',
|
valid_from: '2023-10-06',
|
||||||
valid_to: '2023-10-06', // Same day
|
valid_to: '2023-10-06', // Same day
|
||||||
@@ -40,8 +40,8 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 3,
|
flyer_id: 3,
|
||||||
file_name: 'no-store-flyer.pdf',
|
file_name: 'no-store-flyer.pdf',
|
||||||
item_count: 10,
|
item_count: 10,
|
||||||
image_url: 'http://example.com/flyer3.jpg',
|
image_url: 'https://example.com/flyer3.jpg',
|
||||||
icon_url: 'http://example.com/icon3.png',
|
icon_url: 'https://example.com/icon3.png',
|
||||||
valid_from: '2023-10-07',
|
valid_from: '2023-10-07',
|
||||||
valid_to: '2023-10-08',
|
valid_to: '2023-10-08',
|
||||||
store_address: '456 Side St, Ottawa',
|
store_address: '456 Side St, Ottawa',
|
||||||
@@ -53,7 +53,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 4,
|
flyer_id: 4,
|
||||||
file_name: 'bad-date-flyer.pdf',
|
file_name: 'bad-date-flyer.pdf',
|
||||||
item_count: 5,
|
item_count: 5,
|
||||||
image_url: 'http://example.com/flyer4.jpg',
|
image_url: 'https://example.com/flyer4.jpg',
|
||||||
store: { store_id: 103, name: 'Date Store' },
|
store: { store_id: 103, name: 'Date Store' },
|
||||||
created_at: 'invalid-date',
|
created_at: 'invalid-date',
|
||||||
valid_from: 'invalid-from',
|
valid_from: 'invalid-from',
|
||||||
@@ -163,7 +163,7 @@ describe('FlyerList', () => {
|
|||||||
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
|
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
|
||||||
const iconImage = flyerWithIcon?.querySelector('img');
|
const iconImage = flyerWithIcon?.querySelector('img');
|
||||||
expect(iconImage).toBeInTheDocument();
|
expect(iconImage).toBeInTheDocument();
|
||||||
expect(iconImage).toHaveAttribute('src', 'http://example.com/icon3.png');
|
expect(iconImage).toHaveAttribute('src', 'https://example.com/icon3.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a document icon when icon_url is not present', () => {
|
it('should render a document icon when icon_url is not present', () => {
|
||||||
@@ -257,6 +257,73 @@ describe('FlyerList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Expiration Status Logic', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Expired" for past dates', () => {
|
||||||
|
// Flyer 1 valid_to is 2023-10-11
|
||||||
|
vi.setSystemTime(new Date('2023-10-12T12:00:00Z'));
|
||||||
|
render(
|
||||||
|
<FlyerList
|
||||||
|
flyers={[mockFlyers[0]]}
|
||||||
|
onFlyerSelect={mockOnFlyerSelect}
|
||||||
|
selectedFlyerId={null}
|
||||||
|
profile={mockProfile}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('• Expired')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('• Expired')).toHaveClass('text-red-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Expires today" when valid_to is today', () => {
|
||||||
|
vi.setSystemTime(new Date('2023-10-11T12:00:00Z'));
|
||||||
|
render(
|
||||||
|
<FlyerList
|
||||||
|
flyers={[mockFlyers[0]]}
|
||||||
|
onFlyerSelect={mockOnFlyerSelect}
|
||||||
|
selectedFlyerId={null}
|
||||||
|
profile={mockProfile}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('• Expires today')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('• Expires today')).toHaveClass('text-orange-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Expires in X days" (orange) for <= 3 days', () => {
|
||||||
|
vi.setSystemTime(new Date('2023-10-09T12:00:00Z')); // 2 days left
|
||||||
|
render(
|
||||||
|
<FlyerList
|
||||||
|
flyers={[mockFlyers[0]]}
|
||||||
|
onFlyerSelect={mockOnFlyerSelect}
|
||||||
|
selectedFlyerId={null}
|
||||||
|
profile={mockProfile}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('• Expires in 2 days')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('• Expires in 2 days')).toHaveClass('text-orange-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Expires in X days" (green) for > 3 days', () => {
|
||||||
|
vi.setSystemTime(new Date('2023-10-05T12:00:00Z')); // 6 days left
|
||||||
|
render(
|
||||||
|
<FlyerList
|
||||||
|
flyers={[mockFlyers[0]]}
|
||||||
|
onFlyerSelect={mockOnFlyerSelect}
|
||||||
|
selectedFlyerId={null}
|
||||||
|
profile={mockProfile}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Admin Functionality', () => {
|
describe('Admin Functionality', () => {
|
||||||
const adminProfile: UserProfile = createMockUserProfile({
|
const adminProfile: UserProfile = createMockUserProfile({
|
||||||
user: { user_id: 'admin-1', email: 'admin@example.com' },
|
user: { user_id: 'admin-1', email: 'admin@example.com' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseISO, format, isValid } from 'date-fns';
|
|||||||
import { MapPinIcon, Trash2Icon } from 'lucide-react';
|
import { MapPinIcon, Trash2Icon } from 'lucide-react';
|
||||||
import { logger } from '../../services/logger.client';
|
import { logger } from '../../services/logger.client';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { calculateDaysBetween, formatDateRange } from './dateUtils';
|
import { calculateDaysBetween, formatDateRange, getCurrentDateISOString } from '../../utils/dateUtils';
|
||||||
|
|
||||||
interface FlyerListProps {
|
interface FlyerListProps {
|
||||||
flyers: Flyer[];
|
flyers: Flyer[];
|
||||||
@@ -54,7 +54,7 @@ export const FlyerList: React.FC<FlyerListProps> = ({
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to);
|
const daysLeft = calculateDaysBetween(getCurrentDateISOString(), flyer.valid_to);
|
||||||
let daysLeftText = '';
|
let daysLeftText = '';
|
||||||
let daysLeftColor = '';
|
let daysLeftColor = '';
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,21 @@ import { useNavigate, MemoryRouter } from 'react-router-dom';
|
|||||||
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('../../services/aiApiClient');
|
vi.mock('../../services/aiApiClient', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../services/aiApiClient')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
uploadAndProcessFlyer: vi.fn(),
|
||||||
|
getJobStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock('../../services/logger.client', () => ({
|
vi.mock('../../services/logger.client', () => ({
|
||||||
// Keep the original logger.info/error but also spy on it for test assertions if needed
|
// Keep the original logger.info/error but also spy on it for test assertions if needed
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
|
info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
|
||||||
error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
|
error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
|
||||||
|
warn: vi.fn((...args) => console.warn('[LOGGER.WARN]', ...args)),
|
||||||
|
debug: vi.fn((...args) => console.debug('[LOGGER.DEBUG]', ...args)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../../utils/checksum', () => ({
|
vi.mock('../../utils/checksum', () => ({
|
||||||
@@ -223,14 +232,10 @@ 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({ jobId: 'job-fail' });
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
// The getJobStatus function throws a specific error when the job fails,
|
||||||
state: 'failed',
|
// which is then caught by react-query and placed in the `error` state.
|
||||||
progress: {
|
const jobFailedError = new aiApiClientModule.JobFailedError('AI model exploded', 'UNKNOWN_ERROR');
|
||||||
errorCode: 'UNKNOWN_ERROR',
|
mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);
|
||||||
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();
|
||||||
@@ -243,7 +248,8 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('--- [TEST LOG] ---: 4. AWAITING failure message...');
|
console.log('--- [TEST LOG] ---: 4. AWAITING failure message...');
|
||||||
expect(await screen.findByText(/Processing failed: AI model exploded/i)).toBeInTheDocument();
|
// The UI should now display the error from the `pollError` state, which includes the "Polling failed" prefix.
|
||||||
|
expect(await screen.findByText(/Polling failed: AI model exploded/i)).toBeInTheDocument();
|
||||||
console.log('--- [TEST LOG] ---: 5. SUCCESS: Failure message found.');
|
console.log('--- [TEST LOG] ---: 5. SUCCESS: Failure message found.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for failure message timed out.');
|
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for failure message timed out.');
|
||||||
@@ -257,18 +263,17 @@ describe('FlyerUploader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear the polling timeout when a job fails', async () => {
|
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.');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.');
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' });
|
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
|
// We need at least one 'active' response to establish a timeout loop so we have something to clear
|
||||||
|
// The second call should be a rejection, as this is how getJobStatus signals a failure.
|
||||||
mockedAiApiClient.getJobStatus
|
mockedAiApiClient.getJobStatus
|
||||||
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' } })
|
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
state: 'failed',
|
state: 'active',
|
||||||
progress: { errorCode: 'UNKNOWN_ERROR', message: 'Fatal Error' },
|
progress: { message: 'Working...' },
|
||||||
failedReason: 'Fatal Error',
|
} as aiApiClientModule.JobStatus)
|
||||||
});
|
.mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR'));
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||||
@@ -280,24 +285,13 @@ describe('FlyerUploader', () => {
|
|||||||
await screen.findByText('Working...');
|
await screen.findByText('Working...');
|
||||||
|
|
||||||
// Wait for the failure UI
|
// Wait for the failure UI
|
||||||
await waitFor(() => expect(screen.getByText(/Processing failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
|
await waitFor(() => expect(screen.getByText(/Polling 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 () => {
|
it('should stop polling for job status when the component unmounts', async () => {
|
||||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount polling stop.');
|
||||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount timeout clearance.');
|
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
|
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
|
||||||
|
// Mock getJobStatus to always return 'active' to keep polling
|
||||||
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||||
state: 'active',
|
state: 'active',
|
||||||
progress: { message: 'Polling...' },
|
progress: { message: 'Polling...' },
|
||||||
@@ -309,26 +303,38 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
// Wait for the first poll to complete and the UI to show the polling state
|
// Wait for the first poll to complete and UI to update
|
||||||
await screen.findByText('Polling...');
|
await screen.findByText('Polling...');
|
||||||
|
|
||||||
// Now that we are in a polling state (and a timeout is set), unmount the component
|
// Wait for exactly one call to be sure polling has started.
|
||||||
console.log('--- [TEST LOG] ---: 2. Unmounting component to trigger cleanup effect.');
|
await waitFor(() => {
|
||||||
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
console.log('--- [TEST LOG] ---: 2. First poll confirmed.');
|
||||||
|
|
||||||
|
// Record the number of calls before unmounting.
|
||||||
|
const callsBeforeUnmount = mockedAiApiClient.getJobStatus.mock.calls.length;
|
||||||
|
|
||||||
|
// Now unmount the component, which should stop the polling.
|
||||||
|
console.log('--- [TEST LOG] ---: 3. Unmounting component.');
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// Verify that the cleanup function in the useEffect hook was called
|
// Wait for a duration longer than the polling interval (3s) to see if more calls are made.
|
||||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
console.log('--- [TEST LOG] ---: 4. Waiting for 4 seconds to check for further polling.');
|
||||||
console.log('--- [TEST LOG] ---: 3. clearTimeout confirmed.');
|
await act(() => new Promise((resolve) => setTimeout(resolve, 4000)));
|
||||||
|
|
||||||
clearTimeoutSpy.mockRestore();
|
// Verify that getJobStatus was not called again after unmounting.
|
||||||
|
console.log('--- [TEST LOG] ---: 5. Asserting no new polls occurred.');
|
||||||
|
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBeforeUnmount);
|
||||||
});
|
});
|
||||||
|
|
||||||
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.');
|
||||||
// The API client now throws a structured error for non-2xx responses.
|
// The API client throws a structured error, which useFlyerUploader now parses
|
||||||
|
// to set both the errorMessage and the duplicateFlyerId.
|
||||||
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
|
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
|
||||||
status: 409,
|
status: 409,
|
||||||
body: { flyerId: 99, message: 'Duplicate' },
|
body: { flyerId: 99, message: 'This flyer has already been processed.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||||
@@ -342,9 +348,10 @@ describe('FlyerUploader', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
|
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
|
||||||
expect(
|
// With the fix, the duplicate error message and the link are combined into a single paragraph.
|
||||||
await screen.findByText(/This flyer has already been processed/i),
|
// We now look for this combined message.
|
||||||
).toBeInTheDocument();
|
const errorMessage = await screen.findByText(/This flyer has already been processed. You can view it here:/i);
|
||||||
|
expect(errorMessage).toBeInTheDocument();
|
||||||
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
|
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.');
|
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.');
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
|
|||||||
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
|
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
|
||||||
}, [statusMessage]);
|
}, [statusMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMessage) {
|
||||||
|
logger.error(`[FlyerUploader] Error encountered: ${errorMessage}`, { duplicateFlyerId });
|
||||||
|
}
|
||||||
|
}, [errorMessage, duplicateFlyerId]);
|
||||||
|
|
||||||
// Handle completion and navigation
|
// Handle completion and navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (processingState === 'completed' && flyerId) {
|
if (processingState === 'completed' && flyerId) {
|
||||||
@@ -94,14 +100,15 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
|
|||||||
|
|
||||||
{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>
|
{duplicateFlyerId ? (
|
||||||
{duplicateFlyerId && (
|
|
||||||
<p>
|
<p>
|
||||||
This flyer has already been processed. You can view it here:{' '}
|
{errorMessage} You can view it here:{' '}
|
||||||
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline" data-discover="true">
|
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline" data-discover="true">
|
||||||
Flyer #{duplicateFlyerId}
|
Flyer #{duplicateFlyerId}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
// src/features/flyer/dateUtils.test.ts
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { formatShortDate, calculateDaysBetween, formatDateRange } from './dateUtils';
|
|
||||||
|
|
||||||
describe('formatShortDate', () => {
|
|
||||||
it('should format a valid YYYY-MM-DD date string correctly', () => {
|
|
||||||
expect(formatShortDate('2024-07-26')).toBe('Jul 26');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle single-digit days correctly', () => {
|
|
||||||
expect(formatShortDate('2025-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle dates at the end of the year', () => {
|
|
||||||
expect(formatShortDate('2023-12-31')).toBe('Dec 31');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for a null input', () => {
|
|
||||||
expect(formatShortDate(null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an undefined input', () => {
|
|
||||||
expect(formatShortDate(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an empty string input', () => {
|
|
||||||
expect(formatShortDate('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an invalid date string', () => {
|
|
||||||
expect(formatShortDate('not-a-real-date')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for a malformed date string', () => {
|
|
||||||
expect(formatShortDate('2024-13-01')).toBeNull(); // Invalid month
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly format a full ISO string with time and timezone', () => {
|
|
||||||
expect(formatShortDate('2024-12-25T10:00:00Z')).toBe('Dec 25');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('calculateDaysBetween', () => {
|
|
||||||
it('should calculate the difference in days between two valid date strings', () => {
|
|
||||||
expect(calculateDaysBetween('2023-01-01', '2023-01-05')).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a negative number if the end date is before the start date', () => {
|
|
||||||
expect(calculateDaysBetween('2023-01-05', '2023-01-01')).toBe(-4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Date objects', () => {
|
|
||||||
const start = new Date('2023-01-01');
|
|
||||||
const end = new Date('2023-01-10');
|
|
||||||
expect(calculateDaysBetween(start, end)).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if either date is null or undefined', () => {
|
|
||||||
expect(calculateDaysBetween(null, '2023-01-01')).toBeNull();
|
|
||||||
expect(calculateDaysBetween('2023-01-01', undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if either date is invalid', () => {
|
|
||||||
expect(calculateDaysBetween('invalid', '2023-01-01')).toBeNull();
|
|
||||||
expect(calculateDaysBetween('2023-01-01', 'invalid')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDateRange', () => {
|
|
||||||
it('should format a range with two different valid dates', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-05')).toBe('Jan 1 - Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format a range with the same start and end date as a single date', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-01')).toBe('Jan 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return only the start date if end date is missing', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', null)).toBe('Jan 1');
|
|
||||||
expect(formatDateRange('2023-01-01', undefined)).toBe('Jan 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return only the end date if start date is missing', () => {
|
|
||||||
expect(formatDateRange(null, '2023-01-05')).toBe('Jan 5');
|
|
||||||
expect(formatDateRange(undefined, '2023-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if both dates are missing or invalid', () => {
|
|
||||||
expect(formatDateRange(null, null)).toBeNull();
|
|
||||||
expect(formatDateRange(undefined, undefined)).toBeNull();
|
|
||||||
expect(formatDateRange('invalid', 'invalid')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle one valid and one invalid date by showing only the valid one', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', 'invalid')).toBe('Jan 1');
|
|
||||||
expect(formatDateRange('invalid', '2023-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verbose mode', () => {
|
|
||||||
it('should format a range with two different valid dates verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe(
|
|
||||||
'Deals valid from January 1, 2023 to January 5, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format a range with the same start and end date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe(
|
|
||||||
'Valid on January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format only the start date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe(
|
|
||||||
'Deals start January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format only the end date verbosely', () => {
|
|
||||||
expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe(
|
|
||||||
'Deals end January 5, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle one valid and one invalid date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe(
|
|
||||||
'Deals start January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// src/features/flyer/dateUtils.ts
|
|
||||||
import { parseISO, format, isValid, differenceInDays } from 'date-fns';
|
|
||||||
|
|
||||||
export const formatShortDate = (dateString: string | null | undefined): string | null => {
|
|
||||||
if (!dateString) return null;
|
|
||||||
// Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings.
|
|
||||||
// It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors.
|
|
||||||
const date = parseISO(dateString);
|
|
||||||
if (isValid(date)) {
|
|
||||||
return format(date, 'MMM d');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateDaysBetween = (
|
|
||||||
startDate: string | Date | null | undefined,
|
|
||||||
endDate: string | Date | null | undefined,
|
|
||||||
): number | null => {
|
|
||||||
if (!startDate || !endDate) return null;
|
|
||||||
|
|
||||||
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
|
|
||||||
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
|
|
||||||
|
|
||||||
if (!isValid(start) || !isValid(end)) return null;
|
|
||||||
|
|
||||||
return differenceInDays(end, start);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DateRangeOptions {
|
|
||||||
verbose?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDateRange = (
|
|
||||||
startDate: string | null | undefined,
|
|
||||||
endDate: string | null | undefined,
|
|
||||||
options?: DateRangeOptions,
|
|
||||||
): string | null => {
|
|
||||||
if (!options?.verbose) {
|
|
||||||
const start = formatShortDate(startDate);
|
|
||||||
const end = formatShortDate(endDate);
|
|
||||||
|
|
||||||
if (start && end) {
|
|
||||||
return start === end ? start : `${start} - ${end}`;
|
|
||||||
}
|
|
||||||
return start || end || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verbose format logic
|
|
||||||
const dateFormat = 'MMMM d, yyyy';
|
|
||||||
const formatFn = (dateStr: string | null | undefined) => {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
const date = parseISO(dateStr);
|
|
||||||
return isValid(date) ? format(date, dateFormat) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = formatFn(startDate);
|
|
||||||
const end = formatFn(endDate);
|
|
||||||
|
|
||||||
if (start && end) {
|
|
||||||
return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`;
|
|
||||||
}
|
|
||||||
if (start) return `Deals start ${start}`;
|
|
||||||
if (end) return `Deals end ${end}`;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -236,6 +236,24 @@ describe('ShoppingListComponent (in shopping feature)', () => {
|
|||||||
alertSpy.mockRestore();
|
alertSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show a generic alert if reading aloud fails with a non-Error object', async () => {
|
||||||
|
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue('A string error');
|
||||||
|
|
||||||
|
render(<ShoppingListComponent {...defaultProps} />);
|
||||||
|
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
||||||
|
|
||||||
|
fireEvent.click(readAloudButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith(
|
||||||
|
'Could not read list aloud: An unknown error occurred while generating audio.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
alertSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle interactions with purchased items', () => {
|
it('should handle interactions with purchased items', () => {
|
||||||
render(<ShoppingListComponent {...defaultProps} />);
|
render(<ShoppingListComponent {...defaultProps} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/features/shopping/ShoppingList.tsx
|
// src/features/shopping/ShoppingList.tsx
|
||||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import type { ShoppingList, ShoppingListItem, User } from '../../types';
|
import type { ShoppingList, ShoppingListItem, User } from '../../types';
|
||||||
import { UserIcon } from '../../components/icons/UserIcon';
|
import { UserIcon } from '../../components/icons/UserIcon';
|
||||||
import { ListBulletIcon } from '../../components/icons/ListBulletIcon';
|
import { ListBulletIcon } from '../../components/icons/ListBulletIcon';
|
||||||
@@ -56,28 +56,6 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({
|
|||||||
return { neededItems, purchasedItems };
|
return { neededItems, purchasedItems };
|
||||||
}, [activeList]);
|
}, [activeList]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeList) {
|
|
||||||
console.log('ShoppingList Debug: Active List:', activeList.name);
|
|
||||||
console.log(
|
|
||||||
'ShoppingList Debug: Needed Items:',
|
|
||||||
neededItems.map((i) => ({
|
|
||||||
id: i.shopping_list_item_id,
|
|
||||||
name: i.custom_item_name || i.master_item?.name,
|
|
||||||
raw: i,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'ShoppingList Debug: Purchased Items:',
|
|
||||||
purchasedItems.map((i) => ({
|
|
||||||
id: i.shopping_list_item_id,
|
|
||||||
name: i.custom_item_name || i.master_item?.name,
|
|
||||||
raw: i,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [activeList, neededItems, purchasedItems]);
|
|
||||||
|
|
||||||
const handleCreateList = async () => {
|
const handleCreateList = async () => {
|
||||||
const name = prompt('Enter a name for your new shopping list:');
|
const name = prompt('Enter a name for your new shopping list:');
|
||||||
if (name && name.trim()) {
|
if (name && name.trim()) {
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ describe('WatchedItemsList (in shopping feature)', () => {
|
|||||||
expect(itemsDesc[1]).toHaveTextContent('Eggs');
|
expect(itemsDesc[1]).toHaveTextContent('Eggs');
|
||||||
expect(itemsDesc[2]).toHaveTextContent('Bread');
|
expect(itemsDesc[2]).toHaveTextContent('Bread');
|
||||||
expect(itemsDesc[3]).toHaveTextContent('Apples');
|
expect(itemsDesc[3]).toHaveTextContent('Apples');
|
||||||
|
|
||||||
|
// Click again to sort ascending
|
||||||
|
fireEvent.click(sortButton);
|
||||||
|
|
||||||
|
const itemsAscAgain = screen.getAllByRole('listitem');
|
||||||
|
expect(itemsAscAgain[0]).toHaveTextContent('Apples');
|
||||||
|
expect(itemsAscAgain[1]).toHaveTextContent('Bread');
|
||||||
|
expect(itemsAscAgain[2]).toHaveTextContent('Eggs');
|
||||||
|
expect(itemsAscAgain[3]).toHaveTextContent('Milk');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onAddItemToList when plus icon is clicked', () => {
|
it('should call onAddItemToList when plus icon is clicked', () => {
|
||||||
@@ -222,6 +231,18 @@ describe('WatchedItemsList (in shopping feature)', () => {
|
|||||||
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
|
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
|
||||||
expect(addButton).toBeDisabled();
|
expect(addButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not submit if form is submitted with invalid data', () => {
|
||||||
|
render(<WatchedItemsList {...defaultProps} />);
|
||||||
|
const nameInput = screen.getByPlaceholderText(/add item/i);
|
||||||
|
const form = nameInput.closest('form')!;
|
||||||
|
const categorySelect = screen.getByDisplayValue('Select a category');
|
||||||
|
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: ' ' } });
|
||||||
|
fireEvent.submit(form);
|
||||||
|
expect(mockOnAddItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ import {
|
|||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
|
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
|
||||||
|
|
||||||
// Explicitly mock apiClient to ensure stable spies are used
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
vi.mock('../services/apiClient', () => ({
|
|
||||||
countFlyerItemsForFlyers: vi.fn(),
|
|
||||||
fetchFlyerItemsForFlyers: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the hooks to avoid Missing Context errors
|
// Mock the hooks to avoid Missing Context errors
|
||||||
vi.mock('./useFlyers', () => ({
|
vi.mock('./useFlyers', () => ({
|
||||||
useFlyers: () => mockUseFlyers(),
|
useFlyers: () => mockUseFlyers(),
|
||||||
@@ -30,14 +25,6 @@ vi.mock('../hooks/useUserData', () => ({
|
|||||||
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// Mock the logger to prevent console noise
|
|
||||||
vi.mock('../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
info: vi.fn(), // Added to prevent crashes on abort logging
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set a consistent "today" for testing flyer validity to make tests deterministic
|
// Set a consistent "today" for testing flyer validity to make tests deterministic
|
||||||
const TODAY = new Date('2024-01-15T12:00:00.000Z');
|
const TODAY = new Date('2024-01-15T12:00:00.000Z');
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
import { notifyError } from '../services/notificationService';
|
import { notifyError } from '../services/notificationService';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom React hook to simplify API calls, including loading and error states.
|
* A custom React hook to simplify API calls, including loading and error states.
|
||||||
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
||||||
@@ -26,8 +27,17 @@ export function useApi<T, TArgs extends unknown[]>(
|
|||||||
const [isRefetching, setIsRefetching] = useState<boolean>(false);
|
const [isRefetching, setIsRefetching] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const hasBeenExecuted = useRef(false);
|
const hasBeenExecuted = useRef(false);
|
||||||
|
const lastErrorMessageRef = useRef<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController>(new AbortController());
|
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||||
|
|
||||||
|
// Use a ref to track the latest apiFunction. This allows us to keep `execute` stable
|
||||||
|
// even if `apiFunction` is recreated on every render (common with inline arrow functions).
|
||||||
|
const apiFunctionRef = useRef(apiFunction);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFunctionRef.current = apiFunction;
|
||||||
|
}, [apiFunction]);
|
||||||
|
|
||||||
// This effect ensures that when the component using the hook unmounts,
|
// This effect ensures that when the component using the hook unmounts,
|
||||||
// any in-flight request is cancelled.
|
// any in-flight request is cancelled.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,12 +62,13 @@ export function useApi<T, TArgs extends unknown[]>(
|
|||||||
async (...args: TArgs): Promise<T | null> => {
|
async (...args: TArgs): Promise<T | null> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
lastErrorMessageRef.current = null;
|
||||||
if (hasBeenExecuted.current) {
|
if (hasBeenExecuted.current) {
|
||||||
setIsRefetching(true);
|
setIsRefetching(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFunction(...args, abortControllerRef.current.signal);
|
const response = await apiFunctionRef.current(...args, abortControllerRef.current.signal);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Attempt to parse a JSON error response. This is aligned with ADR-003,
|
// Attempt to parse a JSON error response. This is aligned with ADR-003,
|
||||||
@@ -96,7 +107,17 @@ export function useApi<T, TArgs extends unknown[]>(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
let err: Error;
|
||||||
|
if (e instanceof Error) {
|
||||||
|
err = e;
|
||||||
|
} else if (typeof e === 'object' && e !== null && 'status' in e) {
|
||||||
|
// Handle structured errors (e.g. { status: 409, body: { ... } })
|
||||||
|
const structuredError = e as { status: number; body?: { message?: string } };
|
||||||
|
const message = structuredError.body?.message || `Request failed with status ${structuredError.status}`;
|
||||||
|
err = new Error(message);
|
||||||
|
} else {
|
||||||
|
err = new Error('An unknown error occurred.');
|
||||||
|
}
|
||||||
// If the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
|
// If the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
logger.info('API request was cancelled.', { functionName: apiFunction.name });
|
logger.info('API request was cancelled.', { functionName: apiFunction.name });
|
||||||
@@ -106,7 +127,13 @@ export function useApi<T, TArgs extends unknown[]>(
|
|||||||
error: err.message,
|
error: err.message,
|
||||||
functionName: apiFunction.name,
|
functionName: apiFunction.name,
|
||||||
});
|
});
|
||||||
setError(err);
|
// Only set a new error object if the message is different from the last one.
|
||||||
|
// This prevents creating new object references for the same error (e.g. repeated timeouts)
|
||||||
|
// and helps break infinite loops in components that depend on the `error` object.
|
||||||
|
if (err.message !== lastErrorMessageRef.current) {
|
||||||
|
setError(err);
|
||||||
|
lastErrorMessageRef.current = err.message;
|
||||||
|
}
|
||||||
notifyError(err.message); // Optionally notify the user automatically.
|
notifyError(err.message); // Optionally notify the user automatically.
|
||||||
return null; // Return null on failure.
|
return null; // Return null on failure.
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,7 +141,7 @@ export function useApi<T, TArgs extends unknown[]>(
|
|||||||
setIsRefetching(false);
|
setIsRefetching(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[apiFunction],
|
[], // execute is now stable because it uses apiFunctionRef
|
||||||
); // abortControllerRef is stable
|
); // abortControllerRef is stable
|
||||||
|
|
||||||
return { execute, loading, isRefetching, error, data, reset };
|
return { execute, loading, isRefetching, error, data, reset };
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('useAppInitialization Hook', () => {
|
|||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
Object.defineProperty(window, 'localStorage', {
|
Object.defineProperty(window, 'localStorage', {
|
||||||
value: {
|
value: {
|
||||||
getItem: vi.fn(),
|
getItem: vi.fn().mockReturnValue(null),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
removeItem: vi.fn(),
|
removeItem: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
@@ -74,6 +74,7 @@ describe('useAppInitialization Hook', () => {
|
|||||||
matches: false, // default to light mode
|
matches: false, // default to light mode
|
||||||
})),
|
})),
|
||||||
writable: true,
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,9 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock('../services/apiClient', () => ({
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
// Mock other functions if needed
|
|
||||||
getAuthenticatedUserProfile: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../services/tokenStorage');
|
vi.mock('../services/tokenStorage');
|
||||||
|
|
||||||
// Mock the logger to spy on its methods
|
|
||||||
vi.mock('../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
const mockedTokenStorage = vi.mocked(tokenStorage);
|
const mockedTokenStorage = vi.mocked(tokenStorage);
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { renderHook } from '@testing-library/react';
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useFlyerItems } from './useFlyerItems';
|
import { useFlyerItems } from './useFlyerItems';
|
||||||
import { useApiOnMount } from './useApiOnMount';
|
import { useApiOnMount } from './useApiOnMount';
|
||||||
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
|
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
|
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
|
||||||
vi.mock('./useApiOnMount');
|
vi.mock('./useApiOnMount');
|
||||||
vi.mock('../services/apiClient');
|
|
||||||
|
|
||||||
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
|
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
|
||||||
|
|
||||||
@@ -16,8 +15,8 @@ describe('useFlyerItems Hook', () => {
|
|||||||
const mockFlyer = createMockFlyer({
|
const mockFlyer = createMockFlyer({
|
||||||
flyer_id: 123,
|
flyer_id: 123,
|
||||||
file_name: 'test-flyer.jpg',
|
file_name: 'test-flyer.jpg',
|
||||||
image_url: '/test.jpg',
|
image_url: 'https://example.com/test.jpg',
|
||||||
icon_url: '/icon.jpg',
|
icon_url: 'https://example.com/icon.jpg',
|
||||||
checksum: 'abc',
|
checksum: 'abc',
|
||||||
valid_from: '2024-01-01',
|
valid_from: '2024-01-01',
|
||||||
valid_to: '2024-01-07',
|
valid_to: '2024-01-07',
|
||||||
@@ -61,7 +60,6 @@ describe('useFlyerItems Hook', () => {
|
|||||||
expect(result.current.flyerItems).toEqual([]);
|
expect(result.current.flyerItems).toEqual([]);
|
||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
|
|
||||||
// Assert: Check that useApiOnMount was called with `enabled: false`.
|
// Assert: Check that useApiOnMount was called with `enabled: false`.
|
||||||
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
|
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
|
||||||
expect.any(Function), // the wrapped fetcher function
|
expect.any(Function), // the wrapped fetcher function
|
||||||
@@ -171,11 +169,11 @@ describe('useFlyerItems Hook', () => {
|
|||||||
|
|
||||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||||
const mockResponse = new Response();
|
const mockResponse = new Response();
|
||||||
vi.mocked(apiClient.fetchFlyerItems).mockResolvedValue(mockResponse);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
mockedApiClient.fetchFlyerItems.mockResolvedValue(mockResponse);
|
||||||
const response = await wrappedFetcher(123);
|
const response = await wrappedFetcher(123);
|
||||||
|
|
||||||
expect(apiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
||||||
expect(response).toBe(mockResponse);
|
expect(response).toBe(mockResponse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/hooks/useFlyerUploader.ts
|
// src/hooks/useFlyerUploader.ts
|
||||||
// src/hooks/useFlyerUploader.ts
|
// src/hooks/useFlyerUploader.ts
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
uploadAndProcessFlyer,
|
uploadAndProcessFlyer,
|
||||||
@@ -14,6 +14,28 @@ import type { ProcessingStage } from '../types';
|
|||||||
|
|
||||||
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
|
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
|
||||||
|
|
||||||
|
// Define a type for the structured error thrown by the API client
|
||||||
|
interface ApiError {
|
||||||
|
status: number;
|
||||||
|
body: {
|
||||||
|
message: string;
|
||||||
|
flyerId?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard to check if an error is a structured API error
|
||||||
|
function isApiError(error: unknown): error is ApiError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'status' in error &&
|
||||||
|
typeof (error as { status: unknown }).status === 'number' &&
|
||||||
|
'body' in error &&
|
||||||
|
typeof (error as { body: unknown }).body === 'object' &&
|
||||||
|
(error as { body: unknown }).body !== null &&
|
||||||
|
'message' in ((error as { body: unknown }).body as object)
|
||||||
|
);
|
||||||
|
}
|
||||||
export const useFlyerUploader = () => {
|
export const useFlyerUploader = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
@@ -44,11 +66,16 @@ export const useFlyerUploader = () => {
|
|||||||
enabled: !!jobId,
|
enabled: !!jobId,
|
||||||
// Polling logic: react-query handles the interval
|
// Polling logic: react-query handles the interval
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const data = query.state.data;
|
const data = query.state.data as JobStatus | undefined;
|
||||||
// Stop polling if the job is completed or has failed
|
// Stop polling if the job is completed or has failed
|
||||||
if (data?.state === 'completed' || data?.state === 'failed') {
|
if (data?.state === 'completed' || data?.state === 'failed') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Also stop polling if the query itself has errored (e.g. network error, or JobFailedError thrown from getJobStatus)
|
||||||
|
if (query.state.status === 'error') {
|
||||||
|
logger.warn('[useFlyerUploader] Polling stopped due to query error state.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Otherwise, poll every 3 seconds
|
// Otherwise, poll every 3 seconds
|
||||||
return 3000;
|
return 3000;
|
||||||
},
|
},
|
||||||
@@ -76,40 +103,57 @@ export const useFlyerUploader = () => {
|
|||||||
queryClient.removeQueries({ queryKey: ['jobStatus'] });
|
queryClient.removeQueries({ queryKey: ['jobStatus'] });
|
||||||
}, [uploadMutation, queryClient]);
|
}, [uploadMutation, queryClient]);
|
||||||
|
|
||||||
// Consolidate state for the UI from the react-query hooks
|
// Consolidate state derivation for the UI from the react-query hooks using useMemo.
|
||||||
const processingState = ((): ProcessingState => {
|
// This improves performance by memoizing the derived state and makes the logic easier to follow.
|
||||||
if (uploadMutation.isPending) return 'uploading';
|
const { processingState, errorMessage, duplicateFlyerId, flyerId, statusMessage } = useMemo(() => {
|
||||||
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
|
// The order of these checks is critical. Errors must be checked first to override
|
||||||
return 'polling';
|
// any stale `jobStatus` from a previous successful poll.
|
||||||
if (jobStatus?.state === 'completed') {
|
const state: ProcessingState = (() => {
|
||||||
// If the job is complete but didn't return a flyerId, it's an error state.
|
if (uploadMutation.isError || pollError) return 'error';
|
||||||
if (!jobStatus.returnValue?.flyerId) {
|
if (uploadMutation.isPending) return 'uploading';
|
||||||
return 'error';
|
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
|
||||||
|
return 'polling';
|
||||||
|
if (jobStatus?.state === 'completed') {
|
||||||
|
if (!jobStatus.returnValue?.flyerId) return 'error';
|
||||||
|
return 'completed';
|
||||||
}
|
}
|
||||||
return 'completed';
|
return 'idle';
|
||||||
}
|
})();
|
||||||
if (uploadMutation.isError || jobStatus?.state === 'failed' || pollError) return 'error';
|
|
||||||
return 'idle';
|
|
||||||
})();
|
|
||||||
|
|
||||||
const getErrorMessage = () => {
|
let msg: string | null = null;
|
||||||
const uploadError = uploadMutation.error as any;
|
let dupId: number | null = null;
|
||||||
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();
|
if (state === 'error') {
|
||||||
const duplicateFlyerId = (uploadMutation.error as any)?.body?.flyerId ?? null;
|
if (uploadMutation.isError) {
|
||||||
const flyerId = jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId : null;
|
const uploadError = uploadMutation.error;
|
||||||
|
if (isApiError(uploadError)) {
|
||||||
|
msg = uploadError.body.message;
|
||||||
|
// Specifically handle 409 Conflict for duplicate flyers
|
||||||
|
if (uploadError.status === 409) {
|
||||||
|
dupId = uploadError.body.flyerId ?? null;
|
||||||
|
}
|
||||||
|
} else if (uploadError instanceof Error) {
|
||||||
|
msg = uploadError.message;
|
||||||
|
} else {
|
||||||
|
msg = 'An unknown upload error occurred.';
|
||||||
|
}
|
||||||
|
} else if (pollError) {
|
||||||
|
msg = `Polling failed: ${pollError.message}`;
|
||||||
|
} else if (jobStatus?.state === 'failed') {
|
||||||
|
msg = `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason || 'Unknown reason'}`;
|
||||||
|
} else if (jobStatus?.state === 'completed' && !jobStatus.returnValue?.flyerId) {
|
||||||
|
msg = 'Job completed but did not return a flyer ID.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processingState: state,
|
||||||
|
errorMessage: msg,
|
||||||
|
duplicateFlyerId: dupId,
|
||||||
|
flyerId: jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId ?? null : null,
|
||||||
|
statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
|
||||||
|
};
|
||||||
|
}, [uploadMutation, jobStatus, pollError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processingState,
|
processingState,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
|||||||
createMockFlyer({
|
createMockFlyer({
|
||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
file_name: 'flyer1.jpg',
|
file_name: 'flyer1.jpg',
|
||||||
image_url: 'url1',
|
image_url: 'https://example.com/flyer1.jpg',
|
||||||
item_count: 5,
|
item_count: 5,
|
||||||
created_at: '2024-01-01',
|
created_at: '2024-01-01',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function useInfiniteQuery<T>(
|
|||||||
|
|
||||||
// Use a ref to store the cursor for the next page.
|
// Use a ref to store the cursor for the next page.
|
||||||
const nextCursorRef = useRef<number | string | null | undefined>(initialCursor);
|
const nextCursorRef = useRef<number | string | null | undefined>(initialCursor);
|
||||||
|
const lastErrorMessageRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
async (cursor?: number | string | null) => {
|
async (cursor?: number | string | null) => {
|
||||||
@@ -59,6 +60,7 @@ export function useInfiniteQuery<T>(
|
|||||||
setIsFetchingNextPage(true);
|
setIsFetchingNextPage(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
lastErrorMessageRef.current = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFunction(cursor);
|
const response = await apiFunction(cursor);
|
||||||
@@ -99,7 +101,10 @@ export function useInfiniteQuery<T>(
|
|||||||
error: err.message,
|
error: err.message,
|
||||||
functionName: apiFunction.name,
|
functionName: apiFunction.name,
|
||||||
});
|
});
|
||||||
setError(err);
|
if (err.message !== lastErrorMessageRef.current) {
|
||||||
|
setError(err);
|
||||||
|
lastErrorMessageRef.current = err.message;
|
||||||
|
}
|
||||||
notifyError(err.message);
|
notifyError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -125,6 +130,7 @@ export function useInfiniteQuery<T>(
|
|||||||
// Function to be called by the UI to refetch the entire query from the beginning.
|
// Function to be called by the UI to refetch the entire query from the beginning.
|
||||||
const refetch = useCallback(() => {
|
const refetch = useCallback(() => {
|
||||||
setIsRefetching(true);
|
setIsRefetching(true);
|
||||||
|
lastErrorMessageRef.current = null;
|
||||||
setData([]);
|
setData([]);
|
||||||
fetchPage(initialCursor);
|
fetchPage(initialCursor);
|
||||||
}, [fetchPage, initialCursor]);
|
}, [fetchPage, initialCursor]);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ type MockApiResult = {
|
|||||||
vi.mock('./useApi');
|
vi.mock('./useApi');
|
||||||
vi.mock('../hooks/useAuth');
|
vi.mock('../hooks/useAuth');
|
||||||
vi.mock('../hooks/useUserData');
|
vi.mock('../hooks/useUserData');
|
||||||
vi.mock('../services/apiClient');
|
|
||||||
|
|
||||||
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
||||||
const mockedUseApi = vi.mocked(useApi);
|
const mockedUseApi = vi.mocked(useApi);
|
||||||
@@ -495,6 +494,22 @@ describe('useShoppingLists Hook', () => {
|
|||||||
expect(currentLists[0].items).toHaveLength(1); // Length should remain 1
|
expect(currentLists[0].items).toHaveLength(1); // Length should remain 1
|
||||||
console.log(' LOG: SUCCESS! Duplicate was not added and API was not called.');
|
console.log(' LOG: SUCCESS! Duplicate was not added and API was not called.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log an error and not call the API if the listId does not exist', async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const { result } = renderHook(() => useShoppingLists());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Call with a non-existent list ID (mock lists have IDs 1 and 2)
|
||||||
|
await result.current.addItemToList(999, { customItemName: 'Wont be added' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API should not have been called because the list was not found.
|
||||||
|
expect(mockAddItemApi).not.toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('useShoppingLists: List with ID 999 not found.');
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateItemInList', () => {
|
describe('updateItemInList', () => {
|
||||||
@@ -656,24 +671,14 @@ describe('useShoppingLists Hook', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'updateItemInList',
|
name: 'updateItemInList',
|
||||||
action: (hook: any) => {
|
action: (hook: any) => hook.updateItemInList(101, { is_purchased: true }),
|
||||||
act(() => {
|
|
||||||
hook.setActiveListId(1);
|
|
||||||
});
|
|
||||||
return hook.updateItemInList(101, { is_purchased: true });
|
|
||||||
},
|
|
||||||
apiMock: mockUpdateItemApi,
|
apiMock: mockUpdateItemApi,
|
||||||
mockIndex: 3,
|
mockIndex: 3,
|
||||||
errorMessage: 'Update failed',
|
errorMessage: 'Update failed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'removeItemFromList',
|
name: 'removeItemFromList',
|
||||||
action: (hook: any) => {
|
action: (hook: any) => hook.removeItemFromList(101),
|
||||||
act(() => {
|
|
||||||
hook.setActiveListId(1);
|
|
||||||
});
|
|
||||||
return hook.removeItemFromList(101);
|
|
||||||
},
|
|
||||||
apiMock: mockRemoveItemApi,
|
apiMock: mockRemoveItemApi,
|
||||||
mockIndex: 4,
|
mockIndex: 4,
|
||||||
errorMessage: 'Removal failed',
|
errorMessage: 'Removal failed',
|
||||||
@@ -681,6 +686,17 @@ describe('useShoppingLists Hook', () => {
|
|||||||
])(
|
])(
|
||||||
'should set an error for $name if the API call fails',
|
'should set an error for $name if the API call fails',
|
||||||
async ({ action, apiMock, mockIndex, errorMessage }) => {
|
async ({ action, apiMock, mockIndex, errorMessage }) => {
|
||||||
|
// Setup a default list so activeListId is set automatically
|
||||||
|
const mockList = createMockShoppingList({ shopping_list_id: 1, name: 'List 1' });
|
||||||
|
mockedUseUserData.mockReturnValue({
|
||||||
|
shoppingLists: [mockList],
|
||||||
|
setShoppingLists: mockSetShoppingLists,
|
||||||
|
watchedItems: [],
|
||||||
|
setWatchedItems: vi.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
const apiMocksWithError = [...defaultApiMocks];
|
const apiMocksWithError = [...defaultApiMocks];
|
||||||
apiMocksWithError[mockIndex] = {
|
apiMocksWithError[mockIndex] = {
|
||||||
...apiMocksWithError[mockIndex],
|
...apiMocksWithError[mockIndex],
|
||||||
@@ -689,11 +705,25 @@ describe('useShoppingLists Hook', () => {
|
|||||||
setupApiMocks(apiMocksWithError);
|
setupApiMocks(apiMocksWithError);
|
||||||
apiMock.mockRejectedValue(new Error(errorMessage));
|
apiMock.mockRejectedValue(new Error(errorMessage));
|
||||||
|
|
||||||
|
// Spy on console.error to ensure the catch block is executed for logging
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
const { result } = renderHook(() => useShoppingLists());
|
const { result } = renderHook(() => useShoppingLists());
|
||||||
|
|
||||||
|
// Wait for the effect to set the active list ID
|
||||||
|
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await action(result.current);
|
await action(result.current);
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(result.current.error).toBe(errorMessage));
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.error).toBe(errorMessage);
|
||||||
|
// Verify that our custom logging within the catch block was called
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
51
src/hooks/useUserProfileData.ts
Normal file
51
src/hooks/useUserProfileData.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// src/hooks/useUserProfileData.ts
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
|
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
|
export const useUserProfileData = () => {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [profileRes, achievementsRes] = await Promise.all([
|
||||||
|
apiClient.getAuthenticatedUserProfile(),
|
||||||
|
apiClient.getUserAchievements(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
||||||
|
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
||||||
|
|
||||||
|
const profileData: UserProfile | null = await profileRes.json();
|
||||||
|
const achievementsData: (UserAchievement & Achievement)[] | null =
|
||||||
|
await achievementsRes.json();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ profileData, achievementsCount: achievementsData?.length },
|
||||||
|
'useUserProfileData: Fetched data',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData);
|
||||||
|
}
|
||||||
|
setAchievements(achievementsData || []);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
|
setError(errorMessage);
|
||||||
|
logger.error({ err }, 'Error in useUserProfileData:');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { profile, setProfile, achievements, isLoading, error };
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
vi.mock('./useApi');
|
vi.mock('./useApi');
|
||||||
vi.mock('../hooks/useAuth');
|
vi.mock('../hooks/useAuth');
|
||||||
vi.mock('../hooks/useUserData');
|
vi.mock('../hooks/useUserData');
|
||||||
vi.mock('../services/apiClient');
|
|
||||||
|
|
||||||
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
||||||
const mockedUseApi = vi.mocked(useApi);
|
const mockedUseApi = vi.mocked(useApi);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// src/middleware/errorHandler.test.ts
|
// src/middleware/errorHandler.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
||||||
|
import { DatabaseError } from '../services/processingErrors';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -69,7 +69,7 @@ app.get('/unique-error', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/db-error-500', (req, res, next) => {
|
app.get('/db-error-500', (req, res, next) => {
|
||||||
next(new DatabaseError('A database connection issue occurred.', 500));
|
next(new DatabaseError('A database connection issue occurred.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
||||||
@@ -98,12 +98,15 @@ describe('errorHandler Middleware', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
consoleErrorSpy.mockClear(); // Clear spy for console.error
|
consoleErrorSpy.mockClear(); // Clear spy for console.error
|
||||||
// Ensure NODE_ENV is set to 'test' for console.error logging
|
// Ensure NODE_ENV is set to 'test' for console.error logging
|
||||||
process.env.NODE_ENV = 'test';
|
vi.stubEnv('NODE_ENV', 'test');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs(); // Clean up environment variable stubs after each test
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
consoleErrorSpy.mockRestore(); // Restore console.error after all tests
|
consoleErrorSpy.mockRestore(); // Restore console.error after all tests
|
||||||
delete process.env.NODE_ENV; // Clean up environment variable
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -113,13 +116,14 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect(response.body.message).toBe('A generic server error occurred.');
|
expect(response.body.message).toBe('A generic server error occurred.');
|
||||||
expect(response.body.stack).toBeDefined();
|
expect(response.body.stack).toBeDefined();
|
||||||
expect(response.body.errorId).toEqual(expect.any(String));
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
|
console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
errorId: expect.any(String),
|
errorId: expect.any(String),
|
||||||
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
|
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
@@ -226,7 +230,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
errorId: expect.any(String),
|
errorId: expect.any(String),
|
||||||
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
|
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
@@ -292,11 +296,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
|
|
||||||
describe('when NODE_ENV is "production"', () => {
|
describe('when NODE_ENV is "production"', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.NODE_ENV = 'production';
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
process.env.NODE_ENV = 'test'; // Reset for other test files
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a generic message with an error ID for a 500 error', async () => {
|
it('should return a generic message with an error ID for a 500 error', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
// src/middleware/multer.middleware.test.ts
|
// src/middleware/multer.middleware.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
|
import multer from 'multer';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { createUploadMiddleware, handleMulterError } from './multer.middleware';
|
||||||
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { ValidationError } from '../services/db/errors.db';
|
||||||
|
|
||||||
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
|
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -26,13 +31,41 @@ vi.mock('../services/logger.server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 4. Mock multer to prevent it from doing anything during import.
|
// 4. Mock multer to prevent it from doing anything during import.
|
||||||
vi.mock('multer', () => ({
|
vi.mock('multer', () => {
|
||||||
default: vi.fn(() => ({
|
const diskStorage = vi.fn((options) => options);
|
||||||
single: vi.fn(),
|
// A more realistic mock for MulterError that maps error codes to messages,
|
||||||
array: vi.fn(),
|
// similar to how the actual multer library works.
|
||||||
})),
|
class MulterError extends Error {
|
||||||
diskStorage: vi.fn(),
|
code: string;
|
||||||
}));
|
field?: string;
|
||||||
|
|
||||||
|
constructor(code: string, field?: string) {
|
||||||
|
const messages: { [key: string]: string } = {
|
||||||
|
LIMIT_FILE_SIZE: 'File too large',
|
||||||
|
LIMIT_UNEXPECTED_FILE: 'Unexpected file',
|
||||||
|
// Add other codes as needed for tests
|
||||||
|
};
|
||||||
|
const message = messages[code] || code;
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.name = 'MulterError';
|
||||||
|
if (field) {
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const multer = vi.fn(() => ({
|
||||||
|
single: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||||
|
array: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||||
|
}));
|
||||||
|
(multer as any).diskStorage = diskStorage;
|
||||||
|
(multer as any).MulterError = MulterError;
|
||||||
|
return {
|
||||||
|
default: multer,
|
||||||
|
diskStorage,
|
||||||
|
MulterError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('Multer Middleware Directory Creation', () => {
|
describe('Multer Middleware Directory Creation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -71,4 +104,165 @@ describe('Multer Middleware Directory Creation', () => {
|
|||||||
'Failed to create multer storage directories on startup.',
|
'Failed to create multer storage directories on startup.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createUploadMiddleware', () => {
|
||||||
|
const mockFile = { originalname: 'test.png' } as Express.Multer.File;
|
||||||
|
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@user.com' } });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Avatar Storage', () => {
|
||||||
|
it('should generate a unique filename for an authenticated user', () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
|
createUploadMiddleware({ storageType: 'avatar' });
|
||||||
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockReq = { user: mockUser } as unknown as Request;
|
||||||
|
|
||||||
|
storageOptions.filename!(mockReq, mockFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('user-123-'));
|
||||||
|
expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the callback with an error for an unauthenticated user', () => {
|
||||||
|
// This test covers line 37
|
||||||
|
createUploadMiddleware({ storageType: 'avatar' });
|
||||||
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockReq = {} as Request; // No user on request
|
||||||
|
|
||||||
|
storageOptions.filename!(mockReq, mockFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(
|
||||||
|
new Error('User not authenticated for avatar upload'),
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use a predictable filename in test environment', () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'test');
|
||||||
|
createUploadMiddleware({ storageType: 'avatar' });
|
||||||
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockReq = { user: mockUser } as unknown as Request;
|
||||||
|
|
||||||
|
storageOptions.filename!(mockReq, mockFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(null, 'test-avatar.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Flyer Storage', () => {
|
||||||
|
it('should generate a unique, sanitized filename in production environment', () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
|
const mockFlyerFile = {
|
||||||
|
fieldname: 'flyerFile',
|
||||||
|
originalname: 'My Flyer (Special!).pdf',
|
||||||
|
} as Express.Multer.File;
|
||||||
|
createUploadMiddleware({ storageType: 'flyer' });
|
||||||
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockReq = {} as Request;
|
||||||
|
|
||||||
|
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(
|
||||||
|
null,
|
||||||
|
expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special\.pdf$/i),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a predictable filename in test environment', () => {
|
||||||
|
// This test covers lines 43-46
|
||||||
|
vi.stubEnv('NODE_ENV', 'test');
|
||||||
|
const mockFlyerFile = {
|
||||||
|
fieldname: 'flyerFile',
|
||||||
|
originalname: 'test-flyer.jpg',
|
||||||
|
} as Express.Multer.File;
|
||||||
|
createUploadMiddleware({ storageType: 'flyer' });
|
||||||
|
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockReq = {} as Request;
|
||||||
|
|
||||||
|
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(null, 'flyerFile-test-flyer-image.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image File Filter', () => {
|
||||||
|
it('should accept files with an image mimetype', () => {
|
||||||
|
createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
|
||||||
|
const multerOptions = vi.mocked(multer).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockImageFile = { mimetype: 'image/png' } as Express.Multer.File;
|
||||||
|
|
||||||
|
multerOptions!.fileFilter!({} as Request, mockImageFile, cb);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith(null, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject files without an image mimetype', () => {
|
||||||
|
createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
|
||||||
|
const multerOptions = vi.mocked(multer).mock.calls[0][0];
|
||||||
|
const cb = vi.fn();
|
||||||
|
const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
|
||||||
|
|
||||||
|
multerOptions!.fileFilter!({} as Request, { ...mockTextFile, fieldname: 'test' }, cb);
|
||||||
|
|
||||||
|
const error = (cb as Mock).mock.calls[0][0];
|
||||||
|
expect(error).toBeInstanceOf(ValidationError);
|
||||||
|
expect(error.validationErrors[0].message).toBe('Only image files are allowed!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleMulterError Middleware', () => {
|
||||||
|
let mockRequest: Partial<Request>;
|
||||||
|
let mockResponse: Partial<Response>;
|
||||||
|
let mockNext: NextFunction;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRequest = {};
|
||||||
|
mockResponse = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
};
|
||||||
|
mockNext = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a MulterError (e.g., file too large)', () => {
|
||||||
|
const err = new multer.MulterError('LIMIT_FILE_SIZE');
|
||||||
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||||
|
message: 'File upload error: File too large',
|
||||||
|
});
|
||||||
|
expect(mockNext).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass on a ValidationError to the next handler', () => {
|
||||||
|
const err = new ValidationError([], 'Only image files are allowed!');
|
||||||
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
||||||
|
// It should now pass the error to the global error handler
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(err);
|
||||||
|
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||||
|
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass on non-multer errors to the next error handler', () => {
|
||||||
|
const err = new Error('A generic error');
|
||||||
|
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(err);
|
||||||
|
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { sanitizeFilename } from '../utils/stringUtils';
|
import { sanitizeFilename } from '../utils/stringUtils';
|
||||||
|
import { ValidationError } from '../services/db/errors.db';
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
|
|
||||||
export const flyerStoragePath =
|
export const flyerStoragePath =
|
||||||
@@ -69,8 +70,9 @@ const imageFileFilter = (req: Request, file: Express.Multer.File, cb: multer.Fil
|
|||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
// Reject the file with a specific error that can be caught by a middleware.
|
// Reject the file with a specific error that can be caught by a middleware.
|
||||||
const err = new Error('Only image files are allowed!');
|
const validationIssue = { path: ['file', file.fieldname], message: 'Only image files are allowed!' };
|
||||||
cb(err);
|
const err = new ValidationError([validationIssue], 'Only image files are allowed!');
|
||||||
|
cb(err as Error); // Cast to Error to satisfy multer's type, though ValidationError extends Error.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,9 +116,6 @@ export const handleMulterError = (
|
|||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
// A Multer error occurred when uploading (e.g., file too large).
|
// A Multer error occurred when uploading (e.g., file too large).
|
||||||
return res.status(400).json({ message: `File upload error: ${err.message}` });
|
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.
|
// If it's not a multer error, pass it on.
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('HomePage Component', () => {
|
|||||||
describe('when a flyer is selected', () => {
|
describe('when a flyer is selected', () => {
|
||||||
const mockFlyer: Flyer = createMockFlyer({
|
const mockFlyer: Flyer = createMockFlyer({
|
||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
image_url: 'http://example.com/flyer.jpg',
|
image_url: 'https://example.com/flyer.jpg',
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render FlyerDisplay but not data tables if there are no flyer items', () => {
|
it('should render FlyerDisplay but not data tables if there are no flyer items', () => {
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
// src/components/MyDealsPage.test.tsx
|
// src/pages/MyDealsPage.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||||
import MyDealsPage from './MyDealsPage';
|
import MyDealsPage from './MyDealsPage';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { WatchedItemDeal } from '../types';
|
import type { WatchedItemDeal } from '../types';
|
||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
// Mock the apiClient. The component now directly uses `fetchBestSalePrices`.
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
// By mocking the entire module, we can control the behavior of `fetchBestSalePrices`
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
// for our tests.
|
|
||||||
vi.mock('../services/apiClient');
|
|
||||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock lucide-react icons to prevent rendering errors in the test environment
|
// Mock lucide-react icons to prevent rendering errors in the test environment
|
||||||
vi.mock('lucide-react', () => ({
|
vi.mock('lucide-react', () => ({
|
||||||
|
|||||||
@@ -10,13 +10,7 @@ import { logger } from '../services/logger.client';
|
|||||||
// The apiClient and logger are now mocked globally.
|
// The apiClient and logger are now mocked globally.
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
vi.mock('../services/logger.client', () => ({
|
// The logger is mocked globally.
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Helper function to render the component within a router context
|
// Helper function to render the component within a router context
|
||||||
const renderWithRouter = (token: string) => {
|
const renderWithRouter = (token: string) => {
|
||||||
return render(
|
return render(
|
||||||
@@ -115,6 +109,33 @@ describe('ResetPasswordPage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show an error message if API returns a non-JSON error response', async () => {
|
||||||
|
// Simulate a server error returning HTML instead of JSON
|
||||||
|
mockedApiClient.resetPassword.mockResolvedValue(
|
||||||
|
new Response('<h1>Server Error</h1>', {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithRouter('test-token');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('New Password'), {
|
||||||
|
target: { value: 'newSecurePassword123' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), {
|
||||||
|
target: { value: 'newSecurePassword123' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// The error from response.json() is implementation-dependent.
|
||||||
|
// We check for a substring that is likely to be present.
|
||||||
|
expect(screen.getByText(/not valid JSON/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError) }, 'Failed to reset password.');
|
||||||
|
});
|
||||||
|
|
||||||
it('should show a loading spinner while submitting', async () => {
|
it('should show a loading spinner while submitting', async () => {
|
||||||
let resolvePromise: (value: Response) => void;
|
let resolvePromise: (value: Response) => void;
|
||||||
const mockPromise = new Promise<Response>((resolve) => {
|
const mockPromise = new Promise<Response>((resolve) => {
|
||||||
|
|||||||
@@ -11,16 +11,8 @@ import {
|
|||||||
createMockUser,
|
createMockUser,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
// Mock dependencies
|
// The apiClient, logger, notificationService, and aiApiClient are all mocked globally.
|
||||||
vi.mock('../services/apiClient'); // This was correct
|
// We can get a typed reference to the notificationService for individual test overrides.
|
||||||
vi.mock('../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock('../services/notificationService');
|
|
||||||
vi.mock('../services/aiApiClient'); // Mock aiApiClient as it's used in the component
|
|
||||||
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
|
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
|
||||||
vi.mock('../components/AchievementsList', () => ({
|
vi.mock('../components/AchievementsList', () => ({
|
||||||
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
|
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
|
||||||
@@ -28,13 +20,13 @@ vi.mock('../components/AchievementsList', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// --- Mock Data ---
|
// --- Mock Data ---
|
||||||
const mockProfile: UserProfile = createMockUserProfile({
|
const mockProfile: UserProfile = createMockUserProfile({
|
||||||
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
|
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: 'http://example.com/avatar.jpg',
|
avatar_url: 'https://example.com/avatar.jpg',
|
||||||
points: 150,
|
points: 150,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
@@ -131,6 +123,24 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle null achievements data gracefully on fetch', async () => {
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
// Mock a successful response but with a null body for achievements
|
||||||
|
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
|
||||||
|
// The mock achievements list should show 0 achievements because the component
|
||||||
|
// should handle the null response and pass an empty array to the list.
|
||||||
|
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
|
||||||
|
'Achievements Count: 0',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the profile and achievements on successful fetch', async () => {
|
it('should render the profile and achievements on successful fetch', async () => {
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
new Response(JSON.stringify(mockProfile)),
|
new Response(JSON.stringify(mockProfile)),
|
||||||
@@ -302,6 +312,24 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle non-ok response with null body when saving name', async () => {
|
||||||
|
// This tests the case where the server returns an error status but an empty/null body.
|
||||||
|
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
await screen.findByText('Test User');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// The component should fall back to the default error message.
|
||||||
|
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||||
|
'Failed to update name.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown errors when saving name', async () => {
|
it('should handle unknown errors when saving name', async () => {
|
||||||
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
|
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
|
||||||
render(<UserProfilePage />);
|
render(<UserProfilePage />);
|
||||||
@@ -331,7 +359,7 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should upload a new avatar and update the image source', async () => {
|
it('should upload a new avatar and update the image source', async () => {
|
||||||
const updatedProfile = { ...mockProfile, avatar_url: 'http://example.com/new-avatar.png' };
|
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
|
||||||
|
|
||||||
// Log when the mock is called
|
// Log when the mock is called
|
||||||
mockedApiClient.uploadAvatar.mockImplementation((file) => {
|
mockedApiClient.uploadAvatar.mockImplementation((file) => {
|
||||||
@@ -428,6 +456,22 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle non-ok response with null body when uploading avatar', async () => {
|
||||||
|
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
await screen.findByAltText('User Avatar');
|
||||||
|
|
||||||
|
const fileInput = screen.getByTestId('avatar-file-input');
|
||||||
|
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||||
|
'Failed to upload avatar.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown errors when uploading avatar', async () => {
|
it('should handle unknown errors when uploading avatar', async () => {
|
||||||
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
|
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
|
||||||
render(<UserProfilePage />);
|
render(<UserProfilePage />);
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
import { notifySuccess, notifyError } from '../services/notificationService';
|
import { notifySuccess, notifyError } from '../services/notificationService';
|
||||||
import { AchievementsList } from '../components/AchievementsList';
|
import { AchievementsList } from '../components/AchievementsList';
|
||||||
|
import { useUserProfileData } from '../hooks/useUserProfileData';
|
||||||
|
|
||||||
const UserProfilePage: React.FC = () => {
|
const UserProfilePage: React.FC = () => {
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const { profile, setProfile, achievements, isLoading, error } = useUserProfileData();
|
||||||
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@@ -17,43 +15,10 @@ const UserProfilePage: React.FC = () => {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (profile) {
|
||||||
setIsLoading(true);
|
setEditingName(profile.full_name || '');
|
||||||
try {
|
}
|
||||||
// Fetch profile and achievements data in parallel
|
}, [profile]);
|
||||||
const [profileRes, achievementsRes] = await Promise.all([
|
|
||||||
apiClient.getAuthenticatedUserProfile(),
|
|
||||||
apiClient.getUserAchievements(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
|
||||||
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
|
||||||
|
|
||||||
const profileData: UserProfile = await profileRes.json();
|
|
||||||
const achievementsData: (UserAchievement & Achievement)[] = await achievementsRes.json();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ profileData, achievementsCount: achievementsData?.length },
|
|
||||||
'UserProfilePage: Fetched data',
|
|
||||||
);
|
|
||||||
|
|
||||||
setProfile(profileData);
|
|
||||||
|
|
||||||
if (profileData) {
|
|
||||||
setEditingName(profileData.full_name || '');
|
|
||||||
}
|
|
||||||
setAchievements(achievementsData);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
|
||||||
setError(errorMessage);
|
|
||||||
logger.error({ err }, 'Error fetching user profile data:');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []); // Empty dependency array means this runs once on component mount
|
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
@@ -61,8 +26,8 @@ const UserProfilePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.updateUserProfile({ full_name: editingName });
|
const response = await apiClient.updateUserProfile({ full_name: editingName });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||||
throw new Error(errorData.message || 'Failed to update name.');
|
throw new Error(errorData?.message || 'Failed to update name.');
|
||||||
}
|
}
|
||||||
const updatedProfile = await response.json();
|
const updatedProfile = await response.json();
|
||||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||||
@@ -88,8 +53,8 @@ const UserProfilePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.uploadAvatar(file);
|
const response = await apiClient.uploadAvatar(file);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||||
throw new Error(errorData.message || 'Failed to upload avatar.');
|
throw new Error(errorData?.message || 'Failed to upload avatar.');
|
||||||
}
|
}
|
||||||
const updatedProfile = await response.json();
|
const updatedProfile = await response.json();
|
||||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||||
|
|||||||
@@ -10,21 +10,10 @@ import { logger } from '../services/logger.client';
|
|||||||
// Extensive logging for debugging
|
// Extensive logging for debugging
|
||||||
const LOG_PREFIX = '[TEST DEBUG]';
|
const LOG_PREFIX = '[TEST DEBUG]';
|
||||||
|
|
||||||
vi.mock('../services/notificationService');
|
// The aiApiClient, notificationService, and logger are mocked globally.
|
||||||
|
// We can get a typed reference to the aiApiClient for individual test overrides.
|
||||||
// 1. Mock the module to replace its exports with mock functions.
|
|
||||||
vi.mock('../services/aiApiClient');
|
|
||||||
// 2. Get a typed reference to the mocked module to control its functions in tests.
|
|
||||||
const mockedAiApiClient = vi.mocked(aiApiClient);
|
const mockedAiApiClient = vi.mocked(aiApiClient);
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Define mock at module level so it can be referenced in the implementation
|
// Define mock at module level so it can be referenced in the implementation
|
||||||
const mockAudioPlay = vi.fn(() => {
|
const mockAudioPlay = vi.fn(() => {
|
||||||
console.log(`${LOG_PREFIX} mockAudioPlay executed`);
|
console.log(`${LOG_PREFIX} mockAudioPlay executed`);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const mockLogs: ActivityLogItem[] = [
|
|||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
display_text: 'Processed a new flyer for Walmart.',
|
display_text: 'Processed a new flyer for Walmart.',
|
||||||
user_avatar_url: 'http://example.com/avatar.png',
|
user_avatar_url: 'https://example.com/avatar.png',
|
||||||
user_full_name: 'Test User',
|
user_full_name: 'Test User',
|
||||||
details: { flyer_id: 1, store_name: 'Walmart' },
|
details: { flyer_id: 1, store_name: 'Walmart' },
|
||||||
}),
|
}),
|
||||||
@@ -63,7 +63,7 @@ const mockLogs: ActivityLogItem[] = [
|
|||||||
action: 'recipe_favorited',
|
action: 'recipe_favorited',
|
||||||
display_text: 'User favorited a recipe',
|
display_text: 'User favorited a recipe',
|
||||||
user_full_name: 'Pizza Lover',
|
user_full_name: 'Pizza Lover',
|
||||||
user_avatar_url: 'http://example.com/pizza.png',
|
user_avatar_url: 'https://example.com/pizza.png',
|
||||||
details: { recipe_name: 'Best Pizza' },
|
details: { recipe_name: 'Best Pizza' },
|
||||||
}),
|
}),
|
||||||
createMockActivityLogItem({
|
createMockActivityLogItem({
|
||||||
@@ -136,7 +136,7 @@ describe('ActivityLog', () => {
|
|||||||
// Check for avatar
|
// Check for avatar
|
||||||
const avatar = screen.getByAltText('Test User');
|
const avatar = screen.getByAltText('Test User');
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(avatar).toBeInTheDocument();
|
||||||
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png');
|
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
|
||||||
|
|
||||||
// Check for fallback avatar (Newbie User has no avatar)
|
// Check for fallback avatar (Newbie User has no avatar)
|
||||||
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
|
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SystemCheck } from './components/SystemCheck';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ShieldExclamationIcon } from '../../components/icons/ShieldExclamationIcon';
|
import { ShieldExclamationIcon } from '../../components/icons/ShieldExclamationIcon';
|
||||||
import { ChartBarIcon } from '../../components/icons/ChartBarIcon';
|
import { ChartBarIcon } from '../../components/icons/ChartBarIcon';
|
||||||
|
import { DocumentMagnifyingGlassIcon } from '../../components/icons/DocumentMagnifyingGlassIcon';
|
||||||
|
|
||||||
export const AdminPage: React.FC = () => {
|
export const AdminPage: React.FC = () => {
|
||||||
// The onReady prop for SystemCheck is present to allow for future UI changes,
|
// The onReady prop for SystemCheck is present to allow for future UI changes,
|
||||||
@@ -39,6 +40,13 @@ export const AdminPage: React.FC = () => {
|
|||||||
<ChartBarIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
<ChartBarIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
||||||
<span className="font-semibold">View Statistics</span>
|
<span className="font-semibold">View Statistics</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/flyer-review"
|
||||||
|
className="flex items-center p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<DocumentMagnifyingGlassIcon className="w-6 h-6 mr-3 text-brand-primary" />
|
||||||
|
<span className="font-semibold">Flyer Review Queue</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SystemCheck />
|
<SystemCheck />
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { AdminStatsPage } from './AdminStatsPage';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import type { AppStats } from '../../services/apiClient';
|
import type { AppStats } from '../../services/apiClient';
|
||||||
import { createMockAppStats } from '../../tests/utils/mockFactories';
|
import { createMockAppStats } from '../../tests/utils/mockFactories';
|
||||||
import { StatCard } from './components/StatCard';
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
|
||||||
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// Mock the child StatCard component to use the shared mock and allow spying
|
// Mock the child StatCard component to use the shared mock and allow spying
|
||||||
vi.mock('./components/StatCard', async () => {
|
vi.mock('../../components/StatCard', async () => {
|
||||||
const { MockStatCard } = await import('../../tests/utils/componentMocks');
|
const { MockStatCard } = await import('../../tests/utils/componentMocks');
|
||||||
return { StatCard: vi.fn(MockStatCard) };
|
return { StatCard: vi.fn(MockStatCard) };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DocumentDuplicateIcon } from '../../components/icons/DocumentDuplicateI
|
|||||||
import { BuildingStorefrontIcon } from '../../components/icons/BuildingStorefrontIcon';
|
import { BuildingStorefrontIcon } from '../../components/icons/BuildingStorefrontIcon';
|
||||||
import { BellAlertIcon } from '../../components/icons/BellAlertIcon';
|
import { BellAlertIcon } from '../../components/icons/BellAlertIcon';
|
||||||
import { BookOpenIcon } from '../../components/icons/BookOpenIcon';
|
import { BookOpenIcon } from '../../components/icons/BookOpenIcon';
|
||||||
import { StatCard } from './components/StatCard';
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
|
||||||
export const AdminStatsPage: React.FC = () => {
|
export const AdminStatsPage: React.FC = () => {
|
||||||
const [stats, setStats] = useState<AppStats | null>(null);
|
const [stats, setStats] = useState<AppStats | null>(null);
|
||||||
|
|||||||
172
src/pages/admin/FlyerReviewPage.test.tsx
Normal file
172
src/pages/admin/FlyerReviewPage.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// src/pages/admin/FlyerReviewPage.test.tsx
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { FlyerReviewPage } from './FlyerReviewPage';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import * as apiClient from '../../services/apiClient';
|
||||||
|
import { logger } from '../../services/logger.client';
|
||||||
|
|
||||||
|
// The apiClient and logger are mocked globally.
|
||||||
|
// We can get a typed reference to the apiClient for individual test overrides.
|
||||||
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
|
// Mock LoadingSpinner to simplify DOM and avoid potential issues
|
||||||
|
vi.mock('../../components/LoadingSpinner', () => ({
|
||||||
|
LoadingSpinner: () => <div data-testid="loading-spinner">Loading...</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FlyerReviewPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading spinner initially', () => {
|
||||||
|
// Mock a promise that doesn't resolve immediately to check loading state
|
||||||
|
mockedApiClient.getFlyersForReview.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('status', { name: /loading flyers for review/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no flyers are returned', async () => {
|
||||||
|
mockedApiClient.getFlyersForReview.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [],
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/the review queue is empty/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a list of flyers when API returns data', async () => {
|
||||||
|
const mockFlyers = [
|
||||||
|
{
|
||||||
|
flyer_id: 1,
|
||||||
|
file_name: 'flyer1.jpg',
|
||||||
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
|
store: { name: 'Store A' },
|
||||||
|
icon_url: 'https://example.com/icon1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flyer_id: 2,
|
||||||
|
file_name: 'flyer2.jpg',
|
||||||
|
created_at: '2023-01-02T00:00:00Z',
|
||||||
|
store: { name: 'Store B' },
|
||||||
|
icon_url: 'https://example.com/icon2.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flyer_id: 3,
|
||||||
|
file_name: 'flyer3.jpg',
|
||||||
|
created_at: '2023-01-03T00:00:00Z',
|
||||||
|
store: null,
|
||||||
|
icon_url: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockedApiClient.getFlyersForReview.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockFlyers,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Store A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('flyer1.jpg')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Store B')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('flyer2.jpg')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Test fallback for null store and icon_url
|
||||||
|
expect(screen.getByText('Unknown Store')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('flyer3.jpg')).toBeInTheDocument();
|
||||||
|
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
||||||
|
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
||||||
|
expect(unknownStoreImage).not.toHaveAttribute('src');
|
||||||
|
expect(unknownStoreImage).toHaveAttribute('alt', 'Unknown Store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error message when API response is not ok', async () => {
|
||||||
|
mockedApiClient.getFlyersForReview.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Server error' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
'Failed to fetch flyers for review'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error message when API throws an error', async () => {
|
||||||
|
const networkError = new Error('Network error');
|
||||||
|
mockedApiClient.getFlyersForReview.mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ err: networkError },
|
||||||
|
'Failed to fetch flyers for review'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a generic error for non-Error rejections', async () => {
|
||||||
|
const nonErrorRejection = { message: 'This is not an Error object' };
|
||||||
|
mockedApiClient.getFlyersForReview.mockRejectedValue(nonErrorRejection);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FlyerReviewPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('An unknown error occurred while fetching data.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ err: nonErrorRejection },
|
||||||
|
'Failed to fetch flyers for review',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/pages/admin/FlyerReviewPage.tsx
Normal file
93
src/pages/admin/FlyerReviewPage.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// src/pages/admin/FlyerReviewPage.tsx
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getFlyersForReview } from '../../services/apiClient';
|
||||||
|
import { logger } from '../../services/logger.client';
|
||||||
|
import type { Flyer } from '../../types';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const FlyerReviewPage: React.FC = () => {
|
||||||
|
const [flyers, setFlyers] = useState<Flyer[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFlyers = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await getFlyersForReview();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((await response.json()).message || 'Failed to fetch flyers for review.');
|
||||||
|
}
|
||||||
|
setFlyers(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'An unknown error occurred while fetching data.';
|
||||||
|
logger.error({ err }, 'Failed to fetch flyers for review');
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFlyers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link to="/admin" className="text-brand-primary hover:underline">
|
||||||
|
← Back to Admin Dashboard
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mt-2">
|
||||||
|
Flyer Review Queue
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Review flyers that were processed with low confidence by the AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading flyers for review"
|
||||||
|
className="flex justify-center items-center h-64"
|
||||||
|
>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 bg-red-100 dark:bg-red-900/20 p-4 rounded-lg">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{flyers.length === 0 ? (
|
||||||
|
<li className="p-6 text-center text-gray-500">
|
||||||
|
The review queue is empty. Great job!
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
flyers.map((flyer) => (
|
||||||
|
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
||||||
|
<img src={flyer.icon_url || undefined} alt={flyer.store?.name || 'Unknown Store'} className="w-12 h-12 rounded-md object-cover" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<p>Uploaded: {format(new Date(flyer.created_at), 'MMM d, yyyy')}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// src/pages/admin/components/AddressForm.test.tsx
|
// src/pages/admin/components/AddressForm.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AddressForm } from './AddressForm';
|
import { AddressForm } from './AddressForm';
|
||||||
import { createMockAddress } from '../../../tests/utils/mockFactories';
|
import { createMockAddress } from '../../../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock child components and icons to isolate the form's logic
|
// Mock child components and icons to isolate the form's logic
|
||||||
vi.mock('lucide-react', () => ({
|
vi.mock('lucide-react', () => ({
|
||||||
@@ -30,7 +31,7 @@ describe('AddressForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render all address fields correctly', () => {
|
it('should render all address fields correctly', () => {
|
||||||
render(<AddressForm {...defaultProps} />);
|
renderWithProviders(<AddressForm {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /home address/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /home address/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
|
||||||
@@ -48,7 +49,7 @@ describe('AddressForm', () => {
|
|||||||
city: 'Anytown',
|
city: 'Anytown',
|
||||||
country: 'Canada',
|
country: 'Canada',
|
||||||
});
|
});
|
||||||
render(<AddressForm {...defaultProps} address={fullAddress} />);
|
renderWithProviders(<AddressForm {...defaultProps} address={fullAddress} />);
|
||||||
|
|
||||||
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('123 Main St');
|
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('123 Main St');
|
||||||
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
|
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
|
||||||
@@ -56,7 +57,7 @@ describe('AddressForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onAddressChange with the correct field and value for all inputs', () => {
|
it('should call onAddressChange with the correct field and value for all inputs', () => {
|
||||||
render(<AddressForm {...defaultProps} />);
|
renderWithProviders(<AddressForm {...defaultProps} />);
|
||||||
|
|
||||||
const inputs = [
|
const inputs = [
|
||||||
{ label: /address line 1/i, name: 'address_line_1', value: '123 St' },
|
{ label: /address line 1/i, name: 'address_line_1', value: '123 St' },
|
||||||
@@ -75,7 +76,7 @@ describe('AddressForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onGeocode when the "Re-Geocode" button is clicked', () => {
|
it('should call onGeocode when the "Re-Geocode" button is clicked', () => {
|
||||||
render(<AddressForm {...defaultProps} />);
|
renderWithProviders(<AddressForm {...defaultProps} />);
|
||||||
|
|
||||||
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
|
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
|
||||||
fireEvent.click(geocodeButton);
|
fireEvent.click(geocodeButton);
|
||||||
@@ -84,14 +85,14 @@ describe('AddressForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show MapPinIcon when not geocoding', () => {
|
it('should show MapPinIcon when not geocoding', () => {
|
||||||
render(<AddressForm {...defaultProps} isGeocoding={false} />);
|
renderWithProviders(<AddressForm {...defaultProps} isGeocoding={false} />);
|
||||||
expect(screen.getByTestId('map-pin-icon')).toBeInTheDocument();
|
expect(screen.getByTestId('map-pin-icon')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when isGeocoding is true', () => {
|
describe('when isGeocoding is true', () => {
|
||||||
it('should disable the button and show a loading spinner', () => {
|
it('should disable the button and show a loading spinner', () => {
|
||||||
render(<AddressForm {...defaultProps} isGeocoding={true} />);
|
renderWithProviders(<AddressForm {...defaultProps} isGeocoding={true} />);
|
||||||
|
|
||||||
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
|
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
|
||||||
expect(geocodeButton).toBeDisabled();
|
expect(geocodeButton).toBeDisabled();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/pages/admin/components/AdminBrandManager.test.tsx
|
// src/pages/admin/components/AdminBrandManager.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { AdminBrandManager } from './AdminBrandManager';
|
import { AdminBrandManager } from './AdminBrandManager';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import { createMockBrand } from '../../../tests/utils/mockFactories';
|
import { createMockBrand } from '../../../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// After mocking, we can get a type-safe mocked version of the module.
|
// After mocking, we can get a type-safe mocked version of the module.
|
||||||
// This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions.
|
// This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions.
|
||||||
@@ -18,7 +19,7 @@ const mockBrands = [
|
|||||||
brand_id: 2,
|
brand_id: 2,
|
||||||
name: 'Compliments',
|
name: 'Compliments',
|
||||||
store_name: 'Sobeys',
|
store_name: 'Sobeys',
|
||||||
logo_url: 'http://example.com/compliments.png',
|
logo_url: 'https://example.com/compliments.png',
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {}));
|
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
|
|
||||||
console.log('TEST ASSERTION: Checking for the loading text.');
|
console.log('TEST ASSERTION: Checking for the loading text.');
|
||||||
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
|
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
|
||||||
@@ -49,7 +50,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
|
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
|
||||||
|
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
|
|
||||||
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
|
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -69,7 +70,7 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
|
|
||||||
console.log('TEST ASSERTION: Waiting for brand list to render.');
|
console.log('TEST ASSERTION: Waiting for brand list to render.');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -91,14 +92,14 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
mockedApiClient.uploadBrandLogo.mockImplementation(
|
mockedApiClient.uploadBrandLogo.mockImplementation(
|
||||||
async () =>
|
async () =>
|
||||||
new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), {
|
new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
mockedToast.loading.mockReturnValue('toast-1');
|
mockedToast.loading.mockReturnValue('toast-1');
|
||||||
|
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
console.log('TEST ACTION: Waiting for initial brands to render.');
|
console.log('TEST ACTION: Waiting for initial brands to render.');
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ describe('AdminBrandManager', () => {
|
|||||||
// Check if the UI updates with the new logo
|
// Check if the UI updates with the new logo
|
||||||
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
|
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
'http://example.com/new-logo.png',
|
'https://example.com/new-logo.png',
|
||||||
);
|
);
|
||||||
console.log('TEST SUCCESS: All assertions for successful upload passed.');
|
console.log('TEST SUCCESS: All assertions for successful upload passed.');
|
||||||
});
|
});
|
||||||
@@ -135,7 +136,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error');
|
mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error');
|
||||||
mockedToast.loading.mockReturnValue('toast-non-error');
|
mockedToast.loading.mockReturnValue('toast-non-error');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
||||||
@@ -162,7 +163,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedToast.loading.mockReturnValue('toast-2');
|
mockedToast.loading.mockReturnValue('toast-2');
|
||||||
|
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
console.log('TEST ACTION: Waiting for initial brands to render.');
|
console.log('TEST ACTION: Waiting for initial brands to render.');
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -189,7 +190,7 @@ describe('AdminBrandManager', () => {
|
|||||||
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
||||||
);
|
);
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
console.log('TEST ACTION: Waiting for initial brands to render.');
|
console.log('TEST ACTION: Waiting for initial brands to render.');
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ describe('AdminBrandManager', () => {
|
|||||||
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
||||||
);
|
);
|
||||||
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
console.log('TEST ACTION: Rendering AdminBrandManager component.');
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
console.log('TEST ACTION: Waiting for initial brands to render.');
|
console.log('TEST ACTION: Waiting for initial brands to render.');
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -247,7 +248,7 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
mockedToast.loading.mockReturnValue('toast-3');
|
mockedToast.loading.mockReturnValue('toast-3');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
||||||
@@ -270,7 +271,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedApiClient.fetchAllBrands.mockImplementation(
|
mockedApiClient.fetchAllBrands.mockImplementation(
|
||||||
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
|
||||||
);
|
);
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
console.log('TEST ACTION: Waiting for initial brands to render.');
|
console.log('TEST ACTION: Waiting for initial brands to render.');
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ describe('AdminBrandManager', () => {
|
|||||||
mockedApiClient.fetchAllBrands.mockImplementation(
|
mockedApiClient.fetchAllBrands.mockImplementation(
|
||||||
async () => new Response(JSON.stringify([]), { status: 200 }),
|
async () => new Response(JSON.stringify([]), { status: 200 }),
|
||||||
);
|
);
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
||||||
@@ -309,7 +310,7 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
mockedToast.loading.mockReturnValue('toast-fallback');
|
mockedToast.loading.mockReturnValue('toast-fallback');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
||||||
@@ -333,7 +334,7 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
mockedToast.loading.mockReturnValue('toast-opt');
|
mockedToast.loading.mockReturnValue('toast-opt');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
renderWithProviders(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
// Brand 1: No Frills (initially null logo)
|
// Brand 1: No Frills (initially null logo)
|
||||||
@@ -349,7 +350,7 @@ describe('AdminBrandManager', () => {
|
|||||||
// Brand 2 should still have original logo
|
// Brand 2 should still have original logo
|
||||||
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
|
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
'http://example.com/compliments.png',
|
'https://example.com/compliments.png',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// 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, act } from '@testing-library/react';
|
import { screen, fireEvent, waitFor } 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';
|
||||||
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
||||||
import { createMockUserProfile } from '../../../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../../../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
const mockedApiClient = vi.mocked(apiClient, true);
|
const mockedApiClient = vi.mocked(apiClient, true);
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
describe('Initial Render and Login', () => {
|
describe('Initial Render and Login', () => {
|
||||||
it('should render the Sign In form by default', () => {
|
it('should render the Sign In form by default', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
||||||
@@ -54,7 +55,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow typing in email and password fields', () => {
|
it('should allow typing in email and password fields', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
const emailInput = screen.getByLabelText(/email address/i);
|
const emailInput = screen.getByLabelText(/email address/i);
|
||||||
const passwordInput = screen.getByLabelText(/^password$/i);
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call loginUser and onLoginSuccess on successful login', async () => {
|
it('should call loginUser and onLoginSuccess on successful login', async () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.change(screen.getByLabelText(/email address/i), {
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
||||||
target: { value: 'test@example.com' },
|
target: { value: 'test@example.com' },
|
||||||
});
|
});
|
||||||
@@ -94,7 +95,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
it('should display an error on failed login', async () => {
|
it('should display an error on failed login', async () => {
|
||||||
(mockedApiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
|
(mockedApiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.submit(screen.getByTestId('auth-form'));
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -107,7 +108,7 @@ describe('AuthView', () => {
|
|||||||
(mockedApiClient.loginUser as Mock).mockResolvedValueOnce(
|
(mockedApiClient.loginUser as Mock).mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401 }),
|
new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401 }),
|
||||||
);
|
);
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.submit(screen.getByTestId('auth-form'));
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -120,7 +121,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
describe('Registration', () => {
|
describe('Registration', () => {
|
||||||
it('should switch to the registration form', () => {
|
it('should switch to the registration form', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
|
||||||
@@ -129,7 +130,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call registerUser on successful registration', async () => {
|
it('should call registerUser on successful registration', async () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Test User' } });
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Test User' } });
|
||||||
@@ -157,7 +158,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow registration without providing a full name', async () => {
|
it('should allow registration without providing a full name', async () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
|
|
||||||
// Do not fill in the full name, which is marked as optional
|
// Do not fill in the full name, which is marked as optional
|
||||||
@@ -184,7 +185,7 @@ describe('AuthView', () => {
|
|||||||
(mockedApiClient.registerUser as Mock).mockRejectedValueOnce(
|
(mockedApiClient.registerUser as Mock).mockRejectedValueOnce(
|
||||||
new Error('Email already exists'),
|
new Error('Email already exists'),
|
||||||
);
|
);
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
fireEvent.submit(screen.getByTestId('auth-form'));
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ describe('AuthView', () => {
|
|||||||
(mockedApiClient.registerUser as Mock).mockResolvedValueOnce(
|
(mockedApiClient.registerUser as Mock).mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'User exists' }), { status: 409 }),
|
new Response(JSON.stringify({ message: 'User exists' }), { status: 409 }),
|
||||||
);
|
);
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
fireEvent.submit(screen.getByTestId('auth-form'));
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
describe('Forgot Password', () => {
|
describe('Forgot Password', () => {
|
||||||
it('should switch to the reset password form', () => {
|
it('should switch to the reset password form', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
|
||||||
@@ -217,7 +218,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call requestPasswordReset and show success message', async () => {
|
it('should call requestPasswordReset and show success message', async () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/email address/i), {
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
||||||
@@ -238,7 +239,7 @@ describe('AuthView', () => {
|
|||||||
(mockedApiClient.requestPasswordReset as Mock).mockRejectedValueOnce(
|
(mockedApiClient.requestPasswordReset as Mock).mockRejectedValueOnce(
|
||||||
new Error('User not found'),
|
new Error('User not found'),
|
||||||
);
|
);
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
||||||
|
|
||||||
@@ -251,7 +252,7 @@ describe('AuthView', () => {
|
|||||||
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValueOnce(
|
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Rate limit exceeded' }), { status: 429 }),
|
new Response(JSON.stringify({ message: 'Rate limit exceeded' }), { status: 429 }),
|
||||||
);
|
);
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
||||||
|
|
||||||
@@ -261,7 +262,7 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should switch back to sign in from forgot password', () => {
|
it('should switch back to sign in from forgot password', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /back to sign in/i }));
|
fireEvent.click(screen.getByRole('button', { name: /back to sign in/i }));
|
||||||
|
|
||||||
@@ -287,13 +288,13 @@ describe('AuthView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set window.location.href for Google OAuth', () => {
|
it('should set window.location.href for Google OAuth', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
|
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
|
||||||
expect(window.location.href).toBe('/api/auth/google');
|
expect(window.location.href).toBe('/api/auth/google');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set window.location.href for GitHub OAuth', () => {
|
it('should set window.location.href for GitHub OAuth', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /sign in with github/i }));
|
fireEvent.click(screen.getByRole('button', { name: /sign in with github/i }));
|
||||||
expect(window.location.href).toBe('/api/auth/github');
|
expect(window.location.href).toBe('/api/auth/github');
|
||||||
});
|
});
|
||||||
@@ -301,7 +302,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
describe('UI Logic and Loading States', () => {
|
describe('UI Logic and Loading States', () => {
|
||||||
it('should toggle "Remember me" checkbox', () => {
|
it('should toggle "Remember me" checkbox', () => {
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
const rememberMeCheckbox = screen.getByRole('checkbox', { name: /remember me/i });
|
const rememberMeCheckbox = screen.getByRole('checkbox', { name: /remember me/i });
|
||||||
|
|
||||||
expect(rememberMeCheckbox).not.toBeChecked();
|
expect(rememberMeCheckbox).not.toBeChecked();
|
||||||
@@ -316,7 +317,7 @@ describe('AuthView', () => {
|
|||||||
it('should show loading state during login submission', async () => {
|
it('should show loading state during login submission', async () => {
|
||||||
// Mock a promise that doesn't resolve immediately
|
// Mock a promise that doesn't resolve immediately
|
||||||
(mockedApiClient.loginUser as Mock).mockReturnValue(new Promise(() => {}));
|
(mockedApiClient.loginUser as Mock).mockReturnValue(new Promise(() => {}));
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/email address/i), {
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
||||||
target: { value: 'test@example.com' },
|
target: { value: 'test@example.com' },
|
||||||
@@ -341,7 +342,7 @@ describe('AuthView', () => {
|
|||||||
|
|
||||||
it('should show loading state during password reset submission', async () => {
|
it('should show loading state during password reset submission', async () => {
|
||||||
(mockedApiClient.requestPasswordReset as Mock).mockReturnValue(new Promise(() => {}));
|
(mockedApiClient.requestPasswordReset as Mock).mockReturnValue(new Promise(() => {}));
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
|
||||||
|
|
||||||
@@ -362,7 +363,7 @@ describe('AuthView', () => {
|
|||||||
it('should show loading state during registration submission', async () => {
|
it('should show loading state during registration submission', async () => {
|
||||||
// Mock a promise that doesn't resolve immediately
|
// Mock a promise that doesn't resolve immediately
|
||||||
(mockedApiClient.registerUser as Mock).mockReturnValue(new Promise(() => {}));
|
(mockedApiClient.registerUser as Mock).mockReturnValue(new Promise(() => {}));
|
||||||
render(<AuthView {...defaultProps} />);
|
renderWithProviders(<AuthView {...defaultProps} />);
|
||||||
|
|
||||||
// Switch to registration view
|
// Switch to registration view
|
||||||
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/pages/admin/components/CorrectionRow.test.tsx
|
// src/pages/admin/components/CorrectionRow.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||||
import { CorrectionRow } from './CorrectionRow';
|
import { CorrectionRow } from './CorrectionRow';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
@@ -10,15 +10,11 @@ import {
|
|||||||
createMockMasterGroceryItem,
|
createMockMasterGroceryItem,
|
||||||
createMockCategory,
|
createMockCategory,
|
||||||
} from '../../../tests/utils/mockFactories';
|
} from '../../../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
|
// The apiClient and logger are mocked globally.
|
||||||
// The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
// We can get a typed reference to the apiClient for individual test overrides.
|
||||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../../../services/logger', () => ({
|
|
||||||
logger: { info: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the ConfirmationModal to test its props and interactions
|
// Mock the ConfirmationModal to test its props and interactions
|
||||||
// The ConfirmationModal is now in a different directory.
|
// The ConfirmationModal is now in a different directory.
|
||||||
@@ -80,7 +76,7 @@ const defaultProps = {
|
|||||||
|
|
||||||
// Helper to render the component inside a table structure
|
// Helper to render the component inside a table structure
|
||||||
const renderInTable = (props = defaultProps) => {
|
const renderInTable = (props = defaultProps) => {
|
||||||
return render(
|
return renderWithProviders(
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<CorrectionRow {...props} />
|
<CorrectionRow {...props} />
|
||||||
|
|||||||
@@ -21,25 +21,10 @@ vi.mock('../../../components/PasswordInput', () => ({
|
|||||||
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
|
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The apiClient, notificationService, react-hot-toast, and logger are all mocked globally.
|
||||||
|
// We can get a typed reference to the apiClient for individual test overrides.
|
||||||
const mockedApiClient = vi.mocked(apiClient, true);
|
const mockedApiClient = vi.mocked(apiClient, true);
|
||||||
|
|
||||||
vi.mock('../../../services/notificationService');
|
|
||||||
vi.mock('react-hot-toast', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock('../../../services/logger.client', () => ({
|
|
||||||
logger: {
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnLoginSuccess = vi.fn();
|
const mockOnLoginSuccess = vi.fn();
|
||||||
const mockOnSignOut = vi.fn();
|
const mockOnSignOut = vi.fn();
|
||||||
@@ -50,7 +35,7 @@ const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'tes
|
|||||||
const mockAddressId = 123;
|
const mockAddressId = 123;
|
||||||
const authenticatedProfile = createMockUserProfile({
|
const authenticatedProfile = createMockUserProfile({
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: 'http://example.com/avatar.png',
|
avatar_url: 'https://example.com/avatar.png',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
points: 100,
|
points: 100,
|
||||||
preferences: {
|
preferences: {
|
||||||
@@ -279,6 +264,7 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error if trying to save profile when not logged in', async () => {
|
it('should show an error if trying to save profile when not logged in', async () => {
|
||||||
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||||
// This is an edge case, but good to test the safeguard
|
// This is an edge case, but good to test the safeguard
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||||
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
||||||
@@ -286,6 +272,7 @@ describe('ProfileManager', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
|
||||||
});
|
});
|
||||||
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -511,6 +498,23 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show an error when trying to link a GitHub account', async () => {
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /link github account/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /link github account/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith(
|
||||||
|
'Account linking with github is not yet implemented.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should switch between all tabs correctly', async () => {
|
it('should switch between all tabs correctly', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
@@ -819,6 +823,63 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow changing unit system when preferences are initially null', async () => {
|
||||||
|
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
||||||
|
const { rerender } = render(
|
||||||
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
|
||||||
|
const imperialRadio = await screen.findByLabelText(/imperial/i);
|
||||||
|
const metricRadio = screen.getByLabelText(/metric/i);
|
||||||
|
|
||||||
|
// With null preferences, neither should be checked.
|
||||||
|
expect(imperialRadio).not.toBeChecked();
|
||||||
|
expect(metricRadio).not.toBeChecked();
|
||||||
|
|
||||||
|
// Mock the API response for the update
|
||||||
|
const updatedProfileWithPrefs = {
|
||||||
|
...profileWithoutPrefs,
|
||||||
|
preferences: { darkMode: false, unitSystem: 'metric' as const },
|
||||||
|
};
|
||||||
|
mockedApiClient.updateUserPreferences.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(updatedProfileWithPrefs),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
fireEvent.click(metricRadio);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
||||||
|
{ unitSystem: 'metric' },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rerender with the new profile to check the UI update
|
||||||
|
rerender(
|
||||||
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
expect(await screen.findByLabelText(/metric/i)).toBeChecked();
|
||||||
|
expect(screen.getByLabelText(/imperial/i)).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onProfileUpdate if updating unit system fails', async () => {
|
||||||
|
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
const metricRadio = await screen.findByLabelText(/metric/i);
|
||||||
|
fireEvent.click(metricRadio);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith('API failed');
|
||||||
|
});
|
||||||
|
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should only call updateProfile when only profile data has changed', async () => {
|
it('should only call updateProfile when only profile data has changed', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -883,6 +944,12 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render auth views when the user is already authenticated', () => {
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Create an Account')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should log warning if address fetch returns null', async () => {
|
it('should log warning if address fetch returns null', async () => {
|
||||||
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
|
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
|
||||||
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||||
@@ -905,5 +972,127 @@ describe('ProfileManager', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle updating the user profile and address with empty strings', async () => {
|
||||||
|
mockedApiClient.updateUserProfile.mockImplementation(async (data) =>
|
||||||
|
new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
|
||||||
|
);
|
||||||
|
mockedApiClient.updateUserAddress.mockImplementation(async (data) =>
|
||||||
|
new Response(JSON.stringify({ ...mockAddress, ...data })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: '' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: '' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save profile/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith(
|
||||||
|
{ full_name: '', avatar_url: authenticatedProfile.avatar_url },
|
||||||
|
expect.objectContaining({ signal: expect.anything() }),
|
||||||
|
);
|
||||||
|
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ city: '' }),
|
||||||
|
expect.objectContaining({ signal: expect.anything() }),
|
||||||
|
);
|
||||||
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ full_name: '' })
|
||||||
|
);
|
||||||
|
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly clear the form when userProfile.address_id is null', async () => {
|
||||||
|
const profileNoAddress = { ...authenticatedProfile, address_id: null };
|
||||||
|
render(
|
||||||
|
<ProfileManager
|
||||||
|
{...defaultAuthenticatedProps}
|
||||||
|
userProfile={profileNoAddress as any} // Forcefully override the type to simulate address_id: null
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('');
|
||||||
|
expect(screen.getByLabelText(/city/i)).toHaveValue('');
|
||||||
|
expect(screen.getByLabelText(/province \/ state/i)).toHaveValue('');
|
||||||
|
expect(screen.getByLabelText(/postal \/ zip code/i)).toHaveValue('');
|
||||||
|
expect(screen.getByLabelText(/country/i)).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error notification when manual geocoding fails', async () => {
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
||||||
|
|
||||||
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Geocoding failed'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith('Geocoding failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error notification when auto-geocoding fails', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// FIX: Mock getUserAddress to return an address *without* coordinates.
|
||||||
|
// This is the condition required to trigger the auto-geocoding logic.
|
||||||
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||||
|
mockedApiClient.getUserAddress.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(addressWithoutCoords)),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
|
// Wait for initial load
|
||||||
|
await act(async () => {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Auto-geocode error'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'ErrorCity' } });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifyError).toHaveBeenCalledWith('Auto-geocode error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle permission denied error during geocoding', async () => {
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
||||||
|
|
||||||
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Permission denied'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith('Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not trigger OAuth link if user profile is missing', async () => {
|
||||||
|
// This is an edge case to test the guard clause in handleOAuthLink
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||||
|
|
||||||
|
const linkButton = await screen.findByRole('button', { name: /link google account/i });
|
||||||
|
fireEvent.click(linkButton);
|
||||||
|
|
||||||
|
// The function should just return, so nothing should happen.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { StatCard } from './StatCard';
|
import { StatCard } from './StatCard';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('StatCard', () => {
|
describe('StatCard', () => {
|
||||||
it('should render the title and value correctly', () => {
|
it('should render the title and value correctly', () => {
|
||||||
render(<StatCard title="Test Stat" value="1,234" icon={<div data-testid="icon" />} />);
|
renderWithProviders(<StatCard title="Test Stat" value="1,234" icon={<div data-testid="icon" />} />);
|
||||||
|
|
||||||
expect(screen.getByText('Test Stat')).toBeInTheDocument();
|
expect(screen.getByText('Test Stat')).toBeInTheDocument();
|
||||||
expect(screen.getByText('1,234')).toBeInTheDocument();
|
expect(screen.getByText('1,234')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the icon', () => {
|
it('should render the icon', () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<StatCard title="Test Stat" value={100} icon={<div data-testid="test-icon">Icon</div>} />,
|
<StatCard title="Test Stat" value={100} icon={<div data-testid="test-icon">Icon</div>} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,18 @@
|
|||||||
// src/pages/admin/components/SystemCheck.test.tsx
|
// src/pages/admin/components/SystemCheck.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor, cleanup, fireEvent, act } from '@testing-library/react';
|
import { screen, waitFor, cleanup, fireEvent, 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 } from 'vitest';
|
||||||
import { SystemCheck } from './SystemCheck';
|
import { SystemCheck } from './SystemCheck';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { createMockUser } from '../../../tests/utils/mockFactories';
|
import { createMockUser } from '../../../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the entire apiClient module to ensure all exports are defined.
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
// This is the primary fix for the error: [vitest] No "..." export is defined on the mock.
|
// We can get a type-safe mocked version of the module to override functions for specific tests.
|
||||||
vi.mock('../../../services/apiClient', () => ({
|
|
||||||
pingBackend: vi.fn(),
|
|
||||||
checkStorage: vi.fn(),
|
|
||||||
checkDbPoolHealth: vi.fn(),
|
|
||||||
checkPm2Status: vi.fn(),
|
|
||||||
checkRedisHealth: vi.fn(),
|
|
||||||
checkDbSchema: vi.fn(),
|
|
||||||
loginUser: vi.fn(),
|
|
||||||
triggerFailingJob: vi.fn(),
|
|
||||||
clearGeocodeCache: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Get a type-safe mocked version of the apiClient module.
|
|
||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
// Correct the relative path to the logger module.
|
// The logger and react-hot-toast are mocked globally.
|
||||||
vi.mock('../../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock toast to check for notifications
|
|
||||||
vi.mock('react-hot-toast', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('SystemCheck', () => {
|
describe('SystemCheck', () => {
|
||||||
// Store original env variable
|
// Store original env variable
|
||||||
@@ -100,7 +71,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should render initial idle state and then run checks automatically on mount', async () => {
|
it('should render initial idle state and then run checks automatically on mount', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
// Initially, all checks should be in 'running' state due to auto-run
|
// Initially, all checks should be in 'running' state due to auto-run
|
||||||
// However, the API key check is synchronous and resolves immediately.
|
// However, the API key check is synchronous and resolves immediately.
|
||||||
@@ -126,7 +97,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show API key as failed if GEMINI_API_KEY is not set', async () => {
|
it('should show API key as failed if GEMINI_API_KEY is not set', async () => {
|
||||||
setGeminiApiKey(undefined);
|
setGeminiApiKey(undefined);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
// Wait for the specific error message to appear.
|
// Wait for the specific error message to appear.
|
||||||
expect(
|
expect(
|
||||||
@@ -139,7 +110,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show backend connection as failed if pingBackend fails', async () => {
|
it('should show backend connection as failed if pingBackend fails', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
(mockedApiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
|
(mockedApiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
@@ -164,7 +135,7 @@ describe('SystemCheck', () => {
|
|||||||
new Response(JSON.stringify({ success: false, message: 'PM2 process not found' })),
|
new Response(JSON.stringify({ success: false, message: 'PM2 process not found' })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
|
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
|
||||||
@@ -174,7 +145,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
|
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
|
||||||
setGeminiApiKey('mock-api-key'); // This was missing
|
setGeminiApiKey('mock-api-key'); // This was missing
|
||||||
mockedApiClient.checkDbPoolHealth.mockRejectedValueOnce(new Error('DB connection refused'));
|
mockedApiClient.checkDbPoolHealth.mockRejectedValueOnce(new Error('DB connection refused'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('DB connection refused')).toBeInTheDocument();
|
expect(screen.getByText('DB connection refused')).toBeInTheDocument();
|
||||||
@@ -184,7 +155,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show Redis check as failed if checkRedisHealth fails', async () => {
|
it('should show Redis check as failed if checkRedisHealth fails', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
mockedApiClient.checkRedisHealth.mockRejectedValueOnce(new Error('Redis connection refused'));
|
mockedApiClient.checkRedisHealth.mockRejectedValueOnce(new Error('Redis connection refused'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Redis connection refused')).toBeInTheDocument();
|
expect(screen.getByText('Redis connection refused')).toBeInTheDocument();
|
||||||
@@ -197,7 +168,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkDbPoolHealth.mockImplementationOnce(() =>
|
mockedApiClient.checkDbPoolHealth.mockImplementationOnce(() =>
|
||||||
Promise.reject(new Error('DB connection refused')),
|
Promise.reject(new Error('DB connection refused')),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Verify the specific "skipped" messages for DB-dependent checks
|
// Verify the specific "skipped" messages for DB-dependent checks
|
||||||
@@ -214,7 +185,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
|
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
|
||||||
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
|
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Schema mismatch')).toBeInTheDocument();
|
expect(screen.getByText('Schema mismatch')).toBeInTheDocument();
|
||||||
@@ -224,7 +195,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show seeded user check as failed if loginUser fails', async () => {
|
it('should show seeded user check as failed if loginUser fails', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Incorrect email or password'));
|
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Incorrect email or password'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -236,7 +207,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show a generic failure message for other login errors', async () => {
|
it('should show a generic failure message for other login errors', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Server is on fire'));
|
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Server is on fire'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Failed: Server is on fire')).toBeInTheDocument();
|
expect(screen.getByText('Failed: Server is on fire')).toBeInTheDocument();
|
||||||
@@ -246,7 +217,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should show storage directory check as failed if checkStorage fails', async () => {
|
it('should show storage directory check as failed if checkStorage fails', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
mockedApiClient.checkStorage.mockRejectedValueOnce(new Error('Storage not writable'));
|
mockedApiClient.checkStorage.mockRejectedValueOnce(new Error('Storage not writable'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Storage not writable')).toBeInTheDocument();
|
expect(screen.getByText('Storage not writable')).toBeInTheDocument();
|
||||||
@@ -262,7 +233,7 @@ describe('SystemCheck', () => {
|
|||||||
});
|
});
|
||||||
mockedApiClient.pingBackend.mockImplementation(() => mockPromise);
|
mockedApiClient.pingBackend.mockImplementation(() => mockPromise);
|
||||||
|
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
// The button text changes to "Running Checks..."
|
// The button text changes to "Running Checks..."
|
||||||
const runningButton = screen.getByRole('button', { name: /running checks/i });
|
const runningButton = screen.getByRole('button', { name: /running checks/i });
|
||||||
@@ -283,7 +254,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should re-run checks when the "Re-run Checks" button is clicked', async () => {
|
it('should re-run checks when the "Re-run Checks" button is clicked', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
// Wait for initial auto-run to complete
|
// Wait for initial auto-run to complete
|
||||||
await waitFor(() => expect(screen.getByText(/finished in/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(/finished in/i)).toBeInTheDocument());
|
||||||
@@ -328,7 +299,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
|
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
|
||||||
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
|
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
|
||||||
);
|
);
|
||||||
const { container } = render(<SystemCheck />);
|
const { container } = renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Instead of test-ids, we check for the result: the icon's color class.
|
// Instead of test-ids, we check for the result: the icon's color class.
|
||||||
@@ -344,7 +315,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should display elapsed time after checks complete', async () => {
|
it('should display elapsed time after checks complete', async () => {
|
||||||
setGeminiApiKey('mock-api-key');
|
setGeminiApiKey('mock-api-key');
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const elapsedTimeText = screen.getByText(/finished in \d+\.\d{2} seconds\./i);
|
const elapsedTimeText = screen.getByText(/finished in \d+\.\d{2} seconds\./i);
|
||||||
@@ -357,7 +328,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
describe('Integration: Job Queue Retries', () => {
|
describe('Integration: Job Queue Retries', () => {
|
||||||
it('should call triggerFailingJob and show a success toast', async () => {
|
it('should call triggerFailingJob and show a success toast', async () => {
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
@@ -374,7 +345,7 @@ describe('SystemCheck', () => {
|
|||||||
});
|
});
|
||||||
mockedApiClient.triggerFailingJob.mockImplementation(() => mockPromise);
|
mockedApiClient.triggerFailingJob.mockImplementation(() => mockPromise);
|
||||||
|
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
@@ -390,7 +361,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show an error toast if triggering the job fails', async () => {
|
it('should show an error toast if triggering the job fails', async () => {
|
||||||
mockedApiClient.triggerFailingJob.mockRejectedValueOnce(new Error('Queue is down'));
|
mockedApiClient.triggerFailingJob.mockRejectedValueOnce(new Error('Queue is down'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
@@ -403,7 +374,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.triggerFailingJob.mockResolvedValueOnce(
|
mockedApiClient.triggerFailingJob.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Server error' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'Server error' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
|
||||||
fireEvent.click(triggerButton);
|
fireEvent.click(triggerButton);
|
||||||
|
|
||||||
@@ -420,7 +391,7 @@ describe('SystemCheck', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call clearGeocodeCache and show a success toast', async () => {
|
it('should call clearGeocodeCache and show a success toast', async () => {
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
// Wait for checks to run and Redis to be OK
|
// Wait for checks to run and Redis to be OK
|
||||||
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -435,7 +406,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show an error toast if clearing the cache fails', async () => {
|
it('should show an error toast if clearing the cache fails', async () => {
|
||||||
mockedApiClient.clearGeocodeCache.mockRejectedValueOnce(new Error('Redis is busy'));
|
mockedApiClient.clearGeocodeCache.mockRejectedValueOnce(new Error('Redis is busy'));
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
|
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
|
||||||
await waitFor(() => expect(vi.mocked(toast).error).toHaveBeenCalledWith('Redis is busy'));
|
await waitFor(() => expect(vi.mocked(toast).error).toHaveBeenCalledWith('Redis is busy'));
|
||||||
@@ -443,7 +414,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should not call clearGeocodeCache if user cancels confirmation', async () => {
|
it('should not call clearGeocodeCache if user cancels confirmation', async () => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
||||||
|
|
||||||
const clearButton = screen.getByRole('button', { name: /clear geocode cache/i });
|
const clearButton = screen.getByRole('button', { name: /clear geocode cache/i });
|
||||||
@@ -456,7 +427,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.clearGeocodeCache.mockResolvedValueOnce(
|
mockedApiClient.clearGeocodeCache.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Cache clear failed' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'Cache clear failed' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
|
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
|
||||||
@@ -470,7 +441,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ success: false, message: 'Redis down' })),
|
new Response(JSON.stringify({ success: false, message: 'Redis down' })),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Redis down')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Redis down')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -486,7 +457,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.pingBackend.mockResolvedValueOnce(
|
mockedApiClient.pingBackend.mockResolvedValueOnce(
|
||||||
new Response('unexpected response', { status: 200 }),
|
new Response('unexpected response', { status: 200 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -499,7 +470,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkStorage.mockResolvedValueOnce(
|
mockedApiClient.checkStorage.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Permission denied' }), { status: 403 }),
|
new Response(JSON.stringify({ message: 'Permission denied' }), { status: 403 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
||||||
@@ -511,7 +482,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkDbSchema.mockResolvedValueOnce(
|
mockedApiClient.checkDbSchema.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Schema check failed 500' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'Schema check failed 500' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Schema check failed 500')).toBeInTheDocument();
|
expect(screen.getByText('Schema check failed 500')).toBeInTheDocument();
|
||||||
@@ -523,7 +494,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkDbPoolHealth.mockResolvedValueOnce(
|
mockedApiClient.checkDbPoolHealth.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'DB Pool check failed 500' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'DB Pool check failed 500' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('DB Pool check failed 500')).toBeInTheDocument();
|
expect(screen.getByText('DB Pool check failed 500')).toBeInTheDocument();
|
||||||
@@ -535,7 +506,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkPm2Status.mockResolvedValueOnce(
|
mockedApiClient.checkPm2Status.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'PM2 check failed 500' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'PM2 check failed 500' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('PM2 check failed 500')).toBeInTheDocument();
|
expect(screen.getByText('PM2 check failed 500')).toBeInTheDocument();
|
||||||
@@ -547,7 +518,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Redis check failed 500' }), { status: 500 }),
|
new Response(JSON.stringify({ message: 'Redis check failed 500' }), { status: 500 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Redis check failed 500')).toBeInTheDocument();
|
expect(screen.getByText('Redis check failed 500')).toBeInTheDocument();
|
||||||
@@ -559,7 +530,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ success: false, message: 'Redis is down' })),
|
new Response(JSON.stringify({ success: false, message: 'Redis is down' })),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Redis is down')).toBeInTheDocument();
|
expect(screen.getByText('Redis is down')).toBeInTheDocument();
|
||||||
@@ -571,7 +542,7 @@ describe('SystemCheck', () => {
|
|||||||
mockedApiClient.loginUser.mockResolvedValueOnce(
|
mockedApiClient.loginUser.mockResolvedValueOnce(
|
||||||
new Response(JSON.stringify({ message: 'Invalid credentials' }), { status: 401 }),
|
new Response(JSON.stringify({ message: 'Invalid credentials' }), { status: 401 }),
|
||||||
);
|
);
|
||||||
render(<SystemCheck />);
|
renderWithProviders(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Failed: Invalid credentials')).toBeInTheDocument();
|
expect(screen.getByText('Failed: Invalid credentials')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ import { ApiProvider } from './ApiProvider';
|
|||||||
import { ApiContext } from '../contexts/ApiContext';
|
import { ApiContext } from '../contexts/ApiContext';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
|
|
||||||
// Mock the apiClient module.
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
// Since ApiProvider and ApiContext import * as apiClient, mocking it ensures
|
// This test verifies that the ApiProvider correctly provides this mocked module.
|
||||||
// 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', () => {
|
describe('ApiProvider & ApiContext', () => {
|
||||||
const TestConsumer = () => {
|
const TestConsumer = () => {
|
||||||
|
|||||||
72
src/providers/AppProviders.test.tsx
Normal file
72
src/providers/AppProviders.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// src/providers/AppProviders.test.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { AppProviders } from './AppProviders';
|
||||||
|
|
||||||
|
// Mock all the providers to avoid their side effects and isolate AppProviders logic.
|
||||||
|
// We render a simple div with a data-testid for each to verify nesting.
|
||||||
|
vi.mock('./ModalProvider', () => ({
|
||||||
|
ModalProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="modal-provider">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AuthProvider', () => ({
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="auth-provider">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./FlyersProvider', () => ({
|
||||||
|
FlyersProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="flyers-provider">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./MasterItemsProvider', () => ({
|
||||||
|
MasterItemsProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="master-items-provider">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./UserDataProvider', () => ({
|
||||||
|
UserDataProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="user-data-provider">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppProviders', () => {
|
||||||
|
it('renders children correctly', () => {
|
||||||
|
render(
|
||||||
|
<AppProviders>
|
||||||
|
<div data-testid="test-child">Test Child</div>
|
||||||
|
</AppProviders>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-child')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders providers in the correct nesting order', () => {
|
||||||
|
render(
|
||||||
|
<AppProviders>
|
||||||
|
<div data-testid="test-child">Test Child</div>
|
||||||
|
</AppProviders>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalProvider = screen.getByTestId('modal-provider');
|
||||||
|
const authProvider = screen.getByTestId('auth-provider');
|
||||||
|
const flyersProvider = screen.getByTestId('flyers-provider');
|
||||||
|
const masterItemsProvider = screen.getByTestId('master-items-provider');
|
||||||
|
const userDataProvider = screen.getByTestId('user-data-provider');
|
||||||
|
const child = screen.getByTestId('test-child');
|
||||||
|
|
||||||
|
// Verify nesting structure: Modal -> Auth -> Flyers -> MasterItems -> UserData -> Child
|
||||||
|
expect(modalProvider).toContainElement(authProvider);
|
||||||
|
expect(authProvider).toContainElement(flyersProvider);
|
||||||
|
expect(flyersProvider).toContainElement(masterItemsProvider);
|
||||||
|
expect(masterItemsProvider).toContainElement(userDataProvider);
|
||||||
|
expect(userDataProvider).toContainElement(child);
|
||||||
|
});
|
||||||
|
});
|
||||||
245
src/providers/AuthProvider.test.tsx
Normal file
245
src/providers/AuthProvider.test.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// src/providers/AuthProvider.test.tsx
|
||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||||
|
import { AuthProvider } from './AuthProvider';
|
||||||
|
import { AuthContext } from '../contexts/AuthContext';
|
||||||
|
import * as tokenStorage from '../services/tokenStorage';
|
||||||
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
|
|
||||||
|
// Mocks
|
||||||
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
|
vi.mock('../services/tokenStorage');
|
||||||
|
vi.mock('../services/logger.client', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
const mockedTokenStorage = tokenStorage as Mocked<typeof tokenStorage>;
|
||||||
|
|
||||||
|
const mockProfile = createMockUserProfile({
|
||||||
|
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A simple consumer component to access and display context values
|
||||||
|
const TestConsumer = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
if (!context) {
|
||||||
|
return <div>No Context</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoginWithoutProfile = async () => {
|
||||||
|
try {
|
||||||
|
await context.login('test-token-no-profile');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="auth-status">{context.authStatus}</div>
|
||||||
|
<div data-testid="user-email">{context.userProfile?.user.email ?? 'No User'}</div>
|
||||||
|
<div data-testid="is-loading">{context.isLoading.toString()}</div>
|
||||||
|
{error && <div data-testid="error-display">{error}</div>}
|
||||||
|
<button onClick={() => context.login('test-token', mockProfile)}>Login with Profile</button>
|
||||||
|
<button onClick={handleLoginWithoutProfile}>Login without Profile</button>
|
||||||
|
<button onClick={context.logout}>Logout</button>
|
||||||
|
<button onClick={() => context.updateProfile({ full_name: 'Updated Name' })}>
|
||||||
|
Update Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithProvider = () => {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AuthProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start in "Determining..." state and transition to "SIGNED_OUT" if no token exists', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
||||||
|
renderWithProvider();
|
||||||
|
|
||||||
|
// The transition happens synchronously in the effect when no token is present,
|
||||||
|
// so 'Determining...' might be skipped or flashed too quickly for the test runner.
|
||||||
|
// We check that it settles correctly.
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to "AUTHENTICATED" if a valid token exists', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProvider();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
|
||||||
|
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
|
||||||
|
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle token validation failure by signing out', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue('invalid-token');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid Token'));
|
||||||
|
|
||||||
|
renderWithProvider();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a valid token that returns no profile by signing out', async () => {
|
||||||
|
// This test covers lines 51-55
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue('valid-token-no-profile');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProvider();
|
||||||
|
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('Determining...');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('user-email')).toHaveTextContent('No User');
|
||||||
|
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log in a user with provided profile data', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'));
|
||||||
|
|
||||||
|
const loginButton = screen.getByRole('button', { name: 'Login with Profile' });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(loginButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token');
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
|
||||||
|
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
|
||||||
|
// API should not be called if profile is provided
|
||||||
|
expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log in a user and fetch profile if not provided', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'));
|
||||||
|
|
||||||
|
const loginButton = screen.getByRole('button', { name: 'Login without Profile' });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(loginButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
|
||||||
|
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
|
||||||
|
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error and log out if profile fetch fails after login', async () => {
|
||||||
|
// This test covers lines 109-111
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
||||||
|
const fetchError = new Error('API is down');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(fetchError);
|
||||||
|
|
||||||
|
renderWithProvider();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginButton = screen.getByRole('button', { name: 'Login without Profile' });
|
||||||
|
|
||||||
|
// Click the button that triggers the failing login
|
||||||
|
fireEvent.click(loginButton);
|
||||||
|
|
||||||
|
// After the error is thrown, the state should be rolled back
|
||||||
|
await waitFor(() => {
|
||||||
|
// The error is now caught and displayed by the TestConsumer
|
||||||
|
expect(screen.getByTestId('error-display')).toHaveTextContent(
|
||||||
|
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
|
||||||
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log out the user', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'));
|
||||||
|
|
||||||
|
const logoutButton = screen.getByRole('button', { name: 'Logout' });
|
||||||
|
fireEvent.click(logoutButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
|
||||||
|
expect(screen.getByTestId('user-email')).toHaveTextContent('No User');
|
||||||
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the user profile', async () => {
|
||||||
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'));
|
||||||
|
|
||||||
|
const updateButton = screen.getByRole('button', { name: 'Update Profile' });
|
||||||
|
fireEvent.click(updateButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// The profile object is internal, so we can't directly check it.
|
||||||
|
// A good proxy is to see if a component that uses it would re-render.
|
||||||
|
// Since our consumer doesn't display the name, we just confirm the function was called.
|
||||||
|
// In a real app, we'd check the updated UI element.
|
||||||
|
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
// FIX: Stabilize the apiFunction passed to useApi.
|
// FIX: Stabilize the apiFunction passed to useApi.
|
||||||
// By wrapping this in useCallback, we ensure the same function instance is passed to
|
// By wrapping this in useCallback, we ensure the same function instance is passed to
|
||||||
// useApi on every render. This prevents the `execute` function returned by `useApi`
|
// useApi on every render. This prevents the `execute` function returned by `useApi`
|
||||||
// from being recreated, which in turn breaks the infinite re-render loop in the useEffect below.
|
// from being recreated, which in turn breaks the infinite re-render loop in the useEffect.
|
||||||
const getProfileCallback = useCallback(() => apiClient.getAuthenticatedUserProfile(), []);
|
const getProfileCallback = useCallback(() => apiClient.getAuthenticatedUserProfile(), []);
|
||||||
|
|
||||||
const { execute: checkTokenApi } = useApi<UserProfile, []>(getProfileCallback);
|
const { execute: checkTokenApi } = useApi<UserProfile, []>(getProfileCallback);
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import { FlyersContext, FlyersContextType } from '../contexts/FlyersContext';
|
|||||||
import type { Flyer } from '../types';
|
import type { Flyer } from '../types';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { useInfiniteQuery } from '../hooks/useInfiniteQuery';
|
import { useInfiniteQuery } from '../hooks/useInfiniteQuery';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
// Memoize the fetch function to ensure stability for the useInfiniteQuery hook.
|
||||||
|
const fetchFlyersFn = useCallback(apiClient.fetchFlyers, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: flyers,
|
data: flyers,
|
||||||
isLoading: isLoadingFlyers,
|
isLoading: isLoadingFlyers,
|
||||||
error: flyersError,
|
error: flyersError,
|
||||||
fetchNextPage: fetchNextFlyersPage,
|
fetchNextPage: fetchNextFlyersPage,
|
||||||
hasNextPage: hasNextFlyersPage,
|
hasNextPage: hasNextFlyersPage,
|
||||||
refetch: refetchFlyers,
|
refetch: refetchFlyers,
|
||||||
isRefetching: isRefetchingFlyers,
|
isRefetching: isRefetchingFlyers,
|
||||||
} = useInfiniteQuery<Flyer>(apiClient.fetchFlyers);
|
} = useInfiniteQuery<Flyer>(fetchFlyersFn);
|
||||||
|
|
||||||
const value: FlyersContextType = {
|
const value: FlyersContextType = {
|
||||||
flyers: flyers || [],
|
flyers: flyers || [],
|
||||||
@@ -26,5 +30,5 @@ export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
refetchFlyers,
|
refetchFlyers,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <FlyersContext.Provider value={value}>{children}</FlyersContext.Provider>;
|
return <FlyersContext.Provider value={value}>{children}</FlyersContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
// src/providers/MasterItemsProvider.tsx
|
// src/providers/MasterItemsProvider.tsx
|
||||||
import React, { ReactNode, useMemo } from 'react';
|
import React, { ReactNode, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { MasterItemsContext } from '../contexts/MasterItemsContext';
|
import { MasterItemsContext } from '../contexts/MasterItemsContext';
|
||||||
import type { MasterGroceryItem } from '../types';
|
import type { MasterGroceryItem } from '../types';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { useApiOnMount } from '../hooks/useApiOnMount';
|
import { useApiOnMount } from '../hooks/useApiOnMount';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
export const MasterItemsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const MasterItemsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const { data, loading, error } = useApiOnMount<MasterGroceryItem[], []>(() =>
|
// LOGGING: Check if the provider is unmounting/remounting repeatedly
|
||||||
apiClient.fetchMasterItems(),
|
useEffect(() => {
|
||||||
);
|
logger.debug('MasterItemsProvider: MOUNTED');
|
||||||
|
return () => logger.debug('MasterItemsProvider: UNMOUNTED');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize the fetch function to ensure stability for the useApiOnMount hook.
|
||||||
|
const fetchFn = useCallback(() => apiClient.fetchMasterItems(), []);
|
||||||
|
|
||||||
|
const { data, loading, error } = useApiOnMount<MasterGroceryItem[], []>(fetchFn);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/providers/UserDataProvider.tsx
|
// src/providers/UserDataProvider.tsx
|
||||||
import React, { useState, useEffect, useMemo, ReactNode } from 'react';
|
import { logger } from '../services/logger.client';
|
||||||
|
import React, { useState, useEffect, useMemo, ReactNode, useCallback } from 'react';
|
||||||
import { UserDataContext } from '../contexts/UserDataContext';
|
import { UserDataContext } from '../contexts/UserDataContext';
|
||||||
import type { MasterGroceryItem, ShoppingList } from '../types';
|
import type { MasterGroceryItem, ShoppingList } from '../types';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
@@ -9,18 +10,25 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const { userProfile } = useAuth();
|
const { userProfile } = useAuth();
|
||||||
|
|
||||||
|
// Wrap the API calls in useCallback to prevent unnecessary re-renders.
|
||||||
|
const fetchWatchedItemsFn = useCallback(
|
||||||
|
() => apiClient.fetchWatchedItems(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const fetchShoppingListsFn = useCallback(() => apiClient.fetchShoppingLists(), []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: watchedItemsData,
|
data: watchedItemsData,
|
||||||
loading: isLoadingWatched,
|
loading: isLoadingWatched,
|
||||||
error: watchedItemsError,
|
error: watchedItemsError,
|
||||||
} = useApiOnMount<MasterGroceryItem[], []>(() => apiClient.fetchWatchedItems(), [userProfile], {
|
} = useApiOnMount<MasterGroceryItem[], []>(fetchWatchedItemsFn, [userProfile], {
|
||||||
enabled: !!userProfile,
|
enabled: !!userProfile,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
data: shoppingListsData,
|
data: shoppingListsData,
|
||||||
loading: isLoadingShoppingLists,
|
loading: isLoadingShoppingLists,
|
||||||
error: shoppingListsError,
|
error: shoppingListsError,
|
||||||
} = useApiOnMount<ShoppingList[], []>(() => apiClient.fetchShoppingLists(), [userProfile], {
|
} = useApiOnMount<ShoppingList[], []>(fetchShoppingListsFn, [userProfile], {
|
||||||
enabled: !!userProfile,
|
enabled: !!userProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +40,7 @@ export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When the user logs out (user becomes null), immediately clear all user-specific data.
|
// When the user logs out (user becomes null), immediately clear all user-specific data.
|
||||||
// This also serves to clear out old data when a new user logs in, before their new data arrives.
|
// This also serves to clear out old data when a new user logs in, before their new data arrives.
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
setWatchedItems([]);
|
setWatchedItems([]);
|
||||||
setShoppingLists([]);
|
setShoppingLists([]);
|
||||||
return;
|
return;
|
||||||
@@ -60,7 +68,7 @@ export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }
|
|||||||
watchedItemsError,
|
watchedItemsError,
|
||||||
shoppingListsError,
|
shoppingListsError,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <UserDataContext.Provider value={value}>{children}</UserDataContext.Provider>;
|
return <UserDataContext.Provider value={value}>{children}</UserDataContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// src/routes/admin.content.routes.test.ts
|
// src/routes/admin.content.routes.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
createMockUserProfile,
|
createMockUserProfile,
|
||||||
createMockSuggestedCorrection,
|
createMockSuggestedCorrection,
|
||||||
createMockBrand,
|
createMockBrand,
|
||||||
createMockRecipe,
|
createMockRecipe,
|
||||||
|
createMockFlyer,
|
||||||
createMockRecipeComment,
|
createMockRecipeComment,
|
||||||
createMockUnmatchedFlyerItem,
|
createMockUnmatchedFlyerItem,
|
||||||
} from '../tests/utils/mockFactories';
|
} from '../tests/utils/mockFactories';
|
||||||
@@ -14,6 +16,7 @@ import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from
|
|||||||
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 fs from 'node:fs/promises';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
import { cleanupFiles } from '../tests/utils/cleanupFiles';
|
||||||
|
|
||||||
// Mock the file upload middleware to allow testing the controller's internal check
|
// Mock the file upload middleware to allow testing the controller's internal check
|
||||||
vi.mock('../middleware/fileUpload.middleware', () => ({
|
vi.mock('../middleware/fileUpload.middleware', () => ({
|
||||||
@@ -38,9 +41,11 @@ const { mockedDb } = vi.hoisted(() => {
|
|||||||
rejectCorrection: vi.fn(),
|
rejectCorrection: vi.fn(),
|
||||||
updateSuggestedCorrection: vi.fn(),
|
updateSuggestedCorrection: vi.fn(),
|
||||||
getUnmatchedFlyerItems: vi.fn(),
|
getUnmatchedFlyerItems: vi.fn(),
|
||||||
|
getFlyersForReview: vi.fn(), // Added for flyer review tests
|
||||||
updateRecipeStatus: vi.fn(),
|
updateRecipeStatus: vi.fn(),
|
||||||
updateRecipeCommentStatus: vi.fn(),
|
updateRecipeCommentStatus: vi.fn(),
|
||||||
updateBrandLogo: vi.fn(),
|
updateBrandLogo: vi.fn(),
|
||||||
|
getApplicationStats: vi.fn(),
|
||||||
},
|
},
|
||||||
flyerRepo: {
|
flyerRepo: {
|
||||||
getAllBrands: vi.fn(),
|
getAllBrands: vi.fn(),
|
||||||
@@ -73,10 +78,12 @@ vi.mock('node:fs/promises', () => ({
|
|||||||
// Named exports
|
// Named exports
|
||||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||||
unlink: vi.fn().mockResolvedValue(undefined),
|
unlink: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
// FIX: Add default export to handle `import fs from ...` syntax.
|
// FIX: Add default export to handle `import fs from ...` syntax.
|
||||||
default: {
|
default: {
|
||||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||||
unlink: vi.fn().mockResolvedValue(undefined),
|
unlink: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../services/backgroundJobService');
|
vi.mock('../services/backgroundJobService');
|
||||||
@@ -135,6 +142,26 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Safeguard to clean up any logo files created during tests.
|
||||||
|
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
|
||||||
|
try {
|
||||||
|
const allFiles = await fs.readdir(uploadDir);
|
||||||
|
// Files are named like 'logoImage-timestamp-original.ext'
|
||||||
|
const testFiles = allFiles
|
||||||
|
.filter((f) => f.startsWith('logoImage-'))
|
||||||
|
.map((f) => path.join(uploadDir, f));
|
||||||
|
|
||||||
|
if (testFiles.length > 0) {
|
||||||
|
await cleanupFiles(testFiles);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.error('Error during admin content test file cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe('Corrections Routes', () => {
|
describe('Corrections Routes', () => {
|
||||||
it('GET /corrections should return corrections data', async () => {
|
it('GET /corrections should return corrections data', async () => {
|
||||||
const mockCorrections: SuggestedCorrection[] = [
|
const mockCorrections: SuggestedCorrection[] = [
|
||||||
@@ -223,6 +250,50 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body.message).toBe('Correction with ID 999 not found');
|
expect(response.body.message).toBe('Correction with ID 999 not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(
|
||||||
|
new Error('Generic DB Error'),
|
||||||
|
);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/admin/corrections/101')
|
||||||
|
.send({ suggested_value: 'new value' });
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Generic DB Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Flyer Review Routes', () => {
|
||||||
|
it('GET /review/flyers should return flyers for review', async () => {
|
||||||
|
const mockFlyers = [
|
||||||
|
createMockFlyer({ flyer_id: 1, status: 'needs_review' }),
|
||||||
|
createMockFlyer({ flyer_id: 2, status: 'needs_review' }),
|
||||||
|
];
|
||||||
|
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockResolvedValue(mockFlyers);
|
||||||
|
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockFlyers);
|
||||||
|
expect(vi.mocked(mockedDb.adminRepo.getFlyersForReview)).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /review/flyers should return 500 on DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stats Routes', () => {
|
||||||
|
// This test covers the error path for GET /stats
|
||||||
|
it('GET /stats should return 500 on DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/stats');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Brand Routes', () => {
|
describe('Brand Routes', () => {
|
||||||
@@ -234,6 +305,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.body).toEqual(mockBrands);
|
expect(response.body).toEqual(mockBrands);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /brands should return 500 on DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/brands');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||||
const brandId = 55;
|
const brandId = 55;
|
||||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
||||||
@@ -282,6 +360,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('logoImage-'));
|
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('logoImage-'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /brands/:id/logo should return 400 if a non-image file is uploaded', async () => {
|
||||||
|
const brandId = 55;
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
.attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
// This message comes from the handleMulterError middleware for the imageFileFilter
|
||||||
|
expect(response.body.message).toBe('Only image files are allowed!');
|
||||||
|
});
|
||||||
|
|
||||||
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')
|
||||||
@@ -430,6 +518,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
|
||||||
|
const flyerId = 42;
|
||||||
|
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
|
||||||
|
new Error('Generic DB Error'),
|
||||||
|
);
|
||||||
|
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Generic DB Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
||||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { createTestApp } from '../tests/utils/createTestApp';
|
|||||||
vi.mock('../services/backgroundJobService', () => ({
|
vi.mock('../services/backgroundJobService', () => ({
|
||||||
backgroundJobService: {
|
backgroundJobService: {
|
||||||
runDailyDealCheck: vi.fn(),
|
runDailyDealCheck: vi.fn(),
|
||||||
|
triggerAnalyticsReport: vi.fn(),
|
||||||
|
triggerWeeklyAnalyticsReport: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -142,22 +144,17 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
|
|
||||||
describe('POST /trigger/analytics-report', () => {
|
describe('POST /trigger/analytics-report', () => {
|
||||||
it('should trigger the analytics report job and return 202 Accepted', async () => {
|
it('should trigger the analytics report job and return 202 Accepted', async () => {
|
||||||
const mockJob = { id: 'manual-report-job-123' } as Job;
|
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue('manual-report-job-123');
|
||||||
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
|
|
||||||
|
|
||||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||||
|
|
||||||
expect(response.status).toBe(202);
|
expect(response.status).toBe(202);
|
||||||
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
|
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
|
||||||
expect(analyticsQueue.add).toHaveBeenCalledWith(
|
expect(backgroundJobService.triggerAnalyticsReport).toHaveBeenCalledTimes(1);
|
||||||
'generate-daily-report',
|
|
||||||
expect.objectContaining({ reportDate: expect.any(String) }),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if enqueuing the analytics job fails', async () => {
|
it('should return 500 if enqueuing the analytics job fails', async () => {
|
||||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue error'));
|
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(new Error('Queue error'));
|
||||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
@@ -165,22 +162,17 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
|
|
||||||
describe('POST /trigger/weekly-analytics', () => {
|
describe('POST /trigger/weekly-analytics', () => {
|
||||||
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
|
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
|
||||||
const mockJob = { id: 'manual-weekly-report-job-123' } as Job;
|
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue('manual-weekly-report-job-123');
|
||||||
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue(mockJob);
|
|
||||||
|
|
||||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||||
|
|
||||||
expect(response.status).toBe(202);
|
expect(response.status).toBe(202);
|
||||||
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
|
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
|
||||||
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith(
|
expect(backgroundJobService.triggerWeeklyAnalyticsReport).toHaveBeenCalledTimes(1);
|
||||||
'generate-weekly-report',
|
|
||||||
expect.objectContaining({ reportYear: expect.any(Number), reportWeek: expect.any(Number) }),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
|
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
|
||||||
vi.mocked(weeklyAnalyticsQueue.add).mockRejectedValue(new Error('Queue error'));
|
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(new Error('Queue error'));
|
||||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
@@ -242,15 +234,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 () => {
|
it('should return 404 if the job ID is not found in the weekly-analytics-reporting queue', async () => {
|
||||||
const queueName = 'weekly-analytics-reporting'; // This is in the Zod enum but not the queueMap
|
const queueName = 'weekly-analytics-reporting';
|
||||||
const jobId = 'some-job-id';
|
const jobId = 'some-job-id';
|
||||||
|
|
||||||
|
// Ensure getJob returns undefined (not found)
|
||||||
|
vi.mocked(weeklyAnalyticsQueue.getJob).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
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.status).toBe(404);
|
||||||
expect(response.body.message).toBe(`Queue 'weekly-analytics-reporting' not found.`);
|
expect(response.body.message).toBe(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ vi.mock('../services/workers.server', () => ({
|
|||||||
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
|
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the monitoring service directly to test route error handling
|
||||||
|
vi.mock('../services/monitoringService.server', () => ({
|
||||||
|
monitoringService: {
|
||||||
|
getWorkerStatuses: vi.fn(),
|
||||||
|
getQueueStatuses: 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
|
||||||
vi.mock('../services/db/flyer.db');
|
vi.mock('../services/db/flyer.db');
|
||||||
vi.mock('../services/db/recipe.db');
|
vi.mock('../services/db/recipe.db');
|
||||||
@@ -78,11 +86,8 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
import adminRouter from './admin.routes';
|
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 { monitoringService } from '../services/monitoringService.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 mockedWorkerService = workerService as Mocked<typeof workerService>;
|
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', () => ({
|
||||||
@@ -146,16 +151,26 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
expect(response.body.errors).toBeDefined();
|
expect(response.body.errors).toBeDefined();
|
||||||
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 500 if fetching activity log fails', async () => {
|
||||||
|
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/activity-log');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
|
const mockStatuses = [
|
||||||
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
|
{ name: 'flyer-processing', isRunning: true },
|
||||||
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
{ name: 'email-sending', isRunning: true },
|
||||||
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
|
{ name: 'analytics-reporting', isRunning: false },
|
||||||
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
|
{ name: 'file-cleanup', isRunning: true },
|
||||||
|
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||||
|
];
|
||||||
|
vi.mocked(monitoringService.getWorkerStatuses).mockResolvedValue(mockStatuses);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/admin/workers/status');
|
const response = await supertest(app).get('/api/admin/workers/status');
|
||||||
@@ -170,51 +185,41 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
{ name: 'weekly-analytics-reporting', isRunning: true },
|
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 500 if fetching worker statuses fails', async () => {
|
||||||
|
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/workers/status');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Worker Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /queues/status', () => {
|
describe('GET /queues/status', () => {
|
||||||
it('should return job counts for all registered queues', async () => {
|
it('should return job counts for all registered queues', async () => {
|
||||||
// Arrange: Set the mock job counts for each queue
|
// Arrange: Set the mock job counts for each queue
|
||||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({
|
const mockStatuses = [
|
||||||
waiting: 5,
|
{
|
||||||
active: 1,
|
name: 'flyer-processing',
|
||||||
completed: 100,
|
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||||
failed: 2,
|
},
|
||||||
delayed: 0,
|
{
|
||||||
paused: 0,
|
name: 'email-sending',
|
||||||
});
|
counts: { waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 },
|
||||||
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({
|
},
|
||||||
waiting: 0,
|
{
|
||||||
active: 0,
|
name: 'analytics-reporting',
|
||||||
completed: 50,
|
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
|
||||||
failed: 0,
|
},
|
||||||
delayed: 0,
|
{
|
||||||
paused: 0,
|
name: 'file-cleanup',
|
||||||
});
|
counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 },
|
||||||
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({
|
},
|
||||||
waiting: 0,
|
{
|
||||||
active: 1,
|
name: 'weekly-analytics-reporting',
|
||||||
completed: 10,
|
counts: { waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 },
|
||||||
failed: 1,
|
},
|
||||||
delayed: 0,
|
];
|
||||||
paused: 0,
|
vi.mocked(monitoringService.getQueueStatuses).mockResolvedValue(mockStatuses);
|
||||||
});
|
|
||||||
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({
|
|
||||||
waiting: 2,
|
|
||||||
active: 0,
|
|
||||||
completed: 25,
|
|
||||||
failed: 0,
|
|
||||||
delayed: 0,
|
|
||||||
paused: 0,
|
|
||||||
});
|
|
||||||
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({
|
|
||||||
waiting: 1,
|
|
||||||
active: 0,
|
|
||||||
completed: 5,
|
|
||||||
failed: 0,
|
|
||||||
delayed: 0,
|
|
||||||
paused: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/admin/queues/status');
|
const response = await supertest(app).get('/api/admin/queues/status');
|
||||||
@@ -246,7 +251,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if fetching queue counts fails', async () => {
|
it('should return 500 if fetching queue counts fails', async () => {
|
||||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(
|
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
|
||||||
new Error('Redis is down'),
|
new Error('Redis is down'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
113
src/routes/admin.routes.test.ts
Normal file
113
src/routes/admin.routes.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
|
// Mock dependencies required by admin.routes.ts
|
||||||
|
vi.mock('../services/db/index.db', () => ({
|
||||||
|
adminRepo: {},
|
||||||
|
flyerRepo: {},
|
||||||
|
recipeRepo: {},
|
||||||
|
userRepo: {},
|
||||||
|
personalizationRepo: {},
|
||||||
|
notificationRepo: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/backgroundJobService', () => ({
|
||||||
|
backgroundJobService: {
|
||||||
|
runDailyDealCheck: vi.fn(),
|
||||||
|
triggerAnalyticsReport: vi.fn(),
|
||||||
|
triggerWeeklyAnalyticsReport: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/queueService.server', () => ({
|
||||||
|
flyerQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
emailQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
analyticsQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
cleanupQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
weeklyAnalyticsQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/geocodingService.server', () => ({
|
||||||
|
geocodingService: { clearGeocodeCache: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/logger.server', async () => ({
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bull-board/api');
|
||||||
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
|
vi.mock('@bull-board/express', () => ({
|
||||||
|
ExpressAdapter: class {
|
||||||
|
setBasePath() {}
|
||||||
|
getRouter() { return (req: any, res: any, next: any) => next(); }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
|
// Mock Passport to allow admin access
|
||||||
|
vi.mock('./passport.routes', () => ({
|
||||||
|
default: {
|
||||||
|
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
||||||
|
req.user = createMockUserProfile({ role: 'admin' });
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
isAdmin: (req: any, res: any, next: any) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import adminRouter from './admin.routes';
|
||||||
|
|
||||||
|
describe('Admin Routes Rate Limiting', () => {
|
||||||
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger Rate Limiting', () => {
|
||||||
|
it('should block requests to /trigger/daily-deal-check after exceeding limit', async () => {
|
||||||
|
const limit = 30; // Matches adminTriggerLimiter config
|
||||||
|
|
||||||
|
// Make requests up to the limit
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
await supertest(app)
|
||||||
|
.post('/api/admin/trigger/daily-deal-check')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should be blocked
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/admin/trigger/daily-deal-check')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many administrative triggers');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Rate Limiting', () => {
|
||||||
|
it('should block requests to /brands/:id/logo after exceeding limit', async () => {
|
||||||
|
const limit = 20; // Matches adminUploadLimiter config
|
||||||
|
const brandId = 1;
|
||||||
|
|
||||||
|
// Make requests up to the limit
|
||||||
|
// Note: We don't need to attach a file to test the rate limiter, as it runs before multer
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
await supertest(app)
|
||||||
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many file uploads');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user