Compare commits

..

142 Commits

Author SHA1 Message Date
Gitea Actions
e3c876c7be ci: Bump version to 0.7.24 [skip ci] 2026-01-02 23:23:21 +05:00
32dcf3b89e unit test auto-provider refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m2s
2026-01-02 10:22:27 -08:00
7066b937f6 unit test auto-provider refactor 2026-01-02 10:17:01 -08:00
Gitea Actions
8553ea8811 ci: Bump version to 0.7.23 [skip ci] 2026-01-02 12:13:43 +05:00
19885a50f7 unit test auto-provider refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m30s
2026-01-01 23:12:32 -08:00
Gitea Actions
ce82034b9d ci: Bump version to 0.7.22 [skip ci] 2026-01-02 07:30:53 +05:00
4528da2934 integration test fixes + added new ai models and recipeSuggestion
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m36s
2026-01-01 18:30:03 -08:00
Gitea Actions
146d4c1351 ci: Bump version to 0.7.21 [skip ci] 2026-01-02 03:37:22 +05:00
88625706f4 integration test fixes + added new ai models and recipeSuggestion
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m3s
2026-01-01 14:36:43 -08:00
Gitea Actions
e395faed30 ci: Bump version to 0.7.20 [skip ci] 2026-01-02 01:40:18 +05:00
e8f8399896 integration test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m11s
2026-01-01 12:30:03 -08:00
Gitea Actions
ac0115af2b ci: Bump version to 0.7.19 [skip ci] 2026-01-02 00:55:57 +05:00
f24b15f19b integration test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m22s
2026-01-01 11:55:26 -08:00
Gitea Actions
e64426bd84 ci: Bump version to 0.7.18 [skip ci] 2026-01-02 00:35:49 +05:00
0ec4cd68d2 integration test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m25s
2026-01-01 11:35:23 -08:00
Gitea Actions
840516d2a3 ci: Bump version to 0.7.17 [skip ci] 2026-01-02 00:29:45 +05:00
59355c3eef integration test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 39s
2026-01-01 11:29:10 -08:00
d024935fe9 integration test fixes 2026-01-01 11:18:27 -08:00
Gitea Actions
5a5470634e ci: Bump version to 0.7.16 [skip ci] 2026-01-01 23:07:19 +05:00
392231ad63 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m34s
2026-01-01 10:06:49 -08:00
Gitea Actions
4b1c896621 ci: Bump version to 0.7.15 [skip ci] 2026-01-01 22:33:18 +05:00
720920a51c more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 20m35s
2026-01-01 09:31:49 -08:00
Gitea Actions
460adb9506 ci: Bump version to 0.7.14 [skip ci] 2026-01-01 16:08:43 +05:00
7aa1f756a9 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m26s
2026-01-01 03:08:02 -08:00
Gitea Actions
c484a8ca9b ci: Bump version to 0.7.13 [skip ci] 2026-01-01 15:58:33 +05:00
28d2c9f4ec more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2026-01-01 02:58:02 -08:00
Gitea Actions
ee253e9449 ci: Bump version to 0.7.12 [skip ci] 2026-01-01 15:48:03 +05:00
b6c15e53d0 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m24s
2026-01-01 02:47:31 -08:00
Gitea Actions
722162c2c3 ci: Bump version to 0.7.11 [skip ci] 2026-01-01 15:35:25 +05:00
02a76fe996 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m20s
2026-01-01 02:35:00 -08:00
Gitea Actions
0ebb03a7ab ci: Bump version to 0.7.10 [skip ci] 2026-01-01 15:30:43 +05:00
748ac9e049 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 51s
2026-01-01 02:30:06 -08:00
Gitea Actions
495edd621c ci: Bump version to 0.7.9 [skip ci] 2026-01-01 14:59:38 +05:00
4ffca19db6 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m28s
2026-01-01 01:58:18 -08:00
Gitea Actions
717427c5d7 ci: Bump version to 0.7.8 [skip ci] 2026-01-01 10:08:06 +05:00
cc438a0e36 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 38s
2025-12-31 21:07:40 -08:00
Gitea Actions
a32a0b62fc ci: Bump version to 0.7.7 [skip ci] 2026-01-01 09:44:49 +05:00
342f72b713 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 45s
2025-12-31 20:44:00 -08:00
Gitea Actions
91254d18f3 ci: Bump version to 0.7.6 [skip ci] 2026-01-01 06:02:31 +05:00
40580dbf15 database work !
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2025-12-31 17:01:35 -08:00
7f1d74c047 flyer upload (anon) issues 2025-12-31 09:40:46 -08:00
Gitea Actions
ecec686347 ci: Bump version to 0.7.5 [skip ci] 2025-12-31 22:27:56 +05:00
86de680080 flyer processing fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m36s
2025-12-31 09:27:06 -08:00
Gitea Actions
0371947065 ci: Bump version to 0.7.4 [skip ci] 2025-12-31 22:03:02 +05:00
296698758c flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m20s
2025-12-31 09:02:09 -08:00
Gitea Actions
18c1161587 ci: Bump version to 0.7.3 [skip ci] 2025-12-31 15:09:29 +05:00
0010396780 flyer upload (anon) issues
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2025-12-31 02:08:37 -08:00
Gitea Actions
d4557e13fb ci: Bump version to 0.7.2 [skip ci] 2025-12-31 13:32:58 +05:00
3e41130c69 again
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m59s
2025-12-31 00:31:18 -08:00
Gitea Actions
d9034563d6 ci: Bump version to 0.7.1 [skip ci] 2025-12-31 13:21:54 +05:00
5836a75157 flyer upload (anon) issues
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-31 00:21:19 -08:00
Gitea Actions
790008ae0d ci: Bump version to 0.7.0 for production release [skip ci] 2025-12-31 12:43:41 +05:00
Gitea Actions
b5b91eb968 ci: Bump version to 0.6.6 [skip ci] 2025-12-31 12:29:43 +05:00
38eb810e7a logging the frontend loop
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m55s
2025-12-30 23:28:38 -08:00
Gitea Actions
458588a6e7 ci: Bump version to 0.6.5 [skip ci] 2025-12-31 11:34:23 +05:00
0b4113417f flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
2025-12-30 22:33:55 -08:00
Gitea Actions
b59d2a9533 ci: Bump version to 0.6.4 [skip ci] 2025-12-31 11:11:53 +05:00
6740b35f8a flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m52s
2025-12-30 22:11:21 -08:00
Gitea Actions
92ad82a012 ci: Bump version to 0.6.3 [skip ci] 2025-12-31 10:54:15 +05:00
672e4ca597 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
2025-12-30 21:53:36 -08:00
Gitea Actions
e4d70a9b37 ci: Bump version to 0.6.2 [skip ci] 2025-12-31 10:31:41 +05:00
c30f1c4162 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m55s
2025-12-30 21:30:55 -08:00
Gitea Actions
44062a9f5b ci: Bump version to 0.6.1 [skip ci] 2025-12-31 09:52:26 +05:00
17fac8cf86 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m1s
2025-12-30 20:44:34 -08:00
Gitea Actions
9fa8553486 ci: Bump version to 0.6.0 for production release [skip ci] 2025-12-31 09:04:20 +05:00
Gitea Actions
f5b0b3b543 ci: Bump version to 0.5.5 [skip ci] 2025-12-31 08:29:53 +05:00
e3ed5c7e63 fix tests + flyer upload (anon)
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m0s
2025-12-30 19:28:57 -08:00
Gitea Actions
ae0040e092 ci: Bump version to 0.5.4 [skip ci] 2025-12-31 03:57:03 +05:00
1f3f99d430 fix tests + flyer upload (anon)
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m0s
2025-12-30 14:56:25 -08:00
Gitea Actions
7be72f1758 ci: Bump version to 0.5.3 [skip ci] 2025-12-31 03:42:15 +05:00
0967c7a33d fix tests + flyer upload (anon)
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
2025-12-30 14:41:06 -08:00
1f1c0fa6f3 fix tests + flyer upload (anon) 2025-12-30 14:38:11 -08:00
Gitea Actions
728b1a20d3 ci: Bump version to 0.5.2 [skip ci] 2025-12-30 23:37:58 +05:00
f248f7cbd0 fix tests + flyer upload (anon)
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m42s
2025-12-30 10:37:29 -08:00
Gitea Actions
0ad9bb16c2 ci: Bump version to 0.5.1 [skip ci] 2025-12-30 23:33:27 +05:00
510787bc5b fix tests + flyer upload (anon)
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-30 10:32:58 -08:00
Gitea Actions
9f696e7676 ci: Bump version to 0.5.0 for production release [skip ci] 2025-12-30 22:55:32 +05:00
Gitea Actions
a77105316f ci: Bump version to 0.4.6 [skip ci] 2025-12-30 22:39:46 +05:00
cadacb63f5 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m54s
2025-12-30 03:19:47 -08:00
Gitea Actions
62592f707e ci: Bump version to 0.4.5 [skip ci] 2025-12-30 15:32:34 +05:00
023e48d99a fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m27s
2025-12-30 02:32:02 -08:00
Gitea Actions
99efca0371 ci: Bump version to 0.4.4 [skip ci] 2025-12-30 15:11:01 +05:00
1448950b81 fix unit tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-30 02:10:29 -08:00
Gitea Actions
a811fdac63 ci: Bump version to 0.4.3 [skip ci] 2025-12-30 14:42:51 +05:00
1201fe4d3c fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m41s
2025-12-30 01:42:03 -08:00
Gitea Actions
ba9228c9cb ci: Bump version to 0.4.2 [skip ci] 2025-12-30 13:10:33 +05:00
b392b82c25 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m20s
2025-12-30 00:09:57 -08:00
Gitea Actions
87825d13d6 ci: Bump version to 0.4.1 [skip ci] 2025-12-30 12:24:16 +05:00
21a6a796cf fix some uploading flyer issues + more unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m34s
2025-12-29 23:23:27 -08:00
Gitea Actions
ecd0a73bc8 ci: Bump version to 0.4.0 for production release [skip ci] 2025-12-30 11:22:35 +05:00
Gitea Actions
39d61dc7ad ci: Bump version to 0.3.0 for production release [skip ci] 2025-12-30 11:20:47 +05:00
Gitea Actions
43491359d9 ci: Bump version to 0.2.37 [skip ci] 2025-12-30 10:28:29 +05:00
5ed2cea7e9 /coverage
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m0s
2025-12-29 21:27:28 -08:00
Gitea Actions
cbb16a8d52 ci: Bump version to 0.2.36 [skip ci] 2025-12-30 09:27:29 +05:00
70e94a6ce0 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m5s
2025-12-29 20:27:00 -08:00
Gitea Actions
b61a00003a ci: Bump version to 0.2.35 [skip ci] 2025-12-30 09:16:46 +05:00
52dba6f890 moar!
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-29 20:16:02 -08:00
4242678aab fix unit tests 2025-12-29 20:08:01 -08:00
Gitea Actions
b2e086d5ba ci: Bump version to 0.2.34 [skip ci] 2025-12-30 08:44:55 +05:00
07a9787570 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m5s
2025-12-29 19:44:25 -08:00
Gitea Actions
4bf5dc3d58 ci: Bump version to 0.2.33 [skip ci] 2025-12-30 08:02:02 +05:00
be3d269928 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m3s
2025-12-29 19:01:21 -08:00
Gitea Actions
80a53fae94 ci: Bump version to 0.2.32 [skip ci] 2025-12-30 07:27:55 +05:00
e15d2b6c2f fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m4s
2025-12-29 18:27:30 -08:00
Gitea Actions
7a52bf499e ci: Bump version to 0.2.31 [skip ci] 2025-12-30 06:58:25 +05:00
2489ec8d2d fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m3s
2025-12-29 17:57:40 -08:00
Gitea Actions
4a4f349805 ci: Bump version to 0.2.30 [skip ci] 2025-12-30 06:19:25 +05:00
517a268307 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m5s
2025-12-29 17:18:52 -08:00
Gitea Actions
a94b2a97b1 ci: Bump version to 0.2.29 [skip ci] 2025-12-30 05:41:58 +05:00
542cdfbb82 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m18s
2025-12-29 16:41:32 -08:00
Gitea Actions
262062f468 ci: Bump version to 0.2.28 [skip ci] 2025-12-30 05:38:33 +05:00
0a14193371 fix unit tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 40s
2025-12-29 16:37:55 -08:00
Gitea Actions
7f665f5117 ci: Bump version to 0.2.27 [skip ci] 2025-12-30 05:09:16 +05:00
2782a8fb3b fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m3s
2025-12-29 16:08:49 -08:00
Gitea Actions
c182ef6d30 ci: Bump version to 0.2.26 [skip ci] 2025-12-30 04:38:22 +05:00
fdb3b76cbd fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m59s
2025-12-29 15:37:51 -08:00
Gitea Actions
01e7c843cb ci: Bump version to 0.2.25 [skip ci] 2025-12-30 04:15:41 +05:00
a0dbefbfa0 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m4s
2025-12-29 15:14:44 -08:00
Gitea Actions
ab3fc318a0 ci: Bump version to 0.2.24 [skip ci] 2025-12-30 02:44:22 +05:00
e658b35e43 ffs
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m3s
2025-12-29 13:43:41 -08:00
Gitea Actions
67e106162a ci: Bump version to 0.2.23 [skip ci] 2025-12-30 02:35:43 +05:00
b7f3182fd6 clean up routes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 4m24s
2025-12-29 13:34:26 -08:00
Gitea Actions
ac60072d88 ci: Bump version to 0.2.22 [skip ci] 2025-12-29 12:09:21 +05:00
9390f38bf6 maybe a few too many fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m45s
2025-12-28 23:08:40 -08:00
Gitea Actions
236d5518c9 ci: Bump version to 0.2.21 [skip ci] 2025-12-29 11:45:13 +05:00
fd52a79a72 fixin
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-28 22:38:26 -08:00
Gitea Actions
f72819e343 ci: Bump version to 0.2.20 [skip ci] 2025-12-29 11:26:09 +05:00
1af8be3f15 more fixings
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 38s
2025-12-28 22:20:28 -08:00
Gitea Actions
28d03f4e21 ci: Bump version to 0.2.19 [skip ci] 2025-12-29 10:39:22 +05:00
2e72ee81dd maybe a few too many fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2025-12-28 21:38:31 -08:00
Gitea Actions
ba67ace190 ci: Bump version to 0.2.18 [skip ci] 2025-12-29 04:33:54 +05:00
Gitea Actions
50782c30e5 ci: Bump version to 0.2.16 [skip ci] 2025-12-29 04:33:54 +05:00
4a2ff8afc5 fix unit tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m39s
2025-12-28 15:33:22 -08:00
Gitea Actions
7a1c14ce89 ci: Bump version to 0.2.15 [skip ci] 2025-12-29 04:12:16 +05:00
6fafc3d089 test secrets better
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 8m47s
2025-12-28 15:11:46 -08:00
Gitea Actions
4316866bce ci: Bump version to 0.2.14 [skip ci] 2025-12-29 03:54:44 +05:00
356c1a1894 jwtsecret issue
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 24s
2025-12-28 14:50:57 -08:00
Gitea Actions
2a310648ca ci: Bump version to 0.2.13 [skip ci] 2025-12-29 03:42:41 +05:00
8592633c22 unit test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-28 14:42:11 -08:00
Gitea Actions
0a9cdb8709 ci: Bump version to 0.2.12 [skip ci] 2025-12-29 02:50:56 +05:00
0d21e098f8 Merge branches 'main' and 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m7s
2025-12-28 13:49:58 -08:00
b6799ed167 test fixing and flyer processor refactor 2025-12-28 13:48:27 -08:00
185 changed files with 12038 additions and 5193 deletions

View File

@@ -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

View File

@@ -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
@@ -358,6 +360,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 ---
@@ -376,7 +389,7 @@ jobs:
# Application Secrets # Application Secrets
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com' FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }} JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
@@ -390,8 +403,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
@@ -441,7 +461,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

View File

@@ -11,6 +11,7 @@ if (missingSecrets.length > 0) {
console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:'); console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:');
missingSecrets.forEach(key => console.warn(` - ${key}`)); missingSecrets.forEach(key => console.warn(` - ${key}`));
console.warn('[ecosystem.config.cjs] The application may crash if these are required for startup.\n'); 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 { } else {
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.'); console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
} }
@@ -20,6 +21,7 @@ module.exports = {
{ {
// --- 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',

View File

@@ -13,6 +13,12 @@ RULES:
latest refacter
Refactor `RecipeSuggester.test.tsx` to use `renderWithProviders`.
Create a new test file for `StatCard.tsx` to verify its props and rendering.
UPC SCANNING ! UPC SCANNING !

25
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.2.11", "version": "0.7.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.2.11", "version": "0.7.24",
"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",

View File

@@ -1,7 +1,7 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.2.11", "version": "0.7.24",
"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",

View File

@@ -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) <> ''),
CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
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
); );
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,6 +107,8 @@ 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) <> ''),
CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
); );
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).';
@@ -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,10 +134,16 @@ 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_image_url_check CHECK (image_url ~* '^https://?.*'),
CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
); );
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);
@@ -130,11 +155,14 @@ 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);
CREATE INDEX IF NOT EXISTS idx_flyers_status ON public.flyers(status);
-- 7. The 'master_grocery_items' table. This is the master dictionary. -- 7. The 'master_grocery_items' table. This is the master dictionary.
CREATE TABLE IF NOT EXISTS public.master_grocery_items ( CREATE TABLE IF NOT EXISTS public.master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
@@ -144,7 +172,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,7 +198,9 @@ 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 +215,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 +233,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 +267,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 +277,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 +295,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 +309,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 +322,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 +346,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 +359,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 +371,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 +385,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 +409,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 +438,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,12 +460,13 @@ 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.';
@@ -461,20 +508,22 @@ 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.';
@@ -485,11 +534,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 +546,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 +576,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 +591,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 +599,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 +640,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 +656,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 +670,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 +684,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 +693,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 +706,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 +722,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 +742,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 +780,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 +806,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 +820,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 +847,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 +865,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 +879,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 +905,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,12 +916,13 @@ 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);
@@ -863,13 +933,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 +953,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).
@@ -909,8 +979,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.';
@@ -931,11 +1003,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);

View File

@@ -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 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) <> ''),
CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
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
); );
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,7 +123,9 @@ 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) <> ''),
CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
); );
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).';
@@ -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'').';
@@ -126,15 +145,21 @@ CREATE TABLE IF NOT EXISTS public.flyers (
file_name TEXT NOT NULL, file_name TEXT NOT NULL,
image_url TEXT NOT NULL, image_url TEXT NOT NULL,
icon_url TEXT, icon_url TEXT,
checksum TEXT UNIQUE, checksum TEXT UNIQUE,
store_id BIGINT REFERENCES public.stores(store_id) 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_image_url_check CHECK (image_url ~* '^https://?.*'),
CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
); );
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);
@@ -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,7 +213,9 @@ 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,12 +476,13 @@ 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.';
@@ -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,20 +524,22 @@ 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.';
@@ -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,7 +688,8 @@ 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,
CONSTRAINT planned_meals_meal_type_check CHECK (TRIM(meal_type) <> ''),
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
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.';
@@ -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,11 +935,12 @@ 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,
CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*'),
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
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.';
@@ -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.';
@@ -1173,7 +1248,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;
-- ============================================================================ -- ============================================================================
@@ -2482,16 +2558,21 @@ 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',
jsonb_build_object( jsonb_build_object(
'flyer_id', NEW.flyer_id, 'flyer_id', NEW.flyer_id,
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_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; RETURN NEW;
@@ -2598,6 +2679,7 @@ CREATE TRIGGER on_new_recipe_collection_share
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 integer,
@@ -2612,6 +2694,7 @@ 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,
@@ -2620,14 +2703,18 @@ BEGIN
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 +2727,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 +2747,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;

View File

@@ -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';
@@ -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 />} />

View File

@@ -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', () => {
@@ -24,7 +25,7 @@ describe('AchievementsList', () => {
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
]; ];
render(<AchievementsList achievements={mockAchievements} />); renderWithProviders(<AchievementsList achievements={mockAchievements} />);
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
@@ -44,7 +45,7 @@ describe('AchievementsList', () => {
}); });
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();

View File

@@ -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>,
); );
}; };

View File

@@ -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);

View File

@@ -1,14 +1,16 @@
// 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 { useModal } from '../hooks/useModal'; import { useModal } from '../hooks/useModal';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock dependencies // Mock dependencies
vi.mock('../hooks/useAppInitialization'); vi.mock('../hooks/useAppInitialization');
vi.mock('../hooks/useModal'); vi.mock('../hooks/useModal');
vi.mock('../services/apiClient');
vi.mock('./WhatsNewModal', () => ({ vi.mock('./WhatsNewModal', () => ({
WhatsNewModal: ({ isOpen }: { isOpen: boolean }) => WhatsNewModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="whats-new-modal-mock" /> : null, isOpen ? <div data-testid="whats-new-modal-mock" /> : null,
@@ -38,7 +40,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 +53,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 +66,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 +80,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>,

View File

@@ -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"

View File

@@ -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');

View 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');
});
});

View 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;

View File

@@ -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();

View File

@@ -1,10 +1,11 @@
// 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');
@@ -54,12 +55,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 +68,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 +76,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 +95,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 +115,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 +193,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 +212,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 +239,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());

View File

@@ -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');

View File

@@ -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();
}); });
}); });

View File

@@ -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();
}); });
}); });

View File

@@ -1,12 +1,13 @@
// 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 // Mock the apiClient
vi.mock('../services/apiClient'); // This was correct vi.mock('../services/apiClient'); // This was correct
@@ -45,13 +46,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 +63,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 +73,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 +86,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();
@@ -110,7 +111,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 +130,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

View File

@@ -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();

View File

@@ -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');

View File

@@ -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();
}); });
}); });

View File

@@ -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');

View File

@@ -0,0 +1,164 @@
// 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';
import { suggestRecipe } from '../services/apiClient';
import { logger } from '../services/logger.client';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import '@testing-library/jest-dom';
// Mock the API client
vi.mock('../services/apiClient', () => ({
suggestRecipe: vi.fn(),
}));
// Mock the logger
vi.mock('../services/logger.client', () => ({
logger: {
error: vi.fn(),
},
}));
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(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
vi.mocked(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(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.';
vi.mocked(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');
vi.mocked(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
vi.mocked(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');
});
});

View 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>
);
};

View 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();
});
});

View 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>
);
};

View File

@@ -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);
}); });

View File

@@ -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',
); );

View File

@@ -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();
}); });

View File

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

View File

@@ -111,7 +111,7 @@ async function main() {
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, 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', '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, [

View File

@@ -1,7 +1,7 @@
// 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 './dateUtils';
import type { Flyer, UserProfile } from '../../types'; import type { Flyer, UserProfile } from '../../types';
@@ -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' },

View File

@@ -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.');

View File

@@ -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>
)} )}

View File

@@ -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} />);

View File

@@ -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()) {

View File

@@ -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', () => {

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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]);

View File

@@ -495,6 +495,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 +672,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 +687,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 +706,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();
}, },
); );
}); });

View File

@@ -113,13 +113,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 +227,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+\) ---/),

View File

@@ -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,166 @@ 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' } });
let originalNodeEnv: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
originalNodeEnv = process.env.NODE_ENV;
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});
describe('Avatar Storage', () => {
it('should generate a unique filename for an authenticated user', () => {
process.env.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', () => {
process.env.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', () => {
process.env.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
process.env.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();
});
}); });

View File

@@ -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);

View File

@@ -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 />

View File

@@ -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) };
}); });

View File

@@ -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);

View File

@@ -0,0 +1,179 @@
// 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';
// Mock dependencies
vi.mock('../../services/apiClient', () => ({
getFlyersForReview: vi.fn(),
}));
vi.mock('../../services/logger.client', () => ({
logger: {
error: vi.fn(),
},
}));
// 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
vi.mocked(apiClient.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 () => {
vi.mocked(apiClient.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: 'icon1.jpg',
},
{
flyer_id: 2,
file_name: 'flyer2.jpg',
created_at: '2023-01-02T00:00:00Z',
store: { name: 'Store B' },
icon_url: 'icon2.jpg',
},
{
flyer_id: 3,
file_name: 'flyer3.jpg',
created_at: '2023-01-03T00:00:00Z',
store: null,
icon_url: null,
},
];
vi.mocked(apiClient.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).not.toHaveAttribute('alt');
});
it('renders error message when API response is not ok', async () => {
vi.mocked(apiClient.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');
vi.mocked(apiClient.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' };
vi.mocked(apiClient.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',
);
});
});

View 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">
&larr; 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} 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>
);
};

View File

@@ -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();

View File

@@ -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.
@@ -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(() => {
@@ -98,7 +99,7 @@ describe('AdminBrandManager', () => {
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());
@@ -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)

View File

@@ -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 }));

View File

@@ -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,6 +10,7 @@ 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. // Cast the mocked module to its mocked type to retain type safety and autocompletion.
// The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts. // The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts.
@@ -80,7 +81,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} />

View File

