Compare commits

..

117 Commits

Author SHA1 Message Date
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
Gitea Actions
ee253e9449 ci: Bump version to 0.7.12 [skip ci] 2026-01-01 15:48:03 +05:00
b6c15e53d0 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m24s
2026-01-01 02:47:31 -08:00
Gitea Actions
722162c2c3 ci: Bump version to 0.7.11 [skip ci] 2026-01-01 15:35:25 +05:00
02a76fe996 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m20s
2026-01-01 02:35:00 -08:00
Gitea Actions
0ebb03a7ab ci: Bump version to 0.7.10 [skip ci] 2026-01-01 15:30:43 +05:00
748ac9e049 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 51s
2026-01-01 02:30:06 -08:00
Gitea Actions
495edd621c ci: Bump version to 0.7.9 [skip ci] 2026-01-01 14:59:38 +05:00
4ffca19db6 more db
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m28s
2026-01-01 01:58:18 -08:00
Gitea Actions
717427c5d7 ci: Bump version to 0.7.8 [skip ci] 2026-01-01 10:08:06 +05:00
cc438a0e36 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 38s
2025-12-31 21:07:40 -08:00
Gitea Actions
a32a0b62fc ci: Bump version to 0.7.7 [skip ci] 2026-01-01 09:44:49 +05:00
342f72b713 more db
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 45s
2025-12-31 20:44:00 -08:00
195 changed files with 10275 additions and 3753 deletions

View File

@@ -335,7 +335,8 @@ jobs:
fi fi
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s) # Sanitize commit message to prevent shell injection or build breaks (removes quotes, backticks, backslashes, $)
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s | tr -d '"`\\$')
PACKAGE_VERSION=$(node -p "require('./package.json').version") PACKAGE_VERSION=$(node -p "require('./package.json').version")
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \ VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \ VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \

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

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 ! UPC SCANNING !

4
package-lock.json generated
View File

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

View File

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

