Compare commits

...

150 Commits

Author SHA1 Message Date
Gitea Actions
dfa53a93dd ci: Bump version to 0.9.57 [skip ci] 2026-01-08 04:39:12 +05:00
f30464cd0e fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m1s
2026-01-07 15:38:14 -08:00
Gitea Actions
2d2fa3c2c8 ci: Bump version to 0.9.56 [skip ci] 2026-01-08 00:40:29 +05:00
58cb391f4b fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m36s
2026-01-07 11:39:35 -08:00
Gitea Actions
0ebe2f0806 ci: Bump version to 0.9.55 [skip ci] 2026-01-07 14:43:38 +05:00
7867abc5bc fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 24m23s
2026-01-07 01:42:43 -08:00
Gitea Actions
cc4c8e2839 ci: Bump version to 0.9.54 [skip ci] 2026-01-07 10:49:08 +05:00
33ee2eeac9 switch to instantiating the pm2 worker in the testing threads
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m44s
2026-01-06 21:48:35 -08:00
Gitea Actions
e0b13f26fb ci: Bump version to 0.9.53 [skip ci] 2026-01-07 09:57:37 +05:00
eee7f36756 switch to instantiating the pm2 worker in the testing threads
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m53s
2026-01-06 20:56:39 -08:00
Gitea Actions
622c919733 ci: Bump version to 0.9.52 [skip ci] 2026-01-07 08:26:14 +05:00
c7f6b6369a fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m27s
2026-01-06 19:25:25 -08:00
Gitea Actions
879d956003 ci: Bump version to 0.9.51 [skip ci] 2026-01-07 07:11:22 +05:00
27eaac7ea8 fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m15s
2026-01-06 18:10:47 -08:00
Gitea Actions
93618c57e5 ci: Bump version to 0.9.50 [skip ci] 2026-01-07 06:41:16 +05:00
7f043ef704 fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 29m45s
2026-01-06 17:40:20 -08:00
Gitea Actions
62e35deddc ci: Bump version to 0.9.49 [skip ci] 2026-01-07 02:54:13 +05:00
59f6f43d03 fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m36s
2026-01-06 13:53:00 -08:00
Gitea Actions
e675c1a73c ci: Bump version to 0.9.48 [skip ci] 2026-01-07 01:35:26 +05:00
3c19084a0a fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m17s
2026-01-06 12:34:18 -08:00
Gitea Actions
e2049c6b9f ci: Bump version to 0.9.47 [skip ci] 2026-01-06 23:34:29 +05:00
a3839c2f0d debugging the flyer integration issue
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m13s
2026-01-06 10:33:51 -08:00
Gitea Actions
c1df3d7b1b ci: Bump version to 0.9.46 [skip ci] 2026-01-06 22:39:47 +05:00
94782f030d debugging the flyer integration issue
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m42s
2026-01-06 09:38:14 -08:00
Gitea Actions
1c25b79251 ci: Bump version to 0.9.45 [skip ci] 2026-01-06 14:34:44 +05:00
0b0fa8294d debugging the flyer integration issue
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m54s
2026-01-06 01:33:48 -08:00
Gitea Actions
f49f3a75fb ci: Bump version to 0.9.44 [skip ci] 2026-01-06 13:41:43 +05:00
8f14044ae6 debugging the flyer integration issue
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m27s
2026-01-06 00:41:03 -08:00
Gitea Actions
55e1e425f4 ci: Bump version to 0.9.43 [skip ci] 2026-01-06 12:56:47 +05:00
68b16ad2e8 fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m2s
2026-01-05 23:53:54 -08:00
Gitea Actions
6a28934692 ci: Bump version to 0.9.42 [skip ci] 2026-01-06 12:25:08 +05:00
78c4a5fee6 fix the dang integration tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2026-01-05 23:20:56 -08:00
Gitea Actions
1ce5f481a8 ci: Bump version to 0.9.41 [skip ci] 2026-01-06 11:39:28 +05:00
Gitea Actions
e0120d38fd ci: Bump version to 0.9.39 [skip ci] 2026-01-06 11:39:27 +05:00
6b2079ef2c fix the dang integration tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m44s
2026-01-05 22:38:21 -08:00
Gitea Actions
0478e176d5 ci: Bump version to 0.9.38 [skip ci] 2026-01-06 10:23:22 +05:00
47f7f97cd9 fuck database contraints - seems buggy
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m10s
2026-01-05 21:16:08 -08:00
Gitea Actions
b0719d1e39 ci: Bump version to 0.9.37 [skip ci] 2026-01-06 10:11:19 +05:00
0039ac3752 fuck database contraints - seems buggy
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 37s
2026-01-05 21:08:16 -08:00
Gitea Actions
3c8316f4f7 ci: Bump version to 0.9.36 [skip ci] 2026-01-06 09:03:20 +05:00
2564df1c64 get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
2026-01-05 20:02:44 -08:00
Gitea Actions
696c547238 ci: Bump version to 0.9.35 [skip ci] 2026-01-06 08:11:42 +05:00
38165bdb9a get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
2026-01-05 19:10:46 -08:00
Gitea Actions
6139dca072 ci: Bump version to 0.9.34 [skip ci] 2026-01-06 06:33:46 +05:00
68bfaa50e6 more baseurl work - hopefully that does it for now
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m5s
2026-01-05 17:33:00 -08:00
Gitea Actions
9c42621f74 ci: Bump version to 0.9.33 [skip ci] 2026-01-06 04:34:48 +05:00
1b98282202 more rate limiting
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m19s
2026-01-05 15:31:01 -08:00
Gitea Actions
b6731b220c ci: Bump version to 0.9.32 [skip ci] 2026-01-06 04:13:42 +05:00
3507d455e8 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2026-01-05 15:13:10 -08:00
Gitea Actions
92b2adf8e8 ci: Bump version to 0.9.31 [skip ci] 2026-01-06 04:07:21 +05:00
d6c7452256 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2026-01-05 15:06:55 -08:00
Gitea Actions
d812b681dd ci: Bump version to 0.9.30 [skip ci] 2026-01-06 03:54:42 +05:00
b4306a6092 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 50s
2026-01-05 14:53:49 -08:00
Gitea Actions
57fdd159d5 ci: Bump version to 0.9.29 [skip ci] 2026-01-06 01:08:45 +05:00
4a747ca042 even even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m46s
2026-01-05 12:08:18 -08:00
Gitea Actions
e0bf96824c ci: Bump version to 0.9.28 [skip ci] 2026-01-06 00:28:11 +05:00
e86e09703e even even more and more test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 59s
2026-01-05 11:27:13 -08:00
Gitea Actions
275741c79e ci: Bump version to 0.9.27 [skip ci] 2026-01-05 15:32:08 +05:00
3a40249ddb even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m19s
2026-01-05 02:30:28 -08:00
Gitea Actions
4c70905950 ci: Bump version to 0.9.26 [skip ci] 2026-01-05 14:51:27 +05:00
0b4884ff2a even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m1s
2026-01-05 01:50:54 -08:00
Gitea Actions
e4acab77c8 ci: Bump version to 0.9.25 [skip ci] 2026-01-05 14:26:57 +05:00
4e20b1b430 even more and more test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 54s
2026-01-05 01:26:12 -08:00
Gitea Actions
15747ac942 ci: Bump version to 0.9.24 [skip ci] 2026-01-05 12:37:56 +05:00
e5fa89ef17 even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m55s
2026-01-04 23:36:56 -08:00
Gitea Actions
2c65da31e9 ci: Bump version to 0.9.23 [skip ci] 2026-01-05 05:12:54 +05:00
eeec6af905 even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m33s
2026-01-04 16:01:55 -08:00
Gitea Actions
e7d03951b9 ci: Bump version to 0.9.22 [skip ci] 2026-01-05 03:35:06 +05:00
af8816e0af more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 29m30s
2026-01-04 14:34:16 -08:00
Gitea Actions
64f6427e1a ci: Bump version to 0.9.21 [skip ci] 2026-01-05 01:31:50 +05:00
c9b7a75429 more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m59s
2026-01-04 12:30:44 -08:00
Gitea Actions
0490f6922e ci: Bump version to 0.9.20 [skip ci] 2026-01-05 00:30:12 +05:00
057c4c9174 more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m19s
2026-01-04 11:28:52 -08:00
Gitea Actions
a9e56bc707 ci: Bump version to 0.9.19 [skip ci] 2026-01-04 16:00:35 +05:00
e5d09c73b7 test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 20m31s
2026-01-04 02:59:55 -08:00
Gitea Actions
6e1298b825 ci: Bump version to 0.9.18 [skip ci] 2026-01-04 15:22:37 +05:00
fc8e43437a test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s
2026-01-04 02:21:08 -08:00
Gitea Actions
cb453aa949 ci: Bump version to 0.9.17 [skip ci] 2026-01-04 09:02:18 +05:00
2651bd16ae test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 52s
2026-01-03 20:01:10 -08:00
Gitea Actions
91e0f0c46f ci: Bump version to 0.9.16 [skip ci] 2026-01-04 05:05:33 +05:00
e6986d512b test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m38s
2026-01-03 16:04:04 -08:00
Gitea Actions
8f9c21675c ci: Bump version to 0.9.15 [skip ci] 2026-01-04 03:58:29 +05:00
7fb22cdd20 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m12s
2026-01-03 14:57:40 -08:00
Gitea Actions
780291303d ci: Bump version to 0.9.14 [skip ci] 2026-01-04 02:48:56 +05:00
4f607f7d2f more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 29m49s
2026-01-03 13:47:44 -08:00
Gitea Actions
208227b3ed ci: Bump version to 0.9.13 [skip ci] 2026-01-04 01:35:36 +05:00
bf1c7d4adf more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m41s
2026-01-03 12:35:05 -08:00
Gitea Actions
a7a30cf983 ci: Bump version to 0.9.12 [skip ci] 2026-01-04 01:01:26 +05:00
0bc0676b33 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m39s
2026-01-03 12:00:20 -08:00
Gitea Actions
73484d3eb4 ci: Bump version to 0.9.11 [skip ci] 2026-01-03 23:52:31 +05:00
b3253d5bbc more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m17s
2026-01-03 10:51:44 -08:00
Gitea Actions
54f3769e90 ci: Bump version to 0.9.10 [skip ci] 2026-01-03 13:34:20 +05:00
bad6f74ee6 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m21s
2026-01-03 00:33:47 -08:00
Gitea Actions
bcf16168b6 ci: Bump version to 0.9.9 [skip ci] 2026-01-03 13:03:37 +05:00
498fbd9e0e more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m5s
2026-01-03 00:02:09 -08:00
Gitea Actions
007ff8e538 ci: Bump version to 0.9.8 [skip ci] 2026-01-03 11:34:34 +05:00
1fc70e3915 extend timers duration - prevent jobs from timing out after 30secs, increased to 4mins
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m56s
2026-01-02 22:33:51 -08:00
Gitea Actions
d891e47e02 ci: Bump version to 0.9.7 [skip ci] 2026-01-03 10:36:05 +05:00
08c39afde4 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m33s
2026-01-02 21:33:31 -08:00
Gitea Actions
c579543b8a ci: Bump version to 0.9.6 [skip ci] 2026-01-03 09:31:41 +05:00
0d84137786 test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m17s
2026-01-02 20:31:08 -08:00
Gitea Actions
20ee30c4b4 ci: Bump version to 0.9.5 [skip ci] 2026-01-03 08:52:26 +05:00
93612137e3 test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m23s
2026-01-02 19:51:10 -08:00
Gitea Actions
6e70f08e3c ci: Bump version to 0.9.4 [skip ci] 2026-01-03 07:59:50 +05:00
459f5f7976 sql fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m36s
2026-01-02 18:59:16 -08:00
Gitea Actions
a2e6331ddd ci: Bump version to 0.9.3 [skip ci] 2026-01-03 07:28:11 +05:00
13cd30bec9 sql fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m51s
2026-01-02 18:27:42 -08:00
Gitea Actions
baeb9488c6 ci: Bump version to 0.9.2 [skip ci] 2026-01-03 07:07:42 +05:00
0cba0f987e remove refresh_token as it really should not be stored
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m57s
2026-01-02 18:07:08 -08:00
Gitea Actions
958a79997d ci: Bump version to 0.9.1 [skip ci] 2026-01-03 07:01:27 +05:00
8fb1c96f93 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 46s
2026-01-02 17:56:18 -08:00
6e6fe80c7f sql fixes 2026-01-02 17:55:22 -08:00
Gitea Actions
d1554050bd ci: Bump version to 0.9.0 for production release [skip ci] 2026-01-03 05:50:23 +05:00
Gitea Actions
b1fae270bb ci: Bump version to 0.8.0 for production release [skip ci] 2026-01-03 05:48:40 +05:00
Gitea Actions
c852483e18 ci: Bump version to 0.7.29 [skip ci] 2026-01-03 02:43:54 +05:00
2e01ad5bc9 more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m50s
2026-01-02 13:43:20 -08:00
Gitea Actions
26763c7183 ci: Bump version to 0.7.28 [skip ci] 2026-01-03 02:04:26 +05:00
f0c5c2c45b more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m40s
2026-01-02 13:03:25 -08:00
Gitea Actions
034bb60fd5 ci: Bump version to 0.7.27 [skip ci] 2026-01-03 01:31:54 +05:00
d4b389cb79 more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m39s
2026-01-02 12:31:19 -08:00
Gitea Actions
a71fb81468 ci: Bump version to 0.7.26 [skip ci] 2026-01-03 00:58:34 +05:00
9bee0a013b unit test auto-provider refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m8s
2026-01-02 11:58:03 -08:00
Gitea Actions
8bcb4311b3 ci: Bump version to 0.7.25 [skip ci] 2026-01-03 00:34:45 +05:00
9fd15f3a50 unit test auto-provider refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m58s
2026-01-02 11:33:11 -08:00
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
211 changed files with 10769 additions and 3572 deletions

View File

@@ -113,7 +113,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# --- Integration test specific variables ---
FRONTEND_URL: 'http://localhost:3000'
FRONTEND_URL: 'https://example.com'
VITE_API_BASE_URL: 'http://localhost:3001/api'
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
@@ -335,7 +335,8 @@ jobs:
fi
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
# Sanitize commit message to prevent shell injection or build breaks (removes quotes, backticks, backslashes, $)
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s | tr -d '"`\\$')
PACKAGE_VERSION=$(node -p "require('./package.json').version")
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
@@ -388,7 +389,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# Application Secrets
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
FRONTEND_URL: 'https://example.com'
JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}

View File

@@ -0,0 +1,41 @@
# ADR-027: Standardized Naming Convention for AI and Database Types
**Date**: 2026-01-05
**Status**: Accepted
## Context
The application codebase primarily follows the standard TypeScript convention of `camelCase` for variable and property names. However, the PostgreSQL database uses `snake_case` for column names. Additionally, the AI prompts are designed to extract data that maps directly to these database columns.
Attempting to enforce `camelCase` strictly across the entire stack created friction and ambiguity, particularly in the background processing pipeline where data moves from the AI model directly to the database. Developers were unsure whether to transform keys immediately upon receipt (adding overhead) or keep them as-is.
## Decision
We will adopt a hybrid naming convention strategy to explicitly distinguish between internal application state and external/persisted data formats.
1. **Database and AI Types (`snake_case`)**:
Interfaces, Type definitions, and Zod schemas that represent raw database rows or direct AI responses **MUST** use `snake_case`.
- *Examples*: `AiFlyerDataSchema`, `ExtractedFlyerItemSchema`, `FlyerInsert`.
- *Reasoning*: This avoids unnecessary mapping layers when inserting data into the database or parsing AI output. It serves as a visual cue that the data is "raw", "external", or destined for persistence.
2. **Internal Application Logic (`camelCase`)**:
Variables, function arguments, and processed data structures used within the application logic (Service layer, UI components, utility functions) **MUST** use `camelCase`.
- *Reasoning*: This adheres to standard JavaScript/TypeScript practices and maintains consistency with the rest of the ecosystem (React, etc.).
3. **Boundary Handling**:
- For background jobs that primarily move data from AI to DB, preserving `snake_case` is preferred to minimize transformation logic.
- For API responses sent to the frontend, data should generally be transformed to `camelCase` unless it is a direct dump of a database entity for a specific administrative view.
## Consequences
### Positive
- **Visual Distinction**: It is immediately obvious whether a variable holds raw data (`price_in_cents`) or processed application state (`priceInCents`).
- **Efficiency**: Reduces boilerplate code for mapping keys (e.g., `price_in_cents: data.priceInCents`) when performing bulk inserts or updates.
- **Simplicity**: AI prompts can request JSON keys that match the database schema 1:1, reducing the risk of mapping errors.
### Negative
- **Context Switching**: Developers must be mindful of the casing context.
- **Linter Configuration**: May require specific overrides or `// eslint-disable-next-line` comments if the linter is configured to strictly enforce `camelCase` everywhere.

View File