@@ -883,6 +883,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 +911,106 @@ 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();
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');
});
});
}); });
}); });

View File

@@ -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>} />,
); );

View File

@@ -1,15 +1,22 @@
// 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. // Mock the entire apiClient module to ensure all exports are defined.
// This is the primary fix for the error: [vitest] No "..." export is defined on the mock. // This is the primary fix for the error: [vitest] No "..." export is defined on the mock.
vi.mock('../../../services/apiClient', () => ({ vi.mock('../../../services/apiClient', () => ({
// Mocks for providers used by renderWithProviders
fetchFlyers: vi.fn(),
fetchMasterItems: vi.fn(),
fetchWatchedItems: vi.fn(),
fetchShoppingLists: vi.fn(),
getAuthenticatedUserProfile: vi.fn(),
pingBackend: vi.fn(), pingBackend: vi.fn(),
checkStorage: vi.fn(), checkStorage: vi.fn(),
checkDbPoolHealth: vi.fn(), checkDbPoolHealth: vi.fn(),
@@ -20,7 +27,6 @@ vi.mock('../../../services/apiClient', () => ({
triggerFailingJob: vi.fn(), triggerFailingJob: vi.fn(),
clearGeocodeCache: vi.fn(), clearGeocodeCache: vi.fn(),
})); }));
// Get a type-safe mocked version of the apiClient module. // Get a type-safe mocked version of the apiClient module.
const mockedApiClient = vi.mocked(apiClient); const mockedApiClient = vi.mocked(apiClient);
@@ -100,7 +106,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 +132,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 +145,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 +170,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 +180,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 +190,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 +203,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 +220,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 +230,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 +242,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 +252,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 +268,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 +289,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 +334,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 +350,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 +363,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 +380,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 +396,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 +409,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 +426,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 +441,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 +449,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 +462,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 +476,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 +492,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 +505,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 +517,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 +529,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 +541,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 +553,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 +565,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 +577,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();

View 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);
});
});

View 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 apiClient from '../services/apiClient';
import * as tokenStorage from '../services/tokenStorage';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mocks
vi.mock('../services/apiClient');
vi.mock('../services/tokenStorage');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const mockedApiClient = apiClient as Mocked<typeof 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: API is down',
);
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');
});
});
});

View File

@@ -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);

View File

@@ -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>;
}; };

View File

@@ -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(
() => ({ () => ({

View File

@@ -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>;
}; };

View File

@@ -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[] = [
@@ -225,6 +252,39 @@ describe('Admin Content Management Routes (/api/admin)', () => {
}); });
}); });
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', () => {
it('GET /brands should return a list of all brands', async () => { it('GET /brands should return a list of all brands', async () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })]; const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })];
@@ -282,6 +342,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')

View File

