Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c42621f74 | ||
| 1b98282202 | |||
|
|
b6731b220c | ||
| 3507d455e8 | |||
|
|
92b2adf8e8 | ||
| d6c7452256 | |||
|
|
d812b681dd | ||
| b4306a6092 | |||
|
|
57fdd159d5 | ||
| 4a747ca042 | |||
|
|
e0bf96824c | ||
| e86e09703e | |||
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a | |||
|
|
e4acab77c8 | ||
| 4e20b1b430 | |||
|
|
15747ac942 | ||
| e5fa89ef17 | |||
|
|
2c65da31e9 | ||
| eeec6af905 | |||
|
|
e7d03951b9 | ||
| af8816e0af | |||
|
|
64f6427e1a | ||
| c9b7a75429 | |||
|
|
0490f6922e | ||
| 057c4c9174 | |||
|
|
a9e56bc707 | ||
| e5d09c73b7 | |||
|
|
6e1298b825 | ||
| fc8e43437a | |||
|
|
cb453aa949 | ||
| 2651bd16ae | |||
|
|
91e0f0c46f | ||
| e6986d512b | |||
|
|
8f9c21675c | ||
| 7fb22cdd20 | |||
|
|
780291303d | ||
| 4f607f7d2f | |||
|
|
208227b3ed | ||
| bf1c7d4adf | |||
|
|
a7a30cf983 | ||
| 0bc0676b33 | |||
|
|
73484d3eb4 | ||
| b3253d5bbc | |||
|
|
54f3769e90 | ||
| bad6f74ee6 | |||
|
|
bcf16168b6 | ||
| 498fbd9e0e | |||
|
|
007ff8e538 | ||
| 1fc70e3915 | |||
|
|
d891e47e02 | ||
| 08c39afde4 | |||
|
|
c579543b8a | ||
| 0d84137786 | |||
|
|
20ee30c4b4 | ||
| 93612137e3 | |||
|
|
6e70f08e3c | ||
| 459f5f7976 | |||
|
|
a2e6331ddd | ||
| 13cd30bec9 | |||
|
|
baeb9488c6 | ||
| 0cba0f987e | |||
|
|
958a79997d | ||
| 8fb1c96f93 | |||
| 6e6fe80c7f | |||
|
|
d1554050bd | ||
|
|
b1fae270bb | ||
|
|
c852483e18 | ||
| 2e01ad5bc9 | |||
|
|
26763c7183 | ||
| f0c5c2c45b | |||
|
|
034bb60fd5 | ||
| d4b389cb79 | |||
|
|
a71fb81468 | ||
| 9bee0a013b | |||
|
|
8bcb4311b3 | ||
| 9fd15f3a50 | |||
|
|
e3c876c7be | ||
| 32dcf3b89e | |||
| 7066b937f6 | |||
|
|
8553ea8811 | ||
| 19885a50f7 | |||
|
|
ce82034b9d | ||
| 4528da2934 | |||
|
|
146d4c1351 | ||
| 88625706f4 | |||
|
|
e395faed30 | ||
| e8f8399896 | |||
|
|
ac0115af2b | ||
| f24b15f19b | |||
|
|
e64426bd84 | ||
| 0ec4cd68d2 | |||
|
|
840516d2a3 | ||
| 59355c3eef | |||
| d024935fe9 | |||
|
|
5a5470634e | ||
| 392231ad63 | |||
|
|
4b1c896621 | ||
| 720920a51c | |||
|
|
460adb9506 | ||
| 7aa1f756a9 | |||
|
|
c484a8ca9b | ||
| 28d2c9f4ec | |||
|
|
ee253e9449 | ||
| b6c15e53d0 | |||
|
|
722162c2c3 | ||
| 02a76fe996 | |||
|
|
0ebb03a7ab | ||
| 748ac9e049 | |||
|
|
495edd621c | ||
| 4ffca19db6 | |||
|
|
717427c5d7 | ||
| cc438a0e36 | |||
|
|
a32a0b62fc | ||
| 342f72b713 |
@@ -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 }}" \
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
160
src/App.test.tsx
160
src/App.test.tsx
@@ -20,10 +20,98 @@ import {
|
|||||||
mockUseUserData,
|
mockUseUserData,
|
||||||
mockUseFlyerItems,
|
mockUseFlyerItems,
|
||||||
} from './tests/setup/mockHooks';
|
} from './tests/setup/mockHooks';
|
||||||
|
import './tests/setup/mockUI';
|
||||||
import { useAppInitialization } from './hooks/useAppInitialization';
|
import { useAppInitialization } from './hooks/useAppInitialization';
|
||||||
|
|
||||||
// Mock top-level components rendered by App's routes
|
// Mock top-level components rendered by App's routes
|
||||||
|
|
||||||
|
vi.mock('./components/Header', () => ({
|
||||||
|
Header: ({ onOpenProfile, onOpenVoiceAssistant }: any) => (
|
||||||
|
<div data-testid="header-mock">
|
||||||
|
<button onClick={onOpenProfile}>Open Profile</button>
|
||||||
|
<button onClick={onOpenVoiceAssistant}>Open Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/Footer', () => ({
|
||||||
|
Footer: () => <div data-testid="footer-mock">Mock Footer</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./layouts/MainLayout', async () => {
|
||||||
|
const { Outlet } = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||||
|
return {
|
||||||
|
MainLayout: () => (
|
||||||
|
<div data-testid="main-layout-mock">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./pages/HomePage', () => ({
|
||||||
|
HomePage: ({ selectedFlyer, onOpenCorrectionTool }: any) => (
|
||||||
|
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
|
||||||
|
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminPage', () => ({
|
||||||
|
AdminPage: () => <div data-testid="admin-page-mock">AdminPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/CorrectionsPage', () => ({
|
||||||
|
CorrectionsPage: () => <div data-testid="corrections-page-mock">CorrectionsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminStatsPage', () => ({
|
||||||
|
AdminStatsPage: () => <div data-testid="admin-stats-page-mock">AdminStatsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/FlyerReviewPage', () => ({
|
||||||
|
FlyerReviewPage: () => <div data-testid="flyer-review-page-mock">FlyerReviewPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/VoiceLabPage', () => ({
|
||||||
|
VoiceLabPage: () => <div data-testid="voice-lab-page-mock">VoiceLabPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/ResetPasswordPage', () => ({
|
||||||
|
ResetPasswordPage: () => <div data-testid="reset-password-page-mock">ResetPasswordPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/components/ProfileManager', () => ({
|
||||||
|
ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="profile-manager-mock">
|
||||||
|
<button onClick={onClose}>Close Profile</button>
|
||||||
|
<button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button>
|
||||||
|
<button onClick={() => onLoginSuccess({}, 'token', false)}>Login</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({
|
||||||
|
VoiceAssistant: ({ isOpen, onClose }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="voice-assistant-mock">
|
||||||
|
<button onClick={onClose}>Close Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/FlyerCorrectionTool', () => ({
|
||||||
|
FlyerCorrectionTool: ({ isOpen, onClose, onDataExtracted }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="flyer-correction-tool-mock">
|
||||||
|
<button onClick={onClose}>Close Correction</button>
|
||||||
|
<button onClick={() => onDataExtracted('store_name', 'New Store')}>Extract Store</button>
|
||||||
|
<button onClick={() => onDataExtracted('dates', 'New Dates')}>Extract Dates</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
||||||
// This must be done in any test file that imports App.tsx.
|
// This must be done in any test file that imports App.tsx.
|
||||||
vi.mock('pdfjs-dist', () => ({
|
vi.mock('pdfjs-dist', () => ({
|
||||||
@@ -61,71 +149,6 @@ vi.mock('./hooks/useAuth', async () => {
|
|||||||
return { useAuth: hooks.mockUseAuth };
|
return { useAuth: hooks.mockUseAuth };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./components/Footer', async () => {
|
|
||||||
const { MockFooter } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Footer: MockFooter };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/Header', async () => {
|
|
||||||
const { MockHeader } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Header: MockHeader };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/HomePage', async () => {
|
|
||||||
const { MockHomePage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { HomePage: MockHomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminPage', async () => {
|
|
||||||
const { MockAdminPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminPage: MockAdminPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/CorrectionsPage', async () => {
|
|
||||||
const { MockCorrectionsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { CorrectionsPage: MockCorrectionsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminStatsPage', async () => {
|
|
||||||
const { MockAdminStatsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminStatsPage: MockAdminStatsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/VoiceLabPage', async () => {
|
|
||||||
const { MockVoiceLabPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceLabPage: MockVoiceLabPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/ResetPasswordPage', async () => {
|
|
||||||
const { MockResetPasswordPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ResetPasswordPage: MockResetPasswordPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/components/ProfileManager', async () => {
|
|
||||||
const { MockProfileManager } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ProfileManager: MockProfileManager };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./features/voice-assistant/VoiceAssistant', async () => {
|
|
||||||
const { MockVoiceAssistant } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceAssistant: MockVoiceAssistant };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/FlyerCorrectionTool', async () => {
|
|
||||||
const { MockFlyerCorrectionTool } = await import('./tests/utils/componentMocks');
|
|
||||||
return { FlyerCorrectionTool: MockFlyerCorrectionTool };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/WhatsNewModal', async () => {
|
|
||||||
const { MockWhatsNewModal } = await import('./tests/utils/componentMocks');
|
|
||||||
return { WhatsNewModal: MockWhatsNewModal };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./layouts/MainLayout', async () => {
|
|
||||||
const { MockMainLayout } = await import('./tests/utils/componentMocks');
|
|
||||||
return { MainLayout: MockMainLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/AppGuard', async () => {
|
vi.mock('./components/AppGuard', async () => {
|
||||||
// We need to use the real useModal hook inside our mock AppGuard
|
// We need to use the real useModal hook inside our mock AppGuard
|
||||||
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
||||||
@@ -192,6 +215,7 @@ describe('App Component', () => {
|
|||||||
mockUseUserData.mockReturnValue({
|
mockUseUserData.mockReturnValue({
|
||||||
watchedItems: [],
|
watchedItems: [],
|
||||||
shoppingLists: [],
|
shoppingLists: [],
|
||||||
|
isLoadingShoppingLists: false,
|
||||||
setWatchedItems: vi.fn(),
|
setWatchedItems: vi.fn(),
|
||||||
setShoppingLists: vi.fn(),
|
setShoppingLists: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -361,12 +385,8 @@ describe('App Component', () => {
|
|||||||
it('should select a flyer when flyerId is present in the URL', async () => {
|
it('should select a flyer when flyerId is present in the URL', async () => {
|
||||||
renderApp(['/flyers/2']);
|
renderApp(['/flyers/2']);
|
||||||
|
|
||||||
// The HomePage mock will be rendered. The important part is that the selection logic
|
|
||||||
// in App.tsx runs and passes the correct `selectedFlyer` prop down.
|
|
||||||
// Since HomePage is mocked, we can't see the direct result, but we can
|
|
||||||
// infer that the logic ran without crashing and the correct route was matched.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('home-page-mock')).toHaveAttribute('data-selected-flyer-id', '2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -1,6 +1,6 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Routes, Route, useParams } from 'react-router-dom';
|
import { Routes, Route, useLocation, matchPath } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// src/components/AchievementsList.test.tsx
|
// src/components/AchievementsList.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { AchievementsList } from './AchievementsList';
|
import { AchievementsList } from './AchievementsList';
|
||||||
import { createMockUserAchievement } from '../tests/utils/mockFactories';
|
import { createMockUserAchievement } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('AchievementsList', () => {
|
describe('AchievementsList', () => {
|
||||||
it('should render the list of achievements with correct details', () => {
|
it('should render the list of achievements with correct details', () => {
|
||||||
@@ -22,9 +23,10 @@ describe('AchievementsList', () => {
|
|||||||
points_value: 15,
|
points_value: 15,
|
||||||
}),
|
}),
|
||||||
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
|
||||||
|
createMockUserAchievement({ achievement_id: 4, name: 'No Icon Achievement', icon: '' }), // Triggers the fallback for missing name
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<AchievementsList achievements={mockAchievements} />);
|
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -40,11 +42,19 @@ describe('AchievementsList', () => {
|
|||||||
|
|
||||||
// Check achievement with default icon
|
// Check achievement with default icon
|
||||||
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
expect(screen.getByText('Unknown Achievement')).toBeInTheDocument();
|
||||||
expect(screen.getByText('🏆')).toBeInTheDocument(); // Default icon
|
// We expect at least one trophy (for unknown achievement).
|
||||||
|
// Since we added another one that produces a trophy (No Icon), we use getAllByText.
|
||||||
|
expect(screen.getAllByText('🏆').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check achievement with missing icon (empty string)
|
||||||
|
expect(screen.getByText('No Icon Achievement')).toBeInTheDocument();
|
||||||
|
// Verify the specific placeholder class is rendered, ensuring the early return in Icon component is hit
|
||||||
|
const noIconCard = screen.getByText('No Icon Achievement').closest('.bg-white');
|
||||||
|
expect(noIconCard?.querySelector('.icon-placeholder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a message when there are no achievements', () => {
|
it('should render a message when there are no achievements', () => {
|
||||||
render(<AchievementsList achievements={[]} />);
|
renderWithProviders(<AchievementsList achievements={[]} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
|
screen.getByText('No achievements earned yet. Keep exploring to unlock them!'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/components/AdminRoute.test.tsx
|
// src/components/AdminRoute.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { AdminRoute } from './AdminRoute';
|
import { AdminRoute } from './AdminRoute';
|
||||||
import type { Profile } from '../types';
|
import type { Profile } from '../types';
|
||||||
import { createMockProfile } from '../tests/utils/mockFactories';
|
import { createMockProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Unmock the component to test the real implementation
|
// Unmock the component to test the real implementation
|
||||||
vi.unmock('./AdminRoute');
|
vi.unmock('./AdminRoute');
|
||||||
@@ -14,15 +15,14 @@ const AdminContent = () => <div>Admin Page Content</div>;
|
|||||||
const HomePage = () => <div>Home Page</div>;
|
const HomePage = () => <div>Home Page</div>;
|
||||||
|
|
||||||
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<MemoryRouter initialEntries={[initialPath]}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/admin" element={<AdminRoute profile={profile} />}>
|
||||||
<Route path="/admin" element={<AdminRoute profile={profile} />}>
|
<Route index element={<AdminContent />} />
|
||||||
<Route index element={<AdminContent />} />
|
</Route>
|
||||||
</Route>
|
</Routes>,
|
||||||
</Routes>
|
{ initialEntries: [initialPath] },
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/AnonymousUserBanner.test.tsx
|
// src/components/AnonymousUserBanner.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the icon to ensure it is rendered correctly
|
// Mock the icon to ensure it is rendered correctly
|
||||||
vi.mock('./icons/InformationCircleIcon', () => ({
|
vi.mock('./icons/InformationCircleIcon', () => ({
|
||||||
@@ -14,7 +15,7 @@ vi.mock('./icons/InformationCircleIcon', () => ({
|
|||||||
describe('AnonymousUserBanner', () => {
|
describe('AnonymousUserBanner', () => {
|
||||||
it('should render the banner with the correct text content and accessibility role', () => {
|
it('should render the banner with the correct text content and accessibility role', () => {
|
||||||
const mockOnOpenProfile = vi.fn();
|
const mockOnOpenProfile = vi.fn();
|
||||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||||
|
|
||||||
// Check for accessibility role
|
// Check for accessibility role
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
@@ -30,7 +31,7 @@ describe('AnonymousUserBanner', () => {
|
|||||||
|
|
||||||
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
||||||
const mockOnOpenProfile = vi.fn();
|
const mockOnOpenProfile = vi.fn();
|
||||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
renderWithProviders(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||||
|
|
||||||
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
||||||
fireEvent.click(loginButton);
|
fireEvent.click(loginButton);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// src/components/AppGuard.test.tsx
|
// src/components/AppGuard.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AppGuard } from './AppGuard';
|
import { AppGuard } from './AppGuard';
|
||||||
import { useAppInitialization } from '../hooks/useAppInitialization';
|
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
import { useModal } from '../hooks/useModal';
|
import { useModal } from '../hooks/useModal';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
|
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||||
vi.mock('../hooks/useAppInitialization');
|
vi.mock('../hooks/useAppInitialization');
|
||||||
vi.mock('../hooks/useModal');
|
vi.mock('../hooks/useModal');
|
||||||
vi.mock('./WhatsNewModal', () => ({
|
vi.mock('./WhatsNewModal', () => ({
|
||||||
@@ -19,6 +22,7 @@ vi.mock('../config', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||||
const mockedUseModal = vi.mocked(useModal);
|
const mockedUseModal = vi.mocked(useModal);
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ describe('AppGuard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render children', () => {
|
it('should render children', () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child Content</div>
|
<div>Child Content</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -51,7 +55,7 @@ describe('AppGuard', () => {
|
|||||||
...mockedUseModal(),
|
...mockedUseModal(),
|
||||||
isModalOpen: (modalId) => modalId === 'whatsNew',
|
isModalOpen: (modalId) => modalId === 'whatsNew',
|
||||||
});
|
});
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -64,7 +68,7 @@ describe('AppGuard', () => {
|
|||||||
isDarkMode: true,
|
isDarkMode: true,
|
||||||
unitSystem: 'imperial',
|
unitSystem: 'imperial',
|
||||||
});
|
});
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
@@ -78,7 +82,7 @@ describe('AppGuard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set light mode styles for toaster', async () => {
|
it('should set light mode styles for toaster', async () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<AppGuard>
|
<AppGuard>
|
||||||
<div>Child</div>
|
<div>Child</div>
|
||||||
</AppGuard>,
|
</AppGuard>,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/ConfirmationModal.test.tsx
|
// src/components/ConfirmationModal.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
describe('ConfirmationModal (in components)', () => {
|
describe('ConfirmationModal (in components)', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
@@ -21,12 +22,12 @@ describe('ConfirmationModal (in components)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
it('should not render when isOpen is false', () => {
|
||||||
const { container } = render(<ConfirmationModal {...defaultProps} isOpen={false} />);
|
const { container } = renderWithProviders(<ConfirmationModal {...defaultProps} isOpen={false} />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render correctly when isOpen is true', () => {
|
it('should render correctly when isOpen is true', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
|
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
||||||
@@ -34,38 +35,38 @@ describe('ConfirmationModal (in components)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onConfirm when the confirm button is clicked', () => {
|
it('should call onConfirm when the confirm button is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the cancel button is clicked', () => {
|
it('should call onClose when the cancel button is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the close icon is clicked', () => {
|
it('should call onClose when the close icon is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
|
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onClose when the overlay is clicked', () => {
|
it('should call onClose when the overlay is clicked', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
// The overlay is the parent of the modal content div
|
// The overlay is the parent of the modal content div
|
||||||
fireEvent.click(screen.getByRole('dialog'));
|
fireEvent.click(screen.getByRole('dialog'));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call onClose when clicking inside the modal content', () => {
|
it('should not call onClose when clicking inside the modal content', () => {
|
||||||
render(<ConfirmationModal {...defaultProps} />);
|
renderWithProviders(<ConfirmationModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByText('Are you sure you want to do this?'));
|
fireEvent.click(screen.getByText('Are you sure you want to do this?'));
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render custom button text and classes', () => {
|
it('should render custom button text and classes', () => {
|
||||||
render(
|
renderWithProviders(
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
confirmButtonText="Yes, Delete"
|
confirmButtonText="Yes, Delete"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/components/DarkModeToggle.test.tsx
|
// src/components/DarkModeToggle.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { DarkModeToggle } from './DarkModeToggle';
|
import { DarkModeToggle } from './DarkModeToggle';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
// Mock the icon components to isolate the toggle's logic
|
// Mock the icon components to isolate the toggle's logic
|
||||||
vi.mock('./icons/SunIcon', () => ({
|
vi.mock('./icons/SunIcon', () => ({
|
||||||
@@ -20,7 +21,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render in light mode state', () => {
|
it('should render in light mode state', () => {
|
||||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
@@ -29,7 +30,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render in dark mode state', () => {
|
it('should render in dark mode state', () => {
|
||||||
render(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox');
|
const checkbox = screen.getByRole('checkbox');
|
||||||
expect(checkbox).toBeChecked();
|
expect(checkbox).toBeChecked();
|
||||||
@@ -38,7 +39,7 @@ describe('DarkModeToggle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onToggle when the label is clicked', () => {
|
it('should call onToggle when the label is clicked', () => {
|
||||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
renderWithProviders(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||||
|
|
||||||
// Clicking the label triggers the checkbox change
|
// Clicking the label triggers the checkbox change
|
||||||
const label = screen.getByTitle('Switch to Dark Mode');
|
const label = screen.getByTitle('Switch to Dark Mode');
|
||||||
|
|||||||
67
src/components/Dashboard.test.tsx
Normal file
67
src/components/Dashboard.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// src/components/Dashboard.test.tsx
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { Dashboard } from './Dashboard';
|
||||||
|
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||||
|
|
||||||
|
// Mock child components to isolate Dashboard logic
|
||||||
|
// Note: The Dashboard component imports these using '../components/RecipeSuggester'
|
||||||
|
// which resolves to the same file as './RecipeSuggester' when inside src/components.
|
||||||
|
vi.mock('./RecipeSuggester', () => ({
|
||||||
|
RecipeSuggester: () => <div data-testid="recipe-suggester-mock">Recipe Suggester</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./FlyerCountDisplay', () => ({
|
||||||
|
FlyerCountDisplay: () => <div data-testid="flyer-count-display-mock">Flyer Count Display</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./Leaderboard', () => ({
|
||||||
|
Leaderboard: () => <div data-testid="leaderboard-mock">Leaderboard</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Dashboard Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the dashboard title', () => {
|
||||||
|
console.log('TEST: Verifying dashboard title render');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByRole('heading', { name: /dashboard/i, level: 1 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the RecipeSuggester widget', () => {
|
||||||
|
console.log('TEST: Verifying RecipeSuggester presence');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByTestId('recipe-suggester-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the FlyerCountDisplay widget within the "Your Flyers" section', () => {
|
||||||
|
console.log('TEST: Verifying FlyerCountDisplay presence and section title');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
|
||||||
|
// Check for the section heading
|
||||||
|
expect(screen.getByRole('heading', { name: /your flyers/i, level: 2 })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the component
|
||||||
|
expect(screen.getByTestId('flyer-count-display-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Leaderboard widget in the sidebar area', () => {
|
||||||
|
console.log('TEST: Verifying Leaderboard presence');
|
||||||
|
renderWithProviders(<Dashboard />);
|
||||||
|
expect(screen.getByTestId('leaderboard-mock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with the correct grid layout classes', () => {
|
||||||
|
console.log('TEST: Verifying layout classes');
|
||||||
|
const { container } = renderWithProviders(<Dashboard />);
|
||||||
|
|
||||||
|
// The main grid container
|
||||||
|
const gridContainer = container.querySelector('.grid');
|
||||||
|
expect(gridContainer).toBeInTheDocument();
|
||||||
|
expect(gridContainer).toHaveClass('grid-cols-1');
|
||||||
|
expect(gridContainer).toHaveClass('lg:grid-cols-3');
|
||||||
|
expect(gridContainer).toHaveClass('gap-6');
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/components/Dashboard.tsx
Normal file
33
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RecipeSuggester } from '../components/RecipeSuggester';
|
||||||
|
import { FlyerCountDisplay } from '../components/FlyerCountDisplay';
|
||||||
|
import { Leaderboard } from '../components/Leaderboard';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Recipe Suggester Section */}
|
||||||
|
<RecipeSuggester />
|
||||||
|
|
||||||
|
{/* Other Dashboard Widgets */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Your Flyers</h2>
|
||||||
|
<FlyerCountDisplay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Area */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Leaderboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user