@@ -16,6 +16,27 @@ if (missingSecrets.length > 0) {
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
}
// --- Shared Environment Variables ---
// Define common variables to reduce duplication and ensure consistency across apps.
const sharedEnv = {
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
};
module.exports = {
apps: [
{
@@ -25,6 +46,11 @@ module.exports = {
script: './node_modules/.bin/tsx',
args: 'server.ts',
max_memory_restart: '500M',
// Production Optimization: Run in cluster mode to utilize all CPU cores
instances: 'max',
exec_mode: 'cluster',
kill_timeout: 5000, // Allow 5s for graceful shutdown of API requests
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// Restart Logic
max_restarts: 40,
@@ -36,44 +62,16 @@ module.exports = {
NODE_ENV: 'production',
name: 'flyer-crawler-api',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
...sharedEnv,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'test',
name: 'flyer-crawler-api-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
...sharedEnv,
},
// Development Environment Settings
env_development: {
@@ -81,22 +79,8 @@ module.exports = {
name: 'flyer-crawler-api-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
...sharedEnv,
},
},
{
@@ -105,6 +89,8 @@ module.exports = {
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts',
max_memory_restart: '1G',
kill_timeout: 10000, // Workers may need more time to complete a job
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// Restart Logic
max_restarts: 40,
@@ -116,44 +102,14 @@ module.exports = {
NODE_ENV: 'production',
name: 'flyer-crawler-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'test',
name: 'flyer-crawler-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
// Development Environment Settings
env_development: {
@@ -161,22 +117,7 @@ module.exports = {
name: 'flyer-crawler-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
},
{
@@ -185,6 +126,8 @@ module.exports = {
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts',
max_memory_restart: '1G',
kill_timeout: 10000,
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// Restart Logic
max_restarts: 40,
@@ -196,44 +139,14 @@ module.exports = {
NODE_ENV: 'production',
name: 'flyer-crawler-analytics-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
// Test Environment Settings
env_test: {
NODE_ENV: 'test',
name: 'flyer-crawler-analytics-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
// Development Environment Settings
env_development: {
@@ -241,22 +154,7 @@ module.exports = {
name: 'flyer-crawler-analytics-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
FRONTEND_URL: process.env.FRONTEND_URL,
JWT_SECRET: process.env.JWT_SECRET,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
...sharedEnv,
},
},
],

View File

@@ -13,6 +13,15 @@ RULES:
latest refacter
Refactor `RecipeSuggester.test.tsx` to use `renderWithProviders`.
Create a new test file for `StatCard.tsx` to verify its props and rendering.
while assuming that master_schema_rollup.sql is the "ultimate source of truth", issues can happen and it may not have been properly
updated - look for differences between these files
UPC SCANNING !

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.7.12",
"version": "0.9.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.7.12",
"version": "0.9.57",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.7.12",
"version": "0.9.57",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -1,477 +1,8 @@
-- sql/Initial_triggers_and_functions.sql
-- This file contains all trigger functions and trigger definitions for the database.
-- 1. Set up the trigger to automatically create a profile when a new user signs up.
-- This function is called by a trigger on the `public.users` table.
DROP FUNCTION IF EXISTS public.handle_new_user();
-- It creates a corresponding profile and a default shopping list for the new user.
-- It now accepts full_name and avatar_url from the user's metadata.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
new_profile_id UUID;
user_meta_data JSONB;
BEGIN
-- The user's metadata (full_name, avatar_url) is passed via a temporary session variable.
user_meta_data := current_setting('my_app.user_metadata', true)::JSONB;
INSERT INTO public.profiles (user_id, role, full_name, avatar_url)
VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url')
RETURNING user_id INTO new_profile_id;
-- Also create a default shopping list for the new user.
INSERT INTO public.shopping_lists (user_id, name)
VALUES (new.user_id, 'Main Shopping List');
-- Log the new user event
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (new.user_id, 'user_registered',
COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.',
'user-plus',
jsonb_build_object('email', new.email)
);
RETURN new;
END;
$$ LANGUAGE plpgsql;
-- This trigger calls the function after a new user is created.
DROP TRIGGER IF EXISTS on_auth_user_created ON public.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON public.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- 2. Create a reusable function to automatically update 'updated_at' columns.
DROP FUNCTION IF EXISTS public.handle_updated_at();
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Dynamically apply the 'handle_updated_at' trigger to all tables in the public schema
-- that have an 'updated_at' column. This is more maintainable than creating a separate
-- trigger for each table.
DO $$
DECLARE
t_name TEXT;
BEGIN
FOR t_name IN
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'updated_at'
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS on_%s_updated ON public.%I;
CREATE TRIGGER on_%s_updated
BEFORE UPDATE ON public.%I
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();',
t_name, t_name, t_name, t_name);
END LOOP;
END;
$$;
-- 3. Create a trigger function to populate the item_price_history table on insert.
DROP FUNCTION IF EXISTS public.update_price_history_on_flyer_item_insert();
CREATE OR REPLACE FUNCTION public.update_price_history_on_flyer_item_insert()
RETURNS TRIGGER AS $$
DECLARE
flyer_valid_from DATE;
flyer_valid_to DATE;
current_summary_date DATE;
flyer_location_id BIGINT;
BEGIN
-- If the item could not be matched, add it to the unmatched queue for review.
IF NEW.master_item_id IS NULL THEN
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
VALUES (NEW.flyer_item_id)
ON CONFLICT (flyer_item_id) DO NOTHING;
END IF;
-- Only run if the new flyer item is linked to a master item and has a price.
IF NEW.master_item_id IS NULL OR NEW.price_in_cents IS NULL THEN
RETURN NEW;
END IF;
-- Get the validity dates of the flyer and the store_id.
SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to
FROM public.flyers
WHERE flyer_id = NEW.flyer_id;
-- This single, set-based query is much more performant than looping.
-- It generates all date/location pairs and inserts/updates them in one operation.
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
SELECT
NEW.master_item_id,
d.day,
fl.store_location_id,
NEW.price_in_cents,
NEW.price_in_cents,
NEW.price_in_cents,
1
FROM public.flyer_locations fl
CROSS JOIN generate_series(flyer_valid_from, flyer_valid_to, '1 day'::interval) AS d(day)
WHERE fl.flyer_id = NEW.flyer_id
ON CONFLICT (master_item_id, summary_date, store_location_id)
DO UPDATE SET
min_price_in_cents = LEAST(item_price_history.min_price_in_cents, EXCLUDED.min_price_in_cents),
max_price_in_cents = GREATEST(item_price_history.max_price_in_cents, EXCLUDED.max_price_in_cents),
avg_price_in_cents = ROUND(((item_price_history.avg_price_in_cents * item_price_history.data_points_count) + EXCLUDED.avg_price_in_cents) / (item_price_history.data_points_count + 1.0)),
data_points_count = item_price_history.data_points_count + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for insert.
DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items;
CREATE TRIGGER trigger_update_price_history
AFTER INSERT ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.update_price_history_on_flyer_item_insert();
-- 4. Create a trigger function to recalculate price history when a flyer item is deleted.
DROP FUNCTION IF EXISTS public.recalculate_price_history_on_flyer_item_delete();
CREATE OR REPLACE FUNCTION public.recalculate_price_history_on_flyer_item_delete()
RETURNS TRIGGER AS $$
DECLARE
affected_dates RECORD;
BEGIN
-- Only run if the deleted item was linked to a master item and had a price.
IF OLD.master_item_id IS NULL OR OLD.price_in_cents IS NULL THEN
RETURN OLD;
END IF;
-- This single, set-based query is much more performant than looping.
-- It recalculates aggregates for all affected dates and locations at once.
WITH affected_days_and_locations AS (
-- 1. Get all date/location pairs affected by the deleted item's flyer.
SELECT DISTINCT
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
fl.store_location_id
FROM public.flyers f
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
WHERE f.flyer_id = OLD.flyer_id
),
new_aggregates AS (
-- 2. For each affected date/location, recalculate the aggregates from all other relevant flyer items.
SELECT
adl.summary_date,
adl.store_location_id,
MIN(fi.price_in_cents) AS min_price,
MAX(fi.price_in_cents) AS max_price,
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
COUNT(fi.flyer_item_id)::int AS data_points
FROM affected_days_and_locations adl
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
WHERE fl.flyer_id IS NOT NULL -- Ensure the join was successful
GROUP BY adl.summary_date, adl.store_location_id
)
-- 3. Update the history table with the new aggregates.
UPDATE public.item_price_history iph
SET
min_price_in_cents = na.min_price,
max_price_in_cents = na.max_price,
avg_price_in_cents = na.avg_price,
data_points_count = na.data_points
FROM new_aggregates na
WHERE iph.master_item_id = OLD.master_item_id
AND iph.summary_date = na.summary_date
AND iph.store_location_id = na.store_location_id;
-- 4. Delete any history records that no longer have any data points.
DELETE FROM public.item_price_history iph
WHERE iph.master_item_id = OLD.master_item_id
AND NOT EXISTS (
SELECT 1 FROM new_aggregates na
WHERE na.summary_date = iph.summary_date AND na.store_location_id = iph.store_location_id
);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for DELETE operations.
DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items;
CREATE TRIGGER trigger_recalculate_price_history_on_delete
AFTER DELETE ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.recalculate_price_history_on_flyer_item_delete();
-- 5. Trigger function to update the average rating on the recipes table.
DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.recipes
SET
avg_rating = (
SELECT AVG(rating)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
),
rating_count = (
SELECT COUNT(*)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
)
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after any change to recipe_ratings.
DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings;
CREATE TRIGGER on_recipe_rating_change
AFTER INSERT OR UPDATE OR DELETE ON public.recipe_ratings
FOR EACH ROW EXECUTE FUNCTION public.update_recipe_rating_aggregates();
-- 6. Trigger function to log the creation of a new recipe.
DROP FUNCTION IF EXISTS public.log_new_recipe();
CREATE OR REPLACE FUNCTION public.log_new_recipe()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_created',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
'chef-hat',
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
);
-- Award 'First Recipe' achievement if it's their first one.
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a new recipe is inserted.
DROP TRIGGER IF EXISTS on_new_recipe_created ON public.recipes;
CREATE TRIGGER on_new_recipe_created
AFTER INSERT ON public.recipes
FOR EACH ROW
WHEN (NEW.user_id IS NOT NULL) -- Only log activity for user-created recipes.
EXECUTE FUNCTION public.log_new_recipe();
-- 7a. Trigger function to update the item_count on the flyers table.
DROP FUNCTION IF EXISTS public.update_flyer_item_count();
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
ELSIF (TG_OP = 'DELETE') THEN
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after any change to flyer_items.
-- This ensures the item_count on the parent flyer is always accurate.
DROP TRIGGER IF EXISTS on_flyer_item_change ON public.flyer_items;
CREATE TRIGGER on_flyer_item_change
AFTER INSERT OR DELETE ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.update_flyer_item_count();
-- 7. Trigger function to log the creation of a new flyer.
DROP FUNCTION IF EXISTS public.log_new_flyer();
CREATE OR REPLACE FUNCTION public.log_new_flyer()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (action, display_text, icon, details)
VALUES (
'flyer_uploaded',
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
'file-text',
jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a new flyer is inserted.
DROP TRIGGER IF EXISTS on_new_flyer_created ON public.flyers;
CREATE TRIGGER on_new_flyer_created
AFTER INSERT ON public.flyers
FOR EACH ROW EXECUTE FUNCTION public.log_new_flyer();
-- 8. Trigger function to log when a user favorites a recipe.
DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_favorited',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
'heart',
jsonb_build_object(
'recipe_id', NEW.recipe_id
)
);
-- Award 'First Favorite' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a recipe is favorited.
DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes;
CREATE TRIGGER on_new_favorite_recipe
AFTER INSERT ON public.favorite_recipes
FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe();
-- 9. Trigger function to log when a user shares a shopping list.
DROP FUNCTION IF EXISTS public.log_new_list_share();
CREATE OR REPLACE FUNCTION public.log_new_list_share()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id,
'list_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
'share-2',
jsonb_build_object(
'shopping_list_id', NEW.shopping_list_id,
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
'shared_with_user_id', NEW.shared_with_user_id
)
);
-- Award 'List Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a shopping list is shared.
DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists;
CREATE TRIGGER on_new_list_share
AFTER INSERT ON public.shared_shopping_lists
FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share();
-- 9a. Trigger function to log when a user shares a recipe collection.
DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
RETURNS TRIGGER AS $$
BEGIN
-- Log the activity
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id, 'recipe_collection_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
'book',
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
);
-- Award 'Recipe Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_new_recipe_collection_share ON public.shared_recipe_collections;
CREATE TRIGGER on_new_recipe_collection_share
AFTER INSERT ON public.shared_recipe_collections
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
-- 10. Trigger function to geocode a store location's address.
-- This function is designed to be extensible. In a production environment,
-- you would replace the placeholder with a call to an external geocoding service
-- (e.g., using the `http` extension or a `plpythonu` function) to convert
-- the address into geographic coordinates.
DROP FUNCTION IF EXISTS public.geocode_store_location();
CREATE OR REPLACE FUNCTION public.geocode_store_location()
RETURNS TRIGGER AS $$
DECLARE
full_address TEXT;
BEGIN
-- Only proceed if the address has actually changed.
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.address IS DISTINCT FROM OLD.address) THEN
-- Concatenate address parts into a single string for the geocoder.
full_address := CONCAT_WS(', ', NEW.address, NEW.city, NEW.province_state, NEW.postal_code);
-- ======================================================================
-- Placeholder for Geocoding API Call
-- ======================================================================
-- In a real application, you would call a geocoding service here.
-- For example, using the `http` extension:
--
-- DECLARE
-- response http_get;
-- lat NUMERIC;
-- lon NUMERIC;
-- BEGIN
-- SELECT * INTO response FROM http_get('https://api.geocodingservice.com/geocode?address=' || url_encode(full_address));
-- lat := (response.content::jsonb)->'results'->0->'geometry'->'location'->'lat';
-- lon := (response.content::jsonb)->'results'->0->'geometry'->'location'->'lng';
-- NEW.location := ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography;
-- END;
--
-- For now, this function does nothing, but the trigger is in place.
-- If you manually provide lat/lon, you could parse them here.
-- For this example, we will assume the `location` might be set manually
-- or by a separate batch process.
-- ======================================================================
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the geocoding function.
DROP TRIGGER IF EXISTS on_store_location_address_change ON public.store_locations;
CREATE TRIGGER on_store_location_address_change
BEFORE INSERT OR UPDATE ON public.store_locations
FOR EACH ROW EXECUTE FUNCTION public.geocode_store_location();
-- 11. Trigger function to increment the fork_count on the original recipe.
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
CREATE TRIGGER on_recipe_fork
AFTER INSERT ON public.recipes
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
-- ============================================================================
-- PART 6: DATABASE FUNCTIONS
-- PART 3: DATABASE FUNCTIONS
-- ============================================================================
-- Function to find the best current sale price for a user's watched items.
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_user(UUID);
@@ -1336,8 +867,7 @@ AS $$
'list_shared'
-- 'new_recipe_rating' could be added here later
)
ORDER BY
al.created_at DESC
ORDER BY al.created_at DESC, al.display_text, al.icon
LIMIT p_limit
OFFSET p_offset;
$$;
@@ -1549,16 +1079,18 @@ $$;
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
-- Returns: TABLE(...) - A set of records including user details and deal information.
-- =================================================================
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
RETURNS TABLE(
user_id uuid,
email text,
full_name text,
master_item_id integer,
master_item_id bigint,
item_name text,
best_price_in_cents integer,
store_name text,
flyer_id integer,
flyer_id bigint,
valid_to date
) AS $$
BEGIN
@@ -1569,11 +1101,12 @@ BEGIN
SELECT
fi.master_item_id,
fi.price_in_cents,
f.store_name,
s.name as store_name,
f.flyer_id,
f.valid_to
FROM public.flyer_items fi
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
JOIN public.stores s ON f.store_id = s.store_id
WHERE
fi.master_item_id IS NOT NULL
AND fi.price_in_cents IS NOT NULL
@@ -1616,3 +1149,472 @@ BEGIN
bp.price_rank = 1;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- PART 4: TRIGGERS
-- ============================================================================
-- 1. Trigger to automatically create a profile when a new user signs up.
-- This function is called by a trigger on the `public.users` table.
DROP FUNCTION IF EXISTS public.handle_new_user();
-- It creates a corresponding profile and a default shopping list for the new user.
-- It now accepts full_name and avatar_url from the user's metadata.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
new_profile_id UUID;
user_meta_data JSONB;
BEGIN
-- The user's metadata (full_name, avatar_url) is passed via a temporary session variable.
user_meta_data := current_setting('my_app.user_metadata', true)::JSONB;
INSERT INTO public.profiles (user_id, role, full_name, avatar_url)
VALUES (new.user_id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url')
RETURNING user_id INTO new_profile_id;
-- Also create a default shopping list for the new user.
INSERT INTO public.shopping_lists (user_id, name)
VALUES (new.user_id, 'Main Shopping List');
-- Log the new user event
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (new.user_id, 'user_registered',
COALESCE(user_meta_data->>'full_name', new.email) || ' has registered.',
'user-plus',
jsonb_build_object('email', new.email)
);
RETURN new;
END;
$$ LANGUAGE plpgsql;
-- This trigger calls the function after a new user is created.
DROP TRIGGER IF EXISTS on_auth_user_created ON public.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON public.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- 2. Create a reusable function to automatically update 'updated_at' columns.
DROP FUNCTION IF EXISTS public.handle_updated_at();
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Dynamically apply the 'handle_updated_at' trigger to all tables in the public schema
-- that have an 'updated_at' column. This is more maintainable than creating a separate
-- trigger for each table.
DO $$
DECLARE
t_name TEXT;
BEGIN
FOR t_name IN
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'updated_at'
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS on_%s_updated ON public.%I;
CREATE TRIGGER on_%s_updated
BEFORE UPDATE ON public.%I
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();',
t_name, t_name, t_name, t_name);
END LOOP;
END;
$$;
-- 3. Create a trigger function to populate the item_price_history table on insert.
DROP FUNCTION IF EXISTS public.update_price_history_on_flyer_item_insert();
CREATE OR REPLACE FUNCTION public.update_price_history_on_flyer_item_insert()
RETURNS TRIGGER AS $$
DECLARE
flyer_valid_from DATE;
flyer_valid_to DATE;
current_summary_date DATE;
flyer_location_id BIGINT;
BEGIN
-- If the item could not be matched, add it to the unmatched queue for review.
IF NEW.master_item_id IS NULL THEN
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
VALUES (NEW.flyer_item_id)
ON CONFLICT (flyer_item_id) DO NOTHING;
END IF;
-- Only run if the new flyer item is linked to a master item and has a price.
IF NEW.master_item_id IS NULL OR NEW.price_in_cents IS NULL THEN
RETURN NEW;
END IF;
-- Get the validity dates of the flyer and the store_id.
SELECT valid_from, valid_to INTO flyer_valid_from, flyer_valid_to
FROM public.flyers
WHERE flyer_id = NEW.flyer_id;
-- This single, set-based query is much more performant than looping.
-- It generates all date/location pairs and inserts/updates them in one operation.
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
SELECT
NEW.master_item_id,
d.day,
fl.store_location_id,
NEW.price_in_cents,
NEW.price_in_cents,
NEW.price_in_cents,
1
FROM public.flyer_locations fl
CROSS JOIN generate_series(flyer_valid_from, flyer_valid_to, '1 day'::interval) AS d(day)
WHERE fl.flyer_id = NEW.flyer_id
ON CONFLICT (master_item_id, summary_date, store_location_id)
DO UPDATE SET
min_price_in_cents = LEAST(item_price_history.min_price_in_cents, EXCLUDED.min_price_in_cents),
max_price_in_cents = GREATEST(item_price_history.max_price_in_cents, EXCLUDED.max_price_in_cents),
avg_price_in_cents = ROUND(((item_price_history.avg_price_in_cents * item_price_history.data_points_count) + EXCLUDED.avg_price_in_cents) / (item_price_history.data_points_count + 1.0)),
data_points_count = item_price_history.data_points_count + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for insert.
DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items;
CREATE TRIGGER trigger_update_price_history
AFTER INSERT ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.update_price_history_on_flyer_item_insert();
-- 4. Create a trigger function to recalculate price history when a flyer item is deleted.
DROP FUNCTION IF EXISTS public.recalculate_price_history_on_flyer_item_delete();
CREATE OR REPLACE FUNCTION public.recalculate_price_history_on_flyer_item_delete()
RETURNS TRIGGER AS $$
DECLARE
affected_dates RECORD;
BEGIN
-- Only run if the deleted item was linked to a master item and had a price.
IF OLD.master_item_id IS NULL OR OLD.price_in_cents IS NULL THEN
RETURN OLD;
END IF;
-- This single, set-based query is much more performant than looping.
-- It recalculates aggregates for all affected dates and locations at once.
WITH affected_days_and_locations AS (
-- 1. Get all date/location pairs affected by the deleted item's flyer.
SELECT DISTINCT
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
fl.store_location_id
FROM public.flyers f
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
WHERE f.flyer_id = OLD.flyer_id
),
new_aggregates AS (
-- 2. For each affected date/location, recalculate the aggregates from all other relevant flyer items.
SELECT
adl.summary_date,
adl.store_location_id,
MIN(fi.price_in_cents) AS min_price,
MAX(fi.price_in_cents) AS max_price,
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
COUNT(fi.flyer_item_id)::int AS data_points
FROM affected_days_and_locations adl
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
WHERE fl.flyer_id IS NOT NULL -- Ensure the join was successful
GROUP BY adl.summary_date, adl.store_location_id
)
-- 3. Update the history table with the new aggregates.
UPDATE public.item_price_history iph
SET
min_price_in_cents = na.min_price,
max_price_in_cents = na.max_price,
avg_price_in_cents = na.avg_price,
data_points_count = na.data_points
FROM new_aggregates na
WHERE iph.master_item_id = OLD.master_item_id
AND iph.summary_date = na.summary_date
AND iph.store_location_id = na.store_location_id;
-- 4. Delete any history records that no longer have any data points.
DELETE FROM public.item_price_history iph
WHERE iph.master_item_id = OLD.master_item_id
AND NOT EXISTS (
SELECT 1 FROM new_aggregates na
WHERE na.summary_date = iph.summary_date AND na.store_location_id = iph.store_location_id
);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for DELETE operations.
DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items;
CREATE TRIGGER trigger_recalculate_price_history_on_delete
AFTER DELETE ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.recalculate_price_history_on_flyer_item_delete();
-- 5. Trigger function to update the average rating on the recipes table.
DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.recipes
SET
avg_rating = (
SELECT AVG(rating)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
),
rating_count = (
SELECT COUNT(*)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
)
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after any change to recipe_ratings.
DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings;
CREATE TRIGGER on_recipe_rating_change
AFTER INSERT OR UPDATE OR DELETE ON public.recipe_ratings
FOR EACH ROW EXECUTE FUNCTION public.update_recipe_rating_aggregates();
-- 6. Trigger function to log the creation of a new recipe.
DROP FUNCTION IF EXISTS public.log_new_recipe();
CREATE OR REPLACE FUNCTION public.log_new_recipe()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_created',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
'chef-hat',
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
);
-- Award 'First Recipe' achievement if it's their first one.
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a new recipe is inserted.
DROP TRIGGER IF EXISTS on_new_recipe_created ON public.recipes;
CREATE TRIGGER on_new_recipe_created
AFTER INSERT ON public.recipes
FOR EACH ROW
WHEN (NEW.user_id IS NOT NULL) -- Only log activity for user-created recipes.
EXECUTE FUNCTION public.log_new_recipe();
-- 7a. Trigger function to update the item_count on the flyers table.
DROP FUNCTION IF EXISTS public.update_flyer_item_count();
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
ELSIF (TG_OP = 'DELETE') THEN
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after any change to flyer_items.
-- This ensures the item_count on the parent flyer is always accurate.
DROP TRIGGER IF EXISTS on_flyer_item_change ON public.flyer_items;
CREATE TRIGGER on_flyer_item_change
AFTER INSERT OR DELETE ON public.flyer_items
FOR EACH ROW EXECUTE FUNCTION public.update_flyer_item_count();
-- 7. Trigger function to log the creation of a new flyer.
DROP FUNCTION IF EXISTS public.log_new_flyer();
CREATE OR REPLACE FUNCTION public.log_new_flyer()
RETURNS TRIGGER AS $$
BEGIN
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
-- The award_achievement function handles checking if the user already has it.
IF NEW.uploaded_by IS NOT NULL THEN
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.uploaded_by, -- Log the user who uploaded it
'flyer_uploaded',
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
'file-text',
jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a new flyer is inserted.
DROP TRIGGER IF EXISTS on_new_flyer_created ON public.flyers;
CREATE TRIGGER on_new_flyer_created
AFTER INSERT ON public.flyers
FOR EACH ROW EXECUTE FUNCTION public.log_new_flyer();
-- 8. Trigger function to log when a user favorites a recipe.
DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_favorited',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
'heart',
jsonb_build_object(
'recipe_id', NEW.recipe_id
)
);
-- Award 'First Favorite' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a recipe is favorited.
DROP TRIGGER IF EXISTS on_new_favorite_recipe ON public.favorite_recipes;
CREATE TRIGGER on_new_favorite_recipe
AFTER INSERT ON public.favorite_recipes
FOR EACH ROW EXECUTE FUNCTION public.log_new_favorite_recipe();
-- 9. Trigger function to log when a user shares a shopping list.
DROP FUNCTION IF EXISTS public.log_new_list_share();
CREATE OR REPLACE FUNCTION public.log_new_list_share()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id,
'list_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
'share-2',
jsonb_build_object(
'shopping_list_id', NEW.shopping_list_id,
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
'shared_with_user_id', NEW.shared_with_user_id
)
);
-- Award 'List Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after a shopping list is shared.
DROP TRIGGER IF EXISTS on_new_list_share ON public.shared_shopping_lists;
CREATE TRIGGER on_new_list_share
AFTER INSERT ON public.shared_shopping_lists
FOR EACH ROW EXECUTE FUNCTION public.log_new_list_share();
-- 9a. Trigger function to log when a user shares a recipe collection.
DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
RETURNS TRIGGER AS $$
BEGIN
-- Log the activity
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id, 'recipe_collection_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
'book',
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
);
-- Award 'Recipe Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_new_recipe_collection_share ON public.shared_recipe_collections;
CREATE TRIGGER on_new_recipe_collection_share
AFTER INSERT ON public.shared_recipe_collections
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
-- 10. Trigger function to geocode a store location's address.
-- This function is triggered when an address is inserted or updated, and is
-- designed to be extensible for external geocoding services to populate the
-- latitude, longitude, and location fields.
DROP FUNCTION IF EXISTS public.geocode_address();
CREATE OR REPLACE FUNCTION public.geocode_address()
RETURNS TRIGGER AS $$
DECLARE
full_address TEXT;
BEGIN
-- Only proceed if an address component has actually changed.
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (
NEW.address_line_1 IS DISTINCT FROM OLD.address_line_1 OR
NEW.address_line_2 IS DISTINCT FROM OLD.address_line_2 OR
NEW.city IS DISTINCT FROM OLD.city OR
NEW.province_state IS DISTINCT FROM OLD.province_state OR
NEW.postal_code IS DISTINCT FROM OLD.postal_code OR
NEW.country IS DISTINCT FROM OLD.country
)) THEN
-- Concatenate address parts into a single string for the geocoder.
full_address := CONCAT_WS(', ', NEW.address_line_1, NEW.address_line_2, NEW.city, NEW.province_state, NEW.postal_code, NEW.country);
-- Placeholder for Geocoding API Call
-- In a real application, you would call a service here and update NEW.latitude, NEW.longitude, and NEW.location.
-- e.g., NEW.latitude := result.lat; NEW.longitude := result.lon;
-- NEW.location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- This trigger calls the geocoding function when an address changes.
DROP TRIGGER IF EXISTS on_address_change_geocode ON public.addresses;
CREATE TRIGGER on_address_change_geocode
BEFORE INSERT OR UPDATE ON public.addresses
FOR EACH ROW EXECUTE FUNCTION public.geocode_address();
-- 11. Trigger function to increment the fork_count on the original recipe.
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
CREATE TRIGGER on_recipe_fork
AFTER INSERT ON public.recipes
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();

View File