@@ -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 () => {

View File

@@ -20,49 +20,25 @@ import { validateRequest } from '../middleware/validation.middleware';
import { createBullBoard } from '@bull-board/api'; import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService'; import { backgroundJobService } from '../services/backgroundJobService';
import { import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
flyerQueue,
emailQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
} from '../services/queueService.server'; // Import your queues
import {
analyticsWorker,
cleanupWorker,
emailWorker,
flyerWorker,
weeklyAnalyticsWorker,
} from '../services/workers.server';
import { getSimpleWeekAndYear } from '../utils/dateUtils'; import { getSimpleWeekAndYear } from '../utils/dateUtils';
import { import {
requiredString, requiredString,
numericIdParam, numericIdParam,
uuidParamSchema, uuidParamSchema,
optionalNumeric, optionalNumeric,
optionalString,
} from '../utils/zodUtils'; } from '../utils/zodUtils';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server'; // This was a duplicate, fixed.
import fs from 'node:fs/promises'; import { monitoringService } from '../services/monitoringService.server';
import { userService } from '../services/userService';
/** import { cleanupUploadedFile } from '../utils/fileUtils';
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist. import { brandService } from '../services/brandService';
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded logo file.');
}
};
const updateCorrectionSchema = numericIdParam('id').extend({ const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({ body: z.object({
suggested_value: requiredString('A new suggested_value is required.'), suggested_value: z.string().trim().min(1, 'A new suggested_value is required.'),
}), }),
}); });
@@ -100,13 +76,19 @@ const jobRetrySchema = z.object({
'file-cleanup', 'file-cleanup',
'weekly-analytics-reporting', 'weekly-analytics-reporting',
]), ]),
jobId: requiredString('A valid Job ID is required.'), jobId: z.string().trim().min(1, 'A valid Job ID is required.'),
}), }),
}); });
const emptySchema = z.object({});
const router = Router(); const router = Router();
const upload = createUploadMiddleware({ storageType: 'flyer' }); const brandLogoUpload = createUploadMiddleware({
storageType: 'flyer', // Using flyer storage path is acceptable for brand logos.
fileSize: 2 * 1024 * 1024, // 2MB limit for logos
fileFilter: 'image',
});
// --- Bull Board (Job Queue UI) Setup --- // --- Bull Board (Job Queue UI) Setup ---
const serverAdapter = new ExpressAdapter(); const serverAdapter = new ExpressAdapter();
@@ -138,7 +120,7 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
// --- Admin Routes --- // --- Admin Routes ---
router.get('/corrections', async (req, res, next: NextFunction) => { router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const corrections = await db.adminRepo.getSuggestedCorrections(req.log); const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
res.json(corrections); res.json(corrections);
@@ -148,7 +130,19 @@ router.get('/corrections', async (req, res, next: NextFunction) => {
} }
}); });
router.get('/brands', async (req, res, next: NextFunction) => { router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
req.log.debug('Fetching flyers for review via adminRepo');
const flyers = await db.adminRepo.getFlyersForReview(req.log);
req.log.info({ count: Array.isArray(flyers) ? flyers.length : 'unknown' }, 'Successfully fetched flyers for review');
res.json(flyers);
} catch (error) {
logger.error({ error }, 'Error fetching flyers for review');
next(error);
}
});
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const brands = await db.flyerRepo.getAllBrands(req.log); const brands = await db.flyerRepo.getAllBrands(req.log);
res.json(brands); res.json(brands);
@@ -158,7 +152,7 @@ router.get('/brands', async (req, res, next: NextFunction) => {
} }
}); });
router.get('/stats', async (req, res, next: NextFunction) => { router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const stats = await db.adminRepo.getApplicationStats(req.log); const stats = await db.adminRepo.getApplicationStats(req.log);
res.json(stats); res.json(stats);
@@ -168,7 +162,7 @@ router.get('/stats', async (req, res, next: NextFunction) => {
} }
}); });
router.get('/stats/daily', async (req, res, next: NextFunction) => { router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log); const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
res.json(dailyStats); res.json(dailyStats);
@@ -249,10 +243,9 @@ router.put(
router.post( router.post(
'/brands/:id/logo', '/brands/:id/logo',
validateRequest(numericIdParam('id')), validateRequest(numericIdParam('id')),
upload.single('logoImage'), brandLogoUpload.single('logoImage'),
requireFileUpload('logoImage'), requireFileUpload('logoImage'),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>; const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try { try {
// Although requireFileUpload middleware should ensure the file exists, // Although requireFileUpload middleware should ensure the file exists,
@@ -260,9 +253,8 @@ router.post(
if (!req.file) { if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.'); throw new ValidationError([], 'Logo image file is missing.');
} }
// The storage path is 'flyer-images', so the URL should reflect that for consistency.
const logoUrl = `/flyer-images/${req.file.filename}`; const logoUrl = await brandService.updateBrandLogo(params.id, req.file, req.log);
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`); logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl }); res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
@@ -276,7 +268,7 @@ router.post(
}, },
); );
router.get('/unmatched-items', async (req, res, next: NextFunction) => { router.get('/unmatched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log); const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items); res.json(items);
@@ -346,7 +338,7 @@ router.put(
}, },
); );
router.get('/users', async (req, res, next: NextFunction) => { router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try { try {
const users = await db.adminRepo.getAllUsers(req.log); const users = await db.adminRepo.getAllUsers(req.log);
res.json(users); res.json(users);
@@ -361,14 +353,11 @@ router.get(
validateRequest(activityLogSchema), validateRequest(activityLogSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety. // Apply ADR-003 pattern for type safety.
// We explicitly coerce query params here because the validation middleware might not // We parse the query here to apply Zod's coercions (string to number) and defaults.
// replace req.query with the coerced values in all environments. const { limit, offset } = activityLogSchema.shape.query.parse(req.query);
const query = req.query as unknown as { limit?: string; offset?: string };
const limit = query.limit ? Number(query.limit) : 50;
const offset = query.offset ? Number(query.offset) : 0;
try { try {
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log); const logs = await db.adminRepo.getActivityLog(limit!, offset!, req.log);
res.json(logs); res.json(logs);
} catch (error) { } catch (error) {
logger.error({ error }, 'Error fetching activity log'); logger.error({ error }, 'Error fetching activity log');
@@ -417,10 +406,7 @@ router.delete(
// Apply ADR-003 pattern for type safety // Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>; const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try { try {
if (userProfile.user.user_id === params.id) { await userService.deleteUserAsAdmin(userProfile.user.user_id, params.id, req.log);
throw new ValidationError([], 'Admins cannot delete their own account.');
}
await db.userRepo.deleteUserById(params.id, req.log);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
logger.error({ error }, 'Error deleting user'); logger.error({ error }, 'Error deleting user');
@@ -435,6 +421,7 @@ router.delete(
*/ */
router.post( router.post(
'/trigger/daily-deal-check', '/trigger/daily-deal-check',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
logger.info( logger.info(
@@ -462,6 +449,7 @@ router.post(
*/ */
router.post( router.post(
'/trigger/analytics-report', '/trigger/analytics-report',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
logger.info( logger.info(
@@ -469,14 +457,9 @@ router.post(
); );
try { try {
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const jobId = await backgroundJobService.triggerAnalyticsReport();
// Use a unique job ID for manual triggers to distinguish them from scheduled jobs.
const jobId = `manual-report-${reportDate}-${Date.now()}`;
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
res.status(202).json({ res.status(202).json({
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`, message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
}); });
} catch (error) { } catch (error) {
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.'); logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
@@ -517,7 +500,10 @@ router.post(
* POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail. * POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail.
* This is for testing the retry mechanism and Bull Board UI. * This is for testing the retry mechanism and Bull Board UI.
*/ */
router.post('/trigger/failing-job', async (req: Request, res: Response, next: NextFunction) => { router.post(
'/trigger/failing-job',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
logger.info( logger.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`, `[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
@@ -533,7 +519,8 @@ router.post('/trigger/failing-job', async (req: Request, res: Response, next: Ne
logger.error({ error }, 'Error enqueuing failing job'); logger.error({ error }, 'Error enqueuing failing job');
next(error); next(error);
} }
}); }
);
/** /**
* POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses. * POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses.
@@ -541,6 +528,7 @@ router.post('/trigger/failing-job', async (req: Request, res: Response, next: Ne
*/ */
router.post( router.post(
'/system/clear-geocode-cache', '/system/clear-geocode-cache',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
logger.info( logger.info(
@@ -563,44 +551,23 @@ router.post(
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers. * GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
* This is useful for a system health dashboard to see if any workers have crashed. * This is useful for a system health dashboard to see if any workers have crashed.
*/ */
router.get('/workers/status', async (req: Request, res: Response) => { router.get('/workers/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker]; try {
const workerStatuses = await monitoringService.getWorkerStatuses();
const workerStatuses = await Promise.all( res.json(workerStatuses);
workers.map(async (worker) => { } catch (error) {
return { logger.error({ error }, 'Error fetching worker statuses');
name: worker.name, next(error);
isRunning: worker.isRunning(), }
};
}),
);
res.json(workerStatuses);
}); });
/** /**
* GET /api/admin/queues/status - Get job counts for all BullMQ queues. * GET /api/admin/queues/status - Get job counts for all BullMQ queues.
* This is useful for monitoring the health and backlog of background jobs. * This is useful for monitoring the health and backlog of background jobs.
*/ */
router.get('/queues/status', async (req: Request, res: Response, next: NextFunction) => { router.get('/queues/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue]; const queueStatuses = await monitoringService.getQueueStatuses();
const queueStatuses = await Promise.all(
queues.map(async (queue) => {
return {
name: queue.name,
counts: await queue.getJobCounts(
'waiting',
'active',
'completed',
'failed',
'delayed',
'paused',
),
};
}),
);
res.json(queueStatuses); res.json(queueStatuses);
} catch (error) { } catch (error) {
logger.error({ error }, 'Error fetching queue statuses'); logger.error({ error }, 'Error fetching queue statuses');
@@ -620,35 +587,11 @@ router.post(
params: { queueName, jobId }, params: { queueName, jobId },
} = req as unknown as z.infer<typeof jobRetrySchema>; } = req as unknown as z.infer<typeof jobRetrySchema>;
const queueMap: { [key: string]: Queue } = {
'flyer-processing': flyerQueue,
'email-sending': emailQueue,
'analytics-reporting': analyticsQueue,
'file-cleanup': cleanupQueue,
};
const queue = queueMap[queueName];
if (!queue) {
// Throw a NotFoundError to be handled by the central error handler.
throw new NotFoundError(`Queue '${queueName}' not found.`);
}
try { try {
const job = await queue.getJob(jobId); await monitoringService.retryFailedJob(
if (!job) queueName,
throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`); jobId,
userProfile.user.user_id,
const jobState = await job.getState();
if (jobState !== 'failed')
throw new ValidationError(
[],
`Job is not in a 'failed' state. Current state: ${jobState}.`,
); // This was a duplicate, fixed.
await job.retry();
logger.info(
`[Admin] User ${userProfile.user.user_id} manually retried job ${jobId} in queue ${queueName}.`,
); );
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` }); res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) { } catch (error) {
@@ -663,6 +606,7 @@ router.post(
*/ */
router.post( router.post(
'/trigger/weekly-analytics', '/trigger/weekly-analytics',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed. const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
logger.info( logger.info(
@@ -670,19 +614,10 @@ router.post(
); );
try { try {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear(); const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
const job = await weeklyAnalyticsQueue.add(
'generate-weekly-report',
{ reportYear, reportWeek },
{
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}`, // Add timestamp to avoid ID conflict
},
);
res res
.status(202) .status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id }); .json({ message: 'Successfully enqueued weekly analytics job.', jobId });
} catch (error) { } catch (error) {
logger.error({ error }, 'Error enqueuing weekly analytics job'); logger.error({ error }, 'Error enqueuing weekly analytics job');
next(error); next(error);
@@ -693,4 +628,5 @@ router.post(
/* Catches errors from multer (e.g., file size, file filter) */ /* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError); router.use(handleMulterError);
export default router; export default router;

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
import type { UserProfile, Profile } from '../types'; import type { UserProfile, Profile } from '../types';
import { NotFoundError } from '../services/db/errors.db'; import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
vi.mock('../services/db/index.db', () => ({ vi.mock('../services/db/index.db', () => ({
@@ -22,6 +22,12 @@ vi.mock('../services/db/index.db', () => ({
notificationRepo: {}, notificationRepo: {},
})); }));
vi.mock('../services/userService', () => ({
userService: {
deleteUserAsAdmin: vi.fn(),
},
}));
// Mock other dependencies that are not directly tested but are part of the adminRouter setup // Mock other dependencies that are not directly tested but are part of the adminRouter setup
vi.mock('../services/db/flyer.db'); vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db'); vi.mock('../services/db/recipe.db');
@@ -53,6 +59,7 @@ import adminRouter from './admin.routes';
// Import the mocked repos to control them in tests // Import the mocked repos to control them in tests
import { adminRepo, userRepo } from '../services/db/index.db'; import { adminRepo, userRepo } from '../services/db/index.db';
import { userService } from '../services/userService';
// Mock the passport middleware // Mock the passport middleware
vi.mock('./passport.routes', () => ({ vi.mock('./passport.routes', () => ({
@@ -191,22 +198,27 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
it('should successfully delete a user', async () => { it('should successfully delete a user', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999'; const targetId = '123e4567-e89b-12d3-a456-426614174999';
vi.mocked(userRepo.deleteUserById).mockResolvedValue(undefined); vi.mocked(userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`); const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(204); expect(response.status).toBe(204);
expect(userRepo.deleteUserById).toHaveBeenCalledWith(targetId, expect.any(Object)); expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, targetId, expect.any(Object));
}); });
it('should prevent an admin from deleting their own account', async () => { it('should prevent an admin from deleting their own account', async () => {
const validationError = new ValidationError([], 'Admins cannot delete their own account.');
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(validationError);
const response = await supertest(app).delete(`/api/admin/users/${adminId}`); const response = await supertest(app).delete(`/api/admin/users/${adminId}`);
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.message).toMatch(/Admins cannot delete their own account/); expect(response.body.message).toMatch(/Admins cannot delete their own account/);
expect(userRepo.deleteUserById).not.toHaveBeenCalled(); expect(userRepo.deleteUserById).not.toHaveBeenCalled();
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, adminId, expect.any(Object));
}); });
it('should return 500 on a generic database error', async () => { it('should return 500 on a generic database error', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999'; const targetId = '123e4567-e89b-12d3-a456-426614174999';
const dbError = new Error('DB Error'); const dbError = new Error('DB Error');
vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError); vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError);
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(dbError);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`); const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
}); });

View File

@@ -13,14 +13,21 @@ import {
import * as aiService from '../services/aiService.server'; import * as aiService from '../services/aiService.server';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { ValidationError } from '../services/db/errors.db';
// Mock the AI service methods to avoid making real AI calls // Mock the AI service methods to avoid making real AI calls
vi.mock('../services/aiService.server', () => ({ vi.mock('../services/aiService.server', async (importOriginal) => {
aiService: { const actual = await importOriginal<typeof import('../services/aiService.server')>();
extractTextFromImageArea: vi.fn(), return {
planTripWithMaps: vi.fn(), // Added this missing mock ...actual,
}, aiService: {
})); extractTextFromImageArea: vi.fn(),
planTripWithMaps: vi.fn(),
enqueueFlyerProcessing: vi.fn(),
processLegacyFlyerUpload: vi.fn(),
},
};
});
const { mockedDb } = vi.hoisted(() => ({ const { mockedDb } = vi.hoisted(() => ({
mockedDb: { mockedDb: {
@@ -30,6 +37,9 @@ const { mockedDb } = vi.hoisted(() => ({
adminRepo: { adminRepo: {
logActivity: vi.fn(), logActivity: vi.fn(),
}, },
personalizationRepo: {
getAllMasterItems: vi.fn(),
},
// This function is a standalone export, not part of a repo // This function is a standalone export, not part of a repo
createFlyerAndItems: vi.fn(), createFlyerAndItems: vi.fn(),
}, },
@@ -40,6 +50,7 @@ vi.mock('../services/db/flyer.db', () => ({ createFlyerAndItems: mockedDb.create
vi.mock('../services/db/index.db', () => ({ vi.mock('../services/db/index.db', () => ({
flyerRepo: mockedDb.flyerRepo, flyerRepo: mockedDb.flyerRepo,
adminRepo: mockedDb.adminRepo, adminRepo: mockedDb.adminRepo,
personalizationRepo: mockedDb.personalizationRepo,
})); }));
// Mock the queue service // Mock the queue service
@@ -136,26 +147,27 @@ describe('AI Routes (/api/ai)', () => {
describe('POST /upload-and-process', () => { describe('POST /upload-and-process', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
// A valid SHA-256 checksum is 64 hex characters.
const validChecksum = 'a'.repeat(64);
it('should enqueue a job and return 202 on success', async () => { it('should enqueue a job and return 202 on success', async () => {
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-123' } as unknown as Job);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-123' } as unknown as Job);
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'new-checksum') .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
expect(response.status).toBe(202); expect(response.status).toBe(202);
expect(response.body.message).toBe('Flyer accepted for processing.'); expect(response.body.message).toBe('Flyer accepted for processing.');
expect(response.body.jobId).toBe('job-123'); expect(response.body.jobId).toBe('job-123');
expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', expect.any(Object)); expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
}); });
it('should return 400 if no file is provided', async () => { it('should return 400 if no file is provided', async () => {
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'some-checksum'); .field('checksum', validChecksum);
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.message).toBe('A flyer file (PDF or image) is required.'); expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
@@ -172,13 +184,12 @@ describe('AI Routes (/api/ai)', () => {
}); });
it('should return 409 if flyer checksum already exists', async () => { it('should return 409 if flyer checksum already exists', async () => {
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue( const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
createMockFlyer({ flyer_id: 99 }), vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError);
);
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'duplicate-checksum') .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
expect(response.status).toBe(409); expect(response.status).toBe(409);
@@ -186,12 +197,11 @@ describe('AI Routes (/api/ai)', () => {
}); });
it('should return 500 if enqueuing the job fails', async () => { it('should return 500 if enqueuing the job fails', async () => {
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(new Error('Redis connection failed'));
vi.mocked(flyerQueue.add).mockRejectedValueOnce(new Error('Redis connection failed'));
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'new-checksum') .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
expect(response.status).toBe(500); expect(response.status).toBe(500);
@@ -209,19 +219,20 @@ describe('AI Routes (/api/ai)', () => {
basePath: '/api/ai', basePath: '/api/ai',
authenticatedUser: mockUser, authenticatedUser: mockUser,
}); });
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-456' } as unknown as Job);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-456' } as unknown as Job);
// Act // Act
await supertest(authenticatedApp) await supertest(authenticatedApp)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'auth-checksum') .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
// Assert // Assert
expect(flyerQueue.add).toHaveBeenCalled(); expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userId).toBe('auth-user-1'); const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0];
// Check the userProfile argument (3rd argument)
expect(callArgs[2]?.user.user_id).toBe('auth-user-1');
}); });
it('should pass user profile address to the job when authenticated user has an address', async () => { it('should pass user profile address to the job when authenticated user has an address', async () => {
@@ -243,17 +254,20 @@ describe('AI Routes (/api/ai)', () => {
basePath: '/api/ai', basePath: '/api/ai',
authenticatedUser: mockUserWithAddress, authenticatedUser: mockUserWithAddress,
}); });
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-789' } as unknown as Job);
// Act // Act
await supertest(authenticatedApp) await supertest(authenticatedApp)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.field('checksum', 'addr-checksum') .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
// Assert // Assert
expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userProfileAddress).toBe( expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
'123 Pacific St, Anytown, BC, V8T 1A1, CA', // The service handles address extraction from profile, so we just verify the profile was passed
); const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0];
expect(callArgs[2]?.address?.address_line_1).toBe('123 Pacific St');
}); });
it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => { it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => {
@@ -316,9 +330,7 @@ describe('AI Routes (/api/ai)', () => {
flyer_id: 1, flyer_id: 1,
file_name: mockDataPayload.originalFileName, file_name: mockDataPayload.originalFileName,
}); });
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] });
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
// Act // Act
const response = await supertest(app) const response = await supertest(app)
@@ -329,7 +341,7 @@ describe('AI Routes (/api/ai)', () => {
// Assert // Assert
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body.message).toBe('Flyer processed and saved successfully.'); expect(response.body.message).toBe('Flyer processed and saved successfully.');
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
}); });
it('should return 400 if no flyer image is provided', async () => { it('should return 400 if no flyer image is provided', async () => {
@@ -341,8 +353,8 @@ describe('AI Routes (/api/ai)', () => {
it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => { it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => {
// Arrange // Arrange
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 }); const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined); const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
// Act // Act
@@ -354,7 +366,7 @@ describe('AI Routes (/api/ai)', () => {
// Assert // Assert
expect(response.status).toBe(409); expect(response.status).toBe(409);
expect(response.body.message).toBe('This flyer has already been processed.'); expect(response.body.message).toBe('This flyer has already been processed.');
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws
// Assert that the file was deleted // Assert that the file was deleted
expect(unlinkSpy).toHaveBeenCalledTimes(1); expect(unlinkSpy).toHaveBeenCalledTimes(1);
// The filename is predictable in the test environment because of the multer config in ai.routes.ts // The filename is predictable in the test environment because of the multer config in ai.routes.ts
@@ -369,12 +381,7 @@ describe('AI Routes (/api/ai)', () => {
extractedData: { store_name: 'Partial Store' }, // no items key extractedData: { store_name: 'Partial Store' }, // no items key
}; };
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 2 }));
const mockFlyer = createMockFlyer({
flyer_id: 2,
file_name: partialPayload.originalFileName,
});
vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] });
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/flyers/process') .post('/api/ai/flyers/process')
@@ -382,13 +389,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
// verify the items array passed to DB was an empty array
const callArgs = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0]?.[1];
expect(callArgs).toBeDefined();
expect(Array.isArray(callArgs)).toBe(true);
// use non-null assertion for the runtime-checked variable so TypeScript is satisfied
expect(callArgs!.length).toBe(0);
}); });
it('should fallback to a safe store name when store_name is missing', async () => { it('should fallback to a safe store name when store_name is missing', async () => {
@@ -398,12 +399,7 @@ describe('AI Routes (/api/ai)', () => {
extractedData: { items: [] }, // store_name missing extractedData: { items: [] }, // store_name missing
}; };
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 3 }));
const mockFlyer = createMockFlyer({
flyer_id: 3,
file_name: payloadNoStore.originalFileName,
});
vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] });
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/flyers/process') .post('/api/ai/flyers/process')
@@ -411,19 +407,11 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
// verify the flyerData.store_name passed to DB was the fallback string
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store');
// Also verify the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.',
);
}); });
it('should handle a generic error during flyer creation', async () => { it('should handle a generic error during flyer creation', async () => {
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValueOnce(
vi.mocked(mockedDb.createFlyerAndItems).mockRejectedValueOnce(
new Error('DB transaction failed'), new Error('DB transaction failed'),
); );
@@ -446,8 +434,7 @@ describe('AI Routes (/api/ai)', () => {
beforeEach(() => { beforeEach(() => {
const mockFlyer = createMockFlyer({ flyer_id: 1 }); const mockFlyer = createMockFlyer({ flyer_id: 1 });
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] });
}); });
it('should handle payload where "data" field is an object, not stringified JSON', async () => { it('should handle payload where "data" field is an object, not stringified JSON', async () => {
@@ -457,7 +444,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
}); });
it('should handle payload where extractedData is null', async () => { it('should handle payload where extractedData is null', async () => {
@@ -473,14 +460,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
// Verify that extractedData was correctly defaulted to an empty object
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
expect(mockLogger.warn).toHaveBeenCalledWith(
{ bodyData: expect.any(Object) },
'Missing extractedData in /api/ai/flyers/process payload.',
);
}); });
it('should handle payload where extractedData is a string', async () => { it('should handle payload where extractedData is a string', async () => {
@@ -496,14 +476,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
// Verify that extractedData was correctly defaulted to an empty object
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
expect(mockLogger.warn).toHaveBeenCalledWith(
{ bodyData: expect.any(Object) },
'Missing extractedData in /api/ai/flyers/process payload.',
);
}); });
it('should handle payload where extractedData is at the root of the body', async () => { it('should handle payload where extractedData is at the root of the body', async () => {
@@ -517,9 +490,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); // This test was failing with 500, the fix is in ai.routes.ts expect(response.status).toBe(201); // This test was failing with 500, the fix is in ai.routes.ts
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toBe('Root Store');
}); });
it('should default item quantity to 1 if missing', async () => { it('should default item quantity to 1 if missing', async () => {
@@ -538,9 +509,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath); .attach('flyerImage', imagePath);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
const itemsArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][1];
expect(itemsArg[0].quantity).toBe(1);
}); });
}); });
@@ -549,7 +518,10 @@ describe('AI Routes (/api/ai)', () => {
it('should handle malformed JSON in data field and return 400', async () => { it('should handle malformed JSON in data field and return 400', async () => {
const malformedDataString = '{"checksum":'; // Invalid JSON const malformedDataString = '{"checksum":'; // Invalid JSON
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
// Since the service parses the data, we mock it to throw a ValidationError when parsing fails
// or when it detects the malformed input.
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/flyers/process') .post('/api/ai/flyers/process')
@@ -560,11 +532,8 @@ describe('AI Routes (/api/ai)', () => {
// The handler then fails the checksum validation. // The handler then fails the checksum validation.
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.'); expect(response.body.message).toBe('Checksum is required.');
// It should log the critical error during parsing. // Note: The logging expectation was removed because if the service throws a ValidationError,
expect(mockLogger.error).toHaveBeenCalledWith( // the route handler passes it to the global error handler, which might log differently or not as a "critical error during parsing" in the route itself.
expect.objectContaining({ error: expect.any(Error) }),
'[API /ai/flyers/process] Unexpected error while parsing request body',
);
}); });
it('should return 400 if checksum is missing from legacy payload', async () => { it('should return 400 if checksum is missing from legacy payload', async () => {
@@ -574,6 +543,9 @@ describe('AI Routes (/api/ai)', () => {
}; };
// Spy on fs.promises.unlink to verify file cleanup // Spy on fs.promises.unlink to verify file cleanup
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined); const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
// Mock the service to throw a ValidationError because the checksum is missing
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
const response = await supertest(app) const response = await supertest(app)
.post('/api/ai/flyers/process') .post('/api/ai/flyers/process')

View File

@@ -1,40 +1,32 @@
// src/routes/ai.routes.ts // src/routes/ai.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import path from 'path';
import fs from 'node:fs';
import { z } from 'zod'; import { z } from 'zod';
import passport from './passport.routes'; import passport from './passport.routes';
import { optionalAuth } from './passport.routes'; import { optionalAuth } from './passport.routes';
import * as db from '../services/db/index.db'; import { aiService, DuplicateFlyerError } from '../services/aiService.server';
import { createFlyerAndItems } from '../services/db/flyer.db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import { import {
createUploadMiddleware, createUploadMiddleware,
handleMulterError, handleMulterError,
} from '../middleware/multer.middleware'; } from '../middleware/multer.middleware';
import { generateFlyerIcon } from '../utils/imageProcessor'; import { logger } from '../services/logger.server'; // This was a duplicate, fixed.
import { logger } from '../services/logger.server'; import { UserProfile } from '../types'; // This was a duplicate, fixed.
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
import { flyerQueue } from '../services/queueService.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
import { requiredString } from '../utils/zodUtils'; import { requiredString } from '../utils/zodUtils';
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server';
const router = Router(); const router = Router();
interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
checksum?: string;
originalFileName?: string;
extractedData?: Partial<ExtractedCoreData>;
data?: FlyerProcessPayload; // For nested data structures
}
// --- Zod Schemas for AI Routes (as per ADR-003) --- // --- Zod Schemas for AI Routes (as per ADR-003) ---
const uploadAndProcessSchema = z.object({ const uploadAndProcessSchema = z.object({
body: z.object({ body: z.object({
checksum: requiredString('File checksum is required.'), // Stricter validation for SHA-256 checksum. It must be a 64-character hexadecimal string.
// Potential improvement: If checksum is always a specific format (e.g., SHA-256), checksum: requiredString('File checksum is required.').pipe(
// you could add `.length(64).regex(/^[a-f0-9]+$/)` for stricter validation. z.string()
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
}), }),
}); });
@@ -52,22 +44,6 @@ const errMsg = (e: unknown) => {
return String(e || 'An unknown error occurred.'); return String(e || 'An unknown error occurred.');
}; };
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.promises.unlink(file.path);
} catch (err) {
// Ignore cleanup errors (e.g. file already deleted)
}
};
const cleanupUploadedFiles = async (files?: Express.Multer.File[]) => {
if (!files || !Array.isArray(files)) return;
// Use Promise.all to run cleanups in parallel for efficiency,
// as cleanupUploadedFile is designed to not throw errors.
await Promise.all(files.map((file) => cleanupUploadedFile(file)));
};
const cropAreaObjectSchema = z.object({ const cropAreaObjectSchema = z.object({
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
@@ -103,13 +79,20 @@ const rescanAreaSchema = z.object({
const flyerItemForAnalysisSchema = z const flyerItemForAnalysisSchema = z
.object({ .object({
item: z.string().nullish(), // Sanitize item and name by trimming whitespace.
name: z.string().nullish(), // The transform ensures that null/undefined values are preserved
// while trimming any actual string values.
item: z.string().nullish().transform(val => (val ? val.trim() : val)),
name: z.string().nullish().transform(val => (val ? val.trim() : val)),
}) })
// Using .passthrough() allows extra properties on the item object.
// If the intent is to strictly enforce only 'item' and 'name' (and other known properties),
// consider using .strict() instead for tighter security and data integrity.
.passthrough() .passthrough()
.refine( .refine(
(data) => (data) =>
(data.item && data.item.trim().length > 0) || (data.name && data.name.trim().length > 0), // After the transform, the values are already trimmed.
(data.item && data.item.length > 0) || (data.name && data.name.length > 0),
{ {
message: "Item identifier is required (either 'item' or 'name').", message: "Item identifier is required (either 'item' or 'name').",
}, },
@@ -129,6 +112,8 @@ const comparePricesSchema = z.object({
const planTripSchema = z.object({ const planTripSchema = z.object({
body: z.object({ body: z.object({
// Consider if this array should be non-empty. If a trip plan requires at least one item,
// you could add `.nonempty('At least one item is required to plan a trip.')`
items: z.array(flyerItemForAnalysisSchema), items: z.array(flyerItemForAnalysisSchema),
store: z.object({ name: requiredString('Store name is required.') }), store: z.object({ name: requiredString('Store name is required.') }),
userLocation: z.object({ userLocation: z.object({
@@ -187,57 +172,24 @@ router.post(
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
try { try {
// Manually validate the request body. This will throw if validation fails. // Manually validate the request body. This will throw if validation fails.
uploadAndProcessSchema.parse({ body: req.body }); const { body } = uploadAndProcessSchema.parse({ body: req.body });
if (!req.file) { if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' }); return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
} }
logger.debug( logger.debug(
{ filename: req.file.originalname, size: req.file.size, checksum: req.body?.checksum }, { filename: req.file.originalname, size: req.file.size, checksum: body.checksum },
'Handling /upload-and-process', 'Handling /upload-and-process',
); );
const { checksum } = req.body;
// Check for duplicate flyer using checksum before even creating a job
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
// Use 409 Conflict for duplicates
return res.status(409).json({
message: 'This flyer has already been processed.',
flyerId: existingFlyer.flyer_id,
});
}
const userProfile = req.user as UserProfile | undefined; const userProfile = req.user as UserProfile | undefined;
// Construct a user address string from their profile if they are logged in. const job = await aiService.enqueueFlyerProcessing(
let userProfileAddress: string | undefined = undefined; req.file,
if (userProfile?.address) { body.checksum,
userProfileAddress = [ userProfile,
userProfile.address.address_line_1, req.ip ?? 'unknown',
userProfile.address.address_line_2, req.log,
userProfile.address.city,
userProfile.address.province_state,
userProfile.address.postal_code,
userProfile.address.country,
]
.filter(Boolean)
.join(', ');
}
// Add job to the queue
const job = await flyerQueue.add('process-flyer', {
filePath: req.file.path,
originalFileName: req.file.originalname,
checksum: checksum,
userId: userProfile?.user.user_id,
submitterIp: req.ip, // Capture the submitter's IP address
userProfileAddress: userProfileAddress, // Pass the user's profile address
});
logger.info(
`Enqueued flyer for processing. File: ${req.file.originalname}, Job ID: ${job.id}`,
); );
// Respond immediately to the client with 202 Accepted // Respond immediately to the client with 202 Accepted
@@ -246,9 +198,11 @@ router.post(
jobId: job.id, jobId: job.id,
}); });
} catch (error) { } catch (error) {
// If any error occurs (including validation), ensure the uploaded file is cleaned up.
await cleanupUploadedFile(req.file); await cleanupUploadedFile(req.file);
// Pass the error to the global error handler. if (error instanceof DuplicateFlyerError) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${req.body?.checksum}`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
}
next(error); next(error);
} }
}, },
@@ -265,18 +219,11 @@ router.get(
const { const {
params: { jobId }, params: { jobId },
} = req as unknown as JobIdRequest; } = req as unknown as JobIdRequest;
try { try {
const job = await flyerQueue.getJob(jobId); const jobStatus = await monitoringService.getFlyerJobStatus(jobId); // This was a duplicate, fixed.
if (!job) { logger.debug(`[API /ai/jobs] Status check for job ${jobId}: ${jobStatus.state}`);
// Adhere to ADR-001 by throwing a specific error to be handled centrally. res.json(jobStatus);
return res.status(404).json({ message: 'Job not found.' });
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
logger.debug(`[API /ai/jobs] Status check for job ${jobId}: ${state}`);
res.json({ id: job.id, state, progress, returnValue, failedReason });
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -298,184 +245,22 @@ router.post(
return res.status(400).json({ message: 'Flyer image file is required.' }); return res.status(400).json({ message: 'Flyer image file is required.' });
} }
// Diagnostic & tolerant parsing for flyers/process
logger.debug(
{ keys: Object.keys(req.body || {}) },
'[API /ai/flyers/process] Processing legacy upload',
);
logger.debug({ filePresent: !!req.file }, '[API /ai/flyers/process] file present:');
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> | null | undefined = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = req.body.data ?? req.body.extractedData;
logger.debug(
{ type: typeof raw, length: raw?.length ?? 0 },
'[API /ai/flyers/process] raw extractedData',
);
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign',
);
parsed = (
typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw
) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = 'extractedData' in parsed ? parsed.extractedData : (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object',
);
parsed = (req.body as FlyerProcessPayload) || {};
}
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
if (parsed.data) {
try {
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to parse parsed.data; falling back',
);
extractedData = parsed.data as unknown as Partial<ExtractedCoreData>;
}
} else if (parsed.extractedData) {
extractedData = parsed.extractedData;
} else {
// Assume the body itself is the extracted data if it looks like it (has items or store_name keys)
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
extractedData = parsed as Partial<ExtractedCoreData>;
} else {
extractedData = {};
}
}
}
} catch (err) {
logger.error(
{ error: err },
'[API /ai/flyers/process] Unexpected error while parsing request body',
);
parsed = {};
extractedData = {};
}
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
if (!checksum) {
await cleanupUploadedFile(req.file);
return res.status(400).json({ message: 'Checksum is required.' });
}
const originalFileName =
parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const userProfile = req.user as UserProfile | undefined; const userProfile = req.user as UserProfile | undefined;
// Validate extractedData to avoid database errors (e.g., null store_name) const newFlyer = await aiService.processLegacyFlyerUpload(
if (!extractedData || typeof extractedData !== 'object') { req.file,
logger.warn( req.body,
{ bodyData: parsed }, userProfile,
'Missing extractedData in /api/ai/flyers/process payload.',
);
// Don't fail hard here; proceed with empty items and fallback store name so the upload can be saved for manual review.
extractedData = {};
}
// Transform the extracted items into the format required for database insertion.
// This adds default values for fields like `view_count` and `click_count`
// and makes this legacy endpoint consistent with the newer FlyerDataTransformer service.
const rawItems = extractedData.items ?? [];
const itemsArray = Array.isArray(rawItems)
? rawItems
: typeof rawItems === 'string'
? JSON.parse(rawItems)
: [];
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
quantity: item.quantity ?? 1, // Default to 1 to satisfy DB constraint
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
// Ensure we have a valid store name; the DB requires a non-null store name.
const storeName =
extractedData.store_name && String(extractedData.store_name).trim().length > 0
? String(extractedData.store_name)
: 'Unknown Store (auto)';
if (storeName.startsWith('Unknown')) {
logger.warn(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.',
);
}
// 1. Check for duplicate flyer using checksum
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
await cleanupUploadedFile(req.file);
return res.status(409).json({ message: 'This flyer has already been processed.' });
}
// Generate a 64x64 icon from the uploaded flyer image.
const iconsDir = path.join(path.dirname(req.file.path), 'icons');
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir, req.log);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: `/flyer-images/${req.file.filename}`, // Store the full URL path
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
valid_from: extractedData.valid_from ?? null,
valid_to: extractedData.valid_to ?? null,
store_address: extractedData.store_address ?? null,
item_count: 0, // Set default to 0; the trigger will update it.
uploaded_by: userProfile?.user.user_id, // Associate with user if logged in
};
// 3. Create flyer and its items in a transaction
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(
flyerData,
itemsForDb,
req.log,
);
logger.info(
`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`,
);
// Log this significant event
await db.adminRepo.logActivity(
{
userId: userProfile?.user.user_id,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
},
req.log, req.log,
); );
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer }); res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
} catch (error) { } catch (error) {
await cleanupUploadedFile(req.file); await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
logger.warn(`Duplicate flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
}
next(error); next(error);
} }
}, },
@@ -614,7 +399,7 @@ router.post(
try { try {
const { items, store, userLocation } = req.body; const { items, store, userLocation } = req.body;
logger.debug({ itemCount: items.length, storeName: store.name }, 'Trip planning requested.'); logger.debug({ itemCount: items.length, storeName: store.name }, 'Trip planning requested.');
const result = await aiService.aiService.planTripWithMaps(items, store, userLocation); const result = await aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
logger.error({ error: errMsg(error) }, 'Error in /api/ai/plan-trip endpoint:'); logger.error({ error: errMsg(error) }, 'Error in /api/ai/plan-trip endpoint:');
@@ -674,7 +459,7 @@ router.post(
'Rescan area requested', 'Rescan area requested',
); );
const result = await aiService.aiService.extractTextFromImageArea( const result = await aiService.extractTextFromImageArea(
path, path,
mimetype, mimetype,
cropArea, cropArea,

View File

@@ -2,13 +2,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser'; // This was a duplicate, fixed.
import * as bcrypt from 'bcrypt'; import { createMockUserProfile } from '../tests/utils/mockFactories';
import jwt from 'jsonwebtoken';
import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
// --- FIX: Hoist passport mocks to be available for vi.mock --- // --- FIX: Hoist passport mocks to be available for vi.mock ---
const passportMocks = vi.hoisted(() => { const passportMocks = vi.hoisted(() => {
@@ -69,45 +64,20 @@ vi.mock('./passport.routes', () => ({
optionalAuth: vi.fn((req: Request, res: Response, next: NextFunction) => next()), optionalAuth: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
})); }));
// Mock the DB connection pool to control transactional behavior // Mock the authService, which is now the primary dependency of the routes.
const { mockPool } = vi.hoisted(() => { const { mockedAuthService } = vi.hoisted(() => {
const client = {
query: vi.fn(),
release: vi.fn(),
};
return { return {
mockPool: { mockedAuthService: {
connect: vi.fn(() => Promise.resolve(client)), registerAndLoginUser: vi.fn(),
handleSuccessfulLogin: vi.fn(),
resetPassword: vi.fn(),
updatePassword: vi.fn(),
refreshAccessToken: vi.fn(),
logout: vi.fn(),
}, },
mockClient: client,
}; };
}); });
// Mock the Service Layer directly. vi.mock('../services/authService', () => ({ authService: mockedAuthService }));
// We use async import inside the factory to properly hoist the UniqueConstraintError class usage.
vi.mock('../services/db/index.db', async () => {
const { UniqueConstraintError } = await import('../services/db/errors.db');
return {
userRepo: {
findUserByEmail: vi.fn(),
createUser: vi.fn(),
saveRefreshToken: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
updateUserPassword: vi.fn(),
deleteResetToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
deleteRefreshToken: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
UniqueConstraintError: UniqueConstraintError,
};
});
vi.mock('../services/db/connection.db', () => ({
getPool: () => mockPool,
}));
// Mock the logger // Mock the logger
vi.mock('../services/logger.server', async () => ({ vi.mock('../services/logger.server', async () => ({
@@ -120,15 +90,8 @@ vi.mock('../services/emailService.server', () => ({
sendPasswordResetEmail: vi.fn(), sendPasswordResetEmail: vi.fn(),
})); }));
// Mock bcrypt
vi.mock('bcrypt', async (importOriginal) => {
const actual = await importOriginal<typeof bcrypt>();
return { ...actual, compare: vi.fn() };
});
// Import the router AFTER mocks are established // Import the router AFTER mocks are established
import authRouter from './auth.routes'; import authRouter from './auth.routes';
import * as db from '../services/db/index.db'; // This was a duplicate, fixed.
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
@@ -176,13 +139,11 @@ describe('Auth Routes (/api/auth)', () => {
user: { user_id: 'new-user-id', email: newUserEmail }, user: { user_id: 'new-user-id', email: newUserEmail },
full_name: 'Test User', full_name: 'Test User',
}); });
mockedAuthService.registerAndLoginUser.mockResolvedValue({
// FIX: Mock the method on the imported singleton instance `userRepo` directly, newUserProfile: mockNewUser,
// as this is what the route handler uses. Spying on the prototype does not accessToken: 'new-access-token',
// affect this already-created instance. refreshToken: 'new-refresh-token',
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser); });
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
// Act // Act
const response = await supertest(app).post('/api/auth/register').send({ const response = await supertest(app).post('/api/auth/register').send({
@@ -190,22 +151,61 @@ describe('Auth Routes (/api/auth)', () => {
password: strongPassword, password: strongPassword,
full_name: 'Test User', full_name: 'Test User',
}); });
// Assert // Assert
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!'); expect(response.body.message).toBe('User registered successfully!');
expect(response.body.userprofile.user.email).toBe(newUserEmail); expect(response.body.userprofile.user.email).toBe(newUserEmail);
expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed. expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed.
expect(db.userRepo.createUser).toHaveBeenCalled(); expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
newUserEmail,
strongPassword,
'Test User',
undefined, // avatar_url
mockLogger,
);
});
it('should allow registration with an empty string for avatar_url', async () => {
// Arrange
const email = 'avatar-user@test.com';
const mockNewUser = createMockUserProfile({
user: { user_id: 'avatar-user-id', email },
});
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: mockNewUser,
accessToken: 'avatar-access-token',
refreshToken: 'avatar-refresh-token',
});
// Act
const response = await supertest(app).post('/api/auth/register').send({
email,
password: strongPassword,
full_name: 'Avatar User',
avatar_url: '', // Send an empty string
});
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!');
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
email,
strongPassword,
'Avatar User',
undefined, // The preprocess step in the Zod schema should convert '' to undefined
mockLogger,
);
}); });
it('should set a refresh token cookie on successful registration', async () => { it('should set a refresh token cookie on successful registration', async () => {
const mockNewUser = createMockUserProfile({ const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: 'cookie@test.com' }, user: { user_id: 'new-user-id', email: 'cookie@test.com' },
}); });
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser); mockedAuthService.registerAndLoginUser.mockResolvedValue({
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); newUserProfile: mockNewUser,
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined); accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
const response = await supertest(app).post('/api/auth/register').send({ const response = await supertest(app).post('/api/auth/register').send({
email: 'cookie@test.com', email: 'cookie@test.com',
@@ -235,15 +235,14 @@ describe('Auth Routes (/api/auth)', () => {
expect(errorMessages).toMatch(/Password is too weak/i); expect(errorMessages).toMatch(/Password is too weak/i);
}); });
it('should reject registration if the email already exists', async () => { it('should reject registration if the auth service throws UniqueConstraintError', async () => {
// Create an error object that includes the 'code' property for simulating a PG unique violation. // Create an error object that includes the 'code' property for simulating a PG unique violation.
// This is more type-safe than casting to 'any'. // This is more type-safe than casting to 'any'.
const dbError = new UniqueConstraintError( const dbError = new UniqueConstraintError(
'User with that email already exists.', 'User with that email already exists.',
) as UniqueConstraintError & { code: string }; ) as UniqueConstraintError & { code: string };
dbError.code = '23505'; dbError.code = '23505';
mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
vi.mocked(db.userRepo.createUser).mockRejectedValue(dbError);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/register') .post('/api/auth/register')
@@ -251,12 +250,11 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(409); // 409 Conflict expect(response.status).toBe(409); // 409 Conflict
expect(response.body.message).toBe('User with that email already exists.'); expect(response.body.message).toBe('User with that email already exists.');
expect(db.userRepo.createUser).toHaveBeenCalled();
}); });
it('should return 500 if a generic database error occurs during registration', async () => { it('should return 500 if a generic database error occurs during registration', async () => {
const dbError = new Error('DB connection lost'); const dbError = new Error('DB connection lost');
vi.mocked(db.userRepo.createUser).mockRejectedValue(dbError); mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/register') .post('/api/auth/register')
@@ -289,7 +287,10 @@ describe('Auth Routes (/api/auth)', () => {
it('should successfully log in a user and return a token and cookie', async () => { it('should successfully log in a user and return a token and cookie', async () => {
// Arrange: // Arrange:
const loginCredentials = { email: 'test@test.com', password: 'password123' }; const loginCredentials = { email: 'test@test.com', password: 'password123' };
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
// Act // Act
const response = await supertest(app).post('/api/auth/login').send(loginCredentials); const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
@@ -309,25 +310,6 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.headers['set-cookie']).toBeDefined(); expect(response.headers['set-cookie']).toBeDefined();
}); });
it('should contain the correct payload in the JWT token', async () => {
// Arrange
const loginCredentials = { email: 'payload.test@test.com', password: 'password123' };
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
// Assert
expect(response.status).toBe(200);
const token = response.body.token;
expect(token).toBeTypeOf('string');
const decodedPayload = jwt.decode(token) as { user_id: string; email: string; role: string };
expect(decodedPayload.user_id).toBe('user-123');
expect(decodedPayload.email).toBe(loginCredentials.email);
expect(decodedPayload.role).toBe('user'); // Default role from mock factory
});
it('should reject login for incorrect credentials', async () => { it('should reject login for incorrect credentials', async () => {
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/login') .post('/api/auth/login')
@@ -359,7 +341,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should return 500 if saving the refresh token fails', async () => { it('should return 500 if saving the refresh token fails', async () => {
// Arrange: // Arrange:
const loginCredentials = { email: 'test@test.com', password: 'password123' }; const loginCredentials = { email: 'test@test.com', password: 'password123' };
vi.mocked(db.userRepo.saveRefreshToken).mockRejectedValue(new Error('DB write failed')); mockedAuthService.handleSuccessfulLogin.mockRejectedValue(new Error('DB write failed'));
// Act // Act
const response = await supertest(app).post('/api/auth/login').send(loginCredentials); const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
@@ -401,7 +383,10 @@ describe('Auth Routes (/api/auth)', () => {
password: 'password123', password: 'password123',
rememberMe: true, rememberMe: true,
}; };
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'remember-access-token',
refreshToken: 'remember-refresh-token',
});
// Act // Act
const response = await supertest(app).post('/api/auth/login').send(loginCredentials); const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
@@ -416,10 +401,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /forgot-password', () => { describe('POST /forgot-password', () => {
it('should send a reset link if the user exists', async () => { it('should send a reset link if the user exists', async () => {
// Arrange // Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue( mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }),
);
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Act // Act
const response = await supertest(app) const response = await supertest(app)
@@ -433,7 +415,7 @@ describe('Auth Routes (/api/auth)', () => {
}); });
it('should return a generic success message even if the user does not exist', async () => { it('should return a generic success message even if the user does not exist', async () => {
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(undefined); mockedAuthService.resetPassword.mockResolvedValue(undefined);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/forgot-password') .post('/api/auth/forgot-password')
@@ -444,7 +426,7 @@ describe('Auth Routes (/api/auth)', () => {
}); });
it('should return 500 if the database call fails', async () => { it('should return 500 if the database call fails', async () => {
vi.mocked(db.userRepo.findUserByEmail).mockRejectedValue(new Error('DB connection failed')); mockedAuthService.resetPassword.mockRejectedValue(new Error('DB connection failed'));
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/forgot-password') .post('/api/auth/forgot-password')
.send({ email: 'any@test.com' }); .send({ email: 'any@test.com' });
@@ -452,25 +434,6 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
}); });
it('should still return 200 OK if the email service fails', async () => {
// Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(
createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }),
);
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Mock the email service to fail
const { sendPasswordResetEmail } = await import('../services/emailService.server');
vi.mocked(sendPasswordResetEmail).mockRejectedValue(new Error('SMTP server down'));
// Act
const response = await supertest(app)
.post('/api/auth/forgot-password')
.send({ email: 'test@test.com' });
// Assert: The route should not fail even if the email does.
expect(response.status).toBe(200);
});
it('should return 400 for an invalid email format', async () => { it('should return 400 for an invalid email format', async () => {
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/forgot-password') .post('/api/auth/forgot-password')
@@ -483,16 +446,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /reset-password', () => { describe('POST /reset-password', () => {
it('should reset the password with a valid token and strong password', async () => { it('should reset the password with a valid token and strong password', async () => {
const tokenRecord = { mockedAuthService.updatePassword.mockResolvedValue(true);
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]); // This was a duplicate, fixed.
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Token matches
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
vi.mocked(db.userRepo.deleteResetToken).mockResolvedValue(undefined);
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/reset-password') .post('/api/auth/reset-password')
@@ -503,7 +457,7 @@ describe('Auth Routes (/api/auth)', () => {
}); });
it('should reject with an invalid or expired token', async () => { it('should reject with an invalid or expired token', async () => {
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([]); // No valid tokens found mockedAuthService.updatePassword.mockResolvedValue(null);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/reset-password') .post('/api/auth/reset-password')
@@ -513,31 +467,8 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.body.message).toBe('Invalid or expired password reset token.'); expect(response.body.message).toBe('Invalid or expired password reset token.');
}); });
it('should reject if token does not match any valid tokens in DB', async () => {
const tokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never); // Token does not match
const response = await supertest(app)
.post('/api/auth/reset-password')
.send({ token: 'wrong-token', newPassword: 'a-Very-Strong-Password-123!' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid or expired password reset token.');
});
it('should return 400 for a weak new password', async () => { it('should return 400 for a weak new password', async () => {
const tokenRecord = { // No need to mock the service here as validation runs first
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/reset-password') .post('/api/auth/reset-password')
@@ -557,11 +488,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /refresh-token', () => { describe('POST /refresh-token', () => {
it('should issue a new access token with a valid refresh token cookie', async () => { it('should issue a new access token with a valid refresh token cookie', async () => {
const mockUser = createMockUserWithPasswordHash({ mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-access-token' });
user_id: 'user-123',
email: 'test@test.com',
});
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/refresh-token') .post('/api/auth/refresh-token')
@@ -578,8 +505,7 @@ describe('Auth Routes (/api/auth)', () => {
}); });
it('should return 403 if refresh token is invalid', async () => { it('should return 403 if refresh token is invalid', async () => {
// Mock finding no user for this token, which should trigger the 403 logic mockedAuthService.refreshAccessToken.mockResolvedValue(null);
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(undefined as any);
const response = await supertest(app) const response = await supertest(app)
.post('/api/auth/refresh-token') .post('/api/auth/refresh-token')
@@ -590,7 +516,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should return 500 if the database call fails', async () => { it('should return 500 if the database call fails', async () => {
// Arrange // Arrange
vi.mocked(db.userRepo.findUserByRefreshToken).mockRejectedValue(new Error('DB Error')); mockedAuthService.refreshAccessToken.mockRejectedValue(new Error('DB Error'));
// Act // Act
const response = await supertest(app) const response = await supertest(app)
@@ -604,7 +530,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /logout', () => { describe('POST /logout', () => {
it('should clear the refresh token cookie and return a success message', async () => { it('should clear the refresh token cookie and return a success message', async () => {
// Arrange // Arrange
vi.mocked(db.userRepo.deleteRefreshToken).mockResolvedValue(undefined); mockedAuthService.logout.mockResolvedValue(undefined);
// Act // Act
const response = await supertest(app) const response = await supertest(app)
@@ -627,7 +553,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => { it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
// Arrange // Arrange
const dbError = new Error('DB connection lost'); const dbError = new Error('DB connection lost');
vi.mocked(db.userRepo.deleteRefreshToken).mockRejectedValue(dbError); mockedAuthService.logout.mockRejectedValue(dbError);
const { logger } = await import('../services/logger.server'); const { logger } = await import('../services/logger.server');
// Act // Act
@@ -639,7 +565,7 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(logger.error).toHaveBeenCalledWith( expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ error: dbError }), expect.objectContaining({ error: dbError }),
'Failed to delete refresh token from DB during logout.', 'Logout token invalidation failed in background.',
); );
}); });

View File

@@ -1,26 +1,18 @@
// src/routes/auth.routes.ts // src/routes/auth.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import * as bcrypt from 'bcrypt';
import { z } from 'zod'; import { z } from 'zod';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import passport from './passport.routes'; import passport from './passport.routes';
import { userRepo, adminRepo } from '../services/db/index.db'; import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
import { UniqueConstraintError } from '../services/db/errors.db';
import { getPool } from '../services/db/connection.db';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { sendPasswordResetEmail } from '../services/emailService.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
import type { UserProfile } from '../types'; import type { UserProfile } from '../types';
import { validatePasswordStrength } from '../utils/authUtils'; import { validatePasswordStrength } from '../utils/authUtils';
import { requiredString } from '../utils/zodUtils'; import { requiredString } from '../utils/zodUtils';
import { authService } from '../services/authService';
const router = Router(); const router = Router();
const JWT_SECRET = process.env.JWT_SECRET!;
// Conditionally disable rate limiting for the test environment // Conditionally disable rate limiting for the test environment
const isTestEnv = process.env.NODE_ENV === 'test'; const isTestEnv = process.env.NODE_ENV === 'test';
@@ -31,7 +23,9 @@ const forgotPasswordLimiter = rateLimit({
message: 'Too many password reset requests from this IP, please try again after 15 minutes.', message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment // Do not skip in test environment so we can write integration tests for it.
// The limiter uses an in-memory store by default, so counts are reset when the test server restarts.
// skip: () => isTestEnv,
}); });
const resetPasswordLimiter = rateLimit({ const resetPasswordLimiter = rateLimit({
@@ -45,21 +39,31 @@ const resetPasswordLimiter = rateLimit({
const registerSchema = z.object({ const registerSchema = z.object({
body: z.object({ body: z.object({
email: z.string().email('A valid email is required.'), // Sanitize email by trimming and converting to lowercase.
email: z.string().trim().toLowerCase().email('A valid email is required.'),
password: z password: z
.string() .string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.') .min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => { .superRefine((password, ctx) => {
const strength = validatePasswordStrength(password); const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback }); if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}), }),
full_name: z.string().optional(), // Sanitize optional string inputs.
avatar_url: z.string().url().optional(), full_name: z.string().trim().optional(),
// Allow empty string or valid URL. If empty string is received, convert to undefined.
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().trim().url().optional(),
),
}), }),
}); });
const forgotPasswordSchema = z.object({ const forgotPasswordSchema = z.object({
body: z.object({ email: z.string().email('A valid email is required.') }), body: z.object({
// Sanitize email by trimming and converting to lowercase.
email: z.string().trim().toLowerCase().email('A valid email is required.'),
}),
}); });
const resetPasswordSchema = z.object({ const resetPasswordSchema = z.object({
@@ -67,6 +71,7 @@ const resetPasswordSchema = z.object({
token: requiredString('Token is required.'), token: requiredString('Token is required.'),
newPassword: z newPassword: z
.string() .string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.') .min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => { .superRefine((password, ctx) => {
const strength = validatePasswordStrength(password); const strength = validatePasswordStrength(password);
@@ -88,39 +93,14 @@ router.post(
} = req as unknown as RegisterRequest; } = req as unknown as RegisterRequest;
try { try {
const saltRounds = 10; const { newUserProfile, accessToken, refreshToken } = await authService.registerAndLoginUser(
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
// The createUser method in UserRepository now handles its own transaction.
const newUser = await userRepo.createUser(
email, email,
hashedPassword, password,
{ full_name, avatar_url }, full_name,
avatar_url,
req.log, req.log,
); );
const userEmail = newUser.user.email;
const userId = newUser.user.user_id;
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
// Use the new standardized logging function
await adminRepo.logActivity(
{
userId: newUser.user.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
icon: 'user-plus',
},
req.log,
);
const payload = { user_id: newUser.user.user_id, email: userEmail };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
const refreshToken = crypto.randomBytes(64).toString('hex');
await userRepo.saveRefreshToken(newUser.user.user_id, refreshToken, req.log);
res.cookie('refreshToken', refreshToken, { res.cookie('refreshToken', refreshToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
@@ -128,7 +108,7 @@ router.post(
}); });
return res return res
.status(201) .status(201)
.json({ message: 'User registered successfully!', userprofile: newUser, token }); .json({ message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof UniqueConstraintError) { if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status. // If the email is a duplicate, return a 409 Conflict status.
@@ -154,17 +134,6 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.'); if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
try {
const allUsersInDb = await getPool().query(
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
);
req.log.debug('[API /login] Current users in DB from SERVER perspective:');
console.table(allUsersInDb.rows);
} catch (dbError) {
req.log.error({ dbError }, '[API /login] Could not query users table for debugging.');
}
// --- END DEBUG LOGGING ---
const { rememberMe } = req.body;
if (err) { if (err) {
req.log.error( req.log.error(
{ error: err }, { error: err },
@@ -176,33 +145,24 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
return res.status(401).json({ message: info.message || 'Login failed' }); return res.status(401).json({ message: info.message || 'Login failed' });
} }
const userProfile = user as UserProfile;
const payload = {
user_id: userProfile.user.user_id,
email: userProfile.user.email,
role: userProfile.role,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
try { try {
const refreshToken = crypto.randomBytes(64).toString('hex'); const { rememberMe } = req.body;
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log); const userProfile = user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`); req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
}; };
res.cookie('refreshToken', refreshToken, cookieOptions); res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client. // Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken }); return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) { } catch (tokenErr) {
req.log.error( const email = (user as UserProfile)?.user?.email || req.body.email;
{ error: tokenErr }, req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
`Failed to save refresh token during login for user: ${userProfile.user.email}`,
);
return next(tokenErr); return next(tokenErr);
} }
}, },
@@ -221,38 +181,14 @@ router.post(
} = req as unknown as ForgotPasswordRequest; } = req as unknown as ForgotPasswordRequest;
try { try {
req.log.debug(`[API /forgot-password] Received request for email: ${email}`); // The service handles finding the user, creating the token, and sending the email.
const user = await userRepo.findUserByEmail(email, req.log); const token = await authService.resetPassword(email, req.log);
let token: string | undefined;
req.log.debug(
{ user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' },
`[API /forgot-password] Database search result for ${email}:`,
);
if (user) {
token = crypto.randomBytes(32).toString('hex');
const saltRounds = 10;
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, req.log);
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
try {
await sendPasswordResetEmail(email, resetLink, req.log);
} catch (emailError) {
req.log.error({ emailError }, `Email send failure during password reset for user`);
}
} else {
req.log.warn(`Password reset requested for non-existent email: ${email}`);
}
// For testability, return the token in the response only in the test environment. // For testability, return the token in the response only in the test environment.
const responsePayload: { message: string; token?: string } = { const responsePayload: { message: string; token?: string } = {
message: 'If an account with that email exists, a password reset link has been sent.', message: 'If an account with that email exists, a password reset link has been sent.',
}; };
if (process.env.NODE_ENV === 'test' && user) responsePayload.token = token; if (process.env.NODE_ENV === 'test' && token) responsePayload.token = token;
res.status(200).json(responsePayload); res.status(200).json(responsePayload);
} catch (error) { } catch (error) {
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`); req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
@@ -273,38 +209,12 @@ router.post(
} = req as unknown as ResetPasswordRequest; } = req as unknown as ResetPasswordRequest;
try { try {
const validTokens = await userRepo.getValidResetTokens(req.log); const resetSuccessful = await authService.updatePassword(token, newPassword, req.log);
let tokenRecord;
for (const record of validTokens) {
const isMatch = await bcrypt.compare(token, record.token_hash);
if (isMatch) {
tokenRecord = record;
break;
}
}
if (!tokenRecord) { if (!resetSuccessful) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' }); return res.status(400).json({ message: 'Invalid or expired password reset token.' });
} }
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, req.log);
await userRepo.deleteResetToken(tokenRecord.token_hash, req.log);
// Log this security event after a successful password reset.
await adminRepo.logActivity(
{
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
icon: 'key',
details: { source_ip: req.ip ?? null },
},
req.log,
);
res.status(200).json({ message: 'Password has been reset successfully.' }); res.status(200).json({ message: 'Password has been reset successfully.' });
} catch (error) { } catch (error) {
req.log.error({ error }, `An error occurred during password reset.`); req.log.error({ error }, `An error occurred during password reset.`);
@@ -321,15 +231,11 @@ router.post('/refresh-token', async (req: Request, res: Response, next: NextFunc
} }
try { try {
const user = await userRepo.findUserByRefreshToken(refreshToken, req.log); const result = await authService.refreshAccessToken(refreshToken, req.log);
if (!user) { if (!result) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' }); return res.status(403).json({ message: 'Invalid or expired refresh token.' });
} }
res.json({ token: result.accessToken });
const payload = { user_id: user.user_id, email: user.email };
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
res.json({ token: newAccessToken });
} catch (error) { } catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.'); req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error); next(error);
@@ -346,8 +252,8 @@ router.post('/logout', async (req: Request, res: Response) => {
if (refreshToken) { if (refreshToken) {
// Invalidate the token in the database so it cannot be used again. // Invalidate the token in the database so it cannot be used again.
// We don't need to wait for this to finish to respond to the user. // We don't need to wait for this to finish to respond to the user.
userRepo.deleteRefreshToken(refreshToken, req.log).catch((err: Error) => { authService.logout(refreshToken, req.log).catch((err: Error) => {
req.log.error({ error: err }, 'Failed to delete refresh token from DB during logout.'); req.log.error({ error: err }, 'Logout token invalidation failed in background.');
}); });
} }
// Instruct the browser to clear the cookie by setting its expiration to the past. // Instruct the browser to clear the cookie by setting its expiration to the past.

View File

@@ -1,11 +1,10 @@
// src/routes/gamification.routes.ts // src/routes/gamification.routes.ts
import express, { NextFunction } from 'express'; import express, { NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import passport, { isAdmin } from './passport.routes'; import passport, { isAdmin } from './passport.routes'; // Correctly imported
import { gamificationRepo } from '../services/db/index.db'; import { gamificationService } from '../services/gamificationService';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { UserProfile } from '../types'; import { UserProfile } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, optionalNumeric } from '../utils/zodUtils'; import { requiredString, optionalNumeric } from '../utils/zodUtils';
@@ -14,10 +13,12 @@ const adminGamificationRouter = express.Router(); // Create a new router for adm
// --- Zod Schemas for Gamification Routes (as per ADR-003) --- // --- Zod Schemas for Gamification Routes (as per ADR-003) ---
const leaderboardQuerySchema = z.object({
limit: optionalNumeric({ default: 10, integer: true, positive: true, max: 50 }),
});
const leaderboardSchema = z.object({ const leaderboardSchema = z.object({
query: z.object({ query: leaderboardQuerySchema,
limit: optionalNumeric({ default: 10, integer: true, positive: true, max: 50 }),
}),
}); });
const awardAchievementSchema = z.object({ const awardAchievementSchema = z.object({
@@ -35,7 +36,7 @@ const awardAchievementSchema = z.object({
*/ */
router.get('/', async (req, res, next: NextFunction) => { router.get('/', async (req, res, next: NextFunction) => {
try { try {
const achievements = await gamificationRepo.getAllAchievements(req.log); const achievements = await gamificationService.getAllAchievements(req.log);
res.json(achievements); res.json(achievements);
} catch (error) { } catch (error) {
logger.error({ error }, 'Error fetching all achievements in /api/achievements:'); logger.error({ error }, 'Error fetching all achievements in /api/achievements:');
@@ -51,14 +52,11 @@ router.get(
'/leaderboard', '/leaderboard',
validateRequest(leaderboardSchema), validateRequest(leaderboardSchema),
async (req, res, next: NextFunction): Promise<void> => { async (req, res, next: NextFunction): Promise<void> => {
// Apply ADR-003 pattern for type safety.
// Explicitly coerce query params to ensure numbers are passed to the repo,
// as validateRequest might not replace req.query in all test environments.
const query = req.query as unknown as { limit?: string };
const limit = query.limit ? Number(query.limit) : 10;
try { try {
const leaderboard = await gamificationRepo.getLeaderboard(limit, req.log); // The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit } = leaderboardQuerySchema.parse(req.query);
const leaderboard = await gamificationService.getLeaderboard(limit!, req.log);
res.json(leaderboard); res.json(leaderboard);
} catch (error) { } catch (error) {
logger.error({ error }, 'Error fetching leaderboard:'); logger.error({ error }, 'Error fetching leaderboard:');
@@ -79,7 +77,7 @@ router.get(
async (req, res, next: NextFunction): Promise<void> => { async (req, res, next: NextFunction): Promise<void> => {
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
try { try {
const userAchievements = await gamificationRepo.getUserAchievements( const userAchievements = await gamificationService.getUserAchievements(
userProfile.user.user_id, userProfile.user.user_id,
req.log, req.log,
); );
@@ -111,21 +109,13 @@ adminGamificationRouter.post(
type AwardAchievementRequest = z.infer<typeof awardAchievementSchema>; type AwardAchievementRequest = z.infer<typeof awardAchievementSchema>;
const { body } = req as unknown as AwardAchievementRequest; const { body } = req as unknown as AwardAchievementRequest;
try { try {
await gamificationRepo.awardAchievement(body.userId, body.achievementName, req.log); await gamificationService.awardAchievement(body.userId, body.achievementName, req.log);
res res
.status(200) .status(200)
.json({ .json({
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`, message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
}); });
} catch (error) { } catch (error) {
if (error instanceof ForeignKeyConstraintError) {
res.status(400).json({ message: error.message });
return;
}
logger.error(
{ error, userId: body.userId, achievementName: body.achievementName },
'Error awarding achievement via admin endpoint:',
);
next(error); next(error);
} }
}, },

View File

@@ -164,11 +164,12 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
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] health.routes.test.ts: Verifying logger.error for DB schema check failure');
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
@@ -186,7 +187,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.objectContaining({ message: 'DB connection failed' }), err: expect.objectContaining({ message: 'DB connection failed' }),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
}); });
@@ -220,7 +221,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
@@ -239,7 +240,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
}); });
@@ -300,7 +301,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
@@ -321,7 +322,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.objectContaining({ message: 'Pool is not initialized' }), err: expect.objectContaining({ message: 'Pool is not initialized' }),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
@@ -336,11 +337,12 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('Connection timed out'); expect(response.body.message).toBe('Connection timed out');
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] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
@@ -357,7 +359,7 @@ describe('Health Routes (/api/health)', () => {
expect.objectContaining({ expect.objectContaining({
err: expect.any(Error), err: expect.any(Error),
}), }),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/), expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
); );
}); });
}); });

View File

@@ -482,8 +482,8 @@ describe('Passport Configuration', () => {
const mockReq: Partial<Request> = { const mockReq: Partial<Request> = {
// An object that is not a valid UserProfile (e.g., missing 'role') // An object that is not a valid UserProfile (e.g., missing 'role')
user: { user: {
user_id: 'invalid-user-id', user: { user_id: 'invalid-user-id' }, // Missing 'role' property
} as any, } as unknown as UserProfile, // Cast to UserProfile to satisfy req.user type, but it's intentionally malformed
}; };
// Act // Act

View File

@@ -260,6 +260,13 @@ const jwtOptions = {
secretOrKey: JWT_SECRET, secretOrKey: JWT_SECRET,
}; };
// --- DEBUG LOGGING FOR JWT SECRET ---
if (!JWT_SECRET) {
logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.');
} else {
logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
}
passport.use( passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload, done) => { new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
logger.debug( logger.debug(

View File

@@ -19,6 +19,12 @@ router.get(
validateRequest(emptySchema), validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
// LOGGING: Track how often this heavy DB call is actually made vs served from cache
req.log.info('Fetching master items list from database...');
// Optimization: This list changes rarely. Instruct clients to cache it for 1 hour (3600s).
res.set('Cache-Control', 'public, max-age=3600');
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log); const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems); res.json(masterItems);
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,109 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { reactionRepo } from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import passport from './passport.routes';
import { requiredString } from '../utils/zodUtils';
import { UserProfile } from '../types';
const router = Router();
// --- Zod Schemas for Reaction Routes ---
const getReactionsSchema = z.object({
query: z.object({
userId: z.string().uuid().optional(),
entityType: z.string().optional(),
entityId: z.string().optional(),
}),
});
const toggleReactionSchema = z.object({
body: z.object({
entity_type: requiredString('entity_type is required.'),
entity_id: requiredString('entity_id is required.'),
reaction_type: requiredString('reaction_type is required.'),
}),
});
const getReactionSummarySchema = z.object({
query: z.object({
entityType: requiredString('entityType is required.'),
entityId: requiredString('entityId is required.'),
}),
});
// --- Routes ---
/**
* GET /api/reactions - Fetches user reactions based on query filters.
* Supports filtering by userId, entityType, and entityId.
* This is a public endpoint.
*/
router.get(
'/',
validateRequest(getReactionsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { query } = getReactionsSchema.parse({ query: req.query });
const reactions = await reactionRepo.getReactions(query, req.log);
res.json(reactions);
} catch (error) {
req.log.error({ error }, 'Error fetching user reactions');
next(error);
}
},
);
/**
* GET /api/reactions/summary - Fetches a summary of reactions for a specific entity.
* Example: /api/reactions/summary?entityType=recipe&entityId=123
* This is a public endpoint.
*/
router.get(
'/summary',
validateRequest(getReactionSummarySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { query } = getReactionSummarySchema.parse({ query: req.query });
const summary = await reactionRepo.getReactionSummary(query.entityType, query.entityId, req.log);
res.json(summary);
} catch (error) {
req.log.error({ error }, 'Error fetching reaction summary');
next(error);
}
},
);
/**
* POST /api/reactions/toggle - Toggles a user's reaction to an entity.
* This is a protected endpoint.
*/
router.post(
'/toggle',
passport.authenticate('jwt', { session: false }),
validateRequest(toggleReactionSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type ToggleReactionRequest = z.infer<typeof toggleReactionSchema>;
const { body } = req as unknown as ToggleReactionRequest;
try {
const reactionData = {
user_id: userProfile.user.user_id,
...body,
};
const result = await reactionRepo.toggleReaction(reactionData, req.log);
if (result) {
res.status(201).json({ message: 'Reaction added.', reaction: result });
} else {
res.status(200).json({ message: 'Reaction removed.' });
}
} catch (error) {
req.log.error({ error, body }, 'Error toggling user reaction');
next(error);
}
},
);
export default router;

View File

@@ -2,6 +2,8 @@
import { Router } from 'express'; import { Router } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { aiService } from '../services/aiService.server';
import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils'; import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
@@ -28,6 +30,12 @@ const byIngredientAndTagSchema = z.object({
const recipeIdParamsSchema = numericIdParam('recipeId'); const recipeIdParamsSchema = numericIdParam('recipeId');
const suggestRecipeSchema = z.object({
body: z.object({
ingredients: z.array(z.string().min(1)).nonempty('At least one ingredient is required.'),
}),
});
/** /**
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale. * GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
*/ */
@@ -121,4 +129,31 @@ router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res,
} }
}); });
/**
* POST /api/recipes/suggest - Generates a simple recipe suggestion from a list of ingredients.
* This is a protected endpoint.
*/
router.post(
'/suggest',
passport.authenticate('jwt', { session: false }),
validateRequest(suggestRecipeSchema),
async (req, res, next) => {
try {
const { body } = req as unknown as z.infer<typeof suggestRecipeSchema>;
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
if (!suggestion) {
return res
.status(503)
.json({ message: 'AI service is currently unavailable or failed to generate a suggestion.' });
}
res.json({ suggestion });
} catch (error) {
req.log.error({ error }, 'Error generating recipe suggestion');
next(error);
}
},
);
export default router; export default router;

View File

@@ -28,10 +28,9 @@ router.get(
validateRequest(mostFrequentSalesSchema), validateRequest(mostFrequentSalesSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
// Parse req.query to ensure coercion (string -> number) and defaults are applied. // The `validateRequest` middleware ensures `req.query` is valid.
// Even though validateRequest checks validity, it may not mutate req.query with the parsed result. // We parse it here to apply Zod's coercions (string to number) and defaults.
const { days, limit } = statsQuerySchema.parse(req.query); const { days, limit } = statsQuerySchema.parse(req.query);
const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log); const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log);
res.json(items); res.json(items);
} catch (error) { } catch (error) {

View File

@@ -1,26 +1,15 @@
// src/routes/system.routes.test.ts // src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import systemRouter from './system.routes'; // This was a duplicate, fixed.
import { exec, type ExecException, type ExecOptions } from 'child_process';
import { geocodingService } from '../services/geocodingService.server';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
// FIX: Use the simple factory pattern for child_process to avoid default export issues // 1. Mock the Service Layer
vi.mock('child_process', () => { // This decouples the route test from the service's implementation details.
const mockExec = vi.fn((command, callback) => { vi.mock('../services/systemService', () => ({
if (typeof callback === 'function') { systemService: {
callback(null, 'PM2 OK', ''); getPm2Status: vi.fn(),
} },
return { unref: () => {} }; }));
});
return {
default: { exec: mockExec },
exec: mockExec,
};
});
// 2. Mock Geocoding // 2. Mock Geocoding
vi.mock('../services/geocodingService.server', () => ({ vi.mock('../services/geocodingService.server', () => ({
geocodingService: { geocodingService: {
@@ -39,44 +28,25 @@ vi.mock('../services/logger.server', () => ({
}, },
})); }));
// Import the router AFTER all mocks are defined to ensure systemService picks up the mocked util.promisify
import { systemService } from '../services/systemService';
import systemRouter from './system.routes';
import { geocodingService } from '../services/geocodingService.server';
describe('System Routes (/api/system)', () => { describe('System Routes (/api/system)', () => {
const app = createTestApp({ router: systemRouter, basePath: '/api/system' }); const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
beforeEach(() => { beforeEach(() => {
// We cast here to get type-safe access to mock functions like .mockImplementation
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('GET /pm2-status', () => { describe('GET /pm2-status', () => {
it('should return success: true when pm2 process is online', async () => { it('should return success: true when pm2 process is online', async () => {
// Arrange: Simulate a successful `pm2 describe` output for an online process. // Arrange: Simulate a successful `pm2 describe` output for an online process.
const pm2OnlineOutput = ` vi.mocked(systemService.getPm2Status).mockResolvedValue({
┌─ PM2 info ────────────────┐ success: true,
│ status │ online │ message: 'Application is online and running under PM2.',
└───────────┴───────────┘ });
`;
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
// A robust mock for `exec` that handles its multiple overloads.
// This avoids the complex and error-prone `...args` signature.
vi.mocked(exec).mockImplementation(
(
command: string,
options?: ExecOptions | ExecCallback | null,
callback?: ExecCallback | null,
) => {
// The actual callback can be the second or third argument.
const actualCallback = (
typeof options === 'function' ? options : callback
) as ExecCallback;
if (actualCallback) {
actualCallback(null, pm2OnlineOutput, '');
}
// Return a minimal object that satisfies the ChildProcess type for .unref()
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act // Act
const response = await supertest(app).get('/api/system/pm2-status'); const response = await supertest(app).get('/api/system/pm2-status');
@@ -90,28 +60,10 @@ describe('System Routes (/api/system)', () => {
}); });
it('should return success: false when pm2 process is stopped or errored', async () => { it('should return success: false when pm2 process is stopped or errored', async () => {
const pm2StoppedOutput = `│ status │ stopped │`; vi.mocked(systemService.getPm2Status).mockResolvedValue({
success: false,
vi.mocked(exec).mockImplementation( message: 'Application process exists but is not online.',
( });
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(null, pm2StoppedOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
const response = await supertest(app).get('/api/system/pm2-status'); const response = await supertest(app).get('/api/system/pm2-status');
@@ -122,33 +74,10 @@ describe('System Routes (/api/system)', () => {
it('should return success: false when pm2 process does not exist', async () => { it('should return success: false when pm2 process does not exist', async () => {
// Arrange: Simulate `pm2 describe` failing because the process isn't found. // Arrange: Simulate `pm2 describe` failing because the process isn't found.
const processNotFoundOutput = vi.mocked(systemService.getPm2Status).mockResolvedValue({
"[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist"; success: false,
const processNotFoundError = new Error( message: 'Application process is not running under PM2.',
'Command failed: pm2 describe flyer-crawler-api', });
) as ExecException;
processNotFoundError.code = 1;
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(processNotFoundError, processNotFoundOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act // Act
const response = await supertest(app).get('/api/system/pm2-status'); const response = await supertest(app).get('/api/system/pm2-status');
@@ -163,55 +92,17 @@ describe('System Routes (/api/system)', () => {
it('should return 500 if pm2 command produces stderr output', async () => { it('should return 500 if pm2 command produces stderr output', async () => {
// Arrange: Simulate a successful exit code but with content in stderr. // Arrange: Simulate a successful exit code but with content in stderr.
const stderrOutput = 'A non-fatal warning occurred.'; const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(null, 'Some stdout', stderrOutput);
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
const response = await supertest(app).get('/api/system/pm2-status'); const response = await supertest(app).get('/api/system/pm2-status');
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body.message).toBe(`PM2 command produced an error: ${stderrOutput}`); expect(response.body.message).toBe(serviceError.message);
}); });
it('should return 500 on a generic exec error', async () => { it('should return 500 on a generic exec error', async () => {
vi.mocked(exec).mockImplementation( const serviceError = new Error('System error');
( vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act // Act
const response = await supertest(app).get('/api/system/pm2-status'); const response = await supertest(app).get('/api/system/pm2-status');

View File

@@ -1,11 +1,11 @@
// src/routes/system.routes.ts // src/routes/system.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { exec } from 'child_process';
import { z } from 'zod';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { geocodingService } from '../services/geocodingService.server'; import { geocodingService } from '../services/geocodingService.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
import { z } from 'zod';
import { requiredString } from '../utils/zodUtils'; import { requiredString } from '../utils/zodUtils';
import { systemService } from '../services/systemService';
const router = Router(); const router = Router();
@@ -25,39 +25,13 @@ const emptySchema = z.object({});
router.get( router.get(
'/pm2-status', '/pm2-status',
validateRequest(emptySchema), validateRequest(emptySchema),
(req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file. try {
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => { const status = await systemService.getPm2Status();
if (error) { res.json(status);
// 'pm2 describe' exits with an error if the process is not found. } catch (error) {
// We can treat this as a "fail" status for our check. next(error);
if (stdout && stdout.includes("doesn't exist")) { }
logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.');
return res.json({
success: false,
message: 'Application process is not running under PM2.',
});
}
logger.error(
{ error: stderr || error.message },
'[API /pm2-status] Error executing pm2 describe:',
);
return next(error);
}
// Check if there was output to stderr, even if the exit code was 0 (success).
if (stderr && stderr.trim().length > 0) {
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
return next(new Error(`PM2 command produced an error: ${stderr}`));
}
// If the command succeeds, we can parse stdout to check the status.
const isOnline = /│ status\s+│ online\s+│/m.test(stdout);
const message = isOnline
? 'Application is online and running under PM2.'
: 'Application process exists but is not online.';
res.json({ success: isOnline, message });
});
}, },
); );

View File

@@ -1,8 +1,8 @@
// src/routes/user.routes.test.ts // src/routes/user.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 express from 'express'; import express from 'express';
import * as bcrypt from 'bcrypt'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { import {
createMockUserProfile, createMockUserProfile,
@@ -17,10 +17,12 @@ import {
createMockAddress, createMockAddress,
} from '../tests/utils/mockFactories'; } from '../tests/utils/mockFactories';
import { Appliance, Notification, DietaryRestriction } from '../types'; import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db'; import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { cleanupFiles } from '../tests/utils/cleanupFiles';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { userService } from '../services/userService';
// 1. Mock the Service Layer directly. // 1. Mock the Service Layer directly.
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module. // The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
@@ -29,9 +31,6 @@ vi.mock('../services/db/index.db', () => ({
userRepo: { userRepo: {
findUserProfileById: vi.fn(), findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(), updateUserProfile: vi.fn(),
updateUserPassword: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
deleteUserById: vi.fn(),
updateUserPreferences: vi.fn(), updateUserPreferences: vi.fn(),
}, },
personalizationRepo: { personalizationRepo: {
@@ -70,22 +69,14 @@ vi.mock('../services/db/index.db', () => ({
// Mock userService // Mock userService
vi.mock('../services/userService', () => ({ vi.mock('../services/userService', () => ({
userService: { userService: {
updateUserAvatar: vi.fn(),
updateUserPassword: vi.fn(),
deleteUserAccount: vi.fn(),
getUserAddress: vi.fn(),
upsertUserAddress: vi.fn(), upsertUserAddress: vi.fn(),
}, },
})); }));
// 2. Mock bcrypt.
// We return an object that satisfies both default and named imports to be safe.
vi.mock('bcrypt', () => {
const hash = vi.fn();
const compare = vi.fn();
return {
default: { hash, compare },
hash,
compare,
};
});
// Mock the logger // Mock the logger
vi.mock('../services/logger.server', async () => ({ vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger // Use async import to avoid hoisting issues with mockLogger
@@ -94,7 +85,6 @@ vi.mock('../services/logger.server', async () => ({
// Import the router and other modules AFTER mocks are established // Import the router and other modules AFTER mocks are established
import userRouter from './user.routes'; import userRouter from './user.routes';
import { userService } from '../services/userService'; // Import for checking calls
// Import the mocked db module to control its functions in tests // Import the mocked db module to control its functions in tests
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
@@ -178,6 +168,26 @@ describe('User Routes (/api/users)', () => {
beforeEach(() => { beforeEach(() => {
// All tests in this block will use the authenticated app // All tests in this block will use the authenticated app
}); });
afterAll(async () => {
// Safeguard to clean up any avatar files created during tests.
const uploadDir = path.resolve(__dirname, '../../../uploads/avatars');
try {
const allFiles = await fs.readdir(uploadDir);
// Files are named like 'avatar-user-123-timestamp.ext'
const testFiles = allFiles
.filter((f) => f.startsWith(`avatar-${mockUserProfile.user.user_id}`))
.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 user routes test file cleanup:', error);
}
}
});
describe('GET /profile', () => { describe('GET /profile', () => {
it('should return the full user profile', async () => { it('should return the full user profile', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile); vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
@@ -472,6 +482,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem); expect(response.body).toEqual(mockAddedItem);
expect(db.shoppingRepo.addShoppingListItem).toHaveBeenCalledWith(
listId,
mockUserProfile.user.user_id,
itemData,
expectLogger,
);
}); });
it('should return 400 on foreign key error when adding an item', async () => { it('should return 400 on foreign key error when adding an item', async () => {
@@ -509,6 +525,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedItem); expect(response.body).toEqual(mockUpdatedItem);
expect(db.shoppingRepo.updateShoppingListItem).toHaveBeenCalledWith(
itemId,
mockUserProfile.user.user_id,
updates,
expectLogger,
);
}); });
it('should return 404 if item to update is not found', async () => { it('should return 404 if item to update is not found', async () => {
@@ -544,6 +566,11 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined); vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/items/101'); const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
expect(response.status).toBe(204); expect(response.status).toBe(204);
expect(db.shoppingRepo.removeShoppingListItem).toHaveBeenCalledWith(
101,
mockUserProfile.user.user_id,
expectLogger,
);
}); });
it('should return 404 if item to delete is not found', async () => { it('should return 404 if item to delete is not found', async () => {
@@ -575,6 +602,27 @@ describe('User Routes (/api/users)', () => {
expect(response.body).toEqual(updatedProfile); expect(response.body).toEqual(updatedProfile);
}); });
it('should allow updating the profile with an empty string for avatar_url', async () => {
// Arrange
const profileUpdates = { avatar_url: '' };
// The service should receive `undefined` after Zod preprocessing
const updatedProfile = createMockUserProfile({ ...mockUserProfile, avatar_url: undefined });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
// Act
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
// Verify that the Zod schema preprocessed the empty string to undefined
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
{ avatar_url: undefined },
expectLogger,
);
});
it('should return 500 on a generic database error', async () => { it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Connection Failed'); const dbError = new Error('DB Connection Failed');
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError); vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
@@ -599,20 +647,17 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile/password', () => { describe('PUT /profile/password', () => {
it('should update the password successfully with a strong password', async () => { it('should update the password successfully with a strong password', async () => {
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never); vi.mocked(userService.updateUserPassword).mockResolvedValue(undefined);
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
const response = await supertest(app) const response = await supertest(app)
.put('/api/users/profile/password') .put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' }); .send({ newPassword: 'a-Very-Strong-Password-456!' });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.message).toBe('Password updated successfully.'); expect(response.body.message).toBe('Password updated successfully.');
}); });
it('should return 500 on a generic database error', async () => { it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Connection Failed'); const dbError = new Error('DB Connection Failed');
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never); vi.mocked(userService.updateUserPassword).mockRejectedValue(dbError);
vi.mocked(db.userRepo.updateUserPassword).mockRejectedValue(dbError);
const response = await supertest(app) const response = await supertest(app)
.put('/api/users/profile/password') .put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' }); .send({ newPassword: 'a-Very-Strong-Password-456!' });
@@ -624,7 +669,6 @@ describe('User Routes (/api/users)', () => {
}); });
it('should return 400 for a weak password', async () => { it('should return 400 for a weak password', async () => {
// Use a password long enough to pass .min(8) but weak enough to fail strength check
const response = await supertest(app) const response = await supertest(app)
.put('/api/users/profile/password') .put('/api/users/profile/password')
.send({ newPassword: 'password123' }); .send({ newPassword: 'password123' });
@@ -636,70 +680,38 @@ describe('User Routes (/api/users)', () => {
describe('DELETE /account', () => { describe('DELETE /account', () => {
it('should delete the account with the correct password', async () => { it('should delete the account with the correct password', async () => {
const userWithHash = createMockUserWithPasswordHash({ vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
const response = await supertest(app) const response = await supertest(app)
.delete('/api/users/account') .delete('/api/users/account')
.send({ password: 'correct-password' }); .send({ password: 'correct-password' });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.message).toBe('Account deleted successfully.'); expect(response.body.message).toBe('Account deleted successfully.');
expect(userService.deleteUserAccount).toHaveBeenCalledWith('user-123', 'correct-password', expectLogger);
}); });
it('should return 403 for an incorrect password', async () => { it('should return 400 for an incorrect password', async () => {
const userWithHash = createMockUserWithPasswordHash({ vi.mocked(userService.deleteUserAccount).mockRejectedValue(new ValidationError([], 'Incorrect password.'));
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app) const response = await supertest(app)
.delete('/api/users/account') .delete('/api/users/account')
.send({ password: 'wrong-password' }); .send({ password: 'wrong-password' });
expect(response.status).toBe(403); expect(response.status).toBe(400);
expect(response.body.message).toBe('Incorrect password.'); expect(response.body.message).toBe('Incorrect password.');
}); });
it('should return 404 if the user to delete is not found', async () => { it('should return 404 if the user to delete is not found', async () => {
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockRejectedValue( vi.mocked(userService.deleteUserAccount).mockRejectedValue(new NotFoundError('User not found.'));
new NotFoundError('User not found or password not set.'),
);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'any-password' });
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found or password not set.');
});
it('should return 404 if user is an OAuth user without a password', async () => {
// Simulate an OAuth user who has no password_hash set.
const userWithoutHash = createMockUserWithPasswordHash({
...mockUserProfile.user,
password_hash: null,
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithoutHash);
const response = await supertest(app) const response = await supertest(app)
.delete('/api/users/account') .delete('/api/users/account')
.send({ password: 'any-password' }); .send({ password: 'any-password' });
expect(response.status).toBe(404); expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found or password not set.'); expect(response.body.message).toBe('User not found.');
}); });
it('should return 500 on a generic database error', async () => { it('should return 500 on a generic database error', async () => {
const userWithHash = createMockUserWithPasswordHash({ vi.mocked(userService.deleteUserAccount).mockRejectedValue(new Error('DB Connection Failed'));
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
vi.mocked(db.userRepo.deleteUserById).mockRejectedValue(new Error('DB Connection Failed'));
const response = await supertest(app) const response = await supertest(app)
.delete('/api/users/account') .delete('/api/users/account')
.send({ password: 'correct-password' }); .send({ password: 'correct-password' });
@@ -980,7 +992,7 @@ describe('User Routes (/api/users)', () => {
authenticatedUser: { ...mockUserProfile, address_id: 1 }, authenticatedUser: { ...mockUserProfile, address_id: 1 },
}); });
const mockAddress = createMockAddress({ address_id: 1, address_line_1: '123 Main St' }); const mockAddress = createMockAddress({ address_id: 1, address_line_1: '123 Main St' });
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(mockAddress); vi.mocked(userService.getUserAddress).mockResolvedValue(mockAddress);
const response = await supertest(appWithUser).get('/api/users/addresses/1'); const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(mockAddress); expect(response.body).toEqual(mockAddress);
@@ -992,7 +1004,7 @@ describe('User Routes (/api/users)', () => {
basePath, basePath,
authenticatedUser: { ...mockUserProfile, address_id: 1 }, authenticatedUser: { ...mockUserProfile, address_id: 1 },
}); });
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new Error('DB Error')); vi.mocked(userService.getUserAddress).mockRejectedValue(new Error('DB Error'));
const response = await supertest(appWithUser).get('/api/users/addresses/1'); const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(500); expect(response.status).toBe(500);
}); });
@@ -1005,13 +1017,10 @@ describe('User Routes (/api/users)', () => {
}); });
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => { it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
const appWithDifferentUser = createTestApp({ vi.mocked(userService.getUserAddress).mockRejectedValue(new ValidationError([], 'Forbidden'));
router: userRouter, const response = await supertest(app).get('/api/users/addresses/2'); // Requesting address 2
basePath, expect(response.status).toBe(400); // ValidationError maps to 400 by default in the test error handler
authenticatedUser: { ...mockUserProfile, address_id: 999 }, expect(response.body.message).toBe('Forbidden');
});
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
expect(response.status).toBe(403);
}); });
it('GET /addresses/:addressId should return 404 if address not found', async () => { it('GET /addresses/:addressId should return 404 if address not found', async () => {
@@ -1020,7 +1029,7 @@ describe('User Routes (/api/users)', () => {
basePath, basePath,
authenticatedUser: { ...mockUserProfile, address_id: 1 }, authenticatedUser: { ...mockUserProfile, address_id: 1 },
}); });
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue( vi.mocked(userService.getUserAddress).mockRejectedValue(
new NotFoundError('Address not found.'), new NotFoundError('Address not found.'),
); );
const response = await supertest(appWithUser).get('/api/users/addresses/1'); const response = await supertest(appWithUser).get('/api/users/addresses/1');
@@ -1029,19 +1038,10 @@ describe('User Routes (/api/users)', () => {
}); });
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => { it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const appWithUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: null },
}); // User has no address yet
const addressData = { address_line_1: '123 New St' }; const addressData = { address_line_1: '123 New St' };
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5 vi.mocked(userService.upsertUserAddress).mockResolvedValue(5);
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({
...mockUserProfile,
address_id: 5,
});
const response = await supertest(appWithUser) const response = await supertest(app)
.put('/api/users/profile/address') .put('/api/users/profile/address')
.send(addressData); .send(addressData);
@@ -1073,11 +1073,11 @@ describe('User Routes (/api/users)', () => {
describe('POST /profile/avatar', () => { describe('POST /profile/avatar', () => {
it('should upload an avatar and update the user profile', async () => { it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = { const mockUpdatedProfile = createMockUserProfile({
...mockUserProfile, ...mockUserProfile,
avatar_url: '/uploads/avatars/new-avatar.png', avatar_url: '/uploads/avatars/new-avatar.png',
}; });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUpdatedProfile); vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
// Create a dummy file path for supertest to attach // Create a dummy file path for supertest to attach
const dummyImagePath = 'test-avatar.png'; const dummyImagePath = 'test-avatar.png';
@@ -1087,17 +1087,17 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath); .attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/'); expect(response.body.avatar_url).toContain('/uploads/avatars/'); // This was a duplicate, fixed.
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith( expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id, mockUserProfile.user.user_id,
{ avatar_url: expect.any(String) }, expect.any(Object),
expectLogger, expectLogger,
); );
}); });
it('should return 500 if updating the profile fails after upload', async () => { it('should return 500 if updating the profile fails after upload', async () => {
const dbError = new Error('DB Connection Failed'); const dbError = new Error('DB Connection Failed');
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError); vi.mocked(userService.updateUserAvatar).mockRejectedValue(dbError);
const dummyImagePath = 'test-avatar.png'; const dummyImagePath = 'test-avatar.png';
const response = await supertest(app) const response = await supertest(app)
.post('/api/users/profile/avatar') .post('/api/users/profile/avatar')
@@ -1141,7 +1141,7 @@ describe('User Routes (/api/users)', () => {
const unlinkSpy = vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); const unlinkSpy = vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
const dbError = new Error('DB Connection Failed'); const dbError = new Error('DB Connection Failed');
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError); vi.mocked(userService.updateUserAvatar).mockRejectedValue(dbError);
const dummyImagePath = 'test-avatar.png'; const dummyImagePath = 'test-avatar.png';
const response = await supertest(app) const response = await supertest(app)

View File

@@ -2,8 +2,6 @@
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes'; import passport from './passport.routes';
import multer from 'multer'; // Keep for MulterError type check import multer from 'multer'; // Keep for MulterError type check
import fs from 'node:fs/promises';
import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed.
import { z } from 'zod'; import { z } from 'zod';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { UserProfile } from '../types'; import { UserProfile } from '../types';
@@ -22,25 +20,19 @@ import {
optionalBoolean, optionalBoolean,
} from '../utils/zodUtils'; } from '../utils/zodUtils';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { cleanupUploadedFile } from '../utils/fileUtils';
/**
* Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist.
* @param file The multer file object to delete.
*/
const cleanupUploadedFile = async (file?: Express.Multer.File) => {
if (!file) return;
try {
await fs.unlink(file.path);
} catch (err) {
logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded avatar file.');
}
};
const router = express.Router(); const router = express.Router();
const updateProfileSchema = z.object({ const updateProfileSchema = z.object({
body: z body: z
.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() }) .object({
full_name: z.string().optional(),
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().trim().url().optional(),
),
})
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: 'At least one field to update must be provided.', message: 'At least one field to update must be provided.',
}), }),
@@ -50,6 +42,7 @@ const updatePasswordSchema = z.object({
body: z.object({ body: z.object({
newPassword: z newPassword: z
.string() .string()
.trim() // Trim whitespace from password input.
.min(8, 'Password must be at least 8 characters long.') .min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => { .superRefine((password, ctx) => {
const strength = validatePasswordStrength(password); const strength = validatePasswordStrength(password);
@@ -58,6 +51,9 @@ const updatePasswordSchema = z.object({
}), }),
}); });
// The `requiredString` utility (modified in `zodUtils.ts`) now handles trimming,
// so no changes are needed here, but we are confirming that password trimming
// is now implicitly handled for this schema.
const deleteAccountSchema = z.object({ const deleteAccountSchema = z.object({
body: z.object({ password: requiredString("Field 'password' is required.") }), body: z.object({ password: requiredString("Field 'password' is required.") }),
}); });
@@ -103,14 +99,10 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
// The try-catch block was already correct here. // The try-catch block was already correct here.
try { try {
// The `requireFileUpload` middleware is not used here, so we must check for `req.file`.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' }); if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const userProfile = req.user as UserProfile; const userProfile = req.user as UserProfile;
const avatarUrl = `/uploads/avatars/${req.file.filename}`; const updatedProfile = await userService.updateUserAvatar(userProfile.user.user_id, req.file, req.log);
const updatedProfile = await db.userRepo.updateUserProfile(
userProfile.user.user_id,
{ avatar_url: avatarUrl },
req.log,
);
res.json(updatedProfile); res.json(updatedProfile);
} catch (error) { } catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error), // If an error occurs after the file has been uploaded (e.g., DB error),
@@ -257,9 +249,7 @@ router.put(
const { body } = req as unknown as UpdatePasswordRequest; const { body } = req as unknown as UpdatePasswordRequest;
try { try {
const saltRounds = 10; await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, req.log);
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
await db.userRepo.updateUserPassword(userProfile.user.user_id, hashedPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' }); res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) { } catch (error) {
logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`); logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
@@ -282,20 +272,7 @@ router.delete(
const { body } = req as unknown as DeleteAccountRequest; const { body } = req as unknown as DeleteAccountRequest;
try { try {
const userWithHash = await db.userRepo.findUserWithPasswordHashById( await userService.deleteUserAccount(userProfile.user.user_id, body.password, req.log);
userProfile.user.user_id,
req.log,
);
if (!userWithHash || !userWithHash.password_hash) {
return res.status(404).json({ message: 'User not found or password not set.' });
}
const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash);
if (!isMatch) {
return res.status(403).json({ message: 'Incorrect password.' });
}
await db.userRepo.deleteUserById(userProfile.user.user_id, req.log);
res.status(200).json({ message: 'Account deleted successfully.' }); res.status(200).json({ message: 'Account deleted successfully.' });
} catch (error) { } catch (error) {
logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`); logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
@@ -485,7 +462,11 @@ const addShoppingListItemSchema = shoppingListIdSchema.extend({
body: z body: z
.object({ .object({
masterItemId: z.number().int().positive().optional(), masterItemId: z.number().int().positive().optional(),
customItemName: z.string().min(1, 'customItemName cannot be empty if provided').optional(), customItemName: z
.string()
.trim()
.min(1, 'customItemName cannot be empty if provided')
.optional(),
}) })
.refine((data) => data.masterItemId || data.customItemName, { .refine((data) => data.masterItemId || data.customItemName, {
message: 'Either masterItemId or customItemName must be provided.', message: 'Either masterItemId or customItemName must be provided.',
@@ -497,10 +478,16 @@ router.post(
validateRequest(addShoppingListItemSchema), validateRequest(addShoppingListItemSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`); logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as AddShoppingListItemRequest; const { params, body } = req as unknown as AddShoppingListItemRequest;
try { try {
const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log); const newItem = await db.shoppingRepo.addShoppingListItem(
params.listId,
userProfile.user.user_id,
body,
req.log,
);
res.status(201).json(newItem); res.status(201).json(newItem);
} catch (error) { } catch (error) {
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
@@ -531,11 +518,13 @@ router.put(
validateRequest(updateShoppingListItemSchema), validateRequest(updateShoppingListItemSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`); logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateShoppingListItemRequest; const { params, body } = req as unknown as UpdateShoppingListItemRequest;
try { try {
const updatedItem = await db.shoppingRepo.updateShoppingListItem( const updatedItem = await db.shoppingRepo.updateShoppingListItem(
params.itemId, params.itemId,
userProfile.user.user_id,
body, body,
req.log, req.log,
); );
@@ -560,10 +549,11 @@ router.delete(
validateRequest(shoppingListItemIdSchema), validateRequest(shoppingListItemIdSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety // Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest; const { params } = req as unknown as DeleteShoppingListItemRequest;
try { try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log); await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, req.log);
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
logger.error( logger.error(
@@ -711,13 +701,7 @@ router.get(
const { params } = req as unknown as GetAddressRequest; const { params } = req as unknown as GetAddressRequest;
try { try {
const addressId = params.addressId; const addressId = params.addressId;
// Security check: Ensure the requested addressId matches the one on the user's profile. const address = await userService.getUserAddress(userProfile, addressId, req.log);
if (userProfile.address_id !== addressId) {
return res
.status(403)
.json({ message: 'Forbidden: You can only access your own address.' });
}
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address); res.json(address);
} catch (error) { } catch (error) {
logger.error({ error }, 'Error fetching user address'); logger.error({ error }, 'Error fetching user address');
@@ -732,12 +716,12 @@ router.get(
const updateUserAddressSchema = z.object({ const updateUserAddressSchema = z.object({
body: z body: z
.object({ .object({
address_line_1: z.string().optional(), address_line_1: z.string().trim().optional(),
address_line_2: z.string().optional(), address_line_2: z.string().trim().optional(),
city: z.string().optional(), city: z.string().trim().optional(),
province_state: z.string().optional(), province_state: z.string().trim().optional(),
postal_code: z.string().optional(), postal_code: z.string().trim().optional(),
country: z.string().optional(), country: z.string().trim().optional(),
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: 'At least one address field must be provided.', message: 'At least one address field must be provided.',
@@ -797,13 +781,13 @@ router.delete(
const updateRecipeSchema = recipeIdSchema.extend({ const updateRecipeSchema = recipeIdSchema.extend({
body: z body: z
.object({ .object({
name: z.string().optional(), name: z.string().trim().optional(),
description: z.string().optional(), description: z.string().trim().optional(),
instructions: z.string().optional(), instructions: z.string().trim().optional(),
prep_time_minutes: z.number().int().optional(), prep_time_minutes: z.number().int().optional(),
cook_time_minutes: z.number().int().optional(), cook_time_minutes: z.number().int().optional(),
servings: z.number().int().optional(), servings: z.number().int().optional(),
photo_url: z.string().url().optional(), photo_url: z.string().trim().url().optional(),
}) })
.refine((data) => Object.keys(data).length > 0, { message: 'No fields provided to update.' }), .refine((data) => Object.keys(data).length > 0, { message: 'No fields provided to update.' }),
}); });

View File

@@ -19,13 +19,15 @@ vi.mock('./logger.client', () => ({
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
error: vi.fn(), error: vi.fn(),
warn: vi.fn(),
}, },
})); }));
// 2. Mock ./apiClient to simply pass calls through to the global fetch. // 2. Mock ./apiClient to simply pass calls through to the global fetch.
vi.mock('./apiClient', async (importOriginal) => { vi.mock('./apiClient', async (importOriginal) => {
return { // This is the core logic we want to preserve: it calls the global fetch
apiFetch: ( // which is then intercepted by MSW.
const apiFetch = (
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
apiOptions: import('./apiClient').ApiOptions = {}, apiOptions: import('./apiClient').ApiOptions = {},
@@ -59,6 +61,26 @@ vi.mock('./apiClient', async (importOriginal) => {
const request = new Request(fullUrl, options); const request = new Request(fullUrl, options);
console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`); console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`);
return fetch(request); return fetch(request);
};
return {
// The original mock only had apiFetch. We need to add the helpers.
apiFetch,
// These helpers are what aiApiClient.ts actually calls.
// Their mock implementation should just call our mocked apiFetch.
authedGet: (endpoint: string, options: import('./apiClient').ApiOptions = {}) => {
return apiFetch(endpoint, { method: 'GET' }, options);
},
authedPost: <T>(endpoint: string, body: T, options: import('./apiClient').ApiOptions = {}) => {
return apiFetch(
endpoint,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
options,
);
},
authedPostForm: (endpoint: string, formData: FormData, options: import('./apiClient').ApiOptions = {}) => {
return apiFetch(endpoint, { method: 'POST', body: formData }, options);
}, },
// Add a mock for ApiOptions to satisfy the compiler // Add a mock for ApiOptions to satisfy the compiler
ApiOptions: vi.fn(), ApiOptions: vi.fn(),
@@ -285,9 +307,25 @@ describe('AI API Client (Network Mocking with MSW)', () => {
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Job not found'); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Job not found');
}); });
it('should throw a generic error if the API response is not valid JSON', async () => { it('should throw a specific error if a 200 OK response is not valid JSON', async () => {
server.use(http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => HttpResponse.text('Invalid JSON'))); server.use(
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(expect.any(SyntaxError)); http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
// A 200 OK response that is not JSON is a server-side contract violation.
return HttpResponse.text('This should have been JSON', { status: 200 });
}),
);
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
'Failed to parse job status from a successful API response.',
);
});
it('should throw a generic error with status text if the non-ok API response is not valid JSON', async () => {
server.use(
http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
return HttpResponse.text('Gateway Timeout', { status: 504, statusText: 'Gateway Timeout' });
}),
);
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Gateway Timeout');
}); });
}); });

View File

@@ -12,7 +12,7 @@ import type {
GroundedResponse, GroundedResponse,
} from '../types'; } from '../types';
import { logger } from './logger.client'; import { logger } from './logger.client';
import { apiFetch } from './apiClient'; import { apiFetch, authedGet, authedPost, authedPostForm } from './apiClient';
/** /**
* Uploads a flyer file to the backend to be processed asynchronously. * Uploads a flyer file to the backend to be processed asynchronously.
@@ -33,14 +33,7 @@ export const uploadAndProcessFlyer = async (
logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`); logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`);
const response = await apiFetch( const response = await authedPostForm('/ai/upload-and-process', formData, { tokenOverride });
'/ai/upload-and-process',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
if (!response.ok) { if (!response.ok) {
let errorBody; let errorBody;
@@ -101,18 +94,29 @@ export const getJobStatus = async (
jobId: string, jobId: string,
tokenOverride?: string, tokenOverride?: string,
): Promise<JobStatus> => { ): Promise<JobStatus> => {
const response = await apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride }); const response = await authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
// Handle non-OK responses first, as they might not have a JSON body.
if (!response.ok) {
let errorMessage = `API Error: ${response.status} ${response.statusText}`;
try {
// Try to get a more specific message from the body.
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
} catch (e) {
// The body was not JSON, which is fine for a server error page.
// The default message is sufficient.
logger.warn('getJobStatus received a non-JSON error response.', { status: response.status });
}
throw new Error(errorMessage);
}
// If we get here, the response is OK (2xx). Now parse the body.
try { try {
const statusData: JobStatus = await response.json(); const statusData: JobStatus = await response.json();
if (!response.ok) {
// If the HTTP response itself is an error (e.g., 404, 500), throw an error.
// Use the message from the JSON body if available.
const errorMessage = (statusData as any).message || `API Error: ${response.status}`;
throw new Error(errorMessage);
}
// If the job itself has failed, we should treat this as an error condition // If the job itself has failed, we should treat this as an error condition
// for the polling logic by rejecting the promise. This will stop the polling loop. // for the polling logic by rejecting the promise. This will stop the polling loop.
if (statusData.state === 'failed') { if (statusData.state === 'failed') {
@@ -130,9 +134,13 @@ export const getJobStatus = async (
return statusData; return statusData;
} catch (error) { } catch (error) {
// This block catches errors from `response.json()` (if the body is not valid JSON) // If it's the specific error we threw, just re-throw it.
// and also re-throws the errors we created above. if (error instanceof JobFailedError) {
throw error; throw error;
}
// This now primarily catches JSON parsing errors on an OK response, which is unexpected.
logger.error('getJobStatus failed to parse a successful API response.', { error });
throw new Error('Failed to parse job status from a successful API response.');
} }
}; };
@@ -145,14 +153,7 @@ export const isImageAFlyer = (
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type. // Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
// The URL must be relative, as the helper constructs the full path. // The URL must be relative, as the helper constructs the full path.
return apiFetch( return authedPostForm('/ai/check-flyer', formData, { tokenOverride });
'/ai/check-flyer',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
}; };
export const extractAddressFromImage = ( export const extractAddressFromImage = (
@@ -162,14 +163,7 @@ export const extractAddressFromImage = (
const formData = new FormData(); const formData = new FormData();
formData.append('image', imageFile); formData.append('image', imageFile);
return apiFetch( return authedPostForm('/ai/extract-address', formData, { tokenOverride });
'/ai/extract-address',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
}; };
export const extractLogoFromImage = ( export const extractLogoFromImage = (
@@ -181,14 +175,7 @@ export const extractLogoFromImage = (
formData.append('images', file); formData.append('images', file);
}); });
return apiFetch( return authedPostForm('/ai/extract-logo', formData, { tokenOverride });
'/ai/extract-logo',
{
method: 'POST',
body: formData,
},
{ tokenOverride },
);
}; };
export const getQuickInsights = ( export const getQuickInsights = (
@@ -196,16 +183,7 @@ export const getQuickInsights = (
signal?: AbortSignal, signal?: AbortSignal,
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
return apiFetch( return authedPost('/ai/quick-insights', { items }, { tokenOverride, signal });
'/ai/quick-insights',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
},
{ tokenOverride, signal },
);
}; };
export const getDeepDiveAnalysis = ( export const getDeepDiveAnalysis = (
@@ -213,16 +191,7 @@ export const getDeepDiveAnalysis = (
signal?: AbortSignal, signal?: AbortSignal,
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
return apiFetch( return authedPost('/ai/deep-dive', { items }, { tokenOverride, signal });
'/ai/deep-dive',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
signal,
},
{ tokenOverride, signal },
);
}; };
export const searchWeb = ( export const searchWeb = (
@@ -230,16 +199,7 @@ export const searchWeb = (
signal?: AbortSignal, signal?: AbortSignal,
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
return apiFetch( return authedPost('/ai/search-web', { query }, { tokenOverride, signal });
'/ai/search-web',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal,
},
{ tokenOverride, signal },
);
}; };
// ============================================================================ // ============================================================================
@@ -254,15 +214,7 @@ export const planTripWithMaps = async (
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
logger.debug('Stub: planTripWithMaps called with location:', { userLocation }); logger.debug('Stub: planTripWithMaps called with location:', { userLocation });
return apiFetch( return authedPost('/ai/plan-trip', { items, store, userLocation }, { signal, tokenOverride });
'/ai/plan-trip',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, store, userLocation }),
},
{ signal, tokenOverride },
);
}; };
/** /**
@@ -276,16 +228,7 @@ export const generateImageFromText = (
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
logger.debug('Stub: generateImageFromText called with prompt:', { prompt }); logger.debug('Stub: generateImageFromText called with prompt:', { prompt });
return apiFetch( return authedPost('/ai/generate-image', { prompt }, { tokenOverride, signal });
'/ai/generate-image',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
},
{ tokenOverride, signal },
);
}; };
/** /**
@@ -299,16 +242,7 @@ export const generateSpeechFromText = (
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => { ): Promise<Response> => {
logger.debug('Stub: generateSpeechFromText called with text:', { text }); logger.debug('Stub: generateSpeechFromText called with text:', { text });
return apiFetch( return authedPost('/ai/generate-speech', { text }, { tokenOverride, signal });
'/ai/generate-speech',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal,
},
{ tokenOverride, signal },
);
}; };
/** /**
@@ -361,11 +295,7 @@ export const rescanImageArea = (
formData.append('cropArea', JSON.stringify(cropArea)); formData.append('cropArea', JSON.stringify(cropArea));
formData.append('extractionType', extractionType); formData.append('extractionType', extractionType);
return apiFetch( return authedPostForm('/ai/rescan-area', formData, { tokenOverride });
'/ai/rescan-area',
{ method: 'POST', body: formData },
{ tokenOverride },
);
}; };
/** /**
@@ -379,12 +309,5 @@ export const compareWatchedItemPrices = (
): Promise<Response> => { ): Promise<Response> => {
// Use the apiFetch wrapper for consistency with other API calls in this file. // Use the apiFetch wrapper for consistency with other API calls in this file.
// This centralizes token handling and base URL logic. // This centralizes token handling and base URL logic.
return apiFetch( return authedPost('/ai/compare-prices', { items: watchedItems }, { signal });
'/ai/compare-prices', };
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: watchedItems }),
},
{ signal },
)};

View File

@@ -1,11 +1,19 @@
// src/services/aiService.server.test.ts // src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { Job } from 'bullmq';
import { createMockLogger } from '../tests/utils/mockLogger'; import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import type { MasterGroceryItem } from '../types'; import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks. // Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService, AiFlyerDataSchema, aiService as aiServiceSingleton } from './aiService.server'; import {
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories'; AIService,
aiService as aiServiceSingleton,
DuplicateFlyerError,
type RawFlyerItem,
} from './aiService.server';
import { createMockMasterGroceryItem, createMockFlyer } from '../tests/utils/mockFactories';
import { ValidationError } from './db/errors.db';
import { AiFlyerDataSchema } from '../types/ai';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests. // Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
vi.mock('./logger.server', () => ({ vi.mock('./logger.server', () => ({
@@ -45,6 +53,55 @@ vi.mock('@google/genai', () => {
}; };
}); });
// --- New Mocks for Database and Queue ---
vi.mock('./db/index.db', () => ({
flyerRepo: {
findFlyerByChecksum: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
}));
vi.mock('./queueService.server', () => ({
flyerQueue: {
add: vi.fn(),
},
}));
vi.mock('./db/flyer.db', () => ({
createFlyerAndItems: vi.fn(),
}));
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
}));
// Import mocked modules to assert on them
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
interface MockFlyer {
flyer_id: number;
file_name: string;
image_url: string;
icon_url: string;
checksum: string;
store_name: string;
valid_from: string | null;
valid_to: string | null;
store_address: string | null;
item_count: number;
status: FlyerStatus;
uploaded_by: string | null | undefined;
created_at: string;
updated_at: string;
}
describe('AI Service (Server)', () => { describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service // Create mock dependencies that will be injected into the service
const mockAiClient = { generateContent: vi.fn() }; const mockAiClient = { generateContent: vi.fn() };
@@ -57,6 +114,7 @@ describe('AI Service (Server)', () => {
// Restore all environment variables and clear all mocks before each test // Restore all environment variables and clear all mocks before each test
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
mockGenerateContent.mockReset();
// Reset modules to ensure the service re-initializes with the mocks // Reset modules to ensure the service re-initializes with the mocks
mockAiClient.generateContent.mockResolvedValue({ mockAiClient.generateContent.mockResolvedValue({
@@ -73,14 +131,7 @@ describe('AI Service (Server)', () => {
const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty); const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);
expect(resultNull.success).toBe(false); expect(resultNull.success).toBe(false);
if (!resultNull.success) { // Null checks fail with a generic type error, which is acceptable.
expect(resultNull.error.issues[0].message).toBe('Store name cannot be empty');
}
expect(resultEmpty.success).toBe(false);
if (!resultEmpty.success) {
expect(resultEmpty.error.issues[0].message).toBe('Store name cannot be empty');
}
}); });
}); });
@@ -167,7 +218,7 @@ describe('AI Service (Server)', () => {
await adapter.generateContent(request); await adapter.generateContent(request);
expect(mockGenerateContent).toHaveBeenCalledWith({ expect(mockGenerateContent).toHaveBeenCalledWith({
model: 'gemini-2.5-flash', model: 'gemini-3-flash-preview',
...request, ...request,
}); });
}); });
@@ -192,6 +243,7 @@ describe('AI Service (Server)', () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' }; process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' };
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
mockGenerateContent.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -221,21 +273,22 @@ describe('AI Service (Server)', () => {
expect(mockGenerateContent).toHaveBeenCalledTimes(2); expect(mockGenerateContent).toHaveBeenCalledTimes(2);
// Check first call // Check first call
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
model: 'gemini-2.5-flash', model: 'gemini-3-flash-preview',
...request, ...request,
}); });
// Check second call // Check second call
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
model: 'gemini-3-flash', model: 'gemini-2.5-flash',
...request, ...request,
}); });
// Check that a warning was logged // Check that a warning was logged
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
// The warning should be for the model that failed ('gemini-3-flash-preview'), not the next one.
expect.stringContaining( expect.stringContaining(
"Model 'gemini-2.5-flash' failed due to quota/rate limit. Trying next model.", "Model 'gemini-3-flash-preview' failed due to quota/rate limit. Trying next model.",
), ),
); );
}); });
@@ -258,8 +311,8 @@ describe('AI Service (Server)', () => {
expect(mockGenerateContent).toHaveBeenCalledTimes(1); expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith( expect(logger.error).toHaveBeenCalledWith(
{ error: nonRetriableError }, { error: nonRetriableError }, // The first model in the list is now 'gemini-3-flash-preview'
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`, `[AIService Adapter] Model 'gemini-3-flash-preview' failed with a non-retriable error.`,
); );
}); });
@@ -269,41 +322,174 @@ describe('AI Service (Server)', () => {
const { logger } = await import('./logger.server'); const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger); const serviceWithFallback = new AIService(logger);
const quotaError1 = new Error('Quota exhausted for model 1'); // Access private property for testing purposes to ensure test stays in sync with implementation
const quotaError2 = new Error('429 Too Many Requests for model 2'); const models = (serviceWithFallback as any).models as string[];
const quotaError3 = new Error('RESOURCE_EXHAUSTED for model 3'); // Use a quota error to trigger the fallback logic for each model
const errors = models.map((model, i) => new Error(`Quota error for model ${model} (${i})`));
const lastError = errors[errors.length - 1];
mockGenerateContent // Dynamically setup mocks
.mockRejectedValueOnce(quotaError1) errors.forEach((err) => {
.mockRejectedValueOnce(quotaError2) mockGenerateContent.mockRejectedValueOnce(err);
.mockRejectedValueOnce(quotaError3); });
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] }; const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert // Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow( await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
quotaError3, lastError,
); );
expect(mockGenerateContent).toHaveBeenCalledTimes(3); expect(mockGenerateContent).toHaveBeenCalledTimes(models.length);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: 'gemini-2.5-flash', models.forEach((model, index) => {
...request, expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
}); model: model,
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { ...request,
model: 'gemini-3-flash', });
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, {
model: 'gemini-2.5-flash-lite',
...request,
}); });
expect(logger.error).toHaveBeenCalledWith( expect(logger.error).toHaveBeenCalledWith(
{ lastError: quotaError3 }, { lastError },
'[AIService Adapter] All AI models failed. Throwing last known error.', '[AIService Adapter] All AI models failed. Throwing last known error.',
); );
}); });
it('should use lite models and throw the last error if all lite models fail', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
// We instantiate with the real logger to test the production fallback logic
const serviceWithFallback = new AIService(logger);
// Access private property for testing purposes
const modelsLite = (serviceWithFallback as any).models_lite as string[];
// Use a quota error to trigger the fallback logic for each model
const errors = modelsLite.map((model, i) => new Error(`Quota error for lite model ${model} (${i})`));
const lastError = errors[errors.length - 1];
// Dynamically setup mocks
errors.forEach((err) => {
mockGenerateContent.mockRejectedValueOnce(err);
});
const request = {
contents: [{ parts: [{ text: 'test prompt' }] }],
useLiteModels: true, // This is the key to trigger the lite model list
};
// The adapter strips `useLiteModels` before calling the underlying client,
// so we prepare the expected request shape for our assertions.
const { useLiteModels, ...apiReq } = request;
// Act & Assert
// Expect the entire operation to reject with the error from the very last model attempt.
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
lastError,
);
// Verify that all lite models were attempted in the correct order.
expect(mockGenerateContent).toHaveBeenCalledTimes(modelsLite.length);
modelsLite.forEach((model, index) => {
expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
model: model,
...apiReq,
});
});
});
it('should dynamically try the next model if the first one fails and succeed if the second one works', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
// Access private property for testing purposes
const models = (serviceWithFallback as any).models as string[];
// Ensure we have enough models to test fallback
expect(models.length).toBeGreaterThanOrEqual(2);
const error1 = new Error('Quota exceeded for model 1');
const successResponse = { text: 'Success', candidates: [] };
mockGenerateContent
.mockRejectedValueOnce(error1)
.mockResolvedValueOnce(successResponse);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
// Assert
expect(result).toEqual(successResponse);
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
model: models[0],
...request,
});
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
model: models[1],
...request,
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(`Model '${models[0]}' failed`),
);
});
it('should retry on a 429 error and succeed on the next model', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const models = (serviceWithFallback as any).models as string[];
const retriableError = new Error('429 Too Many Requests');
const successResponse = { text: 'Success from second model', candidates: [] };
mockGenerateContent
.mockRejectedValueOnce(retriableError)
.mockResolvedValueOnce(successResponse);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
// Assert
expect(result).toEqual(successResponse);
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { model: models[0], ...request });
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { model: models[1], ...request });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit.`));
});
it('should fail immediately on a 400 Bad Request error without retrying', async () => {
// Arrange
const { AIService } = await import('./aiService.server');
const { logger } = await import('./logger.server');
const serviceWithFallback = new AIService(logger);
const models = (serviceWithFallback as any).models as string[];
const nonRetriableError = new Error('400 Bad Request: Invalid input');
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
// Act & Assert
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(nonRetriableError);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(mockGenerateContent).toHaveBeenCalledWith({ model: models[0], ...request });
expect(logger.error).toHaveBeenCalledWith(
{ error: nonRetriableError },
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
);
// Ensure it didn't log a warning about trying the next model
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Trying next model'));
});
}); });
describe('extractItemsFromReceiptImage', () => { describe('extractItemsFromReceiptImage', () => {
@@ -596,40 +782,6 @@ describe('AI Service (Server)', () => {
}); });
}); });
describe('_normalizeExtractedItems (private method)', () => {
it('should replace null or undefined fields with default values', () => {
const rawItems: {
item: string;
price_display: null;
quantity: undefined;
category_name: null;
master_item_id: null;
}[] = [
{
item: 'Test',
price_display: null,
quantity: undefined,
category_name: null,
master_item_id: null,
},
];
const [normalized] = (
aiServiceInstance as unknown as {
_normalizeExtractedItems: (items: typeof rawItems) => {
price_display: string;
quantity: string;
category_name: string;
master_item_id: undefined;
}[];
}
)._normalizeExtractedItems(rawItems);
expect(normalized.price_display).toBe('');
expect(normalized.quantity).toBe('');
expect(normalized.category_name).toBe('Other/Miscellaneous');
expect(normalized.master_item_id).toBeUndefined();
});
});
describe('extractTextFromImageArea', () => { describe('extractTextFromImageArea', () => {
it('should call sharp to crop the image and call the AI with the correct prompt', async () => { it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
console.log("TEST START: 'should call sharp to crop...'"); console.log("TEST START: 'should call sharp to crop...'");
@@ -752,9 +904,340 @@ describe('AI Service (Server)', () => {
}); });
}); });
describe('enqueueFlyerProcessing', () => {
const mockFile = {
path: '/tmp/test.pdf',
originalname: 'test.pdf',
} as Express.Multer.File;
const mockProfile = {
user: { user_id: 'user123' },
address: {
address_line_1: '123 St',
city: 'City',
country: 'Country', // This was a duplicate, fixed.
},
} as UserProfile;
it('should throw DuplicateFlyerError if flyer already exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99, checksum: 'checksum123', file_name: 'test.pdf', image_url: '/flyer-images/test.pdf', icon_url: '/flyer-images/icons/test.webp', store_id: 1, status: 'processed', item_count: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
await expect(
aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
mockProfile,
'127.0.0.1',
mockLoggerInstance,
),
).rejects.toThrow(DuplicateFlyerError);
});
it('should enqueue job with user address if profile exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as unknown as Job);
const result = await aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
mockProfile,
'127.0.0.1',
mockLoggerInstance,
);
expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', {
filePath: mockFile.path,
originalFileName: mockFile.originalname,
checksum: 'checksum123',
userId: 'user123',
submitterIp: '127.0.0.1',
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
});
expect(result.id).toBe('job123');
});
it('should enqueue job without address if profile is missing', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as unknown as Job);
await aiServiceInstance.enqueueFlyerProcessing(
mockFile,
'checksum123',
undefined, // No profile
'127.0.0.1',
mockLoggerInstance,
);
expect(flyerQueue.add).toHaveBeenCalledWith(
'process-flyer',
expect.objectContaining({
userId: undefined,
userProfileAddress: undefined,
}),
);
});
});
describe('processLegacyFlyerUpload', () => {
const mockFile = {
path: '/tmp/upload.jpg',
filename: 'upload.jpg',
originalname: 'orig.jpg',
} as Express.Multer.File; // This was a duplicate, fixed.
const mockProfile = { user: { user_id: 'u1' } } as UserProfile;
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: {
flyer_id: 100,
file_name: 'orig.jpg',
image_url: '/flyer-images/upload.jpg',
icon_url: '/flyer-images/icons/icon.jpg',
checksum: 'mock-checksum-123',
store_name: 'Mock Store',
valid_from: null,
valid_to: null,
store_address: null,
item_count: 0,
status: 'processed',
uploaded_by: 'u1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as MockFlyer, // Use the more specific MockFlyer type
items: [],
});
});
it('should throw ValidationError if checksum is missing', async () => {
const body = { data: JSON.stringify({}) }; // No checksum
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(ValidationError);
});
it('should throw DuplicateFlyerError if checksum exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 55 }));
const body = { checksum: 'dup-sum' };
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(DuplicateFlyerError);
});
it('should parse "data" string property containing extractedData', async () => {
const payload = {
checksum: 'abc',
originalFileName: 'test.jpg',
extractedData: {
store_name: 'My Store',
items: [{ item: 'Milk', price_in_cents: 200 }],
},
};
const body = { data: JSON.stringify(payload) };
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'My Store',
checksum: 'abc',
}),
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
mockLoggerInstance,
);
});
it('should handle direct object body with extractedData', async () => {
const body = {
checksum: 'xyz',
extractedData: {
store_name: 'Direct Store',
valid_from: '2023-01-01',
},
};
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'Direct Store',
valid_from: '2023-01-01',
}),
[], // No items
mockLoggerInstance,
);
});
it('should fallback for missing store name and normalize items', async () => {
const body = {
checksum: 'fallback',
extractedData: {
// store_name missing
items: [{ item: 'Bread' }], // minimal item
},
};
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({
store_name: 'Unknown Store (auto)',
}),
expect.arrayContaining([
expect.objectContaining({
item: 'Bread',
quantity: 1, // Default
view_count: 0,
}),
]),
mockLoggerInstance,
);
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
expect.stringContaining('extractedData.store_name missing'),
);
});
it('should log activity and return the new flyer', async () => {
const body = { checksum: 'act', extractedData: { store_name: 'Act Store' } };
const result = await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(result).toHaveProperty('flyer_id', 100);
expect(dbModule.adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'flyer_processed',
userId: 'u1',
}),
mockLoggerInstance,
);
});
it('should catch JSON parsing errors in _parseLegacyPayload and log warning (errMsg coverage)', async () => {
// Sending a body where 'data' is a malformed JSON string to trigger the catch block in _parseLegacyPayload
const body = { data: '{ "malformed": json ' };
// This will eventually throw ValidationError because checksum won't be found
await expect(
aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
),
).rejects.toThrow(ValidationError);
// Verify that the error was caught and logged using errMsg logic
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(String) }),
'[AIService] Failed to parse nested "data" property string.',
);
});
it('should handle body as a string', async () => {
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
const body = JSON.stringify(payload);
await aiServiceInstance.processLegacyFlyerUpload(
mockFile,
body,
mockProfile,
mockLoggerInstance,
);
expect(createFlyerAndItems).toHaveBeenCalledWith(
expect.objectContaining({ checksum: 'str-body' }),
expect.anything(),
mockLoggerInstance,
);
});
});
describe('Singleton Export', () => { describe('Singleton Export', () => {
it('should export a singleton instance of AIService', () => { it('should export a singleton instance of AIService', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService); expect(aiServiceSingleton).toBeInstanceOf(AIService);
}); });
}); });
describe('_normalizeExtractedItems (private method)', () => {
it('should correctly normalize items with null or undefined price_in_cents', () => {
const rawItems: RawFlyerItem[] = [
{
item: 'Valid Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: '1',
category_name: 'Category A',
master_item_id: 1,
},
{
item: 'Item with Null Price',
price_display: null,
price_in_cents: null, // Test case for null
quantity: '1',
category_name: 'Category B',
master_item_id: 2,
},
{
item: 'Item with Undefined Price',
price_display: '$2.99',
price_in_cents: undefined, // Test case for undefined
quantity: '1',
category_name: 'Category C',
master_item_id: 3,
},
{
item: null, // Test null item name
price_display: undefined, // Test undefined display price
price_in_cents: 50,
quantity: null, // Test null quantity
category_name: undefined, // Test undefined category
master_item_id: null, // Test null master_item_id
},
];
// Access the private method for testing
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems);
expect(normalized).toHaveLength(4);
expect(normalized[0].price_in_cents).toBe(199);
expect(normalized[1].price_in_cents).toBe(null); // null should remain null
expect(normalized[2].price_in_cents).toBe(null); // undefined should become null
expect(normalized[3].item).toBe('Unknown Item');
expect(normalized[3].quantity).toBe('');
expect(normalized[3].category_name).toBe('Other/Miscellaneous');
expect(normalized[3].master_item_id).toBeUndefined(); // nullish coalescing to undefined
});
});
}); });

View File

@@ -4,35 +4,47 @@
* It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code. * It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code.
* The `.server.ts` naming convention helps enforce this separation. * The `.server.ts` naming convention helps enforce this separation.
*/ */
import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai'; import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai';
import fsPromises from 'node:fs/promises'; import fsPromises from 'node:fs/promises';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { z } from 'zod'; import { z } from 'zod';
import { pRateLimit } from 'p-ratelimit'; import { pRateLimit } from 'p-ratelimit';
import type { FlyerItem, MasterGroceryItem, ExtractedFlyerItem } from '../types'; import type {
FlyerItem,
MasterGroceryItem,
ExtractedFlyerItem,
UserProfile,
ExtractedCoreData,
FlyerInsert,
Flyer,
} from '../types';
import { FlyerProcessingError } from './processingErrors';
import * as db from './db/index.db';
import { flyerQueue } from './queueService.server';
import type { Job } from 'bullmq';
import { createFlyerAndItems } from './db/flyer.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
import path from 'path';
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
import {
AiFlyerDataSchema,
ExtractedFlyerItemSchema,
} from '../types/ai'; // Import consolidated schemas
// Helper for consistent required string validation (handles missing/null/empty) interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
const requiredString = (message: string) => checksum?: string;
z.preprocess((val) => val ?? '', z.string().min(1, message)); originalFileName?: string;
extractedData?: Partial<ExtractedCoreData>;
data?: FlyerProcessPayload; // For nested data structures
}
// --- Zod Schemas for AI Response Validation (exported for the transformer) --- // Helper to safely extract an error message from unknown `catch` values.
const ExtractedFlyerItemSchema = z.object({ const errMsg = (e: unknown) => {
item: z.string(), if (e instanceof Error) return e.message;
price_display: z.string(), if (typeof e === 'object' && e !== null && 'message' in e)
price_in_cents: z.number().nullable(), return String((e as { message: unknown }).message);
quantity: z.string(), return String(e || 'An unknown error occurred.');
category_name: z.string(), };
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: requiredString('Store name cannot be empty'),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
/** /**
* Defines the contract for a file system utility. This interface allows for * Defines the contract for a file system utility. This interface allows for
@@ -50,6 +62,7 @@ interface IAiClient {
generateContent(request: { generateContent(request: {
contents: Content[]; contents: Content[];
tools?: Tool[]; tools?: Tool[];
useLiteModels?: boolean;
}): Promise<GenerateContentResponse>; }): Promise<GenerateContentResponse>;
} }
@@ -58,21 +71,31 @@ interface IAiClient {
* This type is intentionally loose to accommodate potential null/undefined values * This type is intentionally loose to accommodate potential null/undefined values
* from the AI before they are cleaned and normalized. * from the AI before they are cleaned and normalized.
*/ */
type RawFlyerItem = { export type RawFlyerItem = {
item: string; item: string | null;
price_display: string | null | undefined; price_display: string | null | undefined;
price_in_cents: number | null; price_in_cents: number | null | undefined;
quantity: string | null | undefined; quantity: string | null | undefined;
category_name: string | null | undefined; category_name: string | null | undefined;
master_item_id?: number | null | undefined; master_item_id?: number | null | undefined;
}; };
export class DuplicateFlyerError extends FlyerProcessingError {
constructor(message: string, public flyerId: number) {
super(message, 'DUPLICATE_FLYER', message);
}
}
export class AIService { export class AIService {
private aiClient: IAiClient; private aiClient: IAiClient;
private fs: IFileSystem; private fs: IFileSystem;
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>; private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
private logger: Logger; private logger: Logger;
private readonly models = ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-flash-lite']; // The fallback list is ordered by preference (speed/cost vs. power).
// We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback,
// and finally the 'lite' model as a last resort.
private readonly models = [ 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemma-3-27b', 'gemma-3-12b'];
private readonly models_lite = ["gemma-3-4b", "gemma-3-2b", "gemma-3-1b"];
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) { constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger; this.logger = logger;
@@ -135,7 +158,9 @@ export class AIService {
throw new Error('AIService.generateContent requires at least one content element.'); throw new Error('AIService.generateContent requires at least one content element.');
} }
return this._generateWithFallback(genAI, request); const { useLiteModels, ...apiReq } = request;
const models = useLiteModels ? this.models_lite : this.models;
return this._generateWithFallback(genAI, apiReq, models);
}, },
} }
: { : {
@@ -173,10 +198,11 @@ export class AIService {
private async _generateWithFallback( private async _generateWithFallback(
genAI: GoogleGenAI, genAI: GoogleGenAI,
request: { contents: Content[]; tools?: Tool[] }, request: { contents: Content[]; tools?: Tool[] },
models: string[] = this.models,
): Promise<GenerateContentResponse> { ): Promise<GenerateContentResponse> {
let lastError: Error | null = null; let lastError: Error | null = null;
for (const modelName of this.models) { for (const modelName of models) {
try { try {
this.logger.info( this.logger.info(
`[AIService Adapter] Attempting to generate content with model: ${modelName}`, `[AIService Adapter] Attempting to generate content with model: ${modelName}`,
@@ -193,7 +219,8 @@ export class AIService {
errorMessage.includes('quota') || errorMessage.includes('quota') ||
errorMessage.includes('429') || // HTTP 429 Too Many Requests errorMessage.includes('429') || // HTTP 429 Too Many Requests
errorMessage.includes('resource_exhausted') || // Make case-insensitive errorMessage.includes('resource_exhausted') || // Make case-insensitive
errorMessage.includes('model is overloaded') errorMessage.includes('model is overloaded') ||
errorMessage.includes('not found') // Also retry if model is not found (e.g., regional availability or API version issue)
) { ) {
this.logger.warn( this.logger.warn(
`[AIService Adapter] Model '${modelName}' failed due to quota/rate limit. Trying next model. Error: ${errorMessage}`, `[AIService Adapter] Model '${modelName}' failed due to quota/rate limit. Trying next model. Error: ${errorMessage}`,
@@ -466,7 +493,7 @@ export class AIService {
userProfileAddress?: string, userProfileAddress?: string,
logger: Logger = this.logger, logger: Logger = this.logger,
): Promise<{ ): Promise<{
store_name: string; store_name: string | null;
valid_from: string | null; valid_from: string | null;
valid_to: string | null; valid_to: string | null;
store_address: string | null; store_address: string | null;
@@ -565,6 +592,8 @@ export class AIService {
item.category_name === null || item.category_name === undefined item.category_name === null || item.category_name === undefined
? 'Other/Miscellaneous' ? 'Other/Miscellaneous'
: String(item.category_name), : String(item.category_name),
// Ensure undefined is converted to null to match the Zod schema.
price_in_cents: item.price_in_cents ?? null,
master_item_id: item.master_item_id ?? undefined, master_item_id: item.master_item_id ?? undefined,
})); }));
} }
@@ -644,6 +673,33 @@ export class AIService {
} }
} }
/**
* Generates a simple recipe suggestion based on a list of ingredients.
* Uses the 'lite' models for faster/cheaper generation.
* @param ingredients List of available ingredients.
* @param logger Logger instance.
* @returns The recipe suggestion text.
*/
async generateRecipeSuggestion(
ingredients: string[],
logger: Logger = this.logger,
): Promise<string | null> {
const prompt = `Suggest a simple recipe using these ingredients: ${ingredients.join(', ')}. Keep it brief.`;
try {
const result = await this.rateLimiter(() =>
this.aiClient.generateContent({
contents: [{ parts: [{ text: prompt }] }],
useLiteModels: true,
}),
);
return result.text || null;
} catch (error) {
logger.error({ err: error }, 'Failed to generate recipe suggestion');
return null;
}
}
/** /**
* SERVER-SIDE FUNCTION * SERVER-SIDE FUNCTION
* Uses Google Maps grounding to find nearby stores and plan a shopping trip. * Uses Google Maps grounding to find nearby stores and plan a shopping trip.
@@ -690,6 +746,168 @@ export class AIService {
} }
*/ */
} }
async enqueueFlyerProcessing(
file: Express.Multer.File,
checksum: string,
userProfile: UserProfile | undefined,
submitterIp: string,
logger: Logger,
): Promise<Job> {
// 1. Check for duplicate flyer
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
if (existingFlyer) {
// Throw a specific error for the route to handle
throw new DuplicateFlyerError(
'This flyer has already been processed.',
existingFlyer.flyer_id,
);
}
// 2. Construct user address string
let userProfileAddress: string | undefined = undefined;
if (userProfile?.address) {
userProfileAddress = [
userProfile.address.address_line_1,
userProfile.address.address_line_2,
userProfile.address.city,
userProfile.address.province_state,
userProfile.address.postal_code,
userProfile.address.country,
]
.filter(Boolean)
.join(', ');
}
// 3. Add job to the queue
const job = await flyerQueue.add('process-flyer', {
filePath: file.path,
originalFileName: file.originalname,
checksum: checksum,
userId: userProfile?.user.user_id,
submitterIp: submitterIp,
userProfileAddress: userProfileAddress,
});
logger.info(
`Enqueued flyer for processing. File: ${file.originalname}, Job ID: ${job.id}`,
);
return job;
}
private _parseLegacyPayload(
body: any,
logger: Logger,
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
let parsed: FlyerProcessPayload = {};
try {
parsed = typeof body === 'string' ? JSON.parse(body) : body || {};
} catch (e) {
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
return { parsed: {}, extractedData: {} };
}
// If the real payload is nested inside a 'data' property (which could be a string),
// we parse it out but keep the original `parsed` object for top-level properties like checksum.
let potentialPayload: FlyerProcessPayload = parsed;
if (parsed.data) {
if (typeof parsed.data === 'string') {
try {
potentialPayload = JSON.parse(parsed.data);
} catch (e) {
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse nested "data" property string.');
}
} else if (typeof parsed.data === 'object') {
potentialPayload = parsed.data;
}
}
// The extracted data is either in an `extractedData` key or is the payload itself.
const extractedData = potentialPayload.extractedData ?? potentialPayload;
// Merge for checksum lookup: properties in the outer `parsed` object (like a top-level checksum)
// take precedence over any same-named properties inside `potentialPayload`.
const finalParsed = { ...potentialPayload, ...parsed };
return { parsed: finalParsed, extractedData };
}
async processLegacyFlyerUpload(
file: Express.Multer.File,
body: any,
userProfile: UserProfile | undefined,
logger: Logger,
): Promise<Flyer> {
const { parsed, extractedData: initialExtractedData } = this._parseLegacyPayload(body, logger);
let extractedData = initialExtractedData;
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
if (!checksum) {
throw new ValidationError([], 'Checksum is required.');
}
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
if (existingFlyer) {
throw new DuplicateFlyerError('This flyer has already been processed.', existingFlyer.flyer_id);
}
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? file.originalname;
if (!extractedData || typeof extractedData !== 'object') {
logger.warn({ bodyData: parsed }, 'Missing extractedData in legacy payload.');
extractedData = {};
}
const rawItems = extractedData.items ?? [];
const itemsArray = Array.isArray(rawItems) ? rawItems : typeof rawItems === 'string' ? JSON.parse(rawItems) : [];
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
quantity: item.quantity ?? 1,
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
const storeName = extractedData.store_name && String(extractedData.store_name).trim().length > 0 ? String(extractedData.store_name) : 'Unknown Store (auto)';
if (storeName.startsWith('Unknown')) {
logger.warn('extractedData.store_name missing; using fallback store name.');
}
const iconsDir = path.join(path.dirname(file.path), 'icons');
const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${file.filename}`,
icon_url: iconUrl,
checksum: checksum,
store_name: storeName,
valid_from: extractedData.valid_from ?? null,
valid_to: extractedData.valid_to ?? null,
store_address: extractedData.store_address ?? null,
item_count: 0,
status: 'needs_review',
uploaded_by: userProfile?.user.user_id,
};
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(flyerData, itemsForDb, logger);
logger.info(`Successfully processed legacy flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`);
await db.adminRepo.logActivity({
userId: userProfile?.user.user_id,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
}, logger);
return newFlyer;
}
} }
// Export a singleton instance of the service for use throughout the application. // Export a singleton instance of the service for use throughout the application.

View File

@@ -0,0 +1,153 @@
// src/services/analyticsService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AnalyticsService } from './analyticsService.server';
import { logger } from './logger.server';
import type { Job } from 'bullmq';
import type { AnalyticsJobData, WeeklyAnalyticsJobData } from '../types/job-data';
// Mock logger
vi.mock('./logger.server', () => ({
logger: {
child: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));
describe('AnalyticsService', () => {
let service: AnalyticsService;
let mockLoggerInstance: any;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Setup mock logger instance returned by child()
mockLoggerInstance = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
vi.mocked(logger.child).mockReturnValue(mockLoggerInstance);
service = new AnalyticsService();
});
afterEach(() => {
vi.useRealTimers();
});
const createMockJob = <T>(data: T): Job<T> =>
({
id: 'job-123',
name: 'analytics-job',
data,
attemptsMade: 1,
updateProgress: vi.fn(),
} as unknown as Job<T>);
describe('processDailyReportJob', () => {
it('should process successfully', async () => {
const job = createMockJob<AnalyticsJobData>({ reportDate: '2023-10-27' } as AnalyticsJobData);
const promise = service.processDailyReportJob(job);
// Fast-forward time to bypass the 10s delay
await vi.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result).toEqual({ status: 'success', reportDate: '2023-10-27' });
expect(logger.child).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'job-123',
reportDate: '2023-10-27',
}),
);
expect(mockLoggerInstance.info).toHaveBeenCalledWith('Picked up daily analytics job.');
expect(mockLoggerInstance.info).toHaveBeenCalledWith(
'Successfully generated report for 2023-10-27.',
);
});
it('should handle failure when reportDate is FAIL', async () => {
const job = createMockJob<AnalyticsJobData>({ reportDate: 'FAIL' } as AnalyticsJobData);
const promise = service.processDailyReportJob(job);
await expect(promise).rejects.toThrow('This is a test failure for the analytics job.');
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
attemptsMade: 1,
}),
'Daily analytics job failed.',
);
});
});
describe('processWeeklyReportJob', () => {
it('should process successfully', async () => {
const job = createMockJob<WeeklyAnalyticsJobData>({
reportYear: 2023,
reportWeek: 43,
} as WeeklyAnalyticsJobData);
const promise = service.processWeeklyReportJob(job);
await vi.advanceTimersByTimeAsync(30000);
const result = await promise;
expect(result).toEqual({ status: 'success', reportYear: 2023, reportWeek: 43 });
expect(logger.child).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'job-123',
reportYear: 2023,
reportWeek: 43,
}),
);
expect(mockLoggerInstance.info).toHaveBeenCalledWith('Picked up weekly analytics job.');
expect(mockLoggerInstance.info).toHaveBeenCalledWith(
'Successfully generated weekly report for week 43, 2023.',
);
});
it('should handle errors during processing', async () => {
const job = createMockJob<WeeklyAnalyticsJobData>({
reportYear: 2023,
reportWeek: 43,
} as WeeklyAnalyticsJobData);
// Make the second info call throw to simulate an error inside the try block
mockLoggerInstance.info
.mockImplementationOnce(() => {}) // "Picked up..."
.mockImplementationOnce(() => {
throw new Error('Processing failed');
}); // "Successfully generated..."
// Get the promise from the service method.
const promise = service.processWeeklyReportJob(job);
// Capture the expectation promise BEFORE triggering the rejection.
const expectation = expect(promise).rejects.toThrow('Processing failed');
// Advance timers to trigger the part of the code that throws.
await vi.advanceTimersByTimeAsync(30000);
// Await the expectation to ensure assertions ran.
await expectation;
// Verify the side effect (error logging) after the rejection is confirmed.
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
attemptsMade: 1,
}),
'Weekly analytics job failed.',
);
});
});
});