File diff suppressed because it is too large Load Diff

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), ('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5), ('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10), ('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15) ('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15),
('First-Upload', 'Upload your first flyer.', 'upload-cloud', 25)
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
// src/components/ConfirmationModal.test.tsx // src/components/ConfirmationModal.test.tsx
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConfirmationModal } from './ConfirmationModal'; import { ConfirmationModal } from './ConfirmationModal';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
describe('ConfirmationModal (in components)', () => { describe('ConfirmationModal (in components)', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
@@ -21,12 +22,12 @@ describe('ConfirmationModal (in components)', () => {
}); });
it('should not render when isOpen is false', () => { it('should not render when isOpen is false', () => {
const { container } = render(<ConfirmationModal {...defaultProps} isOpen={false} />); const { container } = renderWithProviders(<ConfirmationModal {...defaultProps} isOpen={false} />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it('should render correctly when isOpen is true', () => { it('should render correctly when isOpen is true', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument(); expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
@@ -34,38 +35,38 @@ describe('ConfirmationModal (in components)', () => {
}); });
it('should call onConfirm when the confirm button is clicked', () => { it('should call onConfirm when the confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1); expect(mockOnConfirm).toHaveBeenCalledTimes(1);
}); });
it('should call onClose when the cancel button is clicked', () => { it('should call onClose when the cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
}); });
it('should call onClose when the close icon is clicked', () => { it('should call onClose when the close icon is clicked', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByLabelText('Close confirmation modal')); fireEvent.click(screen.getByLabelText('Close confirmation modal'));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
}); });
it('should call onClose when the overlay is clicked', () => { it('should call onClose when the overlay is clicked', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
// The overlay is the parent of the modal content div // The overlay is the parent of the modal content div
fireEvent.click(screen.getByRole('dialog')); fireEvent.click(screen.getByRole('dialog'));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
}); });
it('should not call onClose when clicking inside the modal content', () => { it('should not call onClose when clicking inside the modal content', () => {
render(<ConfirmationModal {...defaultProps} />); renderWithProviders(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('Are you sure you want to do this?')); fireEvent.click(screen.getByText('Are you sure you want to do this?'));
expect(mockOnClose).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled();
}); });
it('should render custom button text and classes', () => { it('should render custom button text and classes', () => {
render( renderWithProviders(
<ConfirmationModal <ConfirmationModal
{...defaultProps} {...defaultProps}
confirmButtonText="Yes, Delete" confirmButtonText="Yes, Delete"

View File

@@ -1,8 +1,9 @@
// src/components/DarkModeToggle.test.tsx // src/components/DarkModeToggle.test.tsx
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DarkModeToggle } from './DarkModeToggle'; import { DarkModeToggle } from './DarkModeToggle';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the icon components to isolate the toggle's logic // Mock the icon components to isolate the toggle's logic
vi.mock('./icons/SunIcon', () => ({ vi.mock('./icons/SunIcon', () => ({
@@ -20,7 +21,7 @@ describe('DarkModeToggle', () => {
}); });
it('should render in light mode state', () => { it('should render in light mode state', () => {
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />); renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox'); const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
@@ -29,7 +30,7 @@ describe('DarkModeToggle', () => {
}); });
it('should render in dark mode state', () => { it('should render in dark mode state', () => {
render(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />); renderWithProviders(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
const checkbox = screen.getByRole('checkbox'); const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
@@ -38,7 +39,7 @@ describe('DarkModeToggle', () => {
}); });
it('should call onToggle when the label is clicked', () => { it('should call onToggle when the label is clicked', () => {
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />); renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
// Clicking the label triggers the checkbox change // Clicking the label triggers the checkbox change
const label = screen.getByTitle('Switch to Dark Mode'); const label = screen.getByTitle('Switch to Dark Mode');

View File

@@ -0,0 +1,67 @@
// src/components/Dashboard.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import { Dashboard } from './Dashboard';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock child components to isolate Dashboard logic
// Note: The Dashboard component imports these using '../components/RecipeSuggester'
// which resolves to the same file as './RecipeSuggester' when inside src/components.
vi.mock('./RecipeSuggester', () => ({
RecipeSuggester: () => <div data-testid="recipe-suggester-mock">Recipe Suggester</div>,
}));
vi.mock('./FlyerCountDisplay', () => ({
FlyerCountDisplay: () => <div data-testid="flyer-count-display-mock">Flyer Count Display</div>,
}));
vi.mock('./Leaderboard', () => ({
Leaderboard: () => <div data-testid="leaderboard-mock">Leaderboard</div>,
}));
describe('Dashboard Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the dashboard title', () => {
console.log('TEST: Verifying dashboard title render');
renderWithProviders(<Dashboard />);
expect(screen.getByRole('heading', { name: /dashboard/i, level: 1 })).toBeInTheDocument();
});
it('renders the RecipeSuggester widget', () => {
console.log('TEST: Verifying RecipeSuggester presence');
renderWithProviders(<Dashboard />);
expect(screen.getByTestId('recipe-suggester-mock')).toBeInTheDocument();
});
it('renders the FlyerCountDisplay widget within the "Your Flyers" section', () => {
console.log('TEST: Verifying FlyerCountDisplay presence and section title');
renderWithProviders(<Dashboard />);
// Check for the section heading
expect(screen.getByRole('heading', { name: /your flyers/i, level: 2 })).toBeInTheDocument();
// Check for the component
expect(screen.getByTestId('flyer-count-display-mock')).toBeInTheDocument();
});
it('renders the Leaderboard widget in the sidebar area', () => {
console.log('TEST: Verifying Leaderboard presence');
renderWithProviders(<Dashboard />);
expect(screen.getByTestId('leaderboard-mock')).toBeInTheDocument();
});
it('renders with the correct grid layout classes', () => {
console.log('TEST: Verifying layout classes');
const { container } = renderWithProviders(<Dashboard />);
// The main grid container
const gridContainer = container.querySelector('.grid');
expect(gridContainer).toBeInTheDocument();
expect(gridContainer).toHaveClass('grid-cols-1');
expect(gridContainer).toHaveClass('lg:grid-cols-3');
expect(gridContainer).toHaveClass('gap-6');
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { RecipeSuggester } from '../components/RecipeSuggester';
import { FlyerCountDisplay } from '../components/FlyerCountDisplay';
import { Leaderboard } from '../components/Leaderboard';
export const Dashboard: React.FC = () => {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Dashboard</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content Area */}
<div className="lg:col-span-2 space-y-6">
{/* Recipe Suggester Section */}
<RecipeSuggester />
{/* Other Dashboard Widgets */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Your Flyers</h2>
<FlyerCountDisplay />
</div>
</div>
{/* Sidebar Area */}
<div className="space-y-6">
<Leaderboard />
</div>
</div>
</div>
);
};
export default Dashboard;

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