@@ -265,5 +265,6 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
('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 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;

View File

@@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS public.profiles (
created_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,
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
);
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
COMMENT ON 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.
@@ -108,9 +108,9 @@ CREATE TABLE IF NOT EXISTS public.stores (
created_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
);
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
-- 5. The 'categories' table for normalized category data.
@@ -141,10 +141,10 @@ CREATE TABLE IF NOT EXISTS public.flyers (
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)
);
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https://?.*'),
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
@@ -162,7 +162,6 @@ COMMENT ON COLUMN public.flyers.uploaded_by IS 'The user who uploaded the flyer.
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_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.
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
@@ -199,9 +198,9 @@ CREATE TABLE IF NOT EXISTS public.brands (
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT brands_name_check CHECK (TRIM(name) <> ''),
CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*')
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 COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
@@ -465,9 +464,9 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
created_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://?.*')
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 COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
@@ -522,9 +521,9 @@ CREATE TABLE IF NOT EXISTS public.recipes (
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
created_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://?.*')
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 COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
@@ -921,9 +920,9 @@ CREATE TABLE IF NOT EXISTS public.receipts (
raw_text TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
processed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
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.';
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
@@ -973,6 +972,21 @@ COMMENT ON COLUMN public.user_reactions.reaction_type IS 'The type of reaction (
CREATE INDEX IF NOT EXISTS idx_user_reactions_user_id ON public.user_reactions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_reactions_entity ON public.user_reactions(entity_type, entity_id);
-- 56. Store user-defined budgets for spending analysis.
CREATE TABLE IF NOT EXISTS public.budgets (
budget_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
name TEXT NOT NULL,
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
start_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT budgets_name_check CHECK (TRIM(name) <> '')
);
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);
-- 57. Static table defining available achievements for gamification.
CREATE TABLE IF NOT EXISTS public.achievements (
achievement_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
@@ -998,17 +1012,3 @@ CREATE INDEX IF NOT EXISTS idx_user_achievements_user_id ON public.user_achievem
CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON public.user_achievements(achievement_id);
-- 56. Store user-defined budgets for spending analysis.
CREATE TABLE IF NOT EXISTS public.budgets (
budget_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
name TEXT NOT NULL,
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly')),
start_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT budgets_name_check CHECK (TRIM(name) <> '')
);
COMMENT ON TABLE public.budgets IS 'Allows users to set weekly or monthly grocery budgets for spending tracking.';
CREATE INDEX IF NOT EXISTS idx_budgets_user_id ON public.budgets(user_id);

View File

@@ -102,14 +102,14 @@ CREATE TABLE IF NOT EXISTS public.profiles (
address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
points INTEGER DEFAULT 0 NOT NULL CHECK (points >= 0),
preferences JSONB,
role TEXT CHECK (role IN ('admin', 'user')),
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
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,
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
);
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https?://.*'),
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
COMMENT ON 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.
@@ -124,9 +124,9 @@ CREATE TABLE IF NOT EXISTS public.stores (
created_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
);
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*'),
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
-- 5. The 'categories' table for normalized category data.
@@ -144,7 +144,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
flyer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
file_name TEXT NOT NULL,
image_url TEXT NOT NULL,
icon_url TEXT,
icon_url TEXT NOT NULL,
checksum TEXT UNIQUE,
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
valid_from DATE,
@@ -157,10 +157,10 @@ CREATE TABLE IF NOT EXISTS public.flyers (
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)
);
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https?://.*'),
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url ~* '^https?://.*'),
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
@@ -214,9 +214,9 @@ CREATE TABLE IF NOT EXISTS public.brands (
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT brands_name_check CHECK (TRIM(name) <> ''),
CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*')
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 COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
@@ -481,9 +481,9 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
created_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://?.*')
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 COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
@@ -538,9 +538,9 @@ CREATE TABLE IF NOT EXISTS public.recipes (
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
created_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://?.*')
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 COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
@@ -689,8 +689,8 @@ CREATE TABLE IF NOT EXISTS public.planned_meals (
meal_type TEXT NOT NULL,
servings_to_cook INTEGER,
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,
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 COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
@@ -940,9 +940,9 @@ CREATE TABLE IF NOT EXISTS public.receipts (
raw_text TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
processed_at TIMESTAMPTZ,
CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*'),
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.';
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
@@ -1113,6 +1113,7 @@ DECLARE
ground_beef_id BIGINT; pasta_item_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT;
bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT;
soda_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT;
chicken_thighs_id BIGINT; paper_towels_id BIGINT; toilet_paper_id BIGINT;
-- Tag IDs
quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT;
@@ -1164,6 +1165,9 @@ BEGIN
SELECT mgi.master_grocery_item_id INTO turkey_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'turkey';
SELECT mgi.master_grocery_item_id INTO bread_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'bread';
SELECT mgi.master_grocery_item_id INTO cheese_item_id FROM public.master_grocery_items mgi WHERE mgi.name = 'cheese';
SELECT mgi.master_grocery_item_id INTO chicken_thighs_id FROM public.master_grocery_items mgi WHERE mgi.name = 'chicken thighs';
SELECT mgi.master_grocery_item_id INTO paper_towels_id FROM public.master_grocery_items mgi WHERE mgi.name = 'paper towels';
SELECT mgi.master_grocery_item_id INTO toilet_paper_id FROM public.master_grocery_items mgi WHERE mgi.name = 'toilet paper';
-- Insert ingredients for each recipe
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES
@@ -1200,6 +1204,17 @@ BEGIN
(bolognese_recipe_id, family_tag), (bolognese_recipe_id, beef_tag), (bolognese_recipe_id, weeknight_tag),
(stir_fry_recipe_id, quick_easy_tag), (stir_fry_recipe_id, healthy_tag), (stir_fry_recipe_id, vegetarian_tag)
ON CONFLICT (recipe_id, tag_id) DO NOTHING;
INSERT INTO public.master_item_aliases (master_item_id, alias) VALUES
(ground_beef_id, 'ground chuck'), (ground_beef_id, 'lean ground beef'),
(ground_beef_id, 'extra lean ground beef'), (ground_beef_id, 'hamburger meat'),
(chicken_breast_id, 'boneless skinless chicken breast'), (chicken_breast_id, 'chicken cutlets'),
(chicken_thighs_id, 'boneless skinless chicken thighs'), (chicken_thighs_id, 'bone-in chicken thighs'),
(bell_peppers_id, 'red pepper'), (bell_peppers_id, 'green pepper'), (bell_peppers_id, 'yellow pepper'), (bell_peppers_id, 'orange pepper'),
(soda_item_id, 'pop'), (soda_item_id, 'soft drink'), (soda_item_id, 'coke'), (soda_item_id, 'pepsi'),
(paper_towels_id, 'paper towel'),
(toilet_paper_id, 'bathroom tissue'), (toilet_paper_id, 'toilet tissue')
ON CONFLICT (alias) DO NOTHING;
END $$;
-- Pre-populate the unit_conversions table with common cooking conversions.
@@ -1248,7 +1263,8 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
('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 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;
-- ============================================================================
@@ -2114,6 +2130,61 @@ AS $$
ORDER BY potential_savings_cents DESC;
$$;
-- Function to get a user's spending breakdown by category for a given date range.
DROP FUNCTION IF EXISTS public.get_spending_by_category(UUID, DATE, DATE);
CREATE OR REPLACE FUNCTION public.get_spending_by_category(p_user_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS TABLE (
category_id BIGINT,
category_name TEXT,
total_spent_cents BIGINT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH all_purchases AS (
-- CTE 1: Combine purchases from completed shopping trips.
-- We only consider items that have a price paid.
SELECT
sti.master_item_id,
sti.price_paid_cents
FROM public.shopping_trip_items sti
JOIN public.shopping_trips st ON sti.shopping_trip_id = st.shopping_trip_id
WHERE st.user_id = p_user_id
AND st.completed_at::date BETWEEN p_start_date AND p_end_date
AND sti.price_paid_cents IS NOT NULL
UNION ALL
-- CTE 2: Combine purchases from processed receipts.
SELECT
ri.master_item_id,
ri.price_paid_cents
FROM public.receipt_items ri
JOIN public.receipts r ON ri.receipt_id = r.receipt_id
WHERE r.user_id = p_user_id
AND r.transaction_date::date BETWEEN p_start_date AND p_end_date
AND ri.master_item_id IS NOT NULL -- Only include items matched to a master item
)
-- Final Aggregation: Group all combined purchases by category and sum the spending.
SELECT
c.category_id,
c.name AS category_name,
SUM(ap.price_paid_cents)::BIGINT AS total_spent_cents
FROM all_purchases ap
-- Join with master_grocery_items to get the category_id for each purchase.
JOIN public.master_grocery_items mgi ON ap.master_item_id = mgi.master_grocery_item_id
-- Join with categories to get the category name for display.
JOIN public.categories c ON mgi.category_id = c.category_id
GROUP BY
c.category_id, c.name
HAVING
SUM(ap.price_paid_cents) > 0
ORDER BY
total_spent_cents DESC;
$$;
-- Function to approve a suggested correction and apply it.
DROP FUNCTION IF EXISTS public.approve_correction(BIGINT);
@@ -2557,8 +2628,15 @@ DROP FUNCTION IF EXISTS public.log_new_flyer();
CREATE OR REPLACE FUNCTION public.log_new_flyer()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.activity_log (action, display_text, icon, details)
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
-- The award_achievement function handles checking if the user already has it.
IF NEW.uploaded_by IS NOT NULL THEN
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.uploaded_by, -- Log the user who uploaded it
'flyer_uploaded',
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
'file-text',
@@ -2616,6 +2694,7 @@ BEGIN
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
'share-2',
jsonb_build_object(
'shopping_list_id', NEW.shopping_list_id,
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
'shared_with_user_id', NEW.shared_with_user_id
)
@@ -2663,6 +2742,66 @@ CREATE TRIGGER on_new_recipe_collection_share
AFTER INSERT ON public.shared_recipe_collections
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
-- 10. Trigger function to geocode a store location's address.
-- This function is triggered when an address is inserted or updated, and is
-- designed to be extensible for external geocoding services to populate the
-- latitude, longitude, and location fields.
DROP FUNCTION IF EXISTS public.geocode_address();
CREATE OR REPLACE FUNCTION public.geocode_address()
RETURNS TRIGGER AS $$
DECLARE
full_address TEXT;
BEGIN
-- Only proceed if an address component has actually changed.
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (
NEW.address_line_1 IS DISTINCT FROM OLD.address_line_1 OR
NEW.address_line_2 IS DISTINCT FROM OLD.address_line_2 OR
NEW.city IS DISTINCT FROM OLD.city OR
NEW.province_state IS DISTINCT FROM OLD.province_state OR
NEW.postal_code IS DISTINCT FROM OLD.postal_code OR
NEW.country IS DISTINCT FROM OLD.country
)) THEN
-- Concatenate address parts into a single string for the geocoder.
full_address := CONCAT_WS(', ', NEW.address_line_1, NEW.address_line_2, NEW.city, NEW.province_state, NEW.postal_code, NEW.country);
-- Placeholder for Geocoding API Call.
-- In a real application, you would call a service here and update NEW.latitude, NEW.longitude, and NEW.location.
-- e.g., NEW.latitude := result.lat; NEW.longitude := result.lon;
-- NEW.location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- This trigger calls the geocoding function when an address changes.
DROP TRIGGER IF EXISTS on_address_change_geocode ON public.addresses;
CREATE TRIGGER on_address_change_geocode
BEFORE INSERT OR UPDATE ON public.addresses
FOR EACH ROW EXECUTE FUNCTION public.geocode_address();
-- 11. Trigger function to increment the fork_count on the original recipe.
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
CREATE TRIGGER on_recipe_fork
AFTER INSERT ON public.recipes
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
-- =================================================================
-- Function: get_best_sale_prices_for_all_users()
-- Description: Retrieves the best sale price for every item on every user's watchlist.
@@ -2670,17 +2809,19 @@ CREATE TRIGGER on_new_recipe_collection_share
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
-- Returns: TABLE(...) - A set of records including user details and deal information.
-- =================================================================
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
RETURNS TABLE(
user_id uuid,
email text,
full_name text,
master_item_id integer,
master_item_id bigint,
item_name text,
best_price_in_cents integer,
store_name text,
flyer_id integer,
flyer_id bigint,
valid_to date
) AS $$
BEGIN
@@ -2692,7 +2833,7 @@ BEGIN
SELECT
fi.master_item_id,
fi.price_in_cents,
f.store_name,
s.name as store_name,
f.flyer_id,
f.valid_to
FROM public.flyer_items fi

View File

@@ -20,10 +20,98 @@ import {
mockUseUserData,
mockUseFlyerItems,
} from './tests/setup/mockHooks';
import './tests/setup/mockUI';
import { useAppInitialization } from './hooks/useAppInitialization';
// Mock top-level components rendered by App's routes
vi.mock('./components/Header', () => ({
Header: ({ onOpenProfile, onOpenVoiceAssistant }: any) => (
<div data-testid="header-mock">
<button onClick={onOpenProfile}>Open Profile</button>
<button onClick={onOpenVoiceAssistant}>Open Voice Assistant</button>
</div>
),
}));
vi.mock('./components/Footer', () => ({
Footer: () => <div data-testid="footer-mock">Mock Footer</div>,
}));
vi.mock('./layouts/MainLayout', async () => {
const { Outlet } = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
MainLayout: () => (
<div data-testid="main-layout-mock">
<Outlet />
</div>
),
};
});
vi.mock('./pages/HomePage', () => ({
HomePage: ({ selectedFlyer, onOpenCorrectionTool }: any) => (
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
</div>
),
}));
vi.mock('./pages/admin/AdminPage', () => ({
AdminPage: () => <div data-testid="admin-page-mock">AdminPage</div>,
}));
vi.mock('./pages/admin/CorrectionsPage', () => ({
CorrectionsPage: () => <div data-testid="corrections-page-mock">CorrectionsPage</div>,
}));
vi.mock('./pages/admin/AdminStatsPage', () => ({
AdminStatsPage: () => <div data-testid="admin-stats-page-mock">AdminStatsPage</div>,
}));
vi.mock('./pages/admin/FlyerReviewPage', () => ({
FlyerReviewPage: () => <div data-testid="flyer-review-page-mock">FlyerReviewPage</div>,
}));
vi.mock('./pages/VoiceLabPage', () => ({
VoiceLabPage: () => <div data-testid="voice-lab-page-mock">VoiceLabPage</div>,
}));
vi.mock('./pages/ResetPasswordPage', () => ({
ResetPasswordPage: () => <div data-testid="reset-password-page-mock">ResetPasswordPage</div>,
}));
vi.mock('./pages/admin/components/ProfileManager', () => ({
ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: any) =>
isOpen ? (
<div data-testid="profile-manager-mock">
<button onClick={onClose}>Close Profile</button>
<button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button>
<button onClick={() => onLoginSuccess({}, 'token', false)}>Login</button>
</div>
) : null,
}));
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({
VoiceAssistant: ({ isOpen, onClose }: any) =>
isOpen ? (
<div data-testid="voice-assistant-mock">
<button onClick={onClose}>Close Voice Assistant</button>
</div>
) : null,
}));
vi.mock('./components/FlyerCorrectionTool', () => ({
FlyerCorrectionTool: ({ isOpen, onClose, onDataExtracted }: any) =>
isOpen ? (
<div data-testid="flyer-correction-tool-mock">
<button onClick={onClose}>Close Correction</button>
<button onClick={() => onDataExtracted('store_name', 'New Store')}>Extract Store</button>
<button onClick={() => onDataExtracted('dates', 'New Dates')}>Extract Dates</button>
</div>
) : null,
}));
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
// This must be done in any test file that imports App.tsx.
vi.mock('pdfjs-dist', () => ({
@@ -61,71 +149,6 @@ vi.mock('./hooks/useAuth', async () => {
return { useAuth: hooks.mockUseAuth };
});
vi.mock('./components/Footer', async () => {
const { MockFooter } = await import('./tests/utils/componentMocks');
return { Footer: MockFooter };
});
vi.mock('./components/Header', async () => {
const { MockHeader } = await import('./tests/utils/componentMocks');
return { Header: MockHeader };
});
vi.mock('./pages/HomePage', async () => {
const { MockHomePage } = await import('./tests/utils/componentMocks');
return { HomePage: MockHomePage };
});
vi.mock('./pages/admin/AdminPage', async () => {
const { MockAdminPage } = await import('./tests/utils/componentMocks');
return { AdminPage: MockAdminPage };
});
vi.mock('./pages/admin/CorrectionsPage', async () => {
const { MockCorrectionsPage } = await import('./tests/utils/componentMocks');
return { CorrectionsPage: MockCorrectionsPage };
});
vi.mock('./pages/admin/AdminStatsPage', async () => {
const { MockAdminStatsPage } = await import('./tests/utils/componentMocks');
return { AdminStatsPage: MockAdminStatsPage };
});
vi.mock('./pages/VoiceLabPage', async () => {
const { MockVoiceLabPage } = await import('./tests/utils/componentMocks');
return { VoiceLabPage: MockVoiceLabPage };
});
vi.mock('./pages/ResetPasswordPage', async () => {
const { MockResetPasswordPage } = await import('./tests/utils/componentMocks');
return { ResetPasswordPage: MockResetPasswordPage };
});
vi.mock('./pages/admin/components/ProfileManager', async () => {
const { MockProfileManager } = await import('./tests/utils/componentMocks');
return { ProfileManager: MockProfileManager };
});
vi.mock('./features/voice-assistant/VoiceAssistant', async () => {
const { MockVoiceAssistant } = await import('./tests/utils/componentMocks');
return { VoiceAssistant: MockVoiceAssistant };
});
vi.mock('./components/FlyerCorrectionTool', async () => {
const { MockFlyerCorrectionTool } = await import('./tests/utils/componentMocks');
return { FlyerCorrectionTool: MockFlyerCorrectionTool };
});
vi.mock('./components/WhatsNewModal', async () => {
const { MockWhatsNewModal } = await import('./tests/utils/componentMocks');
return { WhatsNewModal: MockWhatsNewModal };
});
vi.mock('./layouts/MainLayout', async () => {
const { MockMainLayout } = await import('./tests/utils/componentMocks');
return { MainLayout: MockMainLayout };
});
vi.mock('./components/AppGuard', async () => {
// We need to use the real useModal hook inside our mock AppGuard
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
@@ -192,6 +215,7 @@ describe('App Component', () => {
mockUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
isLoadingShoppingLists: false,
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
});
@@ -361,12 +385,8 @@ describe('App Component', () => {
it('should select a flyer when flyerId is present in the URL', async () => {
renderApp(['/flyers/2']);
// The HomePage mock will be rendered. The important part is that the selection logic
// in App.tsx runs and passes the correct `selectedFlyer` prop down.
// Since HomePage is mocked, we can't see the direct result, but we can
// infer that the logic ran without crashing and the correct route was matched.
await waitFor(() => {
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
expect(screen.getByTestId('home-page-mock')).toHaveAttribute('data-selected-flyer-id', '2');
});
});
@@ -608,7 +628,7 @@ describe('App Component', () => {
app: {
version: '2.0.0',
commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0',
commitUrl: 'https://example.com/commit/2.0.0',
},
},
}));
@@ -618,7 +638,7 @@ describe('App Component', () => {
renderApp();
const versionLink = screen.getByText(`Version: 2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
expect(versionLink).toHaveAttribute('href', 'https://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {

View File

@@ -1,6 +1,6 @@
// src/App.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route, useParams } from 'react-router-dom';
import { Routes, Route, useLocation, matchPath } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as pdfjsLib from 'pdfjs-dist';
import { Footer } from './components/Footer';
@@ -45,7 +45,9 @@ function App() {
const { flyers } = useFlyers();
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const { openModal, closeModal, isModalOpen } = useModal();
const params = useParams<{ flyerId?: string }>();
const location = useLocation();
const match = matchPath('/flyers/:flyerId', location.pathname);
const flyerIdFromUrl = match?.params.flyerId;
// This hook now handles initialization effects (OAuth, version check, theme)
// and returns the theme/unit state needed by other components.
@@ -57,7 +59,7 @@ function App() {
console.log('[App] Render:', {
flyersCount: flyers.length,
selectedFlyerId: selectedFlyer?.flyer_id,
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
flyerIdFromUrl,
authStatus,
profileId: userProfile?.user.user_id,
});
@@ -139,8 +141,6 @@ function App() {
// New effect to handle routing to a specific flyer ID from the URL
useEffect(() => {
const flyerIdFromUrl = params.flyerId;
if (flyerIdFromUrl && flyers.length > 0) {
const flyerId = parseInt(flyerIdFromUrl, 10);
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
@@ -148,7 +148,7 @@ function App() {
handleFlyerSelect(flyerToSelect);
}
}
}, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]);
}, [flyers, handleFlyerSelect, selectedFlyer, flyerIdFromUrl]);
// Read the application version injected at build time.
// This will only be available in the production build, not during local development.

View File

@@ -1,9 +1,10 @@
// src/components/AchievementsList.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { AchievementsList } from './AchievementsList';
import { createMockUserAchievement } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('AchievementsList', () => {
it('should render the list of achievements with correct details', () => {
@@ -22,9 +23,10 @@ describe('AchievementsList', () => {
points_value: 15,
}),
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
createMockUserAchievement({ achievement_id: 4, name: 'No Icon Achievement', icon: '' }), // Triggers the fallback for missing name
];
render(<AchievementsList achievements={mockAchievements} />);
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
@@ -40,11 +42,19 @@ describe('AchievementsList', () => {
// Check achievement with default icon
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
expect(screen.getByText('🏆')).toBeInTheDocument(); // Default icon
// We expect at least one trophy (for unknown achievement).
// Since we added another one that produces a trophy (No Icon), we use getAllByText.
expect(screen.getAllByText('🏆').length).toBeGreaterThan(0);
// Check achievement with missing icon (empty string)
expect(screen.getByText('No Icon Achievement')).toBeInTheDocument();
// Verify the specific placeholder class is rendered, ensuring the early return in Icon component is hit
const noIconCard = screen.getByText('No Icon Achievement').closest('.bg-white');
expect(noIconCard?.querySelector('.icon-placeholder')).toBeInTheDocument();
});
it('should render a message when there are no achievements', () => {
render(<AchievementsList achievements={[]} />);
renderWithProviders(<AchievementsList achievements={[]} />);
expect(
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
).toBeInTheDocument();

View File

@@ -1,11 +1,12 @@
// src/components/AdminRoute.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Routes, Route } from 'react-router-dom';
import { AdminRoute } from './AdminRoute';
import type { Profile } from '../types';
import { createMockProfile } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Unmock the component to test the real implementation
vi.unmock('./AdminRoute');
@@ -14,15 +15,14 @@ const AdminContent = () => <div>Admin Page Content</div>;
const HomePage = () => <div>Home Page</div>;
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminRoute profile={profile} />}>
<Route index element={<AdminContent />} />
</Route>
</Routes>
</MemoryRouter>,
renderWithProviders(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminRoute profile={profile} />}>
<Route index element={<AdminContent />} />
</Route>
</Routes>,
{ initialEntries: [initialPath] },
);
};

View File

@@ -1,8 +1,9 @@
// src/components/AnonymousUserBanner.test.tsx
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 { AnonymousUserBanner } from './AnonymousUserBanner';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the icon to ensure it is rendered correctly
vi.mock('./icons/InformationCircleIcon', () => ({
@@ -14,7 +15,7 @@ vi.mock('./icons/InformationCircleIcon', () => ({
describe('AnonymousUserBanner', () => {
it('should render the banner with the correct text content and accessibility role', () => {
const mockOnOpenProfile = vi.fn();
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
// Check for accessibility role
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', () => {
const mockOnOpenProfile = vi.fn();
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
fireEvent.click(loginButton);

View File

@@ -1,12 +1,15 @@
// src/components/AppGuard.test.tsx
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 { AppGuard } from './AppGuard';
import { useAppInitialization } from '../hooks/useAppInitialization';
import * as apiClient from '../services/apiClient';
import { useModal } from '../hooks/useModal';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock dependencies
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
vi.mock('../hooks/useAppInitialization');
vi.mock('../hooks/useModal');
vi.mock('./WhatsNewModal', () => ({
@@ -19,6 +22,7 @@ vi.mock('../config', () => ({
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
const mockedUseModal = vi.mocked(useModal);
@@ -38,7 +42,7 @@ describe('AppGuard', () => {
});
it('should render children', () => {
render(
renderWithProviders(
<AppGuard>
<div>Child Content</div>
</AppGuard>,
@@ -51,7 +55,7 @@ describe('AppGuard', () => {
...mockedUseModal(),
isModalOpen: (modalId) => modalId === 'whatsNew',
});
render(
renderWithProviders(
<AppGuard>
<div>Child</div>
</AppGuard>,
@@ -64,7 +68,7 @@ describe('AppGuard', () => {
isDarkMode: true,
unitSystem: 'imperial',
});
render(
renderWithProviders(
<AppGuard>
<div>Child</div>
</AppGuard>,
@@ -78,7 +82,7 @@ describe('AppGuard', () => {
});
it('should set light mode styles for toaster', async () => {
render(
renderWithProviders(
<AppGuard>
<div>Child</div>
</AppGuard>,

View File

@@ -1,8 +1,9 @@
// src/components/ConfirmationModal.test.tsx
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 { ConfirmationModal } from './ConfirmationModal';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('ConfirmationModal (in components)', () => {
const mockOnClose = vi.fn();
@@ -21,12 +22,12 @@ describe('ConfirmationModal (in components)', () => {
});
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();
});
it('should render correctly when isOpen is true', () => {
render(<ConfirmationModal {...defaultProps} />);
renderWithProviders(<ConfirmationModal {...defaultProps} />);
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to do this?')).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', () => {
render(<ConfirmationModal {...defaultProps} />);
renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when the cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when the close icon is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
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
fireEvent.click(screen.getByRole('dialog'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
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?'));
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should render custom button text and classes', () => {
render(
renderWithProviders(
<ConfirmationModal
{...defaultProps}
confirmButtonText="Yes, Delete"

View File

@@ -1,8 +1,9 @@
// src/components/DarkModeToggle.test.tsx
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 { DarkModeToggle } from './DarkModeToggle';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the icon components to isolate the toggle's logic
vi.mock('./icons/SunIcon', () => ({
@@ -20,7 +21,7 @@ describe('DarkModeToggle', () => {
});
it('should render in light mode state', () => {
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
@@ -29,7 +30,7 @@ describe('DarkModeToggle', () => {
});
it('should render in dark mode state', () => {
render(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
renderWithProviders(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
@@ -38,7 +39,7 @@ describe('DarkModeToggle', () => {
});
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
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
import React from 'react';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ErrorDisplay } from './ErrorDisplay';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('ErrorDisplay (in components)', () => {
it('should not render when the message is empty', () => {
const { container } = render(<ErrorDisplay message="" />);
const { container } = renderWithProviders(<ErrorDisplay message="" />);
expect(container.firstChild).toBeNull();
});
it('should not render when the message is null', () => {
// 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();
});
it('should render the error message when provided', () => {
const errorMessage = 'Something went terribly wrong.';
render(<ErrorDisplay message={errorMessage} />);
renderWithProviders(<ErrorDisplay message={errorMessage} />);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();

View File

@@ -1,31 +1,25 @@
// src/components/FlyerCorrectionTool.test.tsx
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 { FlyerCorrectionTool } from './FlyerCorrectionTool';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError, notifySuccess } from '../services/notificationService';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Unmock the component to test the real implementation
vi.unmock('./FlyerCorrectionTool');
// Mock dependencies
vi.mock('../services/aiApiClient');
vi.mock('../services/notificationService');
vi.mock('../services/logger', () => ({
logger: {
error: vi.fn(),
},
}));
const mockedAiApiClient = aiApiClient as Mocked<typeof aiApiClient>;
// The aiApiClient, notificationService, and logger are mocked globally.
// We can get a typed reference to the aiApiClient for individual test overrides.
const mockedAiApiClient = vi.mocked(aiApiClient);
const mockedNotifySuccess = notifySuccess as Mocked<typeof notifySuccess>;
const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
imageUrl: 'http://example.com/flyer.jpg',
imageUrl: 'https://example.com/flyer.jpg',
onDataExtracted: vi.fn(),
};
@@ -54,12 +48,12 @@ describe('FlyerCorrectionTool', () => {
});
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();
});
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.getByAltText('Flyer for correction')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /extract store name/i })).toBeInTheDocument();
@@ -67,7 +61,7 @@ describe('FlyerCorrectionTool', () => {
});
it('should call onClose when the close button is clicked', () => {
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
// Use the specific aria-label defined in the component to find the close button
const closeButton = screen.getByLabelText(/close correction tool/i);
fireEvent.click(closeButton);
@@ -75,13 +69,13 @@ describe('FlyerCorrectionTool', () => {
});
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 sale dates/i })).toBeDisabled();
});
it('should enable extraction buttons after a selection is made', () => {
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
// Simulate drawing a rectangle
@@ -94,7 +88,7 @@ describe('FlyerCorrectionTool', () => {
});
it('should stop drawing when the mouse leaves the canvas', () => {
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
@@ -114,7 +108,7 @@ describe('FlyerCorrectionTool', () => {
});
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
// Wait for the image fetch to complete to ensure 'imageFile' state is populated
console.log('--- [TEST LOG] ---: Awaiting image fetch inside component...');
@@ -192,7 +186,7 @@ describe('FlyerCorrectionTool', () => {
// Mock fetch to reject
global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as Mocked<typeof fetch>;
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
await waitFor(() => {
expect(mockedNotifyError).toHaveBeenCalledWith('Could not load the image for correction.');
@@ -211,7 +205,7 @@ describe('FlyerCorrectionTool', () => {
return new Promise(() => {});
}) as Mocked<typeof fetch>;
render(<FlyerCorrectionTool {...defaultProps} />);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
@@ -238,7 +232,7 @@ describe('FlyerCorrectionTool', () => {
it('should handle non-standard API errors during rescan', async () => {
console.log('TEST: Starting "should handle non-standard API errors during rescan"');
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
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
@@ -258,4 +252,54 @@ describe('FlyerCorrectionTool', () => {
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
});
});
it('should handle API failure response (ok: false) correctly', async () => {
console.log('TEST: Starting "should handle API failure response (ok: false) correctly"');
mockedAiApiClient.rescanImageArea.mockResolvedValue({
ok: false,
json: async () => ({ message: 'Custom API Error' }),
} as Response);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
// Wait for image fetch
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
// Draw selection
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
fireEvent.mouseMove(canvas, { clientX: 50, clientY: 50 });
fireEvent.mouseUp(canvas);
// Click extract
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
await waitFor(() => {
expect(mockedNotifyError).toHaveBeenCalledWith('Custom API Error');
});
});
it('should redraw the canvas when the image loads', () => {
console.log('TEST: Starting "should redraw the canvas when the image loads"');
const clearRectSpy = vi.fn();
// Override the getContext mock for this test to capture the spy
window.HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
clearRect: clearRectSpy,
strokeRect: vi.fn(),
setLineDash: vi.fn(),
strokeStyle: '',
lineWidth: 0,
})) as any;
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
const image = screen.getByAltText('Flyer for correction');
// The draw function is called on mount via useEffect, so we clear that call.
clearRectSpy.mockClear();
// Simulate image load event which triggers onLoad={draw}
fireEvent.load(image);
expect(clearRectSpy).toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,12 @@
// src/components/FlyerCountDisplay.test.tsx
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 { FlyerCountDisplay } from './FlyerCountDisplay';
import { useFlyers } from '../hooks/useFlyers';
import type { Flyer } from '../types';
import { createMockFlyer } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the dependencies
vi.mock('../hooks/useFlyers');
@@ -32,7 +33,7 @@ describe('FlyerCountDisplay', () => {
});
// Act: Render the component.
render(<FlyerCountDisplay />);
renderWithProviders(<FlyerCountDisplay />);
// Assert: Check that the loading spinner is visible.
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
@@ -53,7 +54,7 @@ describe('FlyerCountDisplay', () => {
});
// Act
render(<FlyerCountDisplay />);
renderWithProviders(<FlyerCountDisplay />);
// Assert: Check that the error message is displayed.
expect(screen.getByRole('alert')).toHaveTextContent(errorMessage);
@@ -73,7 +74,7 @@ describe('FlyerCountDisplay', () => {
});
// Act
render(<FlyerCountDisplay />);
renderWithProviders(<FlyerCountDisplay />);
// Assert: Check that the correct count is displayed.
const countDisplay = screen.getByTestId('flyer-count');

View File

@@ -1,8 +1,9 @@
// src/components/Footer.test.tsx
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 { Footer } from './Footer';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('Footer', () => {
beforeEach(() => {
@@ -21,7 +22,7 @@ describe('Footer', () => {
vi.setSystemTime(mockDate);
// Act: Render the component
render(<Footer />);
renderWithProviders(<Footer />);
// Assert: Check that the rendered text includes the mocked year
expect(screen.getByText('Copyright 2025-2025')).toBeInTheDocument();
@@ -29,7 +30,7 @@ describe('Footer', () => {
it('should display the correct year when it changes', () => {
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'));
render(<Footer />);
renderWithProviders(<Footer />);
expect(screen.getByText('Copyright 2025-2030')).toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
// src/components/Header.test.tsx
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 { MemoryRouter } from 'react-router-dom';
import { Header } from './Header';
import type { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Unmock the component to test the real implementation
vi.unmock('./Header');
@@ -34,12 +34,8 @@ const defaultProps = {
};
// Helper to render with router context
const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) => {
return render(
<MemoryRouter>
<Header {...defaultProps} {...props} />
</MemoryRouter>,
);
const renderHeader = (props: Partial<React.ComponentProps<typeof Header>>) => {
return renderWithProviders(<Header {...defaultProps} {...props} />);
};
describe('Header', () => {
@@ -48,30 +44,30 @@ describe('Header', () => {
});
it('should render the application title', () => {
renderWithRouter({});
renderHeader({});
expect(screen.getByRole('heading', { name: /flyer crawler/i })).toBeInTheDocument();
});
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(/dark mode/i)).toBeInTheDocument();
});
describe('When user is logged out', () => {
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();
});
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 }));
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
});
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 my account settings/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
@@ -80,29 +76,29 @@ describe('Header', () => {
describe('When user is authenticated', () => {
it('should display the user email', () => {
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
renderHeader({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
expect(screen.getByText(mockUserProfile.user.email)).toBeInTheDocument();
});
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();
});
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));
expect(mockOnOpenVoiceAssistant).toHaveBeenCalledTimes(1);
});
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));
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
});
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 }));
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
});
@@ -110,14 +106,14 @@ describe('Header', () => {
describe('Admin user', () => {
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);
expect(adminLink).toBeInTheDocument();
expect(adminLink.closest('a')).toHaveAttribute('href', '/admin');
});
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();
});
});

View File

@@ -1,21 +1,17 @@
// src/components/Leaderboard.test.tsx
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 Leaderboard from './Leaderboard';
import * as apiClient from '../services/apiClient';
import { LeaderboardUser } from '../types';
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { createMockLogger } from '../tests/utils/mockLogger';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the apiClient
vi.mock('../services/apiClient'); // This was correct
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// Mock the logger
vi.mock('../services/logger', () => ({
logger: createMockLogger(),
}));
// The apiClient and logger are mocked globally.
// We can get a typed reference to the apiClient for individual test overrides.
const mockedApiClient = vi.mocked(apiClient);
// Mock lucide-react icons to prevent rendering errors in the test environment
vi.mock('lucide-react', () => ({
@@ -29,7 +25,7 @@ const mockLeaderboardData: LeaderboardUser[] = [
createMockLeaderboardUser({
user_id: 'user-2',
full_name: 'Bob',
avatar_url: 'http://example.com/bob.jpg',
avatar_url: 'https://example.com/bob.jpg',
points: 950,
rank: '2',
}),
@@ -45,13 +41,13 @@ describe('Leaderboard', () => {
it('should display a loading message initially', () => {
// Mock a pending promise that never resolves to keep it in the loading state
mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {}));
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 }));
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -62,7 +58,7 @@ describe('Leaderboard', () => {
it('should display a generic error for unknown error types', async () => {
const unknownError = 'A string error';
mockedApiClient.fetchLeaderboard.mockRejectedValue(unknownError);
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -72,7 +68,7 @@ describe('Leaderboard', () => {
it('should display a message when the leaderboard is empty', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([])));
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(
@@ -85,7 +81,7 @@ describe('Leaderboard', () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Top Users' })).toBeInTheDocument();
@@ -99,7 +95,7 @@ describe('Leaderboard', () => {
// Check for correct avatar URLs
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
expect(bobAvatar.src).toBe('http://example.com/bob.jpg');
expect(bobAvatar.src).toBe('https://example.com/bob.jpg');
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar
@@ -110,7 +106,7 @@ describe('Leaderboard', () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
// Rank 1, 2, and 3 should have a crown icon
@@ -129,7 +125,7 @@ describe('Leaderboard', () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(dataWithMissingNames)),
);
render(<Leaderboard />);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
// Check for fallback name

View File

@@ -1,19 +1,19 @@
// src/components/LoadingSpinner.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { LoadingSpinner } from './LoadingSpinner';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('LoadingSpinner (in components)', () => {
it('should render the SVG with animation classes', () => {
const { container } = render(<LoadingSpinner />);
const { container } = renderWithProviders(<LoadingSpinner />);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
expect(svgElement).toHaveClass('animate-spin');
});
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 path = container.querySelector('path');
expect(circle).toBeInTheDocument();

View File

@@ -1,9 +1,10 @@
// src/components/MapView.test.tsx
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 { MapView } from './MapView';
import config from '../config';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Create a type-safe mocked version of the config for easier manipulation
const mockedConfig = vi.mocked(config);
@@ -40,14 +41,14 @@ describe('MapView', () => {
describe('when API key is not configured', () => {
it('should render a disabled message', () => {
render(<MapView {...defaultProps} />);
renderWithProviders(<MapView {...defaultProps} />);
expect(
screen.getByText('Map view is disabled: API key is not configured.'),
).toBeInTheDocument();
});
it('should not render the iframe', () => {
render(<MapView {...defaultProps} />);
renderWithProviders(<MapView {...defaultProps} />);
// Use queryByTitle because iframes don't have a default "iframe" role
expect(screen.queryByTitle('Map view')).not.toBeInTheDocument();
});
@@ -62,7 +63,7 @@ describe('MapView', () => {
});
it('should render the iframe with the correct src URL', () => {
render(<MapView {...defaultProps} />);
renderWithProviders(<MapView {...defaultProps} />);
// Use getByTitle to access the iframe
const iframe = screen.getByTitle('Map view');

View File

@@ -1,8 +1,9 @@
// src/components/PasswordInput.test.tsx
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 { PasswordInput } from './PasswordInput';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the child PasswordStrengthIndicator component to isolate the test (relative to new location)
vi.mock('./PasswordStrengthIndicator', () => ({
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
@@ -12,13 +13,13 @@ vi.mock('./PasswordStrengthIndicator', () => ({
describe('PasswordInput (in auth feature)', () => {
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');
expect(input).toHaveAttribute('type', 'password');
});
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 toggleButton = screen.getByRole('button', { name: /show password/i });
@@ -38,7 +39,7 @@ describe('PasswordInput (in auth feature)', () => {
it('should pass through standard input attributes', () => {
const handleChange = vi.fn();
render(
renderWithProviders(
<PasswordInput
value="test"
onChange={handleChange}
@@ -56,38 +57,38 @@ describe('PasswordInput (in auth feature)', () => {
});
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();
});
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');
expect(indicator).toBeInTheDocument();
expect(indicator).toHaveTextContent('Strength for: some-password');
});
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();
});
it('should handle undefined className gracefully', () => {
render(<PasswordInput placeholder="No class" />);
renderWithProviders(<PasswordInput placeholder="No class" />);
const input = screen.getByPlaceholderText('No class');
expect(input.className).not.toContain('undefined');
expect(input.className).toContain('block w-full');
});
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();
});
it('should not show strength indicator if value is not a string', () => {
// Force a non-string value to test the typeof check
const props = { value: 12345, showStrength: true, onChange: () => {} } as any;
render(<PasswordInput {...props} />);
renderWithProviders(<PasswordInput {...props} />);
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,9 @@
// src/pages/admin/components/PasswordStrengthIndicator.test.tsx
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 { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import zxcvbn from 'zxcvbn';
// Mock the zxcvbn library to control its output for testing
@@ -11,7 +12,7 @@ vi.mock('zxcvbn');
describe('PasswordStrengthIndicator', () => {
it('should render 5 gray bars when no password is provided', () => {
(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');
expect(bars).toHaveLength(5);
bars.forEach((bar) => {
@@ -28,7 +29,7 @@ describe('PasswordStrengthIndicator', () => {
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
(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
expect(screen.getByText(label)).toBeInTheDocument();
@@ -54,7 +55,7 @@ describe('PasswordStrengthIndicator', () => {
suggestions: [],
},
});
render(<PasswordStrengthIndicator password="password" />);
renderWithProviders(<PasswordStrengthIndicator password="password" />);
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
});
@@ -66,7 +67,7 @@ describe('PasswordStrengthIndicator', () => {
suggestions: ['Add another word or two'],
},
});
render(<PasswordStrengthIndicator password="pass" />);
renderWithProviders(<PasswordStrengthIndicator password="pass" />);
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
});
@@ -75,14 +76,14 @@ describe('PasswordStrengthIndicator', () => {
score: 1,
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.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
});
it('should use default empty string if password prop is undefined', () => {
(zxcvbn as Mock).mockReturnValue({ score: 0, feedback: { warning: '', suggestions: [] } });
const { container } = render(<PasswordStrengthIndicator />);
const { container } = renderWithProviders(<PasswordStrengthIndicator />);
const bars = container.querySelectorAll('.h-1\\.5');
expect(bars).toHaveLength(5);
bars.forEach((bar) => {
@@ -94,7 +95,7 @@ describe('PasswordStrengthIndicator', () => {
it('should handle out-of-range scores gracefully (defensive)', () => {
// Mock a score that isn't 0-4 to hit default switch cases
(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
const bars = container.querySelectorAll('.h-1\\.5');

View File

@@ -0,0 +1,202 @@
// src/components/RecipeSuggester.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RecipeSuggester } from './RecipeSuggester'; // This should be after mocks
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger.client';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import '@testing-library/jest-dom';
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
// We can get a typed reference to it for individual test overrides.
const mockedApiClient = vi.mocked(apiClient);
describe('RecipeSuggester Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset console logs if needed, or just keep them for debug visibility
});
it('renders correctly with initial state', () => {
console.log('TEST: Verifying initial render state');
renderWithProviders(<RecipeSuggester />);
expect(screen.getByText('Get a Recipe Suggestion')).toBeInTheDocument();
expect(screen.getByLabelText(/Ingredients:/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Suggest a Recipe/i })).toBeInTheDocument();
expect(screen.queryByText('Getting suggestion...')).not.toBeInTheDocument();
});
it('shows validation error if no ingredients are entered', async () => {
console.log('TEST: Verifying validation for empty input');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
expect(await screen.findByText('Please enter at least one ingredient.')).toBeInTheDocument();
expect(mockedApiClient.suggestRecipe).not.toHaveBeenCalled();
console.log('TEST: Validation error displayed correctly');
});
it('calls suggestRecipe and displays suggestion on success', async () => {
console.log('TEST: Verifying successful recipe suggestion flow');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'chicken, rice');
// Mock successful API response
const mockSuggestion = 'Here is a nice Chicken and Rice recipe...';
// Add a delay to ensure the loading state is visible during the test
mockedApiClient.suggestRecipe.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { ok: true, json: async () => ({ suggestion: mockSuggestion }) } as Response;
});
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
// Check loading state
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText('Getting suggestion...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(mockSuggestion)).toBeInTheDocument();
});
expect(mockedApiClient.suggestRecipe).toHaveBeenCalledWith(['chicken', 'rice']);
console.log('TEST: Suggestion displayed and API called with correct args');
});
it('handles API errors (non-200 response) gracefully', async () => {
console.log('TEST: Verifying API error handling (400/500 responses)');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'rocks');
// Mock API failure response
const errorMessage = 'Invalid ingredients provided.';
mockedApiClient.suggestRecipe.mockResolvedValue({
ok: false,
json: async () => ({ message: errorMessage }),
} as Response);
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
// Ensure loading state is reset
expect(screen.getByRole('button', { name: /Suggest a Recipe/i })).toBeEnabled();
console.log('TEST: API error message displayed to user');
});
it('handles network exceptions and logs them', async () => {
console.log('TEST: Verifying network exception handling');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'beef');
// Mock network error
const networkError = new Error('Network Error');
mockedApiClient.suggestRecipe.mockRejectedValue(networkError);
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
await waitFor(() => {
expect(screen.getByText('Network Error')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith(
{ error: networkError },
'Failed to fetch recipe suggestion.'
);
console.log('TEST: Network error caught and logged');
});
it('clears previous errors when submitting again', async () => {
console.log('TEST: Verifying error clearing on re-submit');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
// Trigger validation error first
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
expect(screen.getByText('Please enter at least one ingredient.')).toBeInTheDocument();
// Now type something to clear it (state change doesn't clear it, submit does)
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'tofu');
// Mock success for the second click
mockedApiClient.suggestRecipe.mockResolvedValue({
ok: true,
json: async () => ({ suggestion: 'Tofu Stir Fry' }),
} as Response);
await user.click(button);
await waitFor(() => {
expect(screen.queryByText('Please enter at least one ingredient.')).not.toBeInTheDocument();
expect(screen.getByText('Tofu Stir Fry')).toBeInTheDocument();
});
console.log('TEST: Previous error cleared successfully');
});
it('uses default error message when API error response has no message', async () => {
console.log('TEST: Verifying default error message for API failure');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'mystery');
// Mock API failure response without a message property
mockedApiClient.suggestRecipe.mockResolvedValue({
ok: false,
json: async () => ({}), // Empty object
} as Response);
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
await waitFor(() => {
expect(screen.getByText('Failed to get suggestion.')).toBeInTheDocument();
});
});
it('handles non-Error objects thrown during fetch', async () => {
console.log('TEST: Verifying handling of non-Error exceptions');
const user = userEvent.setup();
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'chaos');
// Mock a rejection that is NOT an Error object
mockedApiClient.suggestRecipe.mockRejectedValue('Something weird happened');
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
await waitFor(() => {
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith(
{ error: 'Something weird happened' },
'Failed to fetch recipe suggestion.'
);
});
});

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
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 { UnitSystemToggle } from './UnitSystemToggle';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('UnitSystemToggle', () => {
const mockOnToggle = vi.fn();
@@ -12,7 +13,7 @@ describe('UnitSystemToggle', () => {
});
it('should render correctly for imperial system', () => {
render(<UnitSystemToggle currentSystem="imperial" onToggle={mockOnToggle} />);
renderWithProviders(<UnitSystemToggle currentSystem="imperial" onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
@@ -23,7 +24,7 @@ describe('UnitSystemToggle', () => {
});
it('should render correctly for metric system', () => {
render(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
renderWithProviders(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
@@ -34,7 +35,7 @@ describe('UnitSystemToggle', () => {
});
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'));
expect(mockOnToggle).toHaveBeenCalledTimes(1);
});

View File

@@ -1,34 +1,34 @@
// src/components/UserMenuSkeleton.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserMenuSkeleton } from './UserMenuSkeleton';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('UserMenuSkeleton', () => {
it('should render without crashing', () => {
const { container } = render(<UserMenuSkeleton />);
const { container } = renderWithProviders(<UserMenuSkeleton />);
expect(container.firstChild).toBeInTheDocument();
});
it('should have the main container with pulse animation', () => {
const { container } = render(<UserMenuSkeleton />);
const { container } = renderWithProviders(<UserMenuSkeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('should render two child placeholder elements', () => {
const { container } = render(<UserMenuSkeleton />);
const { container } = renderWithProviders(<UserMenuSkeleton />);
expect(container.firstChild?.childNodes.length).toBe(2);
});
it('should render a rectangular placeholder with correct styles', () => {
const { container } = render(<UserMenuSkeleton />);
const { container } = renderWithProviders(<UserMenuSkeleton />);
expect(container.querySelector('.rounded-md')).toHaveClass(
'h-8 w-24 bg-gray-200 dark:bg-gray-700',
);
});
it('should render a circular placeholder with correct styles', () => {
const { container } = render(<UserMenuSkeleton />);
const { container } = renderWithProviders(<UserMenuSkeleton />);
expect(container.querySelector('.rounded-full')).toHaveClass(
'h-10 w-10 bg-gray-200 dark:bg-gray-700',
);

View File

@@ -1,8 +1,9 @@
// src/components/WhatsNewModal.test.tsx
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 { WhatsNewModal } from './WhatsNewModal';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Unmock the component to test the real implementation
vi.unmock('./WhatsNewModal');
@@ -21,13 +22,13 @@ describe('WhatsNewModal', () => {
});
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.
expect(container.firstChild).toBeNull();
});
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.getByText(`Version: ${defaultProps.version}`)).toBeInTheDocument();
@@ -36,13 +37,13 @@ describe('WhatsNewModal', () => {
});
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 }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
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.
const closeButton = screen.getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
@@ -50,7 +51,7 @@ describe('WhatsNewModal', () => {
});
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.
const overlay = screen.getByRole('dialog').parentElement;
fireEvent.click(overlay!);
@@ -58,7 +59,7 @@ describe('WhatsNewModal', () => {
});
it('should not call onClose when clicking inside the modal content', () => {
render(<WhatsNewModal {...defaultProps} />);
renderWithProviders(<WhatsNewModal {...defaultProps} />);
fireEvent.click(screen.getByText(defaultProps.commitMessage));
expect(mockOnClose).not.toHaveBeenCalled();
});

147
src/config/rateLimiters.ts Normal file
View File

@@ -0,0 +1,147 @@
// src/config/rateLimiters.ts
import rateLimit from 'express-rate-limit';
import { shouldSkipRateLimit } from '../utils/rateLimit';
const standardConfig = {
standardHeaders: true,
legacyHeaders: false,
skip: shouldSkipRateLimit,
};
// --- AUTHENTICATION ---
export const loginLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many login attempts from this IP, please try again after 15 minutes.',
});
export const registerLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many accounts created from this IP, please try again after an hour.',
});
export const forgotPasswordLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
});
export const resetPasswordLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
});
export const refreshTokenLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many token refresh attempts from this IP, please try again after 15 minutes.',
});
export const logoutLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many logout attempts from this IP, please try again after 15 minutes.',
});
// --- GENERAL PUBLIC & USER ---
export const publicReadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many requests from this IP, please try again later.',
});
export const userReadLimiter = publicReadLimiter; // Alias for consistency
export const userUpdateLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many update requests from this IP, please try again after 15 minutes.',
});
export const reactionToggleLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 150,
message: 'Too many reaction requests from this IP, please try again later.',
});
export const trackingLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: 'Too many tracking requests from this IP, please try again later.',
});
// --- SENSITIVE / COSTLY ---
export const userSensitiveUpdateLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many sensitive requests from this IP, please try again after an hour.',
});
export const adminTriggerLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
message: 'Too many administrative triggers from this IP, please try again later.',
});
export const aiGenerationLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many AI generation requests from this IP, please try again after 15 minutes.',
});
export const suggestionLimiter = aiGenerationLimiter; // Alias
export const geocodeLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 100,
message: 'Too many geocoding requests from this IP, please try again later.',
});
export const priceHistoryLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
message: 'Too many price history requests from this IP, please try again later.',
});
// --- UPLOADS / BATCH ---
export const adminUploadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
});
export const userUploadLimiter = adminUploadLimiter; // Alias
export const aiUploadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
});
export const batchLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
message: 'Too many batch requests from this IP, please try again later.',
});
export const budgetUpdateLimiter = batchLimiter; // Alias

View File

@@ -110,8 +110,8 @@ async function main() {
validTo.setDate(today.getDate() + 5);
const flyerQuery = `
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)
INSERT INTO public.flyers (file_name, image_url, icon_url, checksum, store_id, valid_from, valid_to)
VALUES ('safeway-flyer.jpg', 'https://example.com/flyer-images/safeway-flyer.jpg', 'https://example.com/flyer-images/icons/safeway-flyer.jpg', 'a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', ${storeMap.get('Safeway')}, $1, $2)
RETURNING flyer_id;
`;
const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [

View File

@@ -77,6 +77,18 @@ describe('PriceChart', () => {
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
});
it('should render an error message when an error occurs', () => {
mockedUseActiveDeals.mockReturnValue({
...mockedUseActiveDeals(),
activeDeals: [],
isLoading: false,
error: 'Failed to fetch deals.',
});
render(<PriceChart {...defaultProps} />);
expect(screen.getByText('Failed to fetch deals.')).toBeInTheDocument();
});
it('should render the table with deal items when data is provided', () => {
render(<PriceChart {...defaultProps} />);

View File

@@ -8,9 +8,13 @@ interface TopDealsProps {
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
const topDeals = useMemo(() => {
// Use a type guard in the filter to inform TypeScript that price_in_cents is non-null
// in subsequent operations. This allows removing the redundant nullish coalescing in sort.
return [...items]
.filter((item) => item.price_in_cents !== null) // Only include items with a parseable price
.sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity))
.filter(
(item): item is FlyerItem & { price_in_cents: number } => item.price_in_cents !== null,
)
.sort((a, b) => a.price_in_cents - b.price_in_cents)
.slice(0, 10);
}, [items]);

View File

@@ -160,9 +160,9 @@ describe('AnalysisPanel', () => {
results: { WEB_SEARCH: 'Search results text.' },
sources: {
WEB_SEARCH: [
{ title: 'Valid Source', uri: 'http://example.com/source1' },
{ title: 'Valid Source', uri: 'https://example.com/source1' },
{ title: 'Source without URI', uri: null },
{ title: 'Another Valid Source', uri: 'http://example.com/source2' },
{ title: 'Another Valid Source', uri: 'https://example.com/source2' },
],
},
loadingAnalysis: null,
@@ -178,7 +178,7 @@ describe('AnalysisPanel', () => {
expect(screen.getByText('Sources:')).toBeInTheDocument();
const source1 = screen.getByText('Valid Source');
expect(source1).toBeInTheDocument();
expect(source1.closest('a')).toHaveAttribute('href', 'http://example.com/source1');
expect(source1.closest('a')).toHaveAttribute('href', 'https://example.com/source1');
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
});
@@ -278,13 +278,13 @@ describe('AnalysisPanel', () => {
loadingAnalysis: null,
error: null,
runAnalysis: mockRunAnalysis,
generatedImageUrl: 'http://example.com/meal.jpg',
generatedImageUrl: 'https://example.com/meal.jpg',
generateImage: mockGenerateImage,
});
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
const image = screen.getByAltText('AI generated meal plan');
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', 'http://example.com/meal.jpg');
expect(image).toHaveAttribute('src', 'https://example.com/meal.jpg');
});
it('should not show sources for non-search analysis types', () => {

View File

@@ -8,13 +8,13 @@ import { createMockStore } from '../../tests/utils/mockFactories';
const mockStore = createMockStore({
store_id: 1,
name: 'SuperMart',
logo_url: 'http://example.com/logo.png',
logo_url: 'https://example.com/logo.png',
});
const mockOnOpenCorrectionTool = vi.fn();
const defaultProps = {
imageUrl: 'http://example.com/flyer.jpg',
imageUrl: 'https://example.com/flyer.jpg',
store: mockStore,
validFrom: '2023-10-26',
validTo: '2023-11-01',

View File

@@ -1,8 +1,8 @@
// src/features/flyer/FlyerDisplay.tsx
import React from 'react';
import { ScanIcon } from '../../components/icons/ScanIcon';
import { formatDateRange } from '../../utils/dateUtils';
import type { Store } from '../../types';
import { formatDateRange } from './dateUtils';
import { ScanIcon } from '../../components/icons/ScanIcon';
export interface FlyerDisplayProps {
imageUrl: string | null;

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import { FlyerList } from './FlyerList';
import { formatShortDate } from './dateUtils';
import { formatShortDate } from '../../utils/dateUtils';
import type { Flyer, UserProfile } from '../../types';
import { createMockUserProfile } from '../../tests/utils/mockFactories';
import { createMockFlyer } from '../../tests/utils/mockFactories';
@@ -19,7 +19,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 1,
file_name: 'metro_flyer_oct_1.pdf',
item_count: 50,
image_url: 'http://example.com/flyer1.jpg',
image_url: 'https://example.com/flyer1.jpg',
store: { store_id: 101, name: 'Metro' },
valid_from: '2023-10-05',
valid_to: '2023-10-11',
@@ -29,7 +29,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 2,
file_name: 'walmart_flyer.pdf',
item_count: 75,
image_url: 'http://example.com/flyer2.jpg',
image_url: 'https://example.com/flyer2.jpg',
store: { store_id: 102, name: 'Walmart' },
valid_from: '2023-10-06',
valid_to: '2023-10-06', // Same day
@@ -40,8 +40,8 @@ const mockFlyers: Flyer[] = [
flyer_id: 3,
file_name: 'no-store-flyer.pdf',
item_count: 10,
image_url: 'http://example.com/flyer3.jpg',
icon_url: 'http://example.com/icon3.png',
image_url: 'https://example.com/flyer3.jpg',
icon_url: 'https://example.com/icon3.png',
valid_from: '2023-10-07',
valid_to: '2023-10-08',
store_address: '456 Side St, Ottawa',
@@ -53,7 +53,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 4,
file_name: 'bad-date-flyer.pdf',
item_count: 5,
image_url: 'http://example.com/flyer4.jpg',
image_url: 'https://example.com/flyer4.jpg',
store: { store_id: 103, name: 'Date Store' },
created_at: 'invalid-date',
valid_from: 'invalid-from',
@@ -163,7 +163,7 @@ describe('FlyerList', () => {
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
const iconImage = flyerWithIcon?.querySelector('img');
expect(iconImage).toBeInTheDocument();
expect(iconImage).toHaveAttribute('src', 'http://example.com/icon3.png');
expect(iconImage).toHaveAttribute('src', 'https://example.com/icon3.png');
});
it('should render a document icon when icon_url is not present', () => {

View File

@@ -7,7 +7,7 @@ import { parseISO, format, isValid } from 'date-fns';
import { MapPinIcon, Trash2Icon } from 'lucide-react';
import { logger } from '../../services/logger.client';
import * as apiClient from '../../services/apiClient';
import { calculateDaysBetween, formatDateRange } from './dateUtils';
import { calculateDaysBetween, formatDateRange, getCurrentDateISOString } from '../../utils/dateUtils';
interface FlyerListProps {
flyers: Flyer[];
@@ -54,7 +54,7 @@ export const FlyerList: React.FC<FlyerListProps> = ({
verbose: true,
});
const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to);
const daysLeft = calculateDaysBetween(getCurrentDateISOString(), flyer.valid_to);
let daysLeftText = '';
let daysLeftColor = '';

View File

@@ -1,130 +0,0 @@
// src/features/flyer/dateUtils.test.ts
import { describe, it, expect } from 'vitest';
import { formatShortDate, calculateDaysBetween, formatDateRange } from './dateUtils';
describe('formatShortDate', () => {
it('should format a valid YYYY-MM-DD date string correctly', () => {
expect(formatShortDate('2024-07-26')).toBe('Jul 26');
});
it('should handle single-digit days correctly', () => {
expect(formatShortDate('2025-01-05')).toBe('Jan 5');
});
it('should handle dates at the end of the year', () => {
expect(formatShortDate('2023-12-31')).toBe('Dec 31');
});
it('should return null for a null input', () => {
expect(formatShortDate(null)).toBeNull();
});
it('should return null for an undefined input', () => {
expect(formatShortDate(undefined)).toBeNull();
});
it('should return null for an empty string input', () => {
expect(formatShortDate('')).toBeNull();
});
it('should return null for an invalid date string', () => {
expect(formatShortDate('not-a-real-date')).toBeNull();
});
it('should return null for a malformed date string', () => {
expect(formatShortDate('2024-13-01')).toBeNull(); // Invalid month
});
it('should correctly format a full ISO string with time and timezone', () => {
expect(formatShortDate('2024-12-25T10:00:00Z')).toBe('Dec 25');
});
});
describe('calculateDaysBetween', () => {
it('should calculate the difference in days between two valid date strings', () => {
expect(calculateDaysBetween('2023-01-01', '2023-01-05')).toBe(4);
});
it('should return a negative number if the end date is before the start date', () => {
expect(calculateDaysBetween('2023-01-05', '2023-01-01')).toBe(-4);
});
it('should handle Date objects', () => {
const start = new Date('2023-01-01');
const end = new Date('2023-01-10');
expect(calculateDaysBetween(start, end)).toBe(9);
});
it('should return null if either date is null or undefined', () => {
expect(calculateDaysBetween(null, '2023-01-01')).toBeNull();
expect(calculateDaysBetween('2023-01-01', undefined)).toBeNull();
});
it('should return null if either date is invalid', () => {
expect(calculateDaysBetween('invalid', '2023-01-01')).toBeNull();
expect(calculateDaysBetween('2023-01-01', 'invalid')).toBeNull();
});
});
describe('formatDateRange', () => {
it('should format a range with two different valid dates', () => {
expect(formatDateRange('2023-01-01', '2023-01-05')).toBe('Jan 1 - Jan 5');
});
it('should format a range with the same start and end date as a single date', () => {
expect(formatDateRange('2023-01-01', '2023-01-01')).toBe('Jan 1');
});
it('should return only the start date if end date is missing', () => {
expect(formatDateRange('2023-01-01', null)).toBe('Jan 1');
expect(formatDateRange('2023-01-01', undefined)).toBe('Jan 1');
});
it('should return only the end date if start date is missing', () => {
expect(formatDateRange(null, '2023-01-05')).toBe('Jan 5');
expect(formatDateRange(undefined, '2023-01-05')).toBe('Jan 5');
});
it('should return null if both dates are missing or invalid', () => {
expect(formatDateRange(null, null)).toBeNull();
expect(formatDateRange(undefined, undefined)).toBeNull();
expect(formatDateRange('invalid', 'invalid')).toBeNull();
});
it('should handle one valid and one invalid date by showing only the valid one', () => {
expect(formatDateRange('2023-01-01', 'invalid')).toBe('Jan 1');
expect(formatDateRange('invalid', '2023-01-05')).toBe('Jan 5');
});
describe('verbose mode', () => {
it('should format a range with two different valid dates verbosely', () => {
expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe(
'Deals valid from January 1, 2023 to January 5, 2023',
);
});
it('should format a range with the same start and end date verbosely', () => {
expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe(
'Valid on January 1, 2023',
);
});
it('should format only the start date verbosely', () => {
expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe(
'Deals start January 1, 2023',
);
});
it('should format only the end date verbosely', () => {
expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe(
'Deals end January 5, 2023',
);
});
it('should handle one valid and one invalid date verbosely', () => {
expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe(
'Deals start January 1, 2023',
);
});
});
});

View File

@@ -1,65 +0,0 @@
// src/features/flyer/dateUtils.ts
import { parseISO, format, isValid, differenceInDays } from 'date-fns';
export const formatShortDate = (dateString: string | null | undefined): string | null => {
if (!dateString) return null;
// Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings.
// It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors.
const date = parseISO(dateString);
if (isValid(date)) {
return format(date, 'MMM d');
}
return null;
};
export const calculateDaysBetween = (
startDate: string | Date | null | undefined,
endDate: string | Date | null | undefined,
): number | null => {
if (!startDate || !endDate) return null;
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
if (!isValid(start) || !isValid(end)) return null;
return differenceInDays(end, start);
};
interface DateRangeOptions {
verbose?: boolean;
}
export const formatDateRange = (
startDate: string | null | undefined,
endDate: string | null | undefined,
options?: DateRangeOptions,
): string | null => {
if (!options?.verbose) {
const start = formatShortDate(startDate);
const end = formatShortDate(endDate);
if (start && end) {
return start === end ? start : `${start} - ${end}`;
}
return start || end || null;
}
// Verbose format logic
const dateFormat = 'MMMM d, yyyy';
const formatFn = (dateStr: string | null | undefined) => {
if (!dateStr) return null;
const date = parseISO(dateStr);
return isValid(date) ? format(date, dateFormat) : null;
};
const start = formatFn(startDate);
const end = formatFn(endDate);
if (start && end) {
return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`;
}
if (start) return `Deals start ${start}`;
if (end) return `Deals end ${end}`;
return null;
};

View File

@@ -12,12 +12,7 @@ import {
} from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
// Explicitly mock apiClient to ensure stable spies are used
vi.mock('../services/apiClient', () => ({
countFlyerItemsForFlyers: vi.fn(),
fetchFlyerItemsForFlyers: vi.fn(),
}));
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
// Mock the hooks to avoid Missing Context errors
vi.mock('./useFlyers', () => ({
useFlyers: () => mockUseFlyers(),
@@ -30,14 +25,6 @@ vi.mock('../hooks/useUserData', () => ({
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedApiClient = vi.mocked(apiClient);
// Mock the logger to prevent console noise
vi.mock('../services/logger.client', () => ({
logger: {
error: vi.fn(),
info: vi.fn(), // Added to prevent crashes on abort logging
},
}));
// Set a consistent "today" for testing flyer validity to make tests deterministic
const TODAY = new Date('2024-01-15T12:00:00.000Z');

View File

@@ -11,21 +11,9 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
import { logger } from '../services/logger.client';
// Mock the dependencies
vi.mock('../services/apiClient', () => ({
// Mock other functions if needed
getAuthenticatedUserProfile: vi.fn(),
}));
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
vi.mock('../services/tokenStorage');
// Mock the logger to spy on its methods
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedTokenStorage = vi.mocked(tokenStorage);

View File

@@ -3,12 +3,11 @@ import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFlyerItems } from './useFlyerItems';
import { useApiOnMount } from './useApiOnMount';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
import * as apiClient from '../services/apiClient';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
vi.mock('./useApiOnMount');
vi.mock('../services/apiClient');
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
@@ -16,8 +15,8 @@ describe('useFlyerItems Hook', () => {
const mockFlyer = createMockFlyer({
flyer_id: 123,
file_name: 'test-flyer.jpg',
image_url: '/test.jpg',
icon_url: '/icon.jpg',
image_url: 'https://example.com/test.jpg',
icon_url: 'https://example.com/icon.jpg',
checksum: 'abc',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
@@ -61,7 +60,6 @@ describe('useFlyerItems Hook', () => {
expect(result.current.flyerItems).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
// Assert: Check that useApiOnMount was called with `enabled: false`.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
expect.any(Function), // the wrapped fetcher function
@@ -171,11 +169,11 @@ describe('useFlyerItems Hook', () => {
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
const mockResponse = new Response();
vi.mocked(apiClient.fetchFlyerItems).mockResolvedValue(mockResponse);
const mockedApiClient = vi.mocked(apiClient);
mockedApiClient.fetchFlyerItems.mockResolvedValue(mockResponse);
const response = await wrappedFetcher(123);
expect(apiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
expect(response).toBe(mockResponse);
});
});

View File

@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
createMockFlyer({
flyer_id: 1,
file_name: 'flyer1.jpg',
image_url: 'url1',
image_url: 'https://example.com/flyer1.jpg',
item_count: 5,
created_at: '2024-01-01',
}),

View File

@@ -29,7 +29,6 @@ type MockApiResult = {
vi.mock('./useApi');
vi.mock('../hooks/useAuth');
vi.mock('../hooks/useUserData');
vi.mock('../services/apiClient');
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedUseApi = vi.mocked(useApi);

View File

@@ -0,0 +1,51 @@
// src/hooks/useUserProfileData.ts
import { useState, useEffect } from 'react';
import * as apiClient from '../services/apiClient';
import { UserProfile, Achievement, UserAchievement } from '../types';
import { logger } from '../services/logger.client';
export const useUserProfileData = () => {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const [profileRes, achievementsRes] = await Promise.all([
apiClient.getAuthenticatedUserProfile(),
apiClient.getUserAchievements(),
]);
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
const profileData: UserProfile | null = await profileRes.json();
const achievementsData: (UserAchievement & Achievement)[] | null =
await achievementsRes.json();
logger.info(
{ profileData, achievementsCount: achievementsData?.length },
'useUserProfileData: Fetched data',
);
if (profileData) {
setProfile(profileData);
}
setAchievements(achievementsData || []);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
setError(errorMessage);
logger.error({ err }, 'Error in useUserProfileData:');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return { profile, setProfile, achievements, isLoading, error };
};

View File

@@ -17,7 +17,6 @@ import {
vi.mock('./useApi');
vi.mock('../hooks/useAuth');
vi.mock('../hooks/useUserData');
vi.mock('../services/apiClient');
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedUseApi = vi.mocked(useApi);

View File

@@ -1,10 +1,10 @@
// src/middleware/errorHandler.test.ts
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
import { DatabaseError } from '../services/processingErrors';
import {
DatabaseError,
ForeignKeyConstraintError,
UniqueConstraintError,
ValidationError,
@@ -69,7 +69,7 @@ app.get('/unique-error', (req, res, next) => {
});
app.get('/db-error-500', (req, res, next) => {
next(new DatabaseError('A database connection issue occurred.', 500));
next(new DatabaseError('A database connection issue occurred.'));
});
app.get('/unauthorized-error-no-status', (req, res, next) => {
@@ -98,12 +98,15 @@ describe('errorHandler Middleware', () => {
vi.clearAllMocks();
consoleErrorSpy.mockClear(); // Clear spy for console.error
// Ensure NODE_ENV is set to 'test' for console.error logging
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
});
afterEach(() => {
vi.unstubAllEnvs(); // Clean up environment variable stubs after each test
});
afterAll(() => {
consoleErrorSpy.mockRestore(); // Restore console.error after all tests
delete process.env.NODE_ENV; // Clean up environment variable
});
it('should return a generic 500 error for a standard Error object', async () => {
@@ -293,11 +296,7 @@ describe('errorHandler Middleware', () => {
describe('when NODE_ENV is "production"', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
afterAll(() => {
process.env.NODE_ENV = 'test'; // Reset for other test files
vi.stubEnv('NODE_ENV', 'production');
});
it('should return a generic message with an error ID for a 500 error', async () => {

View File

@@ -109,20 +109,19 @@ describe('Multer Middleware Directory Creation', () => {
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;
vi.unstubAllEnvs();
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
vi.unstubAllEnvs();
});
describe('Avatar Storage', () => {
it('should generate a unique filename for an authenticated user', () => {
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
@@ -150,7 +149,7 @@ describe('createUploadMiddleware', () => {
});
it('should use a predictable filename in test environment', () => {
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
@@ -164,7 +163,7 @@ describe('createUploadMiddleware', () => {
describe('Flyer Storage', () => {
it('should generate a unique, sanitized filename in production environment', () => {
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
const mockFlyerFile = {
fieldname: 'flyerFile',
originalname: 'My Flyer (Special!).pdf',
@@ -184,7 +183,7 @@ describe('createUploadMiddleware', () => {
it('should generate a predictable filename in test environment', () => {
// This test covers lines 43-46
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
const mockFlyerFile = {
fieldname: 'flyerFile',
originalname: 'test-flyer.jpg',

View File

@@ -79,7 +79,7 @@ describe('HomePage Component', () => {
describe('when a flyer is selected', () => {
const mockFlyer: Flyer = createMockFlyer({
flyer_id: 1,
image_url: 'http://example.com/flyer.jpg',
image_url: 'https://example.com/flyer.jpg',
});
it('should render FlyerDisplay but not data tables if there are no flyer items', () => {

View File

@@ -1,25 +1,15 @@
// src/components/MyDealsPage.test.tsx
// src/pages/MyDealsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import MyDealsPage from './MyDealsPage';
import * as apiClient from '../services/apiClient';
import { WatchedItemDeal } from '../types';
import type { WatchedItemDeal } from '../types';
import { logger } from '../services/logger.client';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
// Mock the apiClient. The component now directly uses `fetchBestSalePrices`.
// By mocking the entire module, we can control the behavior of `fetchBestSalePrices`
// for our tests.
vi.mock('../services/apiClient');
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// Mock the logger
vi.mock('../services/logger.client', () => ({
logger: {
error: vi.fn(),
},
}));
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
const mockedApiClient = vi.mocked(apiClient);
// Mock lucide-react icons to prevent rendering errors in the test environment
vi.mock('lucide-react', () => ({

View File

@@ -10,13 +10,7 @@ import { logger } from '../services/logger.client';
// The apiClient and logger are now mocked globally.
const mockedApiClient = vi.mocked(apiClient);
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// The logger is mocked globally.
// Helper function to render the component within a router context
const renderWithRouter = (token: string) => {
return render(
@@ -115,6 +109,33 @@ describe('ResetPasswordPage', () => {
);
});
it('should show an error message if API returns a non-JSON error response', async () => {
// Simulate a server error returning HTML instead of JSON
mockedApiClient.resetPassword.mockResolvedValue(
new Response('<h1>Server Error</h1>', {
status: 500,
headers: { 'Content-Type': 'text/html' },
}),
);
renderWithRouter('test-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), {
target: { value: 'newSecurePassword123' },
});
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), {
target: { value: 'newSecurePassword123' },
});
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
// The error from response.json() is implementation-dependent.
// We check for a substring that is likely to be present.
expect(screen.getByText(/not valid JSON/i)).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError) }, 'Failed to reset password.');
});
it('should show a loading spinner while submitting', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>((resolve) => {

View File

@@ -11,16 +11,8 @@ import {
createMockUser,
} from '../tests/utils/mockFactories';
// Mock dependencies
vi.mock('../services/apiClient'); // This was correct
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../services/notificationService');
vi.mock('../services/aiApiClient'); // Mock aiApiClient as it's used in the component
// The apiClient, logger, notificationService, and aiApiClient are all mocked globally.
// We can get a typed reference to the notificationService for individual test overrides.
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
vi.mock('../components/AchievementsList', () => ({
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
@@ -28,13 +20,13 @@ vi.mock('../components/AchievementsList', () => ({
),
}));
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
const mockedApiClient = vi.mocked(apiClient);
// --- Mock Data ---
const mockProfile: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.jpg',
avatar_url: 'https://example.com/avatar.jpg',
points: 150,
role: 'user',
});
@@ -131,6 +123,24 @@ describe('UserProfilePage', () => {
});
});
it('should handle null achievements data gracefully on fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
// Mock a successful response but with a null body for achievements
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
render(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
// The mock achievements list should show 0 achievements because the component
// should handle the null response and pass an empty array to the list.
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 0',
);
});
});
it('should render the profile and achievements on successful fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
@@ -302,6 +312,24 @@ describe('UserProfilePage', () => {
});
});
it('should handle non-ok response with null body when saving name', async () => {
// This tests the case where the server returns an error status but an empty/null body.
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
render(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// The component should fall back to the default error message.
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
render(<UserProfilePage />);
@@ -331,7 +359,7 @@ describe('UserProfilePage', () => {
});
it('should upload a new avatar and update the image source', async () => {
const updatedProfile = { ...mockProfile, avatar_url: 'http://example.com/new-avatar.png' };
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
// Log when the mock is called
mockedApiClient.uploadAvatar.mockImplementation((file) => {
@@ -428,6 +456,22 @@ describe('UserProfilePage', () => {
});
});
it('should handle non-ok response with null body when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
render(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
render(<UserProfilePage />);

View File

@@ -1,15 +1,13 @@
import React, { useState, useEffect, useRef } from 'react';
import * as apiClient from '../services/apiClient';
import { UserProfile, Achievement, UserAchievement } from '../types';
import type { UserProfile } from '../types';
import { logger } from '../services/logger.client';
import { notifySuccess, notifyError } from '../services/notificationService';
import { AchievementsList } from '../components/AchievementsList';
import { useUserProfileData } from '../hooks/useUserProfileData';
const UserProfilePage: React.FC = () => {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { profile, setProfile, achievements, isLoading, error } = useUserProfileData();
const [isEditingName, setIsEditingName] = useState(false);
const [editingName, setEditingName] = useState('');
const [isUploading, setIsUploading] = useState(false);
@@ -17,43 +15,10 @@ const UserProfilePage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch profile and achievements data in parallel
const [profileRes, achievementsRes] = await Promise.all([
apiClient.getAuthenticatedUserProfile(),
apiClient.getUserAchievements(),
]);
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
const profileData: UserProfile = await profileRes.json();
const achievementsData: (UserAchievement & Achievement)[] = await achievementsRes.json();
logger.info(
{ profileData, achievementsCount: achievementsData?.length },
'UserProfilePage: Fetched data',
);
setProfile(profileData);
if (profileData) {
setEditingName(profileData.full_name || '');
}
setAchievements(achievementsData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
setError(errorMessage);
logger.error({ err }, 'Error fetching user profile data:');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []); // Empty dependency array means this runs once on component mount
if (profile) {
setEditingName(profile.full_name || '');
}
}, [profile]);
const handleSaveName = async () => {
if (!profile) return;
@@ -61,8 +26,8 @@ const UserProfilePage: React.FC = () => {
try {
const response = await apiClient.updateUserProfile({ full_name: editingName });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update name.');
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
throw new Error(errorData?.message || 'Failed to update name.');
}
const updatedProfile = await response.json();
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
@@ -88,8 +53,8 @@ const UserProfilePage: React.FC = () => {
try {
const response = await apiClient.uploadAvatar(file);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to upload avatar.');
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
throw new Error(errorData?.message || 'Failed to upload avatar.');
}
const updatedProfile = await response.json();
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));

View File

@@ -10,21 +10,10 @@ import { logger } from '../services/logger.client';
// Extensive logging for debugging
const LOG_PREFIX = '[TEST DEBUG]';
vi.mock('../services/notificationService');
// 1. Mock the module to replace its exports with mock functions.
vi.mock('../services/aiApiClient');
// 2. Get a typed reference to the mocked module to control its functions in tests.
// The aiApiClient, notificationService, and logger are mocked globally.
// We can get a typed reference to the aiApiClient for individual test overrides.
const mockedAiApiClient = vi.mocked(aiApiClient);
// Mock the logger
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// Define mock at module level so it can be referenced in the implementation
const mockAudioPlay = vi.fn(() => {
console.log(`${LOG_PREFIX} mockAudioPlay executed`);

View File

@@ -30,7 +30,7 @@ const mockLogs: ActivityLogItem[] = [
user_id: 'user-123',
action: 'flyer_processed',
display_text: 'Processed a new flyer for Walmart.',
user_avatar_url: 'http://example.com/avatar.png',
user_avatar_url: 'https://example.com/avatar.png',
user_full_name: 'Test User',
details: { flyer_id: 1, store_name: 'Walmart' },
}),
@@ -63,7 +63,7 @@ const mockLogs: ActivityLogItem[] = [
action: 'recipe_favorited',
display_text: 'User favorited a recipe',
user_full_name: 'Pizza Lover',
user_avatar_url: 'http://example.com/pizza.png',
user_avatar_url: 'https://example.com/pizza.png',
details: { recipe_name: 'Best Pizza' },
}),
createMockActivityLogItem({
@@ -136,7 +136,7 @@ describe('ActivityLog', () => {
// Check for avatar
const avatar = screen.getByAltText('Test User');
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png');
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
// Check for fallback avatar (Newbie User has no avatar)
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.

View File

@@ -7,13 +7,13 @@ import { AdminStatsPage } from './AdminStatsPage';
import * as apiClient from '../../services/apiClient';
import type { AppStats } from '../../services/apiClient';
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.
const mockedApiClient = vi.mocked(apiClient);
// 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');
return { StatCard: vi.fn(MockStatCard) };
});

View File

@@ -10,7 +10,7 @@ import { DocumentDuplicateIcon } from '../../components/icons/DocumentDuplicateI
import { BuildingStorefrontIcon } from '../../components/icons/BuildingStorefrontIcon';
import { BellAlertIcon } from '../../components/icons/BellAlertIcon';
import { BookOpenIcon } from '../../components/icons/BookOpenIcon';
import { StatCard } from './components/StatCard';
import { StatCard } from '../../components/StatCard';
export const AdminStatsPage: React.FC = () => {
const [stats, setStats] = useState<AppStats | null>(null);

View File

@@ -6,16 +6,9 @@ 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(),
},
}));
// The apiClient and logger are mocked globally.
// We can get a typed reference to the apiClient for individual test overrides.
const mockedApiClient = vi.mocked(apiClient);
// Mock LoadingSpinner to simplify DOM and avoid potential issues
vi.mock('../../components/LoadingSpinner', () => ({
@@ -29,7 +22,7 @@ describe('FlyerReviewPage', () => {
it('renders loading spinner initially', () => {
// Mock a promise that doesn't resolve immediately to check loading state
vi.mocked(apiClient.getFlyersForReview).mockReturnValue(new Promise(() => {}));
mockedApiClient.getFlyersForReview.mockReturnValue(new Promise(() => {}));
render(
<MemoryRouter>
@@ -41,7 +34,7 @@ describe('FlyerReviewPage', () => {
});
it('renders empty state when no flyers are returned', async () => {
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
mockedApiClient.getFlyersForReview.mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
@@ -66,14 +59,14 @@ describe('FlyerReviewPage', () => {
file_name: 'flyer1.jpg',
created_at: '2023-01-01T00:00:00Z',
store: { name: 'Store A' },
icon_url: 'icon1.jpg',
icon_url: 'https://example.com/icon1.jpg',
},
{
flyer_id: 2,
file_name: 'flyer2.jpg',
created_at: '2023-01-02T00:00:00Z',
store: { name: 'Store B' },
icon_url: 'icon2.jpg',
icon_url: 'https://example.com/icon2.jpg',
},
{
flyer_id: 3,
@@ -84,7 +77,7 @@ describe('FlyerReviewPage', () => {
},
];
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
mockedApiClient.getFlyersForReview.mockResolvedValue({
ok: true,
json: async () => mockFlyers,
} as Response);
@@ -110,11 +103,11 @@ describe('FlyerReviewPage', () => {
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
expect(unknownStoreImage).not.toHaveAttribute('src');
expect(unknownStoreImage).not.toHaveAttribute('alt');
expect(unknownStoreImage).toHaveAttribute('alt', 'Unknown Store');
});
it('renders error message when API response is not ok', async () => {
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
mockedApiClient.getFlyersForReview.mockResolvedValue({
ok: false,
json: async () => ({ message: 'Server error' }),
} as Response);
@@ -138,7 +131,7 @@ describe('FlyerReviewPage', () => {
it('renders error message when API throws an error', async () => {
const networkError = new Error('Network error');
vi.mocked(apiClient.getFlyersForReview).mockRejectedValue(networkError);
mockedApiClient.getFlyersForReview.mockRejectedValue(networkError);
render(
<MemoryRouter>
@@ -159,7 +152,7 @@ describe('FlyerReviewPage', () => {
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);
mockedApiClient.getFlyersForReview.mockRejectedValue(nonErrorRejection);
render(
<MemoryRouter>

View File

@@ -73,7 +73,7 @@ export const FlyerReviewPage: React.FC = () => {
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" />
<img src={flyer.icon_url || undefined} alt={flyer.store?.name || 'Unknown Store'} className="w-12 h-12 rounded-md object-cover" />
<div className="flex-1">
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>

View File

@@ -1,9 +1,10 @@
// src/pages/admin/components/AddressForm.test.tsx
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 { AddressForm } from './AddressForm';
import { createMockAddress } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Mock child components and icons to isolate the form's logic
vi.mock('lucide-react', () => ({
@@ -30,7 +31,7 @@ describe('AddressForm', () => {
});
it('should render all address fields correctly', () => {
render(<AddressForm {...defaultProps} />);
renderWithProviders(<AddressForm {...defaultProps} />);
expect(screen.getByRole('heading', { name: /home address/i })).toBeInTheDocument();
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
@@ -48,7 +49,7 @@ describe('AddressForm', () => {
city: 'Anytown',
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(/city/i)).toHaveValue('Anytown');
@@ -56,7 +57,7 @@ describe('AddressForm', () => {
});
it('should call onAddressChange with the correct field and value for all inputs', () => {
render(<AddressForm {...defaultProps} />);
renderWithProviders(<AddressForm {...defaultProps} />);
const inputs = [
{ 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', () => {
render(<AddressForm {...defaultProps} />);
renderWithProviders(<AddressForm {...defaultProps} />);
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
fireEvent.click(geocodeButton);
@@ -84,14 +85,14 @@ describe('AddressForm', () => {
});
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.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
describe('when isGeocoding is true', () => {
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 });
expect(geocodeButton).toBeDisabled();

View File

@@ -1,11 +1,12 @@
// src/pages/admin/components/AdminBrandManager.test.tsx
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 toast from 'react-hot-toast';
import { AdminBrandManager } from './AdminBrandManager';
import * as apiClient from '../../../services/apiClient';
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.
// This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions.
@@ -18,7 +19,7 @@ const mockBrands = [
brand_id: 2,
name: 'Compliments',
store_name: 'Sobeys',
logo_url: 'http://example.com/compliments.png',
logo_url: 'https://example.com/compliments.png',
}),
];
@@ -34,7 +35,7 @@ describe('AdminBrandManager', () => {
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {}));
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Checking for the loading text.');
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
@@ -49,7 +50,7 @@ describe('AdminBrandManager', () => {
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
await waitFor(() => {
@@ -69,7 +70,7 @@ describe('AdminBrandManager', () => {
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for brand list to render.');
await waitFor(() => {
@@ -91,14 +92,14 @@ describe('AdminBrandManager', () => {
);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () =>
new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), {
new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
status: 200,
}),
);
mockedToast.loading.mockReturnValue('toast-1');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
@@ -119,7 +120,7 @@ describe('AdminBrandManager', () => {
// Check if the UI updates with the new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
'src',
'http://example.com/new-logo.png',
'https://example.com/new-logo.png',
);
console.log('TEST SUCCESS: All assertions for successful upload passed.');
});
@@ -135,7 +136,7 @@ describe('AdminBrandManager', () => {
mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error');
mockedToast.loading.mockReturnValue('toast-non-error');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
@@ -162,7 +163,7 @@ describe('AdminBrandManager', () => {
mockedToast.loading.mockReturnValue('toast-2');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
@@ -189,7 +190,7 @@ describe('AdminBrandManager', () => {
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
@@ -217,7 +218,7 @@ describe('AdminBrandManager', () => {
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
@@ -247,7 +248,7 @@ describe('AdminBrandManager', () => {
);
mockedToast.loading.mockReturnValue('toast-3');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
@@ -270,7 +271,7 @@ describe('AdminBrandManager', () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
@@ -291,7 +292,7 @@ describe('AdminBrandManager', () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify([]), { status: 200 }),
);
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
@@ -309,7 +310,7 @@ describe('AdminBrandManager', () => {
);
mockedToast.loading.mockReturnValue('toast-fallback');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
@@ -333,7 +334,7 @@ describe('AdminBrandManager', () => {
);
mockedToast.loading.mockReturnValue('toast-opt');
render(<AdminBrandManager />);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
// Brand 1: No Frills (initially null logo)
@@ -349,7 +350,7 @@ describe('AdminBrandManager', () => {
// Brand 2 should still have original logo
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
'src',
'http://example.com/compliments.png',
'https://example.com/compliments.png',
);
});
});

View File

@@ -1,11 +1,12 @@
// src/pages/admin/components/AuthView.test.tsx
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 { AuthView } from './AuthView';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
import { createMockUserProfile } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
const mockedApiClient = vi.mocked(apiClient, true);
@@ -46,7 +47,7 @@ describe('AuthView', () => {
describe('Initial Render and Login', () => {
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.getByLabelText(/email address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
@@ -54,7 +55,7 @@ describe('AuthView', () => {
});
it('should allow typing in email and password fields', () => {
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
const emailInput = screen.getByLabelText(/email address/i);
const passwordInput = screen.getByLabelText(/^password$/i);
@@ -66,7 +67,7 @@ describe('AuthView', () => {
});
it('should call loginUser and onLoginSuccess on successful login', async () => {
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'test@example.com' },
});
@@ -94,7 +95,7 @@ describe('AuthView', () => {
it('should display an error on failed login', async () => {
(mockedApiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
@@ -107,7 +108,7 @@ describe('AuthView', () => {
(mockedApiClient.loginUser as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401 }),
);
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
@@ -120,7 +121,7 @@ describe('AuthView', () => {
describe('Registration', () => {
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 }));
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
@@ -129,7 +130,7 @@ describe('AuthView', () => {
});
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.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 () => {
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
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
@@ -184,7 +185,7 @@ describe('AuthView', () => {
(mockedApiClient.registerUser as Mock).mockRejectedValueOnce(
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.submit(screen.getByTestId('auth-form'));
@@ -197,7 +198,7 @@ describe('AuthView', () => {
(mockedApiClient.registerUser as Mock).mockResolvedValueOnce(
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.submit(screen.getByTestId('auth-form'));
@@ -209,7 +210,7 @@ describe('AuthView', () => {
describe('Forgot Password', () => {
it('should switch to the reset password form', () => {
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
@@ -217,7 +218,7 @@ describe('AuthView', () => {
});
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.change(screen.getByLabelText(/email address/i), {
@@ -238,7 +239,7 @@ describe('AuthView', () => {
(mockedApiClient.requestPasswordReset as Mock).mockRejectedValueOnce(
new Error('User not found'),
);
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
fireEvent.submit(screen.getByTestId('reset-password-form'));
@@ -251,7 +252,7 @@ describe('AuthView', () => {
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValueOnce(
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.submit(screen.getByTestId('reset-password-form'));
@@ -261,7 +262,7 @@ describe('AuthView', () => {
});
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: /back to sign in/i }));
@@ -287,13 +288,13 @@ describe('AuthView', () => {
});
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 }));
expect(window.location.href).toBe('/api/auth/google');
});
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 }));
expect(window.location.href).toBe('/api/auth/github');
});
@@ -301,7 +302,7 @@ describe('AuthView', () => {
describe('UI Logic and Loading States', () => {
it('should toggle "Remember me" checkbox', () => {
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
const rememberMeCheckbox = screen.getByRole('checkbox', { name: /remember me/i });
expect(rememberMeCheckbox).not.toBeChecked();
@@ -316,7 +317,7 @@ describe('AuthView', () => {
it('should show loading state during login submission', async () => {
// Mock a promise that doesn't resolve immediately
(mockedApiClient.loginUser as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'test@example.com' },
@@ -341,7 +342,7 @@ describe('AuthView', () => {
it('should show loading state during password reset submission', async () => {
(mockedApiClient.requestPasswordReset as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
@@ -362,7 +363,7 @@ describe('AuthView', () => {
it('should show loading state during registration submission', async () => {
// Mock a promise that doesn't resolve immediately
(mockedApiClient.registerUser as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
renderWithProviders(<AuthView {...defaultProps} />);
// Switch to registration view
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
import React from 'react';
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 { CorrectionRow } from './CorrectionRow';
import * as apiClient from '../../../services/apiClient';
@@ -10,15 +10,11 @@ import {
createMockMasterGroceryItem,
createMockCategory,
} from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
// The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts.
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// Mock the logger
vi.mock('../../../services/logger', () => ({
logger: { info: vi.fn(), error: vi.fn() },
}));
// The apiClient and logger are mocked globally.
// We can get a typed reference to the apiClient for individual test overrides.
const mockedApiClient = vi.mocked(apiClient);
// Mock the ConfirmationModal to test its props and interactions
// The ConfirmationModal is now in a different directory.
@@ -80,7 +76,7 @@ const defaultProps = {
// Helper to render the component inside a table structure
const renderInTable = (props = defaultProps) => {
return render(
return renderWithProviders(
<table>
<tbody>
<CorrectionRow {...props} />

View File

@@ -21,25 +21,10 @@ vi.mock('../../../components/PasswordInput', () => ({
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
}));
// The apiClient, notificationService, react-hot-toast, and logger are all mocked globally.
// We can get a typed reference to the apiClient for individual test overrides.
const mockedApiClient = vi.mocked(apiClient, true);
vi.mock('../../../services/notificationService');
vi.mock('react-hot-toast', () => ({
__esModule: true,
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../services/logger.client', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const mockOnClose = vi.fn();
const mockOnLoginSuccess = vi.fn();
const mockOnSignOut = vi.fn();
@@ -50,7 +35,7 @@ const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'tes
const mockAddressId = 123;
const authenticatedProfile = createMockUserProfile({
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png',
avatar_url: 'https://example.com/avatar.png',
role: 'user',
points: 100,
preferences: {
@@ -279,6 +264,7 @@ describe('ProfileManager', () => {
});
it('should show an error if trying to save profile when not logged in', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'warn');
// This is an edge case, but good to test the safeguard
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
@@ -286,6 +272,7 @@ describe('ProfileManager', () => {
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
});
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
});
@@ -511,6 +498,23 @@ describe('ProfileManager', () => {
});
});
it('should show an error when trying to link a GitHub account', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /link github account/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /link github account/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith(
'Account linking with github is not yet implemented.',
);
});
});
it('should switch between all tabs correctly', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
@@ -819,6 +823,63 @@ describe('ProfileManager', () => {
});
});
it('should allow changing unit system when preferences are initially null', async () => {
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
const { rerender } = render(
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
);
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const imperialRadio = await screen.findByLabelText(/imperial/i);
const metricRadio = screen.getByLabelText(/metric/i);
// With null preferences, neither should be checked.
expect(imperialRadio).not.toBeChecked();
expect(metricRadio).not.toBeChecked();
// Mock the API response for the update
const updatedProfileWithPrefs = {
...profileWithoutPrefs,
preferences: { darkMode: false, unitSystem: 'metric' as const },
};
mockedApiClient.updateUserPreferences.mockResolvedValue({
ok: true,
json: () => Promise.resolve(updatedProfileWithPrefs),
} as Response);
fireEvent.click(metricRadio);
await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
{ unitSystem: 'metric' },
expect.anything(),
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
});
// Rerender with the new profile to check the UI update
rerender(
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
);
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
expect(await screen.findByLabelText(/metric/i)).toBeChecked();
expect(screen.getByLabelText(/imperial/i)).not.toBeChecked();
});
it('should not call onProfileUpdate if updating unit system fails', async () => {
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const metricRadio = await screen.findByLabelText(/metric/i);
fireEvent.click(metricRadio);
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('API failed');
});
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
});
it('should only call updateProfile when only profile data has changed', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() =>
@@ -883,6 +944,12 @@ describe('ProfileManager', () => {
});
});
it('should not render auth views when the user is already authenticated', () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
expect(screen.queryByText('Create an Account')).not.toBeInTheDocument();
});
it('should log warning if address fetch returns null', async () => {
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
const loggerSpy = vi.spyOn(logger.logger, 'warn');
@@ -905,5 +972,127 @@ describe('ProfileManager', () => {
);
});
});
it('should handle updating the user profile and address with empty strings', async () => {
mockedApiClient.updateUserProfile.mockImplementation(async (data) =>
new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
);
mockedApiClient.updateUserAddress.mockImplementation(async (data) =>
new Response(JSON.stringify({ ...mockAddress, ...data })),
);
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => {
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name);
});
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: '' } });
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: '' } });
const saveButton = screen.getByRole('button', { name: /save profile/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith(
{ full_name: '', avatar_url: authenticatedProfile.avatar_url },
expect.objectContaining({ signal: expect.anything() }),
);
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
expect.objectContaining({ city: '' }),
expect.objectContaining({ signal: expect.anything() }),
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ full_name: '' })
);
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
});
});
it('should correctly clear the form when userProfile.address_id is null', async () => {
const profileNoAddress = { ...authenticatedProfile, address_id: null };
render(
<ProfileManager
{...defaultAuthenticatedProps}
userProfile={profileNoAddress as any} // Forcefully override the type to simulate address_id: null
/>,
);
await waitFor(() => {
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('');
expect(screen.getByLabelText(/city/i)).toHaveValue('');
expect(screen.getByLabelText(/province \/ state/i)).toHaveValue('');
expect(screen.getByLabelText(/postal \/ zip code/i)).toHaveValue('');
expect(screen.getByLabelText(/country/i)).toHaveValue('');
});
});
it('should show error notification when manual geocoding fails', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Geocoding failed'));
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Geocoding failed');
});
});
it('should show error notification when auto-geocoding fails', async () => {
vi.useFakeTimers();
// FIX: Mock getUserAddress to return an address *without* coordinates.
// This is the condition required to trigger the auto-geocoding logic.
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)),
);
render(<ProfileManager {...defaultAuthenticatedProps} />);
// Wait for initial load
await act(async () => {
await vi.runAllTimersAsync();
});
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Auto-geocode error'));
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'ErrorCity' } });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(notifyError).toHaveBeenCalledWith('Auto-geocode error');
});
it('should handle permission denied error during geocoding', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Permission denied'));
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Permission denied');
});
});
it('should not trigger OAuth link if user profile is missing', async () => {
// This is an edge case to test the guard clause in handleOAuthLink
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
fireEvent.click(screen.getByRole('button', { name: /security/i }));
const linkButton = await screen.findByRole('button', { name: /link google account/i });
fireEvent.click(linkButton);
// The function should just return, so nothing should happen.
await waitFor(() => {
expect(notifyError).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,18 +1,19 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatCard } from './StatCard';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
describe('StatCard', () => {
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('1,234')).toBeInTheDocument();
});
it('should render the icon', () => {
render(
renderWithProviders(
<StatCard title="Test Stat" value={100} icon={<div data-testid="test-icon">Icon</div>} />,
);

View File

@@ -1,47 +1,18 @@
// src/pages/admin/components/SystemCheck.test.tsx
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 { SystemCheck } from './SystemCheck';
import * as apiClient from '../../../services/apiClient';
import toast from 'react-hot-toast';
import { createMockUser } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// 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.
vi.mock('../../../services/apiClient', () => ({
pingBackend: vi.fn(),
checkStorage: vi.fn(),
checkDbPoolHealth: vi.fn(),
checkPm2Status: vi.fn(),
checkRedisHealth: vi.fn(),
checkDbSchema: vi.fn(),
loginUser: vi.fn(),
triggerFailingJob: vi.fn(),
clearGeocodeCache: vi.fn(),
}));
// Get a type-safe mocked version of the apiClient module.
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
// We can get a type-safe mocked version of the module to override functions for specific tests.
const mockedApiClient = vi.mocked(apiClient);
// Correct the relative path to the logger module.
vi.mock('../../../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock toast to check for notifications
vi.mock('react-hot-toast', () => ({
__esModule: true,
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// The logger and react-hot-toast are mocked globally.
describe('SystemCheck', () => {
// Store original env variable
@@ -100,7 +71,7 @@ describe('SystemCheck', () => {
it('should render initial idle state and then run checks automatically on mount', async () => {
setGeminiApiKey('mock-api-key');
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
// Initially, all checks should be in 'running' state due to auto-run
// However, the API key check is synchronous and resolves immediately.
@@ -126,7 +97,7 @@ describe('SystemCheck', () => {
it('should show API key as failed if GEMINI_API_KEY is not set', async () => {
setGeminiApiKey(undefined);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
// Wait for the specific error message to appear.
expect(
@@ -139,7 +110,7 @@ describe('SystemCheck', () => {
it('should show backend connection as failed if pingBackend fails', async () => {
setGeminiApiKey('mock-api-key');
(mockedApiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
@@ -164,7 +135,7 @@ describe('SystemCheck', () => {
new Response(JSON.stringify({ success: false, message: 'PM2 process not found' })),
),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
@@ -174,7 +145,7 @@ describe('SystemCheck', () => {
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
setGeminiApiKey('mock-api-key'); // This was missing
mockedApiClient.checkDbPoolHealth.mockRejectedValueOnce(new Error('DB connection refused'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('DB connection refused')).toBeInTheDocument();
@@ -184,7 +155,7 @@ describe('SystemCheck', () => {
it('should show Redis check as failed if checkRedisHealth fails', async () => {
setGeminiApiKey('mock-api-key');
mockedApiClient.checkRedisHealth.mockRejectedValueOnce(new Error('Redis connection refused'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Redis connection refused')).toBeInTheDocument();
@@ -197,7 +168,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkDbPoolHealth.mockImplementationOnce(() =>
Promise.reject(new Error('DB connection refused')),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
// Verify the specific "skipped" messages for DB-dependent checks
@@ -214,7 +185,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Schema mismatch')).toBeInTheDocument();
@@ -224,7 +195,7 @@ describe('SystemCheck', () => {
it('should show seeded user check as failed if loginUser fails', async () => {
setGeminiApiKey('mock-api-key');
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Incorrect email or password'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(
@@ -236,7 +207,7 @@ describe('SystemCheck', () => {
it('should show a generic failure message for other login errors', async () => {
setGeminiApiKey('mock-api-key');
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Server is on fire'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Failed: Server is on fire')).toBeInTheDocument();
@@ -246,7 +217,7 @@ describe('SystemCheck', () => {
it('should show storage directory check as failed if checkStorage fails', async () => {
setGeminiApiKey('mock-api-key');
mockedApiClient.checkStorage.mockRejectedValueOnce(new Error('Storage not writable'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Storage not writable')).toBeInTheDocument();
@@ -262,7 +233,7 @@ describe('SystemCheck', () => {
});
mockedApiClient.pingBackend.mockImplementation(() => mockPromise);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
// The button text changes to "Running Checks..."
const runningButton = screen.getByRole('button', { name: /running checks/i });
@@ -283,7 +254,7 @@ describe('SystemCheck', () => {
it('should re-run checks when the "Re-run Checks" button is clicked', async () => {
setGeminiApiKey('mock-api-key');
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
// Wait for initial auto-run to complete
await waitFor(() => expect(screen.getByText(/finished in/i)).toBeInTheDocument());
@@ -328,7 +299,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkDbSchema.mockImplementationOnce(() =>
Promise.resolve(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' }))),
);
const { container } = render(<SystemCheck />);
const { container } = renderWithProviders(<SystemCheck />);
await waitFor(() => {
// Instead of test-ids, we check for the result: the icon's color class.
@@ -344,7 +315,7 @@ describe('SystemCheck', () => {
it('should display elapsed time after checks complete', async () => {
setGeminiApiKey('mock-api-key');
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
const elapsedTimeText = screen.getByText(/finished in \d+\.\d{2} seconds\./i);
@@ -357,7 +328,7 @@ describe('SystemCheck', () => {
describe('Integration: Job Queue Retries', () => {
it('should call triggerFailingJob and show a success toast', async () => {
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
fireEvent.click(triggerButton);
@@ -374,7 +345,7 @@ describe('SystemCheck', () => {
});
mockedApiClient.triggerFailingJob.mockImplementation(() => mockPromise);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
fireEvent.click(triggerButton);
@@ -390,7 +361,7 @@ describe('SystemCheck', () => {
it('should show an error toast if triggering the job fails', async () => {
mockedApiClient.triggerFailingJob.mockRejectedValueOnce(new Error('Queue is down'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
fireEvent.click(triggerButton);
@@ -403,7 +374,7 @@ describe('SystemCheck', () => {
mockedApiClient.triggerFailingJob.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Server error' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
fireEvent.click(triggerButton);
@@ -420,7 +391,7 @@ describe('SystemCheck', () => {
});
it('should call clearGeocodeCache and show a success toast', async () => {
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
// Wait for checks to run and Redis to be OK
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
@@ -435,7 +406,7 @@ describe('SystemCheck', () => {
it('should show an error toast if clearing the cache fails', async () => {
mockedApiClient.clearGeocodeCache.mockRejectedValueOnce(new Error('Redis is busy'));
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
await waitFor(() => expect(vi.mocked(toast).error).toHaveBeenCalledWith('Redis is busy'));
@@ -443,7 +414,7 @@ describe('SystemCheck', () => {
it('should not call clearGeocodeCache if user cancels confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
const clearButton = screen.getByRole('button', { name: /clear geocode cache/i });
@@ -456,7 +427,7 @@ describe('SystemCheck', () => {
mockedApiClient.clearGeocodeCache.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Cache clear failed' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
@@ -470,7 +441,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
new Response(JSON.stringify({ success: false, message: 'Redis down' })),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis down')).toBeInTheDocument());
@@ -486,7 +457,7 @@ describe('SystemCheck', () => {
mockedApiClient.pingBackend.mockResolvedValueOnce(
new Response('unexpected response', { status: 200 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(
@@ -499,7 +470,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkStorage.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Permission denied' }), { status: 403 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Permission denied')).toBeInTheDocument();
@@ -511,7 +482,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkDbSchema.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Schema check failed 500' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Schema check failed 500')).toBeInTheDocument();
@@ -523,7 +494,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkDbPoolHealth.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'DB Pool check failed 500' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('DB Pool check failed 500')).toBeInTheDocument();
@@ -535,7 +506,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkPm2Status.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'PM2 check failed 500' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('PM2 check failed 500')).toBeInTheDocument();
@@ -547,7 +518,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Redis check failed 500' }), { status: 500 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Redis check failed 500')).toBeInTheDocument();
@@ -559,7 +530,7 @@ describe('SystemCheck', () => {
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(
new Response(JSON.stringify({ success: false, message: 'Redis is down' })),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Redis is down')).toBeInTheDocument();
@@ -571,7 +542,7 @@ describe('SystemCheck', () => {
mockedApiClient.loginUser.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Invalid credentials' }), { status: 401 }),
);
render(<SystemCheck />);
renderWithProviders(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Failed: Invalid credentials')).toBeInTheDocument();

View File

@@ -6,14 +6,8 @@ import { ApiProvider } from './ApiProvider';
import { ApiContext } from '../contexts/ApiContext';
import * as apiClient from '../services/apiClient';
// Mock the apiClient module.
// Since ApiProvider and ApiContext import * as apiClient, mocking it ensures
// we control the reference identity and can verify it's being passed correctly.
vi.mock('../services/apiClient', () => ({
fetchFlyers: vi.fn(),
fetchMasterItems: vi.fn(),
// Add other mocked methods as needed for the shape to be valid-ish
}));
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
// This test verifies that the ApiProvider correctly provides this mocked module.
describe('ApiProvider & ApiContext', () => {
const TestConsumer = () => {

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 tokenStorage from '../services/tokenStorage';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import * as apiClient from '../services/apiClient';
// Mocks
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
vi.mock('../services/tokenStorage');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedTokenStorage = tokenStorage as Mocked<typeof tokenStorage>;
const mockProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@example.com' },
});
// A simple consumer component to access and display context values
const TestConsumer = () => {
const context = useContext(AuthContext);
const [error, setError] = useState<string | null>(null);
if (!context) {
return <div>No Context</div>;
}
const handleLoginWithoutProfile = async () => {
try {
await context.login('test-token-no-profile');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div>
<div data-testid="auth-status">{context.authStatus}</div>
<div data-testid="user-email">{context.userProfile?.user.email ?? 'No User'}</div>
<div data-testid="is-loading">{context.isLoading.toString()}</div>
{error && <div data-testid="error-display">{error}</div>}
<button onClick={() => context.login('test-token', mockProfile)}>Login with Profile</button>
<button onClick={handleLoginWithoutProfile}>Login without Profile</button>
<button onClick={context.logout}>Logout</button>
<button onClick={() => context.updateProfile({ full_name: 'Updated Name' })}>
Update Profile
</button>
</div>
);
};
const renderWithProvider = () => {
return render(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
};
describe('AuthProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should start in "Determining..." state and transition to "SIGNED_OUT" if no token exists', async () => {
mockedTokenStorage.getToken.mockReturnValue(null);
renderWithProvider();
// The transition happens synchronously in the effect when no token is present,
// so 'Determining...' might be skipped or flashed too quickly for the test runner.
// We check that it settles correctly.
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
});
expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled();
});
it('should transition to "AUTHENTICATED" if a valid token exists', async () => {
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
});
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1);
});
it('should handle token validation failure by signing out', async () => {
mockedTokenStorage.getToken.mockReturnValue('invalid-token');
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid Token'));
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
});
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
});
it('should handle a valid token that returns no profile by signing out', async () => {
// This test covers lines 51-55
mockedTokenStorage.getToken.mockReturnValue('valid-token-no-profile');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(null)),
);
renderWithProvider();
expect(screen.getByTestId('auth-status')).toHaveTextContent('Determining...');
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
});
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(screen.getByTestId('user-email')).toHaveTextContent('No User');
expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
});
it('should log in a user with provided profile data', async () => {
mockedTokenStorage.getToken.mockReturnValue(null);
renderWithProvider();
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'));
const loginButton = screen.getByRole('button', { name: 'Login with Profile' });
await act(async () => {
fireEvent.click(loginButton);
});
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token');
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
// API should not be called if profile is provided
expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled();
});
it('should log in a user and fetch profile if not provided', async () => {
mockedTokenStorage.getToken.mockReturnValue(null);
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
renderWithProvider();
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'));
const loginButton = screen.getByRole('button', { name: 'Login without Profile' });
await act(async () => {
fireEvent.click(loginButton);
});
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com');
});
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1);
});
it('should throw an error and log out if profile fetch fails after login', async () => {
// This test covers lines 109-111
mockedTokenStorage.getToken.mockReturnValue(null);
const fetchError = new Error('API is down');
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(fetchError);
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
});
const loginButton = screen.getByRole('button', { name: 'Login without Profile' });
// Click the button that triggers the failing login
fireEvent.click(loginButton);
// After the error is thrown, the state should be rolled back
await waitFor(() => {
// The error is now caught and displayed by the TestConsumer
expect(screen.getByTestId('error-display')).toHaveTextContent(
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.',
);
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
});
});
it('should log out the user', async () => {
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
renderWithProvider();
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'));
const logoutButton = screen.getByRole('button', { name: 'Logout' });
fireEvent.click(logoutButton);
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
expect(screen.getByTestId('user-email')).toHaveTextContent('No User');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
});
it('should update the user profile', async () => {
mockedTokenStorage.getToken.mockReturnValue('valid-token');
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
renderWithProvider();
await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'));
const updateButton = screen.getByRole('button', { name: 'Update Profile' });
fireEvent.click(updateButton);
await waitFor(() => {
// The profile object is internal, so we can't directly check it.
// A good proxy is to see if a component that uses it would re-render.
// Since our consumer doesn't display the name, we just confirm the function was called.
// In a real app, we'd check the updated UI element.
expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED');
});
});
});

View File

@@ -250,6 +250,17 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('Correction with ID 999 not found');
});
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(
new Error('Generic DB Error'),
);
const response = await supertest(app)
.put('/api/admin/corrections/101')
.send({ suggested_value: 'new value' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Generic DB Error');
});
});
describe('Flyer Review Routes', () => {
@@ -294,6 +305,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockBrands);
});
it('GET /brands should return 500 on DB error', async () => {
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/brands');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
const brandId = 55;
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
@@ -500,6 +518,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body.message).toBe('Flyer with ID 999 not found.');
});
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
const flyerId = 42;
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
new Error('Generic DB Error'),
);
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Generic DB Error');
});
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).delete('/api/admin/flyers/abc');
expect(response.status).toBe(400);

View File

@@ -54,6 +54,14 @@ vi.mock('../services/workers.server', () => ({
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
}));
// Mock the monitoring service directly to test route error handling
vi.mock('../services/monitoringService.server', () => ({
monitoringService: {
getWorkerStatuses: vi.fn(),
getQueueStatuses: vi.fn(),
},
}));
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db');
@@ -78,11 +86,8 @@ vi.mock('@bull-board/express', () => ({
import adminRouter from './admin.routes';
// Import the mocked modules to control them
import * as queueService from '../services/queueService.server';
import * as workerService from '../services/workers.server';
import { monitoringService } from '../services/monitoringService.server';
import { adminRepo } from '../services/db/index.db';
const mockedQueueService = queueService as Mocked<typeof queueService>;
const mockedWorkerService = workerService as Mocked<typeof workerService>;
// Mock the logger
vi.mock('../services/logger.server', () => ({
@@ -146,16 +151,26 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
});
it('should return 500 if fetching activity log fails', async () => {
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/activity-log');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
});
describe('GET /workers/status', () => {
it('should return the status of all registered workers', async () => {
// Arrange: Set the mock status for each worker
vi.mocked(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
const mockStatuses = [
{ name: 'flyer-processing', isRunning: true },
{ name: 'email-sending', isRunning: true },
{ name: 'analytics-reporting', isRunning: false },
{ name: 'file-cleanup', isRunning: true },
{ name: 'weekly-analytics-reporting', isRunning: true },
];
vi.mocked(monitoringService.getWorkerStatuses).mockResolvedValue(mockStatuses);
// Act
const response = await supertest(app).get('/api/admin/workers/status');
@@ -170,51 +185,41 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
{ name: 'weekly-analytics-reporting', isRunning: true },
]);
});
it('should return 500 if fetching worker statuses fails', async () => {
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
const response = await supertest(app).get('/api/admin/workers/status');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Worker Error');
});
});
describe('GET /queues/status', () => {
it('should return job counts for all registered queues', async () => {
// Arrange: Set the mock job counts for each queue
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({
waiting: 5,
active: 1,
completed: 100,
failed: 2,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({
waiting: 0,
active: 0,
completed: 50,
failed: 0,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({
waiting: 0,
active: 1,
completed: 10,
failed: 1,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({
waiting: 2,
active: 0,
completed: 25,
failed: 0,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({
waiting: 1,
active: 0,
completed: 5,
failed: 0,
delayed: 0,
paused: 0,
});
const mockStatuses = [
{
name: 'flyer-processing',
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
},
{
name: 'email-sending',
counts: { waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 },
},
{
name: 'analytics-reporting',
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
},
{
name: 'file-cleanup',
counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 },
},
{
name: 'weekly-analytics-reporting',
counts: { waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 },
},
];
vi.mocked(monitoringService.getQueueStatuses).mockResolvedValue(mockStatuses);
// Act
const response = await supertest(app).get('/api/admin/queues/status');
@@ -246,7 +251,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
});
it('should return 500 if fetching queue counts fails', async () => {
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
new Error('Redis is down'),
);

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock dependencies required by admin.routes.ts
vi.mock('../services/db/index.db', () => ({
adminRepo: {},
flyerRepo: {},
recipeRepo: {},
userRepo: {},
personalizationRepo: {},
notificationRepo: {},
}));
vi.mock('../services/backgroundJobService', () => ({
backgroundJobService: {
runDailyDealCheck: vi.fn(),
triggerAnalyticsReport: vi.fn(),
triggerWeeklyAnalyticsReport: vi.fn(),
},
}));
vi.mock('../services/queueService.server', () => ({
flyerQueue: { add: vi.fn(), getJob: vi.fn() },
emailQueue: { add: vi.fn(), getJob: vi.fn() },
analyticsQueue: { add: vi.fn(), getJob: vi.fn() },
cleanupQueue: { add: vi.fn(), getJob: vi.fn() },
weeklyAnalyticsQueue: { add: vi.fn(), getJob: vi.fn() },
}));
vi.mock('../services/geocodingService.server', () => ({
geocodingService: { clearGeocodeCache: vi.fn() },
}));
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
vi.mock('@bull-board/api');
vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath() {}
getRouter() { return (req: any, res: any, next: any) => next(); }
},
}));
vi.mock('node:fs/promises');
// Mock Passport to allow admin access
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
req.user = createMockUserProfile({ role: 'admin' });
next();
}),
},
isAdmin: (req: any, res: any, next: any) => next(),
}));
import adminRouter from './admin.routes';
describe('Admin Routes Rate Limiting', () => {
const app = createTestApp({ router: adminRouter, basePath: '/api/admin' });
beforeEach(() => {
vi.clearAllMocks();
});
describe('Trigger Rate Limiting', () => {
it('should block requests to /trigger/daily-deal-check after exceeding limit', async () => {
const limit = 30; // Matches adminTriggerLimiter config
// Make requests up to the limit
for (let i = 0; i < limit; i++) {
await supertest(app)
.post('/api/admin/trigger/daily-deal-check')
.set('X-Test-Rate-Limit-Enable', 'true');
}
// The next request should be blocked
const response = await supertest(app)
.post('/api/admin/trigger/daily-deal-check')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(429);
expect(response.text).toContain('Too many administrative triggers');
});
});
describe('Upload Rate Limiting', () => {
it('should block requests to /brands/:id/logo after exceeding limit', async () => {
const limit = 20; // Matches adminUploadLimiter config
const brandId = 1;
// Make requests up to the limit
// Note: We don't need to attach a file to test the rate limiter, as it runs before multer
for (let i = 0; i < limit; i++) {
await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.set('X-Test-Rate-Limit-Enable', 'true');
}
const response = await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(429);
expect(response.text).toContain('Too many file uploads');
});
});
});

View File

@@ -35,6 +35,7 @@ import { monitoringService } from '../services/monitoringService.server';
import { userService } from '../services/userService';
import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -242,6 +243,7 @@ router.put(
router.post(
'/brands/:id/logo',
adminUploadLimiter,
validateRequest(numericIdParam('id')),
brandLogoUpload.single('logoImage'),
requireFileUpload('logoImage'),
@@ -421,6 +423,7 @@ router.delete(
*/
router.post(
'/trigger/daily-deal-check',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -449,6 +452,7 @@ router.post(
*/
router.post(
'/trigger/analytics-report',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -474,6 +478,7 @@ router.post(
*/
router.post(
'/flyers/:flyerId/cleanup',
adminTriggerLimiter,
validateRequest(numericIdParam('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -502,6 +507,7 @@ router.post(
*/
router.post(
'/trigger/failing-job',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -528,6 +534,7 @@ router.post(
*/
router.post(
'/system/clear-geocode-cache',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -580,6 +587,7 @@ router.get('/queues/status', validateRequest(emptySchema), async (req: Request,
*/
router.post(
'/jobs/:queueName/:jobId/retry',
adminTriggerLimiter,
validateRequest(jobRetrySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -606,6 +614,7 @@ router.post(
*/
router.post(
'/trigger/weekly-analytics',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.

View File

@@ -225,6 +225,7 @@ describe('AI Routes (/api/ai)', () => {
// Act
await supertest(authenticatedApp)
.post('/api/ai/upload-and-process')
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
.field('checksum', validChecksum)
.attach('flyerFile', imagePath);
@@ -260,6 +261,7 @@ describe('AI Routes (/api/ai)', () => {
// Act
await supertest(authenticatedApp)
.post('/api/ai/upload-and-process')
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
.field('checksum', validChecksum)
.attach('flyerFile', imagePath);
@@ -316,6 +318,76 @@ describe('AI Routes (/api/ai)', () => {
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
});
describe('POST /upload-legacy', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockUser = createMockUserProfile({
user: { user_id: 'legacy-user-1', email: 'legacy-user@test.com' },
});
// This route requires authentication, so we create an app instance with a user.
const authenticatedApp = createTestApp({
router: aiRouter,
basePath: '/api/ai',
authenticatedUser: mockUser,
});
it('should process a legacy flyer and return 200 on success', async () => {
// Arrange
const mockFlyer = createMockFlyer({ flyer_id: 10 });
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
// Act
const response = await supertest(authenticatedApp)
.post('/api/ai/upload-legacy')
.field('some_legacy_field', 'value') // simulate some body data
.attach('flyerFile', imagePath);
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyer);
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
expect.any(Object), // req.file
expect.any(Object), // req.body
mockUser,
expect.any(Object), // req.log
);
});
it('should return 400 if no flyer file is uploaded', async () => {
const response = await supertest(authenticatedApp)
.post('/api/ai/upload-legacy')
.field('some_legacy_field', 'value');
expect(response.status).toBe(400);
expect(response.body.message).toBe('No flyer file uploaded.');
});
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
const duplicateError = new aiService.DuplicateFlyerError('Duplicate legacy flyer.', 101);
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
expect(response.status).toBe(409);
expect(response.body.message).toBe('Duplicate legacy flyer.');
expect(response.body.flyerId).toBe(101);
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
it('should return 500 and cleanup file on a generic service error', async () => {
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new Error('Internal service failure'));
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Internal service failure');
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
});
describe('POST /flyers/process (Legacy)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockDataPayload = {

View File

@@ -14,6 +14,7 @@ import { validateRequest } from '../middleware/validation.middleware';
import { requiredString } from '../utils/zodUtils';
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server';
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
const router = Router();
@@ -27,6 +28,7 @@ const uploadAndProcessSchema = z.object({
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
baseUrl: z.string().url().optional(),
}),
});
@@ -165,6 +167,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
*/
router.post(
'/upload-and-process',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('flyerFile'),
// Validation is now handled inside the route to ensure file cleanup on failure.
@@ -183,13 +186,20 @@ router.post(
'Handling /upload-and-process',
);
const userProfile = req.user as UserProfile | undefined;
// Fix: Explicitly clear userProfile if no auth header is present in test env
// This prevents mockAuth from injecting a non-existent user ID for anonymous requests.
let userProfile = req.user as UserProfile | undefined;
if (process.env.NODE_ENV === 'test' && !req.headers['authorization']) {
userProfile = undefined;
}
const job = await aiService.enqueueFlyerProcessing(
req.file,
body.checksum,
userProfile,
req.ip ?? 'unknown',
req.log,
body.baseUrl,
);
// Respond immediately to the client with 202 Accepted
@@ -208,6 +218,35 @@ router.post(
},
);
/**
* POST /api/ai/upload-legacy - Process a flyer upload from a legacy client.
* This is an authenticated route that processes the flyer synchronously.
* This is used for integration testing the legacy upload flow.
*/
router.post(
'/upload-legacy',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('flyerFile'),
async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No flyer file uploaded.' });
}
const userProfile = req.user as UserProfile;
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
res.status(200).json(newFlyer);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
logger.warn(`Duplicate legacy flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
}
next(error);
}
},
);
/**
* NEW ENDPOINT: Checks the status of a background job.
*/
@@ -237,6 +276,7 @@ router.get(
*/
router.post(
'/flyers/process',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('flyerImage'),
async (req, res, next: NextFunction) => {
@@ -272,6 +312,7 @@ router.post(
*/
router.post(
'/check-flyer',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -291,6 +332,7 @@ router.post(
router.post(
'/extract-address',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -310,6 +352,7 @@ router.post(
router.post(
'/extract-logo',
aiUploadLimiter,
optionalAuth,
uploadToDisk.array('images'),
async (req, res, next: NextFunction) => {
@@ -329,6 +372,7 @@ router.post(
router.post(
'/quick-insights',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -345,6 +389,7 @@ router.post(
router.post(
'/deep-dive',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -361,6 +406,7 @@ router.post(
router.post(
'/search-web',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(searchWebSchema),
async (req, res, next: NextFunction) => {
@@ -375,6 +421,7 @@ router.post(
router.post(
'/compare-prices',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(comparePricesSchema),
async (req, res, next: NextFunction) => {
@@ -393,6 +440,7 @@ router.post(
router.post(
'/plan-trip',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(planTripSchema),
async (req, res, next: NextFunction) => {
@@ -412,6 +460,7 @@ router.post(
router.post(
'/generate-image',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateImageSchema),
(req: Request, res: Response) => {
@@ -424,6 +473,7 @@ router.post(
router.post(
'/generate-speech',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateSpeechSchema),
(req: Request, res: Response) => {
@@ -440,6 +490,7 @@ router.post(
*/
router.post(
'/rescan-area',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'),
validateRequest(rescanAreaSchema),

View File

@@ -197,6 +197,33 @@ describe('Auth Routes (/api/auth)', () => {
);
});
it('should allow registration with an empty string for full_name', async () => {
// Arrange
const email = 'empty-name@test.com';
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email } }),
accessToken: 'token',
refreshToken: 'token',
});
// Act
const response = await supertest(app).post('/api/auth/register').send({
email,
password: strongPassword,
full_name: '', // Send an empty string
});
// Assert
expect(response.status).toBe(201);
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
email,
strongPassword,
undefined, // The preprocess step in the Zod schema should convert '' to undefined
undefined,
mockLogger,
);
});
it('should set a refresh token cookie on successful registration', async () => {
const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
@@ -396,6 +423,24 @@ describe('Auth Routes (/api/auth)', () => {
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader[0]).toContain('Max-Age=2592000'); // 30 days in seconds
});
it('should return 400 for an invalid email format', async () => {
const response = await supertest(app)
.post('/api/auth/login')
.send({ email: 'not-an-email', password: 'password123' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
});
it('should return 400 if password is missing', async () => {
const response = await supertest(app)
.post('/api/auth/login')
.send({ email: 'test@test.com' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Password is required.');
});
});
describe('POST /forgot-password', () => {
@@ -550,12 +595,15 @@ describe('Auth Routes (/api/auth)', () => {
expect(setCookieHeader[0]).toContain('Max-Age=0');
});
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
it('should still return 200 OK and log an error if deleting the refresh token from DB fails', async () => {
// Arrange
const dbError = new Error('DB connection lost');
mockedAuthService.logout.mockRejectedValue(dbError);
const { logger } = await import('../services/logger.server');
// Spy on logger.error to ensure it's called
const errorSpy = vi.spyOn(logger, 'error');
// Act
const response = await supertest(app)
.post('/api/auth/logout')
@@ -563,7 +611,12 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(200);
expect(logger.error).toHaveBeenCalledWith(
// Because authService.logout is fire-and-forget (not awaited), we need to
// give the event loop a moment to process the rejected promise and trigger the .catch() block.
await new Promise((resolve) => setImmediate(resolve));
expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({ error: dbError }),
'Logout token invalidation failed in background.',
);
@@ -578,4 +631,280 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
});
});
describe('Rate Limiting on /forgot-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const email = 'rate-limit-test@example.com';
const maxRequests = 5; // from the rate limiter config
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
// Act: Make `maxRequests` successful calls with the special header
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter for this test
.send({ email });
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
}
// Act: Make one more call, which should be blocked
const blockedResponse = await supertest(app)
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ email });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many password reset requests');
});
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
// Arrange
const email = 'no-rate-limit-test@example.com';
const overLimitRequests = 7; // More than the max of 5
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
// Act: Make more calls than the limit. They should all succeed because the limiter is skipped.
for (let i = 0; i < overLimitRequests; i++) {
const response = await supertest(app)
.post('/api/auth/forgot-password')
// NO 'X-Test-Rate-Limit-Enable' header is sent
.send({ email });
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
}
});
});
describe('Rate Limiting on /reset-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 10; // from the rate limiter config in auth.routes.ts
const newPassword = 'a-Very-Strong-Password-123!';
const token = 'some-token-for-rate-limit-test';
// Mock the service to return a consistent value for the first `maxRequests` calls.
// The endpoint returns 400 for invalid tokens, which is fine for this test.
// We just need to ensure it's not a 429.
mockedAuthService.updatePassword.mockResolvedValue(null);
// Act: Make `maxRequests` calls. They should not be rate-limited.
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/reset-password')
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter
.send({ token, newPassword });
// The expected status is 400 because the token is invalid, but not 429.
expect(response.status, `Request ${i + 1} should not be rate-limited`).toBe(400);
}
// Act: Make one more call, which should be blocked by the rate limiter.
const blockedResponse = await supertest(app)
.post('/api/auth/reset-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ token, newPassword });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many password reset attempts');
});
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
// Arrange
const maxRequests = 12; // Limit is 10
const newPassword = 'a-Very-Strong-Password-123!';
const token = 'some-token-for-skip-limit-test';
mockedAuthService.updatePassword.mockResolvedValue(null);
// Act: Make more calls than the limit.
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/reset-password')
.send({ token, newPassword });
expect(response.status).toBe(400);
}
});
});
describe('Rate Limiting on /register', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 5; // Limit is 5 per hour
const newUser = {
email: 'rate-limit-reg@test.com',
password: 'StrongPassword123!',
full_name: 'Rate Limit User',
};
// Mock success to ensure we are hitting the limiter and not failing early
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
accessToken: 'token',
refreshToken: 'refresh',
});
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/register')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(newUser);
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(app)
.post('/api/auth/register')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(newUser);
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many accounts created');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 7;
const newUser = {
email: 'no-limit-reg@test.com',
password: 'StrongPassword123!',
full_name: 'No Limit User',
};
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
accessToken: 'token',
refreshToken: 'refresh',
});
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app).post('/api/auth/register').send(newUser);
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /login', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 5; // Limit is 5 per 15 mins
const credentials = { email: 'rate-limit-login@test.com', password: 'password123' };
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'token',
refreshToken: 'refresh',
});
// Act
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/login')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(credentials);
expect(response.status).not.toBe(429);
}
const blockedResponse = await supertest(app)
.post('/api/auth/login')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(credentials);
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many login attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 7;
const credentials = { email: 'no-limit-login@test.com', password: 'password123' };
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'token',
refreshToken: 'refresh',
});
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app).post('/api/auth/login').send(credentials);
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /refresh-token', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 20; // Limit is 20 per 15 mins
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many token refresh attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 22;
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token');
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /logout', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 10; // Limit is 10 per 15 mins
mockedAuthService.logout.mockResolvedValue(undefined);
// Act
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).not.toBe(429);
}
const blockedResponse = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many logout attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 12;
mockedAuthService.logout.mockResolvedValue(undefined);
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token');
expect(response.status).not.toBe(429);
}
});
});
});

View File

@@ -1,7 +1,6 @@
// src/routes/auth.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes';
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
import { logger } from '../services/logger.server';
@@ -9,48 +8,36 @@ import { validateRequest } from '../middleware/validation.middleware';
import type { UserProfile } from '../types';
import { validatePasswordStrength } from '../utils/authUtils';
import { requiredString } from '../utils/zodUtils';
import {
loginLimiter,
registerLimiter,
forgotPasswordLimiter,
resetPasswordLimiter,
refreshTokenLimiter,
logoutLimiter,
} from '../config/rateLimiters';
import { authService } from '../services/authService';
const router = Router();
// Conditionally disable rate limiting for the test environment
const isTestEnv = process.env.NODE_ENV === 'test';
// --- Reusable Schemas ---
// --- Rate Limiting Configuration ---
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
// 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({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
const passwordSchema = z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
});
const registerSchema = z.object({
body: z.object({
// Sanitize email by trimming and converting to lowercase.
email: z.string().trim().toLowerCase().email('A valid email is required.'),
password: z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
password: passwordSchema,
// Sanitize optional string inputs.
full_name: z.string().trim().optional(),
full_name: z.preprocess((val) => (val === '' ? undefined : val), 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),
@@ -59,6 +46,14 @@ const registerSchema = z.object({
}),
});
const loginSchema = z.object({
body: z.object({
email: z.string().trim().toLowerCase().email('A valid email is required.'),
password: requiredString('Password is required.'),
rememberMe: z.boolean().optional(),
}),
});
const forgotPasswordSchema = z.object({
body: z.object({
// Sanitize email by trimming and converting to lowercase.
@@ -69,14 +64,7 @@ const forgotPasswordSchema = z.object({
const resetPasswordSchema = z.object({
body: z.object({
token: requiredString('Token is required.'),
newPassword: z
.string()
.trim() // Prevent leading/trailing whitespace in passwords.
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
newPassword: passwordSchema,
}),
});
@@ -85,6 +73,7 @@ const resetPasswordSchema = z.object({
// Registration Route
router.post(
'/register',
registerLimiter,
validateRequest(registerSchema),
async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>;
@@ -122,52 +111,57 @@ router.post(
);
// Login Route
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
passport.authenticate(
'local',
{ session: false },
async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
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.');
router.post(
'/login',
loginLimiter,
validateRequest(loginSchema),
(req: Request, res: Response, next: NextFunction) => {
passport.authenticate(
'local',
{ session: false },
async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
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 (err) {
req.log.error(
{ error: err },
`Login authentication error in /login route for email: ${req.body.email}`,
);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
if (err) {
req.log.error(
{ error: err },
`Login authentication error in /login route for email: ${req.body.email}`,
);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
try {
const { rememberMe } = req.body;
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}`);
try {
const { rememberMe } = req.body;
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}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
};
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
};
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
return next(tokenErr);
}
},
)(req, res, next);
});
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
return next(tokenErr);
}
},
)(req, res, next);
},
);
// Route to request a password reset
router.post(
@@ -224,7 +218,7 @@ router.post(
);
// New Route to refresh the access token
router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => {
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
@@ -247,7 +241,7 @@ router.post('/refresh-token', async (req: Request, res: Response, next: NextFunc
* It clears the refresh token from the database and instructs the client to
* expire the `refreshToken` cookie.
*/
router.post('/logout', async (req: Request, res: Response) => {
router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.

View File

@@ -6,6 +6,7 @@ import { budgetRepo } from '../services/db/index.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import { budgetUpdateLimiter } from '../config/rateLimiters';
const router = express.Router();
@@ -37,6 +38,9 @@ const spendingAnalysisSchema = z.object({
// Middleware to ensure user is authenticated for all budget routes
router.use(passport.authenticate('jwt', { session: false }));
// Apply rate limiting to all subsequent budget routes
router.use(budgetUpdateLimiter);
/**
* GET /api/budgets - Get all budgets for the authenticated user.
*/

View File

@@ -103,4 +103,18 @@ describe('Deals Routes (/api/users/deals)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply userReadLimiter to GET /best-watched-prices', async () => {
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue([]);
const response = await supertest(authenticatedApp)
.get('/api/users/deals/best-watched-prices')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -5,6 +5,7 @@ import passport from './passport.routes';
import { dealsRepo } from '../services/db/deals.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { userReadLimiter } from '../config/rateLimiters';
const router = express.Router();
@@ -27,6 +28,7 @@ router.use(passport.authenticate('jwt', { session: false }));
*/
router.get(
'/best-watched-prices',
userReadLimiter,
validateRequest(bestWatchedPricesSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;

View File

@@ -13,7 +13,7 @@ vi.mock('../services/db/index.db', () => ({
getFlyerItems: vi.fn(),
getFlyerItemsForFlyers: vi.fn(),
countFlyerItemsForFlyers: vi.fn(),
trackFlyerItemInteraction: vi.fn(),
trackFlyerItemInteraction: vi.fn().mockResolvedValue(undefined),
},
}));
@@ -50,6 +50,8 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyers);
// Also assert that the default limit and offset were used.
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
});
it('should pass limit and offset query parameters to the db function', async () => {
@@ -58,6 +60,18 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
});
it('should use default for offset when only limit is provided', async () => {
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
await supertest(app).get('/api/flyers?limit=5');
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 5, 0);
});
it('should use default for limit when only offset is provided', async () => {
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
await supertest(app).get('/api/flyers?offset=10');
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 10);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
@@ -151,7 +165,7 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
{ error: dbError, flyerId: 123 },
'Error fetching flyer items in /api/flyers/:id/items:',
);
});
@@ -276,5 +290,75 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ type: 'invalid' });
expect(response.status).toBe(400);
});
it('should return 202 and log an error if the tracking function fails', async () => {
const trackingError = new Error('Tracking DB is down');
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockRejectedValue(trackingError);
const response = await supertest(app)
.post('/api/flyers/items/99/track')
.send({ type: 'click' });
expect(response.status).toBe(202);
// Allow the event loop to process the unhandled promise rejection from the fire-and-forget call
await new Promise((resolve) => setImmediate(resolve));
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: trackingError, itemId: 99 },
'Flyer item interaction tracking failed',
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/flyers')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue([]);
const response = await supertest(app)
.post('/api/flyers/items/batch-fetch')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
it('should apply batchLimiter to POST /items/batch-count', async () => {
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(0);
const response = await supertest(app)
.post('/api/flyers/items/batch-count')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
// Mock fire-and-forget promise
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/flyers/items/1/track')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(200);
});
});
});

View File

@@ -4,6 +4,11 @@ import * as db from '../services/db/index.db';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
batchLimiter,
trackingLimiter,
} from '../config/rateLimiters';
const router = Router();
@@ -48,12 +53,12 @@ const trackItemSchema = z.object({
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
type GetFlyersRequest = z.infer<typeof getFlyersSchema>;
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
const { query } = req as unknown as GetFlyersRequest;
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
try {
const limit = query.limit ? Number(query.limit) : 20;
const offset = query.offset ? Number(query.offset) : 0;
// The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
res.json(flyers);
} catch (error) {
@@ -65,14 +70,14 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promis
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
try {
const flyer = await db.flyerRepo.getFlyerById(params.id);
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const flyer = await db.flyerRepo.getFlyerById(id);
res.json(flyer);
} catch (error) {
req.log.error({ error, flyerId: params.id }, 'Error fetching flyer by ID:');
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
next(error);
}
});
@@ -82,14 +87,17 @@ router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next):
*/
router.get(
'/:id/items',
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
try {
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const items = await db.flyerRepo.getFlyerItems(id, req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
next(error);
}
},
@@ -101,10 +109,13 @@ router.get(
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
router.post(
'/items/batch-fetch',
batchLimiter,
validateRequest(batchFetchSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchFetchRequest;
try {
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
// and `express.json()` has parsed it. There's no type coercion to apply.
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
} catch (error) {
@@ -120,12 +131,14 @@ router.post(
type BatchCountRequest = z.infer<typeof batchCountSchema>;
router.post(
'/items/batch-count',
batchLimiter,
validateRequest(batchCountSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchCountRequest;
try {
// The DB function handles an empty array, so we can simplify.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
// The schema ensures flyerIds is an array of numbers.
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
res.json({ count });
} catch (error) {
req.log.error({ error }, 'Error counting batch flyer items');
@@ -137,11 +150,22 @@ router.post(
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
type TrackItemRequest = z.infer<typeof trackItemSchema>;
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res): void => {
const { params, body } = req as unknown as TrackItemRequest;
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log);
res.status(202).send();
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
try {
// Explicitly parse to get coerced types.
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
// We add a .catch to log any potential errors without crashing the server process.
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
});
res.status(202).send();
} catch (error) {
// This will catch Zod parsing errors if they occur.
next(error);
}
});
export default router;

View File

@@ -336,4 +336,50 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue([]);
const response = await supertest(unauthenticatedApp)
.get('/api/achievements')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply userReadLimiter to GET /me', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue([]);
const response = await supertest(authenticatedApp)
.get('/api/achievements/me')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply adminTriggerLimiter to POST /award', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
const response = await supertest(adminApp)
.post('/api/achievements/award')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ userId: 'some-user', achievementName: 'some-achievement' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(30);
});
});
});

View File

@@ -7,6 +7,11 @@ import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
userReadLimiter,
adminTriggerLimiter,
} from '../config/rateLimiters';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -34,7 +39,7 @@ const awardAchievementSchema = z.object({
* GET /api/achievements - Get the master list of all available achievements.
* This is a public endpoint.
*/
router.get('/', async (req, res, next: NextFunction) => {
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
try {
const achievements = await gamificationService.getAllAchievements(req.log);
res.json(achievements);
@@ -50,6 +55,7 @@ router.get('/', async (req, res, next: NextFunction) => {
*/
router.get(
'/leaderboard',
publicReadLimiter,
validateRequest(leaderboardSchema),
async (req, res, next: NextFunction): Promise<void> => {
try {
@@ -74,6 +80,7 @@ router.get(
router.get(
'/me',
passport.authenticate('jwt', { session: false }),
userReadLimiter,
async (req, res, next: NextFunction): Promise<void> => {
const userProfile = req.user as UserProfile;
try {
@@ -103,6 +110,7 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
*/
adminGamificationRouter.post(
'/award',
adminTriggerLimiter,
validateRequest(awardAchievementSchema),
async (req, res, next: NextFunction): Promise<void> => {
// Infer type and cast request object as per ADR-003

View File

@@ -102,6 +102,7 @@ vi.mock('passport', () => {
// Now, import the passport configuration which will use our mocks
import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes';
import { logger } from '../services/logger.server';
import { ForbiddenError } from '../services/db/errors.db';
describe('Passport Configuration', () => {
beforeEach(() => {
@@ -414,6 +415,29 @@ describe('Passport Configuration', () => {
// Assert
expect(done).toHaveBeenCalledWith(dbError, false);
});
it('should call done(err, false) if jwt_payload is null', async () => {
// Arrange
const jwtPayload = null;
const done = vi.fn();
// Act
// We know the mock setup populates the callback.
if (verifyCallbackWrapper.callback) {
// The strategy would not even call the callback if the token is invalid/missing.
// However, to test the robustness of our callback, we can invoke it directly with null.
await verifyCallbackWrapper.callback(jwtPayload as any, done);
}
// Assert
// The code will throw a TypeError because it tries to access 'user_id' of null.
// The catch block in the strategy will catch this and call done(err, false).
expect(done).toHaveBeenCalledWith(expect.any(TypeError), false);
expect(logger.error).toHaveBeenCalledWith(
{ error: expect.any(TypeError) },
'Error during JWT authentication strategy:',
);
});
});
describe('isAdmin Middleware', () => {
@@ -445,7 +469,7 @@ describe('Passport Configuration', () => {
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden if user does not have "admin" role', () => {
it('should call next with a ForbiddenError if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: createMockUserProfile({
@@ -458,14 +482,11 @@ describe('Passport Configuration', () => {
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden if req.user is missing', () => {
it('should call next with a ForbiddenError if req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
@@ -473,28 +494,100 @@ describe('Passport Configuration', () => {
isAdmin(mockReq, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 403 Forbidden if req.user is not a valid UserProfile object', () => {
it('should log a warning when a non-admin user tries to access an admin route', () => {
// Arrange
const mockReq: Partial<Request> = {
// An object that is not a valid UserProfile (e.g., missing 'role')
user: {
user_id: 'invalid-user-id',
} as any,
user: createMockUserProfile({
role: 'user',
user: { user_id: 'user-id-123', email: 'user@test.com' },
}),
};
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Forbidden: Administrator access required.',
});
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: user-id-123');
});
it('should log a warning with "unknown" user when req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
// Act
isAdmin(mockReq, mockRes as Response, mockNext);
// Assert
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: unknown');
});
it('should call next with a ForbiddenError for various invalid user object shapes', () => {
const mockNext = vi.fn();
const mockRes: Partial<Response> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
// Case 1: user is not an object (e.g., a string)
const req1 = { user: 'not-an-object' } as unknown as Request;
isAdmin(req1, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 2: user is null
const req2 = { user: null } as unknown as Request;
isAdmin(req2, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 3: user object is missing 'user' property
const req3 = { user: { role: 'admin' } } as unknown as Request;
isAdmin(req3, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 4: user.user is not an object
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
isAdmin(req4, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 5: user.user is missing 'user_id'
const req5 = {
user: { role: 'admin', user: { email: 'test@test.com' } },
} as unknown as Request;
isAdmin(req5, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Reset the main mockNext for other tests in the suite
mockNext.mockClear();
});
it('should call next with a ForbiddenError if req.user is not a valid UserProfile object', () => {
// Arrange
const mockReq: Partial<Request> = {
// An object that is not a valid UserProfile (e.g., missing 'role')
user: {
user: { user_id: 'invalid-user-id' }, // Missing 'role' property
} as unknown as UserProfile, // Cast to UserProfile to satisfy req.user type, but it's intentionally malformed
};
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
});
});
@@ -611,28 +704,31 @@ describe('Passport Configuration', () => {
optionalAuth(mockReq, mockRes as Response, mockNext);
// Assert
// The new implementation logs a warning and proceeds.
expect(logger.warn).toHaveBeenCalledWith(
{ error: authError },
'Optional auth encountered an error, proceeding anonymously.',
);
expect(mockReq.user).toBeUndefined();
expect(mockNext).toHaveBeenCalledTimes(1);
});
});
describe('mockAuth Middleware', () => {
const mockNext: NextFunction = vi.fn();
let mockRes: Partial<Response>;
let originalNodeEnv: string | undefined;
const mockNext: NextFunction = vi.fn(); // This was a duplicate, fixed.
const mockRes: Partial<Response> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
beforeEach(() => {
mockRes = { status: vi.fn().mockReturnThis(), json: vi.fn() };
originalNodeEnv = process.env.NODE_ENV;
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
// Unstub env variables before each test in this block to ensure a clean state.
vi.unstubAllEnvs();
});
it('should attach a mock admin user to req when NODE_ENV is "test"', () => {
// Arrange
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
const mockReq = {} as Request;
// Act
@@ -646,7 +742,7 @@ describe('Passport Configuration', () => {
it('should do nothing and call next() when NODE_ENV is not "test"', () => {
// Arrange
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
const mockReq = {} as Request;
// Act

View File

@@ -11,6 +11,7 @@ import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { ForbiddenError } from '../services/db/errors.db';
const JWT_SECRET = process.env.JWT_SECRET!;
@@ -307,7 +308,7 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
logger.warn(`Admin access denied for user: ${userIdForLog}`);
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
next(new ForbiddenError('Forbidden: Administrator access required.'));
}
};
@@ -323,12 +324,17 @@ export const optionalAuth = (req: Request, res: Response, next: NextFunction) =>
'jwt',
{ session: false },
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
if (err) {
// An actual error occurred during authentication (e.g., malformed token).
// For optional auth, we log this but still proceed without a user.
logger.warn({ error: err }, 'Optional auth encountered an error, proceeding anonymously.');
return next();
}
if (info) {
// The patch requested this specific error handling.
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
} // The patch requested this specific error handling.
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
}
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds.
next(); // Always proceed to the next middleware
},

View File

@@ -40,7 +40,7 @@ describe('Personalization Routes (/api/personalization)', () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/personalization/master-items');
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
@@ -49,7 +49,7 @@ describe('Personalization Routes (/api/personalization)', () => {
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/personalization/master-items');
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
@@ -106,4 +106,16 @@ describe('Personalization Routes (/api/personalization)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /master-items', async () => {
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
});
});
});

View File

@@ -3,6 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
const router = Router();
@@ -16,6 +17,7 @@ const emptySchema = z.object({});
*/
router.get(
'/master-items',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -39,6 +41,7 @@ router.get(
*/
router.get(
'/dietary-restrictions',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -59,6 +62,7 @@ router.get(
*/
router.get(
'/appliances',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@@ -1,8 +1,10 @@
// src/routes/price.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock the price repository
vi.mock('../services/db/price.db', () => ({
@@ -17,12 +19,29 @@ vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
},
),
},
}));
// Import the router AFTER other setup.
import priceRouter from './price.routes';
import { priceRepo } from '../services/db/price.db';
describe('Price Routes (/api/price-history)', () => {
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' });
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history', authenticatedUser: mockUser });
beforeEach(() => {
vi.clearAllMocks();
});
@@ -130,4 +149,18 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
});
});
describe('Rate Limiting', () => {
it('should apply priceHistoryLimiter to POST /', async () => {
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
const response = await supertest(app)
.post('/api/price-history')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ masterItemIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
});
});

View File

@@ -1,9 +1,11 @@
// src/routes/price.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware';
import { priceRepo } from '../services/db/price.db';
import { optionalNumeric } from '../utils/zodUtils';
import { priceHistoryLimiter } from '../config/rateLimiters';
const router = Router();
@@ -26,21 +28,27 @@ type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
* This endpoint retrieves price points over time for specified master grocery items.
*/
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => {
// Cast 'req' to the inferred type for full type safety.
const {
body: { masterItemIds, limit, offset },
} = req as unknown as PriceHistoryRequest;
req.log.info(
{ itemCount: masterItemIds.length, limit, offset },
'[API /price-history] Received request for historical price data.',
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
} catch (error) {
next(error);
}
});
router.post(
'/',
passport.authenticate('jwt', { session: false }),
priceHistoryLimiter,
validateRequest(priceHistorySchema),
async (req: Request, res: Response, next: NextFunction) => {
// Cast 'req' to the inferred type for full type safety.
const {
body: { masterItemIds, limit, offset },
} = req as unknown as PriceHistoryRequest;
req.log.info(
{ itemCount: masterItemIds.length, limit, offset },
'[API /price-history] Received request for historical price data.',
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
} catch (error) {
next(error);
}
},
);
export default router;

View File

@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/index.db', () => ({
reactionRepo: {
getReactions: vi.fn(),
getReactionSummary: vi.fn(),
toggleReaction: vi.fn(),
},
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock Passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
() => (req: any, res: any, next: any) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
import reactionsRouter from './reactions.routes';
import { reactionRepo } from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
describe('Reaction Routes (/api/reactions)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return a list of reactions', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like', entity_id: '123' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockReactions);
expect(reactionRepo.getReactions).toHaveBeenCalledWith({}, expectLogger);
});
it('should filter by query parameters', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
const response = await supertest(app).get('/api/reactions').query(query);
expect(response.status).toBe(200);
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
expect.objectContaining(query),
expectLogger
);
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.getReactions).mockRejectedValue(error);
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching user reactions'
);
});
});
describe('GET /summary', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return reaction summary for an entity', async () => {
const mockSummary = { like: 10, love: 5 };
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary as any);
const response = await supertest(app)
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSummary);
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith(
'recipe',
'123',
expectLogger
);
});
it('should return 400 if required parameters are missing', async () => {
const response = await supertest(app).get('/api/reactions/summary');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('required');
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.getReactionSummary).mockRejectedValue(error);
const response = await supertest(app)
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching reaction summary'
);
});
});
describe('POST /toggle', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const app = createTestApp({
router: reactionsRouter,
basePath: '/api/reactions',
authenticatedUser: mockUser,
});
const validBody = {
entity_type: 'recipe',
entity_id: '123',
reaction_type: 'like',
};
it('should return 201 when a reaction is added', async () => {
const mockResult = { ...validBody, id: 1, user_id: 'user-123' };
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult as any);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(201);
expect(response.body).toEqual({ message: 'Reaction added.', reaction: mockResult });
expect(reactionRepo.toggleReaction).toHaveBeenCalledWith(
{ user_id: 'user-123', ...validBody },
expectLogger
);
});
it('should return 200 when a reaction is removed', async () => {
// Returning null/false from toggleReaction implies the reaction was removed
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Reaction removed.' });
});
it('should return 400 if body is invalid', async () => {
const response = await supertest(app)
.post('/api/reactions/toggle')
.send({ entity_type: 'recipe' }); // Missing other required fields
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('should return 401 if not authenticated', async () => {
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
const response = await supertest(unauthApp)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(401);
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.toggleReaction).mockRejectedValue(error);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error, body: validBody },
'Error toggling user reaction'
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
vi.mocked(reactionRepo.getReactions).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/reactions')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
});
it('should apply userUpdateLimiter to POST /toggle', async () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const app = createTestApp({
router: reactionsRouter,
basePath: '/api/reactions',
authenticatedUser: mockUser,
});
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
const response = await supertest(app)
.post('/api/reactions/toggle')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ entity_type: 'recipe', entity_id: '1', reaction_type: 'like' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
});
});
});

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