View File

@@ -1,7 +1,7 @@
// src/services/analyticsService.server.ts // src/services/analyticsService.server.ts
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { logger as globalLogger } from './logger.server'; import { logger as globalLogger } from './logger.server';
import type { AnalyticsJobData, WeeklyAnalyticsJobData } from './queues.server'; import type { AnalyticsJobData, WeeklyAnalyticsJobData } from '../types/job-data';
/** /**
* A service class to encapsulate business logic for analytics-related background jobs. * A service class to encapsulate business logic for analytics-related background jobs.

View File

@@ -7,6 +7,17 @@ import { http, HttpResponse } from 'msw';
vi.unmock('./apiClient'); vi.unmock('./apiClient');
import * as apiClient from './apiClient'; import * as apiClient from './apiClient';
import {
createMockAddressPayload,
createMockBudget,
createMockLoginPayload,
createMockProfileUpdatePayload,
createMockRecipeCommentPayload,
createMockRegisterUserPayload,
createMockSearchQueryPayload,
createMockShoppingListItemPayload,
createMockWatchedItemPayload,
} from '../tests/utils/mockFactories';
// Mock the logger to keep test output clean and verifiable. // Mock the logger to keep test output clean and verifiable.
vi.mock('./logger', () => ({ vi.mock('./logger', () => ({
@@ -229,33 +240,6 @@ describe('API Client', () => {
}); });
}); });
describe('Analytics API Functions', () => {
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// We can now await this properly because we added 'return' in apiClient.ts
await apiClient.trackFlyerItemInteraction(123, 'click');
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
});
it('logSearchQuery should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
await apiClient.logSearchQuery({
query_text: 'test',
result_count: 0,
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
});
});
describe('apiFetch (with FormData)', () => { describe('apiFetch (with FormData)', () => {
it('should handle FormData correctly by not setting Content-Type', async () => { it('should handle FormData correctly by not setting Content-Type', async () => {
localStorage.setItem('authToken', 'form-data-token'); localStorage.setItem('authToken', 'form-data-token');
@@ -317,10 +301,11 @@ describe('API Client', () => {
}); });
it('addWatchedItem should send a POST request with the correct body', async () => { it('addWatchedItem should send a POST request with the correct body', async () => {
await apiClient.addWatchedItem('Apples', 'Produce'); const watchedItemData = createMockWatchedItemPayload({ itemName: 'Apples', category: 'Produce' });
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category);
expect(capturedUrl?.pathname).toBe('/api/users/watched-items'); expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
expect(capturedBody).toEqual({ itemName: 'Apples', category: 'Produce' }); expect(capturedBody).toEqual(watchedItemData);
}); });
it('removeWatchedItem should send a DELETE request to the correct URL', async () => { it('removeWatchedItem should send a DELETE request to the correct URL', async () => {
@@ -337,12 +322,12 @@ describe('API Client', () => {
}); });
it('createBudget should send a POST request with budget data', async () => { it('createBudget should send a POST request with budget data', async () => {
const budgetData = { const budgetData = createMockBudget({
name: 'Groceries', name: 'Groceries',
amount_cents: 50000, amount_cents: 50000,
period: 'monthly' as const, period: 'monthly',
start_date: '2024-01-01', start_date: '2024-01-01',
}; });
await apiClient.createBudget(budgetData); await apiClient.createBudget(budgetData);
expect(capturedUrl?.pathname).toBe('/api/budgets'); expect(capturedUrl?.pathname).toBe('/api/budgets');
@@ -461,7 +446,7 @@ describe('API Client', () => {
it('addShoppingListItem should send a POST request with item data', async () => { it('addShoppingListItem should send a POST request with item data', async () => {
const listId = 42; const listId = 42;
const itemData = { customItemName: 'Paper Towels' }; const itemData = createMockShoppingListItemPayload({ customItemName: 'Paper Towels' });
await apiClient.addShoppingListItem(listId, itemData); await apiClient.addShoppingListItem(listId, itemData);
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`);
@@ -547,7 +532,7 @@ describe('API Client', () => {
it('addRecipeComment should send a POST request with content and optional parentId', async () => { it('addRecipeComment should send a POST request with content and optional parentId', async () => {
const recipeId = 456; const recipeId = 456;
const commentData = { content: 'This is a reply', parentCommentId: 789 }; const commentData = createMockRecipeCommentPayload({ content: 'This is a reply', parentCommentId: 789 });
await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId); await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId);
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`);
expect(capturedBody).toEqual(commentData); expect(capturedBody).toEqual(commentData);
@@ -558,12 +543,19 @@ describe('API Client', () => {
await apiClient.deleteRecipe(recipeId); await apiClient.deleteRecipe(recipeId);
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`);
}); });
it('suggestRecipe should send a POST request with ingredients', async () => {
const ingredients = ['chicken', 'rice'];
await apiClient.suggestRecipe(ingredients);
expect(capturedUrl?.pathname).toBe('/api/recipes/suggest');
expect(capturedBody).toEqual({ ingredients });
});
}); });
describe('User Profile and Settings API Functions', () => { describe('User Profile and Settings API Functions', () => {
it('updateUserProfile should send a PUT request with profile data', async () => { it('updateUserProfile should send a PUT request with profile data', async () => {
localStorage.setItem('authToken', 'user-settings-token'); localStorage.setItem('authToken', 'user-settings-token');
const profileData = { full_name: 'John Doe' }; const profileData = createMockProfileUpdatePayload({ full_name: 'John Doe' });
await apiClient.updateUserProfile(profileData, { tokenOverride: 'override-token' }); await apiClient.updateUserProfile(profileData, { tokenOverride: 'override-token' });
expect(capturedUrl?.pathname).toBe('/api/users/profile'); expect(capturedUrl?.pathname).toBe('/api/users/profile');
expect(capturedBody).toEqual(profileData); expect(capturedBody).toEqual(profileData);
@@ -619,14 +611,14 @@ describe('API Client', () => {
}); });
it('registerUser should send a POST request with user data', async () => { it('registerUser should send a POST request with user data', async () => {
await apiClient.registerUser('test@example.com', 'password123', 'Test User'); const userData = createMockRegisterUserPayload({
expect(capturedUrl?.pathname).toBe('/api/auth/register');
expect(capturedBody).toEqual({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
full_name: 'Test User', full_name: 'Test User',
avatar_url: undefined,
}); });
await apiClient.registerUser(userData.email, userData.password, userData.full_name);
expect(capturedUrl?.pathname).toBe('/api/auth/register');
expect(capturedBody).toEqual(userData);
}); });
it('deleteUserAccount should send a DELETE request with the confirmation password', async () => { it('deleteUserAccount should send a DELETE request with the confirmation password', async () => {
@@ -654,7 +646,7 @@ describe('API Client', () => {
}); });
it('updateUserAddress should send a PUT request with address data', async () => { it('updateUserAddress should send a PUT request with address data', async () => {
const addressData = { address_line_1: '123 Main St', city: 'Anytown' }; const addressData = createMockAddressPayload({ address_line_1: '123 Main St', city: 'Anytown' });
await apiClient.updateUserAddress(addressData); await apiClient.updateUserAddress(addressData);
expect(capturedUrl?.pathname).toBe('/api/users/profile/address'); expect(capturedUrl?.pathname).toBe('/api/users/profile/address');
expect(capturedBody).toEqual(addressData); expect(capturedBody).toEqual(addressData);
@@ -890,6 +882,11 @@ describe('API Client', () => {
expect(capturedUrl?.pathname).toBe('/api/admin/corrections'); expect(capturedUrl?.pathname).toBe('/api/admin/corrections');
}); });
it('getFlyersForReview should call the correct endpoint', async () => {
await apiClient.getFlyersForReview();
expect(capturedUrl?.pathname).toBe('/api/admin/review/flyers');
});
it('rejectCorrection should send a POST request to the correct URL', async () => { it('rejectCorrection should send a POST request to the correct URL', async () => {
const correctionId = 46; const correctionId = 46;
await apiClient.rejectCorrection(correctionId); await apiClient.rejectCorrection(correctionId);
@@ -942,53 +939,49 @@ describe('API Client', () => {
}); });
it('logSearchQuery should send a POST request with query data', async () => { it('logSearchQuery should send a POST request with query data', async () => {
const queryData = { query_text: 'apples', result_count: 10, was_successful: true }; const queryData = createMockSearchQueryPayload({ query_text: 'apples', result_count: 10, was_successful: true });
await apiClient.logSearchQuery(queryData); await apiClient.logSearchQuery(queryData as any);
expect(capturedUrl?.pathname).toBe('/api/search/log'); expect(capturedUrl?.pathname).toBe('/api/search/log');
expect(capturedBody).toEqual(queryData); expect(capturedBody).toEqual(queryData);
}); });
it('trackFlyerItemInteraction should log a warning on failure', async () => { it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed'); const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError); vi.mocked(global.fetch).mockRejectedValue(apiError);
const { logger } = await import('./logger.client');
// We can now await this properly because we added 'return' in apiClient.ts // We can now await this properly because we added 'return' in apiClient.ts
await apiClient.trackFlyerItemInteraction(123, 'click'); await apiClient.trackFlyerItemInteraction(123, 'click');
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', { expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError, error: apiError,
}); });
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
}); });
it('logSearchQuery should log a warning on failure', async () => { it('logSearchQuery should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed'); const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError); vi.mocked(global.fetch).mockRejectedValue(apiError);
const { logger } = await import('./logger.client');
await apiClient.logSearchQuery({ const queryData = createMockSearchQueryPayload({
query_text: 'test', query_text: 'test',
result_count: 0, result_count: 0,
was_successful: false, was_successful: false,
}); });
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError }); await apiClient.logSearchQuery(queryData as any);
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError }); expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
}); });
}); });
describe('Authentication API Functions', () => { describe('Authentication API Functions', () => {
it('loginUser should send a POST request with credentials', async () => { it('loginUser should send a POST request with credentials', async () => {
await apiClient.loginUser('test@example.com', 'password123', true); const loginData = createMockLoginPayload({
expect(capturedUrl?.pathname).toBe('/api/auth/login');
expect(capturedBody).toEqual({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
rememberMe: true, rememberMe: true,
}); });
await apiClient.loginUser(loginData.email, loginData.password, loginData.rememberMe);
expect(capturedUrl?.pathname).toBe('/api/auth/login');
expect(capturedBody).toEqual(loginData);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { UserProfile } from '../types';
import type * as jsonwebtoken from 'jsonwebtoken';
describe('AuthService', () => {
let authService: typeof import('./authService').authService;
let bcrypt: typeof import('bcrypt');
let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
let userRepo: typeof import('./db/index.db').userRepo;
let adminRepo: typeof import('./db/index.db').adminRepo;
let logger: typeof import('./logger.server').logger;
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
const reqLog = {}; // Mock request logger object
const mockUser = {
user_id: 'user-123',
email: 'test@example.com',
password_hash: 'hashed-password',
failed_login_attempts: 0,
last_failed_login: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
refresh_token: null,
};
const mockUserProfile: UserProfile = {
user: mockUser,
role: 'user',
} as unknown as UserProfile;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Set environment variables before any modules are imported
process.env.JWT_SECRET = 'test-secret';
process.env.FRONTEND_URL = 'http://localhost:3000';
// Mock all dependencies before dynamically importing the service
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
vi.mock('bcrypt');
vi.mock('./db/index.db', () => ({
userRepo: {
createUser: vi.fn(),
saveRefreshToken: vi.fn(),
findUserByEmail: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
updateUserPassword: vi.fn(),
deleteResetToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
findUserProfileById: vi.fn(),
deleteRefreshToken: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
}));
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
vi.mock('./emailService.server', () => ({
sendPasswordResetEmail: vi.fn(),
}));
vi.mock('./db/connection.db', () => ({ getPool: vi.fn() }));
vi.mock('../utils/authUtils', () => ({ validatePasswordStrength: vi.fn() }));
// Dynamically import modules to get the mocked versions and the service instance
authService = (await import('./authService')).authService;
bcrypt = await import('bcrypt');
jwt = (await import('jsonwebtoken')) as typeof jwt;
const dbModule = await import('./db/index.db');
userRepo = dbModule.userRepo;
adminRepo = dbModule.adminRepo;
logger = (await import('./logger.server')).logger;
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
});
describe('registerUser', () => {
it('should successfully register a new user', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
const result = await authService.registerUser(
'test@example.com',
'password123',
'Test User',
undefined,
reqLog,
);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(userRepo.createUser).toHaveBeenCalledWith(
'test@example.com',
'hashed-password',
{ full_name: 'Test User', avatar_url: undefined },
reqLog,
);
expect(adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'user_registered',
userId: 'user-123',
}),
reqLog,
);
expect(result).toEqual(mockUserProfile);
});
it('should throw UniqueConstraintError if email already exists', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
const error = new UniqueConstraintError('Email exists');
vi.mocked(userRepo.createUser).mockRejectedValue(error);
await expect(
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
).rejects.toThrow(UniqueConstraintError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log expected unique constraint errors as system errors
});
it('should log and throw other errors', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
const error = new Error('Database failed');
vi.mocked(userRepo.createUser).mockRejectedValue(error);
await expect(
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
).rejects.toThrow('Database failed');
expect(logger.error).toHaveBeenCalled();
});
});
describe('registerAndLoginUser', () => {
it('should register user and return tokens', async () => {
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
// FIX: The global mock for jsonwebtoken provides a `default` export.
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
// We must mock `jwt.default.sign` to affect the code under test.
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
const result = await authService.registerAndLoginUser(
'test@example.com',
'password123',
'Test User',
undefined,
reqLog,
);
expect(result).toEqual({
newUserProfile: mockUserProfile,
accessToken: 'access-token',
refreshToken: 'mocked_random_id',
});
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith(
'user-123',
'mocked_random_id',
reqLog,
);
});
});
describe('generateAuthTokens', () => {
it('should generate access and refresh tokens', () => {
// FIX: The global mock for jsonwebtoken provides a `default` export.
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
// We must mock `jwt.default.sign` to affect the code under test.
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
const result = authService.generateAuthTokens(mockUserProfile);
expect(vi.mocked(jwt.default.sign)).toHaveBeenCalledWith(
{
user_id: 'user-123',
email: 'test@example.com',
role: 'user',
},
'test-secret',
{ expiresIn: '15m' },
);
expect(result).toEqual({
accessToken: 'access-token',
refreshToken: 'mocked_random_id',
});
});
});
describe('saveRefreshToken', () => {
it('should save refresh token to db', async () => {
await authService.saveRefreshToken('user-123', 'token', reqLog);
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
});
it('should log and throw error on failure', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
'DB Error',
);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ error }),
expect.stringContaining('Failed to save refresh token'),
);
});
});
describe('resetPassword', () => {
it('should process password reset for existing user', async () => {
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
const result = await authService.resetPassword('test@example.com', reqLog);
expect(userRepo.createPasswordResetToken).toHaveBeenCalledWith(
'user-123',
'hashed-token',
expect.any(Date),
reqLog,
);
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
'test@example.com',
expect.stringContaining('/reset-password/mocked_random_id'),
reqLog,
);
expect(result).toBe('mocked_random_id');
});
it('should log warning and return undefined for non-existent user', async () => {
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(undefined);
const result = await authService.resetPassword('unknown@example.com', reqLog);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Password reset requested for non-existent email'),
);
expect(sendPasswordResetEmail).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should log error and throw on failure', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.findUserByEmail).mockRejectedValue(error);
await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(
'DB Error',
);
expect(logger.error).toHaveBeenCalled();
});
});
describe('updatePassword', () => {
it('should update password if token is valid', async () => {
const mockTokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
};
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
vi.mocked(bcrypt.compare).mockImplementation(async () => true); // Match found
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
expect(userRepo.updateUserPassword).toHaveBeenCalledWith(
'user-123',
'new-hashed-password',
reqLog,
);
expect(userRepo.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
expect(adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: 'password_reset' }),
reqLog,
);
expect(result).toBe(true);
});
it('should return null if token is invalid or not found', async () => {
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
expect(userRepo.updateUserPassword).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('getUserByRefreshToken', () => {
it('should return user profile if token exists', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
const result = await authService.getUserByRefreshToken('valid-token', reqLog);
expect(result).toEqual(mockUserProfile);
});
it('should return null if token not found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
const result = await authService.getUserByRefreshToken('invalid-token', reqLog);
expect(result).toBeNull();
});
});
describe('logout', () => {
it('should delete refresh token', async () => {
await authService.logout('token', reqLog);
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
});
it('should log and throw on error', async () => {
const error = new Error('DB Error');
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
await expect(authService.logout('token', reqLog)).rejects.toThrow('DB Error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('refreshAccessToken', () => {
it('should return new access token if user found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
// FIX: The global mock for jsonwebtoken provides a `default` export.
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
// We must mock `jwt.default.sign` to affect the code under test.
vi.mocked(jwt.default.sign).mockImplementation(() => 'new-access-token');
const result = await authService.refreshAccessToken('valid-token', reqLog);
expect(result).toEqual({ accessToken: 'new-access-token' });
});
it('should return null if user not found', async () => {
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
const result = await authService.refreshAccessToken('invalid-token', reqLog);
expect(result).toBeNull();
});
});
});

221
src/services/authService.ts Normal file
View File

@@ -0,0 +1,221 @@
// src/services/authService.ts
import * as bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { userRepo, adminRepo } from './db/index.db';
import { UniqueConstraintError } from './db/errors.db';
import { getPool } from './db/connection.db';
import { logger } from './logger.server';
import { sendPasswordResetEmail } from './emailService.server';
import type { UserProfile } from '../types';
import { validatePasswordStrength } from '../utils/authUtils';
const JWT_SECRET = process.env.JWT_SECRET!;
class AuthService {
async registerUser(
email: string,
password: string,
fullName: string | undefined,
avatarUrl: string | undefined,
reqLog: any,
) {
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
// The createUser method in UserRepository now handles its own transaction.
const newUser = await userRepo.createUser(
email,
hashedPassword,
{ full_name: fullName, avatar_url: avatarUrl },
reqLog,
);
const userEmail = newUser.user.email;
const userId = newUser.user.user_id;
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
// Use the new standardized logging function
await adminRepo.logActivity(
{
userId: newUser.user.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
icon: 'user-plus',
},
reqLog,
);
return newUser;
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
throw error;
}
logger.error({ error }, `User registration route failed for email: ${email}.`);
// Pass the error to the centralized handler
throw error;
}
}
async registerAndLoginUser(
email: string,
password: string,
fullName: string | undefined,
avatarUrl: string | undefined,
reqLog: any,
): Promise<{ newUserProfile: UserProfile; accessToken: string; refreshToken: string }> {
const newUserProfile = await this.registerUser(
email,
password,
fullName,
avatarUrl,
reqLog,
);
const { accessToken, refreshToken } = await this.handleSuccessfulLogin(newUserProfile, reqLog);
return { newUserProfile, accessToken, refreshToken };
}
generateAuthTokens(userProfile: UserProfile) {
const payload = {
user_id: userProfile.user.user_id,
email: userProfile.user.email,
role: userProfile.role,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = crypto.randomBytes(64).toString('hex');
return { accessToken, refreshToken };
}
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
try {
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
} catch (tokenErr) {
logger.error(
{ error: tokenErr },
`Failed to save refresh token during login for user: ${userId}`,
);
throw tokenErr;
}
}
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
const { accessToken, refreshToken } = this.generateAuthTokens(userProfile);
await this.saveRefreshToken(userProfile.user.user_id, refreshToken, reqLog);
return { accessToken, refreshToken };
}
async resetPassword(email: string, reqLog: any) {
try {
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await userRepo.findUserByEmail(email, reqLog);
let token: string | undefined;
logger.debug(
{ user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' },
`[API /forgot-password] Database search result for ${email}:`,
);
if (user) {
token = crypto.randomBytes(32).toString('hex');
const saltRounds = 10;
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog);
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
try {
await sendPasswordResetEmail(email, resetLink, reqLog);
} catch (emailError) {
logger.error({ emailError }, `Email send failure during password reset for user`);
}
} else {
logger.warn(`Password reset requested for non-existent email: ${email}`);
}
return token;
} catch (error) {
logger.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
throw error;
}
}
async updatePassword(token: string, newPassword: string, reqLog: any) {
try {
const validTokens = await userRepo.getValidResetTokens(reqLog);
let tokenRecord;
for (const record of validTokens) {
const isMatch = await bcrypt.compare(token, record.token_hash);
if (isMatch) {
tokenRecord = record;
break;
}
}
if (!tokenRecord) {
return null;
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
await userRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
// Log this security event after a successful password reset.
await adminRepo.logActivity(
{
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
icon: 'key',
details: { source_ip: null },
},
reqLog,
);
return true;
} catch (error) {
logger.error({ error }, `An error occurred during password reset.`);
throw error;
}
}
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
try {
const basicUser = await userRepo.findUserByRefreshToken(refreshToken, reqLog);
if (!basicUser) {
return null;
}
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
return userProfile;
} catch (error) {
logger.error({ error }, 'An error occurred during /refresh-token.');
throw error;
}
}
async logout(refreshToken: string, reqLog: any) {
try {
await userRepo.deleteRefreshToken(refreshToken, reqLog);
} catch (err: any) {
logger.error({ error: err }, 'Failed to delete refresh token from DB during logout.');
throw err;
}
}
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
const user = await this.getUserByRefreshToken(refreshToken, reqLog);
if (!user) {
return null;
}
const { accessToken } = this.generateAuthTokens(user);
return { accessToken };
}
}
export const authService = new AuthService();

View File

@@ -335,8 +335,14 @@ describe('Background Job Service', () => {
// Use fake timers to control promise resolution // Use fake timers to control promise resolution
vi.useFakeTimers(); vi.useFakeTimers();
// Create a controllable promise
let resolveRun!: () => void;
const runPromise = new Promise<void>((resolve) => {
resolveRun = resolve;
});
// Make the first call hang indefinitely // Make the first call hang indefinitely
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {})); vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(runPromise);
startBackgroundJobs( startBackgroundJobs(
mockBackgroundJobService, mockBackgroundJobService,
@@ -352,6 +358,9 @@ describe('Background Job Service', () => {
// Trigger it a second time immediately // Trigger it a second time immediately
const secondCall = dailyDealCheckCallback(); const secondCall = dailyDealCheckCallback();
// Resolve the first call so the test can finish
resolveRun();
await Promise.all([firstCall, secondCall]); await Promise.all([firstCall, secondCall]);
// The service method should only have been called once // The service method should only have been called once
@@ -362,12 +371,18 @@ describe('Background Job Service', () => {
// Use fake timers to control promise resolution // Use fake timers to control promise resolution
vi.useFakeTimers(); vi.useFakeTimers();
// Create a controllable promise
let resolveRun!: () => void;
const runPromise = new Promise<void>((resolve) => {
resolveRun = resolve;
});
// Make the first call hang indefinitely to keep the lock active // Make the first call hang indefinitely to keep the lock active
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {})); vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(runPromise);
// Make logger.warn throw an error. This is outside the main try/catch in the cron job. // Make logger.warn throw an error. This is outside the main try/catch in the cron job.
const warnError = new Error('Logger warn failed'); const warnError = new Error('Logger warn failed');
vi.mocked(globalMockLogger.warn).mockImplementation(() => { vi.mocked(globalMockLogger.warn).mockImplementationOnce(() => {
throw warnError; throw warnError;
}); });
@@ -382,7 +397,13 @@ describe('Background Job Service', () => {
// Trigger the job once, it will hang and set the lock. Then trigger it a second time // Trigger the job once, it will hang and set the lock. Then trigger it a second time
// to enter the `if (isDailyDealCheckRunning)` block and call the throwing logger.warn. // to enter the `if (isDailyDealCheckRunning)` block and call the throwing logger.warn.
await Promise.allSettled([dailyDealCheckCallback(), dailyDealCheckCallback()]); const firstCall = dailyDealCheckCallback();
const secondCall = dailyDealCheckCallback();
// Resolve the first call so the test can finish
resolveRun();
await Promise.allSettled([firstCall, secondCall]);
// The outer catch block should have been called with the error from logger.warn // The outer catch block should have been called with the error from logger.warn
expect(globalMockLogger.error).toHaveBeenCalledWith( expect(globalMockLogger.error).toHaveBeenCalledWith(

View File

@@ -7,6 +7,7 @@ import { getSimpleWeekAndYear } from '../utils/dateUtils';
// Import types for repositories from their source files // Import types for repositories from their source files
import type { PersonalizationRepository } from './db/personalization.db'; import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db'; import type { NotificationRepository } from './db/notification.db';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
interface EmailJobData { interface EmailJobData {
to: string; to: string;
@@ -23,6 +24,24 @@ export class BackgroundJobService {
private logger: Logger, private logger: Logger,
) {} ) {}
public async triggerAnalyticsReport(): Promise<string> {
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const jobId = `manual-report-${reportDate}-${Date.now()}`;
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
return job.id!;
}
public async triggerWeeklyAnalyticsReport(): Promise<string> {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
const jobId = `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}`;
const job = await weeklyAnalyticsQueue.add(
'generate-weekly-report',
{ reportYear, reportWeek },
{ jobId },
);
return job.id!;
}
/** /**
* Prepares the data for an email notification job based on a user's deals. * Prepares the data for an email notification job based on a user's deals.
* @param user The user to whom the email will be sent. * @param user The user to whom the email will be sent.

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { brandService } from './brandService';
import * as db from './db/index.db';
import type { Logger } from 'pino';
// Mock dependencies
vi.mock('./db/index.db', () => ({
adminRepo: {
updateBrandLogo: vi.fn(),
},
}));
describe('BrandService', () => {
const mockLogger = {} as Logger;
beforeEach(() => {
vi.clearAllMocks();
});
describe('updateBrandLogo', () => {
it('should update brand logo and return the new URL', async () => {
const brandId = 123;
const mockFile = {
filename: 'test-logo.jpg',
} as Express.Multer.File;
vi.mocked(db.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
const result = await brandService.updateBrandLogo(brandId, mockFile, mockLogger);
expect(result).toBe('/flyer-images/test-logo.jpg');
expect(db.adminRepo.updateBrandLogo).toHaveBeenCalledWith(
brandId,
'/flyer-images/test-logo.jpg',
mockLogger,
);
});
it('should throw error if database update fails', async () => {
const brandId = 123;
const mockFile = {
filename: 'test-logo.jpg',
} as Express.Multer.File;
const dbError = new Error('DB Error');
vi.mocked(db.adminRepo.updateBrandLogo).mockRejectedValue(dbError);
await expect(brandService.updateBrandLogo(brandId, mockFile, mockLogger)).rejects.toThrow('DB Error');
});
});
});

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