Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8316f4f7 | ||
| 2564df1c64 | |||
|
|
696c547238 | ||
| 38165bdb9a | |||
|
|
6139dca072 | ||
| 68bfaa50e6 | |||
|
|
9c42621f74 | ||
| 1b98282202 | |||
|
|
b6731b220c | ||
| 3507d455e8 | |||
|
|
92b2adf8e8 | ||
| d6c7452256 | |||
|
|
d812b681dd | ||
| b4306a6092 | |||
|
|
57fdd159d5 | ||
| 4a747ca042 | |||
|
|
e0bf96824c | ||
| e86e09703e | |||
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a | |||
|
|
e4acab77c8 | ||
| 4e20b1b430 | |||
|
|
15747ac942 | ||
| e5fa89ef17 | |||
|
|
2c65da31e9 | ||
| eeec6af905 | |||
|
|
e7d03951b9 | ||
| af8816e0af | |||
|
|
64f6427e1a | ||
| c9b7a75429 | |||
|
|
0490f6922e | ||
| 057c4c9174 | |||
|
|
a9e56bc707 | ||
| e5d09c73b7 | |||
|
|
6e1298b825 | ||
| fc8e43437a |
@@ -113,7 +113,7 @@ jobs:
|
|||||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||||
|
|
||||||
# --- Integration test specific variables ---
|
# --- Integration test specific variables ---
|
||||||
FRONTEND_URL: 'http://localhost:3000'
|
FRONTEND_URL: 'https://example.com'
|
||||||
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
||||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||||
|
|
||||||
@@ -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 }}" \
|
||||||
@@ -388,7 +389,7 @@ jobs:
|
|||||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||||
|
|
||||||
# Application Secrets
|
# Application Secrets
|
||||||
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
|
FRONTEND_URL: 'https://example.com'
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
|
|||||||
@@ -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,46 +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,
|
|
||||||
DB_USER: process.env.DB_USER,
|
|
||||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
|
||||||
DB_NAME: process.env.DB_NAME,
|
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
|
||||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
|
||||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
|
||||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
||||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
|
||||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
|
||||||
SMTP_PASS: process.env.SMTP_PASS,
|
|
||||||
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
|
||||||
WORKER_LOCK_DURATION: '120000',
|
WORKER_LOCK_DURATION: '120000',
|
||||||
|
...sharedEnv,
|
||||||
},
|
},
|
||||||
// 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,
|
|
||||||
DB_USER: process.env.DB_USER,
|
|
||||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
|
||||||
DB_NAME: process.env.DB_NAME,
|
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
|
||||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
|
||||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
|
||||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
||||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
|
||||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
|
||||||
SMTP_PASS: process.env.SMTP_PASS,
|
|
||||||
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
|
||||||
WORKER_LOCK_DURATION: '120000',
|
WORKER_LOCK_DURATION: '120000',
|
||||||
|
...sharedEnv,
|
||||||
},
|
},
|
||||||
// Development Environment Settings
|
// Development Environment Settings
|
||||||
env_development: {
|
env_development: {
|
||||||
@@ -83,23 +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,
|
|
||||||
DB_USER: process.env.DB_USER,
|
|
||||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
|
||||||
DB_NAME: process.env.DB_NAME,
|
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
|
||||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
|
||||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
|
||||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
||||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
|
||||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
|
||||||
SMTP_PASS: process.env.SMTP_PASS,
|
|
||||||
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
|
||||||
WORKER_LOCK_DURATION: '120000',
|
WORKER_LOCK_DURATION: '120000',
|
||||||
|
...sharedEnv,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,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,
|
||||||
@@ -119,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: {
|
||||||
@@ -164,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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -188,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,
|
||||||
@@ -199,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: {
|
||||||
@@ -244,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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.17",
|
"version": "0.9.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.17",
|
"version": "0.9.36",
|
||||||
"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.9.17",
|
"version": "0.9.36",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
164
src/App.test.tsx
164
src/App.test.tsx
@@ -20,10 +20,98 @@ import {
|
|||||||
mockUseUserData,
|
mockUseUserData,
|
||||||
mockUseFlyerItems,
|
mockUseFlyerItems,
|
||||||
} from './tests/setup/mockHooks';
|
} from './tests/setup/mockHooks';
|
||||||
|
import './tests/setup/mockUI';
|
||||||
import { useAppInitialization } from './hooks/useAppInitialization';
|
import { useAppInitialization } from './hooks/useAppInitialization';
|
||||||
|
|
||||||
// Mock top-level components rendered by App's routes
|
// Mock top-level components rendered by App's routes
|
||||||
|
|
||||||
|
vi.mock('./components/Header', () => ({
|
||||||
|
Header: ({ onOpenProfile, onOpenVoiceAssistant }: any) => (
|
||||||
|
<div data-testid="header-mock">
|
||||||
|
<button onClick={onOpenProfile}>Open Profile</button>
|
||||||
|
<button onClick={onOpenVoiceAssistant}>Open Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/Footer', () => ({
|
||||||
|
Footer: () => <div data-testid="footer-mock">Mock Footer</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./layouts/MainLayout', async () => {
|
||||||
|
const { Outlet } = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||||
|
return {
|
||||||
|
MainLayout: () => (
|
||||||
|
<div data-testid="main-layout-mock">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./pages/HomePage', () => ({
|
||||||
|
HomePage: ({ selectedFlyer, onOpenCorrectionTool }: any) => (
|
||||||
|
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
|
||||||
|
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminPage', () => ({
|
||||||
|
AdminPage: () => <div data-testid="admin-page-mock">AdminPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/CorrectionsPage', () => ({
|
||||||
|
CorrectionsPage: () => <div data-testid="corrections-page-mock">CorrectionsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/AdminStatsPage', () => ({
|
||||||
|
AdminStatsPage: () => <div data-testid="admin-stats-page-mock">AdminStatsPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/FlyerReviewPage', () => ({
|
||||||
|
FlyerReviewPage: () => <div data-testid="flyer-review-page-mock">FlyerReviewPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/VoiceLabPage', () => ({
|
||||||
|
VoiceLabPage: () => <div data-testid="voice-lab-page-mock">VoiceLabPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/ResetPasswordPage', () => ({
|
||||||
|
ResetPasswordPage: () => <div data-testid="reset-password-page-mock">ResetPasswordPage</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/admin/components/ProfileManager', () => ({
|
||||||
|
ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="profile-manager-mock">
|
||||||
|
<button onClick={onClose}>Close Profile</button>
|
||||||
|
<button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button>
|
||||||
|
<button onClick={() => onLoginSuccess({}, 'token', false)}>Login</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({
|
||||||
|
VoiceAssistant: ({ isOpen, onClose }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="voice-assistant-mock">
|
||||||
|
<button onClick={onClose}>Close Voice Assistant</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/FlyerCorrectionTool', () => ({
|
||||||
|
FlyerCorrectionTool: ({ isOpen, onClose, onDataExtracted }: any) =>
|
||||||
|
isOpen ? (
|
||||||
|
<div data-testid="flyer-correction-tool-mock">
|
||||||
|
<button onClick={onClose}>Close Correction</button>
|
||||||
|
<button onClick={() => onDataExtracted('store_name', 'New Store')}>Extract Store</button>
|
||||||
|
<button onClick={() => onDataExtracted('dates', 'New Dates')}>Extract Dates</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
||||||
// This must be done in any test file that imports App.tsx.
|
// This must be done in any test file that imports App.tsx.
|
||||||
vi.mock('pdfjs-dist', () => ({
|
vi.mock('pdfjs-dist', () => ({
|
||||||
@@ -61,71 +149,6 @@ vi.mock('./hooks/useAuth', async () => {
|
|||||||
return { useAuth: hooks.mockUseAuth };
|
return { useAuth: hooks.mockUseAuth };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./components/Footer', async () => {
|
|
||||||
const { MockFooter } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Footer: MockFooter };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/Header', async () => {
|
|
||||||
const { MockHeader } = await import('./tests/utils/componentMocks');
|
|
||||||
return { Header: MockHeader };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/HomePage', async () => {
|
|
||||||
const { MockHomePage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { HomePage: MockHomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminPage', async () => {
|
|
||||||
const { MockAdminPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminPage: MockAdminPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/CorrectionsPage', async () => {
|
|
||||||
const { MockCorrectionsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { CorrectionsPage: MockCorrectionsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/AdminStatsPage', async () => {
|
|
||||||
const { MockAdminStatsPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { AdminStatsPage: MockAdminStatsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/VoiceLabPage', async () => {
|
|
||||||
const { MockVoiceLabPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceLabPage: MockVoiceLabPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/ResetPasswordPage', async () => {
|
|
||||||
const { MockResetPasswordPage } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ResetPasswordPage: MockResetPasswordPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./pages/admin/components/ProfileManager', async () => {
|
|
||||||
const { MockProfileManager } = await import('./tests/utils/componentMocks');
|
|
||||||
return { ProfileManager: MockProfileManager };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./features/voice-assistant/VoiceAssistant', async () => {
|
|
||||||
const { MockVoiceAssistant } = await import('./tests/utils/componentMocks');
|
|
||||||
return { VoiceAssistant: MockVoiceAssistant };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/FlyerCorrectionTool', async () => {
|
|
||||||
const { MockFlyerCorrectionTool } = await import('./tests/utils/componentMocks');
|
|
||||||
return { FlyerCorrectionTool: MockFlyerCorrectionTool };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/WhatsNewModal', async () => {
|
|
||||||
const { MockWhatsNewModal } = await import('./tests/utils/componentMocks');
|
|
||||||
return { WhatsNewModal: MockWhatsNewModal };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./layouts/MainLayout', async () => {
|
|
||||||
const { MockMainLayout } = await import('./tests/utils/componentMocks');
|
|
||||||
return { MainLayout: MockMainLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('./components/AppGuard', async () => {
|
vi.mock('./components/AppGuard', async () => {
|
||||||
// We need to use the real useModal hook inside our mock AppGuard
|
// We need to use the real useModal hook inside our mock AppGuard
|
||||||
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
|
||||||
@@ -192,6 +215,7 @@ describe('App Component', () => {
|
|||||||
mockUseUserData.mockReturnValue({
|
mockUseUserData.mockReturnValue({
|
||||||
watchedItems: [],
|
watchedItems: [],
|
||||||
shoppingLists: [],
|
shoppingLists: [],
|
||||||
|
isLoadingShoppingLists: false,
|
||||||
setWatchedItems: vi.fn(),
|
setWatchedItems: vi.fn(),
|
||||||
setShoppingLists: vi.fn(),
|
setShoppingLists: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -361,12 +385,8 @@ describe('App Component', () => {
|
|||||||
it('should select a flyer when flyerId is present in the URL', async () => {
|
it('should select a flyer when flyerId is present in the URL', async () => {
|
||||||
renderApp(['/flyers/2']);
|
renderApp(['/flyers/2']);
|
||||||
|
|
||||||
// The HomePage mock will be rendered. The important part is that the selection logic
|
|
||||||
// in App.tsx runs and passes the correct `selectedFlyer` prop down.
|
|
||||||
// Since HomePage is mocked, we can't see the direct result, but we can
|
|
||||||
// infer that the logic ran without crashing and the correct route was matched.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('home-page-mock')).toHaveAttribute('data-selected-flyer-id', '2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,7 +628,7 @@ describe('App Component', () => {
|
|||||||
app: {
|
app: {
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
commitMessage: 'A new version!',
|
commitMessage: 'A new version!',
|
||||||
commitUrl: 'http://example.com/commit/2.0.0',
|
commitUrl: 'https://example.com/commit/2.0.0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -618,7 +638,7 @@ describe('App Component', () => {
|
|||||||
renderApp();
|
renderApp();
|
||||||
const versionLink = screen.getByText(`Version: 2.0.0`);
|
const versionLink = screen.getByText(`Version: 2.0.0`);
|
||||||
expect(versionLink).toBeInTheDocument();
|
expect(versionLink).toBeInTheDocument();
|
||||||
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
|
expect(versionLink).toHaveAttribute('href', 'https://example.com/commit/2.0.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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
|
||||||
];
|
];
|
||||||
|
|
||||||
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
renderWithProviders(<AchievementsList achievements={mockAchievements} />);
|
||||||
@@ -41,7 +42,15 @@ 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', () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
|
|||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
imageUrl: 'http://example.com/flyer.jpg',
|
imageUrl: 'https://example.com/flyer.jpg',
|
||||||
onDataExtracted: vi.fn(),
|
onDataExtracted: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,4 +252,54 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle API failure response (ok: false) correctly', async () => {
|
||||||
|
console.log('TEST: Starting "should handle API failure response (ok: false) correctly"');
|
||||||
|
mockedAiApiClient.rescanImageArea.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Custom API Error' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
|
||||||
|
// Wait for image fetch
|
||||||
|
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Draw selection
|
||||||
|
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||||
|
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||||
|
fireEvent.mouseMove(canvas, { clientX: 50, clientY: 50 });
|
||||||
|
fireEvent.mouseUp(canvas);
|
||||||
|
|
||||||
|
// Click extract
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedNotifyError).toHaveBeenCalledWith('Custom API Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redraw the canvas when the image loads', () => {
|
||||||
|
console.log('TEST: Starting "should redraw the canvas when the image loads"');
|
||||||
|
const clearRectSpy = vi.fn();
|
||||||
|
// Override the getContext mock for this test to capture the spy
|
||||||
|
window.HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
||||||
|
clearRect: clearRectSpy,
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
setLineDash: vi.fn(),
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 0,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
|
||||||
|
const image = screen.getByAltText('Flyer for correction');
|
||||||
|
|
||||||
|
// The draw function is called on mount via useEffect, so we clear that call.
|
||||||
|
clearRectSpy.mockClear();
|
||||||
|
|
||||||
|
// Simulate image load event which triggers onLoad={draw}
|
||||||
|
fireEvent.load(image);
|
||||||
|
|
||||||
|
expect(clearRectSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const mockLeaderboardData: LeaderboardUser[] = [
|
|||||||
createMockLeaderboardUser({
|
createMockLeaderboardUser({
|
||||||
user_id: 'user-2',
|
user_id: 'user-2',
|
||||||
full_name: 'Bob',
|
full_name: 'Bob',
|
||||||
avatar_url: 'http://example.com/bob.jpg',
|
avatar_url: 'https://example.com/bob.jpg',
|
||||||
points: 950,
|
points: 950,
|
||||||
rank: '2',
|
rank: '2',
|
||||||
}),
|
}),
|
||||||
@@ -95,7 +95,7 @@ describe('Leaderboard', () => {
|
|||||||
|
|
||||||
// Check for correct avatar URLs
|
// Check for correct avatar URLs
|
||||||
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
|
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
|
||||||
expect(bobAvatar.src).toBe('http://example.com/bob.jpg');
|
expect(bobAvatar.src).toBe('https://example.com/bob.jpg');
|
||||||
|
|
||||||
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
|
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
|
||||||
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar
|
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar
|
||||||
|
|||||||
@@ -153,4 +153,50 @@ describe('RecipeSuggester Component', () => {
|
|||||||
});
|
});
|
||||||
console.log('TEST: Previous error cleared successfully');
|
console.log('TEST: Previous error cleared successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses default error message when API error response has no message', async () => {
|
||||||
|
console.log('TEST: Verifying default error message for API failure');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'mystery');
|
||||||
|
|
||||||
|
// Mock API failure response without a message property
|
||||||
|
mockedApiClient.suggestRecipe.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({}), // Empty object
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to get suggestion.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-Error objects thrown during fetch', async () => {
|
||||||
|
console.log('TEST: Verifying handling of non-Error exceptions');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<RecipeSuggester />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Ingredients:/i);
|
||||||
|
await user.type(input, 'chaos');
|
||||||
|
|
||||||
|
// Mock a rejection that is NOT an Error object
|
||||||
|
mockedApiClient.suggestRecipe.mockRejectedValue('Something weird happened');
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: 'Something weird happened' },
|
||||||
|
'Failed to fetch recipe suggestion.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
147
src/config/rateLimiters.ts
Normal file
147
src/config/rateLimiters.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// src/config/rateLimiters.ts
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { shouldSkipRateLimit } from '../utils/rateLimit';
|
||||||
|
|
||||||
|
const standardConfig = {
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: shouldSkipRateLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- AUTHENTICATION ---
|
||||||
|
export const loginLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many login attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many accounts created from this IP, please try again after an hour.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forgotPasswordLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetPasswordLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshTokenLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many token refresh attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many logout attempts from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- GENERAL PUBLIC & USER ---
|
||||||
|
export const publicReadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userReadLimiter = publicReadLimiter; // Alias for consistency
|
||||||
|
|
||||||
|
export const userUpdateLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many update requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reactionToggleLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 150,
|
||||||
|
message: 'Too many reaction requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const trackingLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200,
|
||||||
|
message: 'Too many tracking requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- SENSITIVE / COSTLY ---
|
||||||
|
export const userSensitiveUpdateLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many sensitive requests from this IP, please try again after an hour.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminTriggerLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 30,
|
||||||
|
message: 'Too many administrative triggers from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiGenerationLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many AI generation requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const suggestionLimiter = aiGenerationLimiter; // Alias
|
||||||
|
|
||||||
|
export const geocodeLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many geocoding requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const priceHistoryLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 50,
|
||||||
|
message: 'Too many price history requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UPLOADS / BATCH ---
|
||||||
|
export const adminUploadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userUploadLimiter = adminUploadLimiter; // Alias
|
||||||
|
|
||||||
|
export const aiUploadLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const batchLimiter = rateLimit({
|
||||||
|
...standardConfig,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 50,
|
||||||
|
message: 'Too many batch requests from this IP, please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const budgetUpdateLimiter = batchLimiter; // Alias
|
||||||
@@ -77,6 +77,18 @@ describe('PriceChart', () => {
|
|||||||
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render an error message when an error occurs', () => {
|
||||||
|
mockedUseActiveDeals.mockReturnValue({
|
||||||
|
...mockedUseActiveDeals(),
|
||||||
|
activeDeals: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to fetch deals.',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Failed to fetch deals.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the table with deal items when data is provided', () => {
|
it('should render the table with deal items when data is provided', () => {
|
||||||
render(<PriceChart {...defaultProps} />);
|
render(<PriceChart {...defaultProps} />);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ interface TopDealsProps {
|
|||||||
|
|
||||||
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
export const TopDeals: React.FC<TopDealsProps> = ({ items }) => {
|
||||||
const topDeals = useMemo(() => {
|
const topDeals = useMemo(() => {
|
||||||
|
// Use a type guard in the filter to inform TypeScript that price_in_cents is non-null
|
||||||
|
// in subsequent operations. This allows removing the redundant nullish coalescing in sort.
|
||||||
return [...items]
|
return [...items]
|
||||||
.filter((item) => item.price_in_cents !== null) // Only include items with a parseable price
|
.filter(
|
||||||
.sort((a, b) => (a.price_in_cents ?? Infinity) - (b.price_in_cents ?? Infinity))
|
(item): item is FlyerItem & { price_in_cents: number } => item.price_in_cents !== null,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.price_in_cents - b.price_in_cents)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ describe('AnalysisPanel', () => {
|
|||||||
results: { WEB_SEARCH: 'Search results text.' },
|
results: { WEB_SEARCH: 'Search results text.' },
|
||||||
sources: {
|
sources: {
|
||||||
WEB_SEARCH: [
|
WEB_SEARCH: [
|
||||||
{ title: 'Valid Source', uri: 'http://example.com/source1' },
|
{ title: 'Valid Source', uri: 'https://example.com/source1' },
|
||||||
{ title: 'Source without URI', uri: null },
|
{ title: 'Source without URI', uri: null },
|
||||||
{ title: 'Another Valid Source', uri: 'http://example.com/source2' },
|
{ title: 'Another Valid Source', uri: 'https://example.com/source2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
loadingAnalysis: null,
|
loadingAnalysis: null,
|
||||||
@@ -178,7 +178,7 @@ describe('AnalysisPanel', () => {
|
|||||||
expect(screen.getByText('Sources:')).toBeInTheDocument();
|
expect(screen.getByText('Sources:')).toBeInTheDocument();
|
||||||
const source1 = screen.getByText('Valid Source');
|
const source1 = screen.getByText('Valid Source');
|
||||||
expect(source1).toBeInTheDocument();
|
expect(source1).toBeInTheDocument();
|
||||||
expect(source1.closest('a')).toHaveAttribute('href', 'http://example.com/source1');
|
expect(source1.closest('a')).toHaveAttribute('href', 'https://example.com/source1');
|
||||||
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
|
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
|
expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -278,13 +278,13 @@ describe('AnalysisPanel', () => {
|
|||||||
loadingAnalysis: null,
|
loadingAnalysis: null,
|
||||||
error: null,
|
error: null,
|
||||||
runAnalysis: mockRunAnalysis,
|
runAnalysis: mockRunAnalysis,
|
||||||
generatedImageUrl: 'http://example.com/meal.jpg',
|
generatedImageUrl: 'https://example.com/meal.jpg',
|
||||||
generateImage: mockGenerateImage,
|
generateImage: mockGenerateImage,
|
||||||
});
|
});
|
||||||
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
||||||
const image = screen.getByAltText('AI generated meal plan');
|
const image = screen.getByAltText('AI generated meal plan');
|
||||||
expect(image).toBeInTheDocument();
|
expect(image).toBeInTheDocument();
|
||||||
expect(image).toHaveAttribute('src', 'http://example.com/meal.jpg');
|
expect(image).toHaveAttribute('src', 'https://example.com/meal.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show sources for non-search analysis types', () => {
|
it('should not show sources for non-search analysis types', () => {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { createMockStore } from '../../tests/utils/mockFactories';
|
|||||||
const mockStore = createMockStore({
|
const mockStore = createMockStore({
|
||||||
store_id: 1,
|
store_id: 1,
|
||||||
name: 'SuperMart',
|
name: 'SuperMart',
|
||||||
logo_url: 'http://example.com/logo.png',
|
logo_url: 'https://example.com/logo.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockOnOpenCorrectionTool = vi.fn();
|
const mockOnOpenCorrectionTool = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
imageUrl: 'http://example.com/flyer.jpg',
|
imageUrl: 'https://example.com/flyer.jpg',
|
||||||
store: mockStore,
|
store: mockStore,
|
||||||
validFrom: '2023-10-26',
|
validFrom: '2023-10-26',
|
||||||
validTo: '2023-11-01',
|
validTo: '2023-11-01',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/features/flyer/FlyerDisplay.tsx
|
// src/features/flyer/FlyerDisplay.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ScanIcon } from '../../components/icons/ScanIcon';
|
import { formatDateRange } from '../../utils/dateUtils';
|
||||||
import type { Store } from '../../types';
|
import type { Store } from '../../types';
|
||||||
import { formatDateRange } from './dateUtils';
|
import { ScanIcon } from '../../components/icons/ScanIcon';
|
||||||
|
|
||||||
export interface FlyerDisplayProps {
|
export interface FlyerDisplayProps {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||||
import { FlyerList } from './FlyerList';
|
import { FlyerList } from './FlyerList';
|
||||||
import { formatShortDate } from './dateUtils';
|
import { formatShortDate } from '../../utils/dateUtils';
|
||||||
import type { Flyer, UserProfile } from '../../types';
|
import type { Flyer, UserProfile } from '../../types';
|
||||||
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||||
import { createMockFlyer } from '../../tests/utils/mockFactories';
|
import { createMockFlyer } from '../../tests/utils/mockFactories';
|
||||||
@@ -19,7 +19,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
file_name: 'metro_flyer_oct_1.pdf',
|
file_name: 'metro_flyer_oct_1.pdf',
|
||||||
item_count: 50,
|
item_count: 50,
|
||||||
image_url: 'http://example.com/flyer1.jpg',
|
image_url: 'https://example.com/flyer1.jpg',
|
||||||
store: { store_id: 101, name: 'Metro' },
|
store: { store_id: 101, name: 'Metro' },
|
||||||
valid_from: '2023-10-05',
|
valid_from: '2023-10-05',
|
||||||
valid_to: '2023-10-11',
|
valid_to: '2023-10-11',
|
||||||
@@ -29,7 +29,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 2,
|
flyer_id: 2,
|
||||||
file_name: 'walmart_flyer.pdf',
|
file_name: 'walmart_flyer.pdf',
|
||||||
item_count: 75,
|
item_count: 75,
|
||||||
image_url: 'http://example.com/flyer2.jpg',
|
image_url: 'https://example.com/flyer2.jpg',
|
||||||
store: { store_id: 102, name: 'Walmart' },
|
store: { store_id: 102, name: 'Walmart' },
|
||||||
valid_from: '2023-10-06',
|
valid_from: '2023-10-06',
|
||||||
valid_to: '2023-10-06', // Same day
|
valid_to: '2023-10-06', // Same day
|
||||||
@@ -40,8 +40,8 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 3,
|
flyer_id: 3,
|
||||||
file_name: 'no-store-flyer.pdf',
|
file_name: 'no-store-flyer.pdf',
|
||||||
item_count: 10,
|
item_count: 10,
|
||||||
image_url: 'http://example.com/flyer3.jpg',
|
image_url: 'https://example.com/flyer3.jpg',
|
||||||
icon_url: 'http://example.com/icon3.png',
|
icon_url: 'https://example.com/icon3.png',
|
||||||
valid_from: '2023-10-07',
|
valid_from: '2023-10-07',
|
||||||
valid_to: '2023-10-08',
|
valid_to: '2023-10-08',
|
||||||
store_address: '456 Side St, Ottawa',
|
store_address: '456 Side St, Ottawa',
|
||||||
@@ -53,7 +53,7 @@ const mockFlyers: Flyer[] = [
|
|||||||
flyer_id: 4,
|
flyer_id: 4,
|
||||||
file_name: 'bad-date-flyer.pdf',
|
file_name: 'bad-date-flyer.pdf',
|
||||||
item_count: 5,
|
item_count: 5,
|
||||||
image_url: 'http://example.com/flyer4.jpg',
|
image_url: 'https://example.com/flyer4.jpg',
|
||||||
store: { store_id: 103, name: 'Date Store' },
|
store: { store_id: 103, name: 'Date Store' },
|
||||||
created_at: 'invalid-date',
|
created_at: 'invalid-date',
|
||||||
valid_from: 'invalid-from',
|
valid_from: 'invalid-from',
|
||||||
@@ -163,7 +163,7 @@ describe('FlyerList', () => {
|
|||||||
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
|
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
|
||||||
const iconImage = flyerWithIcon?.querySelector('img');
|
const iconImage = flyerWithIcon?.querySelector('img');
|
||||||
expect(iconImage).toBeInTheDocument();
|
expect(iconImage).toBeInTheDocument();
|
||||||
expect(iconImage).toHaveAttribute('src', 'http://example.com/icon3.png');
|
expect(iconImage).toHaveAttribute('src', 'https://example.com/icon3.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a document icon when icon_url is not present', () => {
|
it('should render a document icon when icon_url is not present', () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseISO, format, isValid } from 'date-fns';
|
|||||||
import { MapPinIcon, Trash2Icon } from 'lucide-react';
|
import { MapPinIcon, Trash2Icon } from 'lucide-react';
|
||||||
import { logger } from '../../services/logger.client';
|
import { logger } from '../../services/logger.client';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { calculateDaysBetween, formatDateRange } from './dateUtils';
|
import { calculateDaysBetween, formatDateRange, getCurrentDateISOString } from '../../utils/dateUtils';
|
||||||
|
|
||||||
interface FlyerListProps {
|
interface FlyerListProps {
|
||||||
flyers: Flyer[];
|
flyers: Flyer[];
|
||||||
@@ -54,7 +54,7 @@ export const FlyerList: React.FC<FlyerListProps> = ({
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to);
|
const daysLeft = calculateDaysBetween(getCurrentDateISOString(), flyer.valid_to);
|
||||||
let daysLeftText = '';
|
let daysLeftText = '';
|
||||||
let daysLeftColor = '';
|
let daysLeftColor = '';
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
// src/features/flyer/dateUtils.test.ts
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { formatShortDate, calculateDaysBetween, formatDateRange } from './dateUtils';
|
|
||||||
|
|
||||||
describe('formatShortDate', () => {
|
|
||||||
it('should format a valid YYYY-MM-DD date string correctly', () => {
|
|
||||||
expect(formatShortDate('2024-07-26')).toBe('Jul 26');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle single-digit days correctly', () => {
|
|
||||||
expect(formatShortDate('2025-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle dates at the end of the year', () => {
|
|
||||||
expect(formatShortDate('2023-12-31')).toBe('Dec 31');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for a null input', () => {
|
|
||||||
expect(formatShortDate(null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an undefined input', () => {
|
|
||||||
expect(formatShortDate(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an empty string input', () => {
|
|
||||||
expect(formatShortDate('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for an invalid date string', () => {
|
|
||||||
expect(formatShortDate('not-a-real-date')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for a malformed date string', () => {
|
|
||||||
expect(formatShortDate('2024-13-01')).toBeNull(); // Invalid month
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly format a full ISO string with time and timezone', () => {
|
|
||||||
expect(formatShortDate('2024-12-25T10:00:00Z')).toBe('Dec 25');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('calculateDaysBetween', () => {
|
|
||||||
it('should calculate the difference in days between two valid date strings', () => {
|
|
||||||
expect(calculateDaysBetween('2023-01-01', '2023-01-05')).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a negative number if the end date is before the start date', () => {
|
|
||||||
expect(calculateDaysBetween('2023-01-05', '2023-01-01')).toBe(-4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Date objects', () => {
|
|
||||||
const start = new Date('2023-01-01');
|
|
||||||
const end = new Date('2023-01-10');
|
|
||||||
expect(calculateDaysBetween(start, end)).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if either date is null or undefined', () => {
|
|
||||||
expect(calculateDaysBetween(null, '2023-01-01')).toBeNull();
|
|
||||||
expect(calculateDaysBetween('2023-01-01', undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if either date is invalid', () => {
|
|
||||||
expect(calculateDaysBetween('invalid', '2023-01-01')).toBeNull();
|
|
||||||
expect(calculateDaysBetween('2023-01-01', 'invalid')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDateRange', () => {
|
|
||||||
it('should format a range with two different valid dates', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-05')).toBe('Jan 1 - Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format a range with the same start and end date as a single date', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-01')).toBe('Jan 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return only the start date if end date is missing', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', null)).toBe('Jan 1');
|
|
||||||
expect(formatDateRange('2023-01-01', undefined)).toBe('Jan 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return only the end date if start date is missing', () => {
|
|
||||||
expect(formatDateRange(null, '2023-01-05')).toBe('Jan 5');
|
|
||||||
expect(formatDateRange(undefined, '2023-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if both dates are missing or invalid', () => {
|
|
||||||
expect(formatDateRange(null, null)).toBeNull();
|
|
||||||
expect(formatDateRange(undefined, undefined)).toBeNull();
|
|
||||||
expect(formatDateRange('invalid', 'invalid')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle one valid and one invalid date by showing only the valid one', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', 'invalid')).toBe('Jan 1');
|
|
||||||
expect(formatDateRange('invalid', '2023-01-05')).toBe('Jan 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verbose mode', () => {
|
|
||||||
it('should format a range with two different valid dates verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe(
|
|
||||||
'Deals valid from January 1, 2023 to January 5, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format a range with the same start and end date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe(
|
|
||||||
'Valid on January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format only the start date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe(
|
|
||||||
'Deals start January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format only the end date verbosely', () => {
|
|
||||||
expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe(
|
|
||||||
'Deals end January 5, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle one valid and one invalid date verbosely', () => {
|
|
||||||
expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe(
|
|
||||||
'Deals start January 1, 2023',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// src/features/flyer/dateUtils.ts
|
|
||||||
import { parseISO, format, isValid, differenceInDays } from 'date-fns';
|
|
||||||
|
|
||||||
export const formatShortDate = (dateString: string | null | undefined): string | null => {
|
|
||||||
if (!dateString) return null;
|
|
||||||
// Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings.
|
|
||||||
// It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors.
|
|
||||||
const date = parseISO(dateString);
|
|
||||||
if (isValid(date)) {
|
|
||||||
return format(date, 'MMM d');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateDaysBetween = (
|
|
||||||
startDate: string | Date | null | undefined,
|
|
||||||
endDate: string | Date | null | undefined,
|
|
||||||
): number | null => {
|
|
||||||
if (!startDate || !endDate) return null;
|
|
||||||
|
|
||||||
const start = typeof startDate === 'string' ? parseISO(startDate) : startDate;
|
|
||||||
const end = typeof endDate === 'string' ? parseISO(endDate) : endDate;
|
|
||||||
|
|
||||||
if (!isValid(start) || !isValid(end)) return null;
|
|
||||||
|
|
||||||
return differenceInDays(end, start);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DateRangeOptions {
|
|
||||||
verbose?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDateRange = (
|
|
||||||
startDate: string | null | undefined,
|
|
||||||
endDate: string | null | undefined,
|
|
||||||
options?: DateRangeOptions,
|
|
||||||
): string | null => {
|
|
||||||
if (!options?.verbose) {
|
|
||||||
const start = formatShortDate(startDate);
|
|
||||||
const end = formatShortDate(endDate);
|
|
||||||
|
|
||||||
if (start && end) {
|
|
||||||
return start === end ? start : `${start} - ${end}`;
|
|
||||||
}
|
|
||||||
return start || end || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verbose format logic
|
|
||||||
const dateFormat = 'MMMM d, yyyy';
|
|
||||||
const formatFn = (dateStr: string | null | undefined) => {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
const date = parseISO(dateStr);
|
|
||||||
return isValid(date) ? format(date, dateFormat) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = formatFn(startDate);
|
|
||||||
const end = formatFn(endDate);
|
|
||||||
|
|
||||||
if (start && end) {
|
|
||||||
return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`;
|
|
||||||
}
|
|
||||||
if (start) return `Deals start ${start}`;
|
|
||||||
if (end) return `Deals end ${end}`;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -15,8 +15,8 @@ describe('useFlyerItems Hook', () => {
|
|||||||
const mockFlyer = createMockFlyer({
|
const mockFlyer = createMockFlyer({
|
||||||
flyer_id: 123,
|
flyer_id: 123,
|
||||||
file_name: 'test-flyer.jpg',
|
file_name: 'test-flyer.jpg',
|
||||||
image_url: 'http://example.com/test.jpg',
|
image_url: 'https://example.com/test.jpg',
|
||||||
icon_url: 'http://example.com/icon.jpg',
|
icon_url: 'https://example.com/icon.jpg',
|
||||||
checksum: 'abc',
|
checksum: 'abc',
|
||||||
valid_from: '2024-01-01',
|
valid_from: '2024-01-01',
|
||||||
valid_to: '2024-01-07',
|
valid_to: '2024-01-07',
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
|||||||
createMockFlyer({
|
createMockFlyer({
|
||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
file_name: 'flyer1.jpg',
|
file_name: 'flyer1.jpg',
|
||||||
image_url: 'http://example.com/flyer1.jpg',
|
image_url: 'https://example.com/flyer1.jpg',
|
||||||
item_count: 5,
|
item_count: 5,
|
||||||
created_at: '2024-01-01',
|
created_at: '2024-01-01',
|
||||||
}),
|
}),
|
||||||
|
|||||||
51
src/hooks/useUserProfileData.ts
Normal file
51
src/hooks/useUserProfileData.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// src/hooks/useUserProfileData.ts
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import * as apiClient from '../services/apiClient';
|
||||||
|
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||||
|
import { logger } from '../services/logger.client';
|
||||||
|
|
||||||
|
export const useUserProfileData = () => {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [profileRes, achievementsRes] = await Promise.all([
|
||||||
|
apiClient.getAuthenticatedUserProfile(),
|
||||||
|
apiClient.getUserAchievements(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
||||||
|
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
||||||
|
|
||||||
|
const profileData: UserProfile | null = await profileRes.json();
|
||||||
|
const achievementsData: (UserAchievement & Achievement)[] | null =
|
||||||
|
await achievementsRes.json();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ profileData, achievementsCount: achievementsData?.length },
|
||||||
|
'useUserProfileData: Fetched data',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData);
|
||||||
|
}
|
||||||
|
setAchievements(achievementsData || []);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
|
setError(errorMessage);
|
||||||
|
logger.error({ err }, 'Error in useUserProfileData:');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { profile, setProfile, achievements, isLoading, error };
|
||||||
|
};
|
||||||
@@ -3,8 +3,8 @@ import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vites
|
|||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
||||||
|
import { DatabaseError } from '../services/processingErrors';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -69,7 +69,7 @@ app.get('/unique-error', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/db-error-500', (req, res, next) => {
|
app.get('/db-error-500', (req, res, next) => {
|
||||||
next(new DatabaseError('A database connection issue occurred.', 500));
|
next(new DatabaseError('A database connection issue occurred.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
app.get('/unauthorized-error-no-status', (req, res, next) => {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('HomePage Component', () => {
|
|||||||
describe('when a flyer is selected', () => {
|
describe('when a flyer is selected', () => {
|
||||||
const mockFlyer: Flyer = createMockFlyer({
|
const mockFlyer: Flyer = createMockFlyer({
|
||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
image_url: 'http://example.com/flyer.jpg',
|
image_url: 'https://example.com/flyer.jpg',
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render FlyerDisplay but not data tables if there are no flyer items', () => {
|
it('should render FlyerDisplay but not data tables if there are no flyer items', () => {
|
||||||
|
|||||||
@@ -109,6 +109,33 @@ describe('ResetPasswordPage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show an error message if API returns a non-JSON error response', async () => {
|
||||||
|
// Simulate a server error returning HTML instead of JSON
|
||||||
|
mockedApiClient.resetPassword.mockResolvedValue(
|
||||||
|
new Response('<h1>Server Error</h1>', {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithRouter('test-token');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('New Password'), {
|
||||||
|
target: { value: 'newSecurePassword123' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), {
|
||||||
|
target: { value: 'newSecurePassword123' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// The error from response.json() is implementation-dependent.
|
||||||
|
// We check for a substring that is likely to be present.
|
||||||
|
expect(screen.getByText(/not valid JSON/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError) }, 'Failed to reset password.');
|
||||||
|
});
|
||||||
|
|
||||||
it('should show a loading spinner while submitting', async () => {
|
it('should show a loading spinner while submitting', async () => {
|
||||||
let resolvePromise: (value: Response) => void;
|
let resolvePromise: (value: Response) => void;
|
||||||
const mockPromise = new Promise<Response>((resolve) => {
|
const mockPromise = new Promise<Response>((resolve) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const mockedApiClient = vi.mocked(apiClient);
|
|||||||
const mockProfile: UserProfile = createMockUserProfile({
|
const mockProfile: UserProfile = createMockUserProfile({
|
||||||
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
|
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: 'http://example.com/avatar.jpg',
|
avatar_url: 'https://example.com/avatar.jpg',
|
||||||
points: 150,
|
points: 150,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
@@ -123,6 +123,24 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle null achievements data gracefully on fetch', async () => {
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(mockProfile)),
|
||||||
|
);
|
||||||
|
// Mock a successful response but with a null body for achievements
|
||||||
|
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
|
||||||
|
// The mock achievements list should show 0 achievements because the component
|
||||||
|
// should handle the null response and pass an empty array to the list.
|
||||||
|
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
|
||||||
|
'Achievements Count: 0',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the profile and achievements on successful fetch', async () => {
|
it('should render the profile and achievements on successful fetch', async () => {
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||||
new Response(JSON.stringify(mockProfile)),
|
new Response(JSON.stringify(mockProfile)),
|
||||||
@@ -294,6 +312,24 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle non-ok response with null body when saving name', async () => {
|
||||||
|
// This tests the case where the server returns an error status but an empty/null body.
|
||||||
|
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
await screen.findByText('Test User');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// The component should fall back to the default error message.
|
||||||
|
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||||
|
'Failed to update name.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown errors when saving name', async () => {
|
it('should handle unknown errors when saving name', async () => {
|
||||||
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
|
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
|
||||||
render(<UserProfilePage />);
|
render(<UserProfilePage />);
|
||||||
@@ -323,7 +359,7 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should upload a new avatar and update the image source', async () => {
|
it('should upload a new avatar and update the image source', async () => {
|
||||||
const updatedProfile = { ...mockProfile, avatar_url: 'http://example.com/new-avatar.png' };
|
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
|
||||||
|
|
||||||
// Log when the mock is called
|
// Log when the mock is called
|
||||||
mockedApiClient.uploadAvatar.mockImplementation((file) => {
|
mockedApiClient.uploadAvatar.mockImplementation((file) => {
|
||||||
@@ -420,6 +456,22 @@ describe('UserProfilePage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle non-ok response with null body when uploading avatar', async () => {
|
||||||
|
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
|
||||||
|
render(<UserProfilePage />);
|
||||||
|
await screen.findByAltText('User Avatar');
|
||||||
|
|
||||||
|
const fileInput = screen.getByTestId('avatar-file-input');
|
||||||
|
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||||
|
'Failed to upload avatar.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown errors when uploading avatar', async () => {
|
it('should handle unknown errors when uploading avatar', async () => {
|
||||||
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
|
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
|
||||||
render(<UserProfilePage />);
|
render(<UserProfilePage />);
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { logger } from '../services/logger.client';
|
import { logger } from '../services/logger.client';
|
||||||
import { notifySuccess, notifyError } from '../services/notificationService';
|
import { notifySuccess, notifyError } from '../services/notificationService';
|
||||||
import { AchievementsList } from '../components/AchievementsList';
|
import { AchievementsList } from '../components/AchievementsList';
|
||||||
|
import { useUserProfileData } from '../hooks/useUserProfileData';
|
||||||
|
|
||||||
const UserProfilePage: React.FC = () => {
|
const UserProfilePage: React.FC = () => {
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const { profile, setProfile, achievements, isLoading, error } = useUserProfileData();
|
||||||
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@@ -17,43 +15,10 @@ const UserProfilePage: React.FC = () => {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (profile) {
|
||||||
setIsLoading(true);
|
setEditingName(profile.full_name || '');
|
||||||
try {
|
}
|
||||||
// Fetch profile and achievements data in parallel
|
}, [profile]);
|
||||||
const [profileRes, achievementsRes] = await Promise.all([
|
|
||||||
apiClient.getAuthenticatedUserProfile(),
|
|
||||||
apiClient.getUserAchievements(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
|
||||||
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
|
||||||
|
|
||||||
const profileData: UserProfile = await profileRes.json();
|
|
||||||
const achievementsData: (UserAchievement & Achievement)[] = await achievementsRes.json();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ profileData, achievementsCount: achievementsData?.length },
|
|
||||||
'UserProfilePage: Fetched data',
|
|
||||||
);
|
|
||||||
|
|
||||||
setProfile(profileData);
|
|
||||||
|
|
||||||
if (profileData) {
|
|
||||||
setEditingName(profileData.full_name || '');
|
|
||||||
}
|
|
||||||
setAchievements(achievementsData);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
|
||||||
setError(errorMessage);
|
|
||||||
logger.error({ err }, 'Error fetching user profile data:');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []); // Empty dependency array means this runs once on component mount
|
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
@@ -61,8 +26,8 @@ const UserProfilePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.updateUserProfile({ full_name: editingName });
|
const response = await apiClient.updateUserProfile({ full_name: editingName });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||||
throw new Error(errorData.message || 'Failed to update name.');
|
throw new Error(errorData?.message || 'Failed to update name.');
|
||||||
}
|
}
|
||||||
const updatedProfile = await response.json();
|
const updatedProfile = await response.json();
|
||||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||||
@@ -88,8 +53,8 @@ const UserProfilePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.uploadAvatar(file);
|
const response = await apiClient.uploadAvatar(file);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||||
throw new Error(errorData.message || 'Failed to upload avatar.');
|
throw new Error(errorData?.message || 'Failed to upload avatar.');
|
||||||
}
|
}
|
||||||
const updatedProfile = await response.json();
|
const updatedProfile = await response.json();
|
||||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const mockLogs: ActivityLogItem[] = [
|
|||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
display_text: 'Processed a new flyer for Walmart.',
|
display_text: 'Processed a new flyer for Walmart.',
|
||||||
user_avatar_url: 'http://example.com/avatar.png',
|
user_avatar_url: 'https://example.com/avatar.png',
|
||||||
user_full_name: 'Test User',
|
user_full_name: 'Test User',
|
||||||
details: { flyer_id: 1, store_name: 'Walmart' },
|
details: { flyer_id: 1, store_name: 'Walmart' },
|
||||||
}),
|
}),
|
||||||
@@ -63,7 +63,7 @@ const mockLogs: ActivityLogItem[] = [
|
|||||||
action: 'recipe_favorited',
|
action: 'recipe_favorited',
|
||||||
display_text: 'User favorited a recipe',
|
display_text: 'User favorited a recipe',
|
||||||
user_full_name: 'Pizza Lover',
|
user_full_name: 'Pizza Lover',
|
||||||
user_avatar_url: 'http://example.com/pizza.png',
|
user_avatar_url: 'https://example.com/pizza.png',
|
||||||
details: { recipe_name: 'Best Pizza' },
|
details: { recipe_name: 'Best Pizza' },
|
||||||
}),
|
}),
|
||||||
createMockActivityLogItem({
|
createMockActivityLogItem({
|
||||||
@@ -136,7 +136,7 @@ describe('ActivityLog', () => {
|
|||||||
// Check for avatar
|
// Check for avatar
|
||||||
const avatar = screen.getByAltText('Test User');
|
const avatar = screen.getByAltText('Test User');
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(avatar).toBeInTheDocument();
|
||||||
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png');
|
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
|
||||||
|
|
||||||
// Check for fallback avatar (Newbie User has no avatar)
|
// Check for fallback avatar (Newbie User has no avatar)
|
||||||
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
|
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
|
||||||
|
|||||||
@@ -59,21 +59,21 @@ describe('FlyerReviewPage', () => {
|
|||||||
file_name: 'flyer1.jpg',
|
file_name: 'flyer1.jpg',
|
||||||
created_at: '2023-01-01T00:00:00Z',
|
created_at: '2023-01-01T00:00:00Z',
|
||||||
store: { name: 'Store A' },
|
store: { name: 'Store A' },
|
||||||
icon_url: 'http://example.com/icon1.jpg',
|
icon_url: 'https://example.com/icon1.jpg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
flyer_id: 2,
|
flyer_id: 2,
|
||||||
file_name: 'flyer2.jpg',
|
file_name: 'flyer2.jpg',
|
||||||
created_at: '2023-01-02T00:00:00Z',
|
created_at: '2023-01-02T00:00:00Z',
|
||||||
store: { name: 'Store B' },
|
store: { name: 'Store B' },
|
||||||
icon_url: 'http://example.com/icon2.jpg',
|
icon_url: 'https://example.com/icon2.jpg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
flyer_id: 3,
|
flyer_id: 3,
|
||||||
file_name: 'flyer3.jpg',
|
file_name: 'flyer3.jpg',
|
||||||
created_at: '2023-01-03T00:00:00Z',
|
created_at: '2023-01-03T00:00:00Z',
|
||||||
store: null,
|
store: null,
|
||||||
icon_url: 'http://example.com/icon2.jpg',
|
icon_url: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ describe('FlyerReviewPage', () => {
|
|||||||
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
||||||
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
||||||
expect(unknownStoreImage).not.toHaveAttribute('src');
|
expect(unknownStoreImage).not.toHaveAttribute('src');
|
||||||
expect(unknownStoreImage).not.toHaveAttribute('alt');
|
expect(unknownStoreImage).toHaveAttribute('alt', 'Unknown Store');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders error message when API response is not ok', async () => {
|
it('renders error message when API response is not ok', async () => {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const FlyerReviewPage: React.FC = () => {
|
|||||||
flyers.map((flyer) => (
|
flyers.map((flyer) => (
|
||||||
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
||||||
<img src={flyer.icon_url || undefined} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
<img src={flyer.icon_url || undefined} alt={flyer.store?.name || 'Unknown Store'} className="w-12 h-12 rounded-md object-cover" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const mockBrands = [
|
|||||||
brand_id: 2,
|
brand_id: 2,
|
||||||
name: 'Compliments',
|
name: 'Compliments',
|
||||||
store_name: 'Sobeys',
|
store_name: 'Sobeys',
|
||||||
logo_url: 'http://example.com/compliments.png',
|
logo_url: 'https://example.com/compliments.png',
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ describe('AdminBrandManager', () => {
|
|||||||
);
|
);
|
||||||
mockedApiClient.uploadBrandLogo.mockImplementation(
|
mockedApiClient.uploadBrandLogo.mockImplementation(
|
||||||
async () =>
|
async () =>
|
||||||
new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), {
|
new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -120,7 +120,7 @@ describe('AdminBrandManager', () => {
|
|||||||
// Check if the UI updates with the new logo
|
// Check if the UI updates with the new logo
|
||||||
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
|
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
'http://example.com/new-logo.png',
|
'https://example.com/new-logo.png',
|
||||||
);
|
);
|
||||||
console.log('TEST SUCCESS: All assertions for successful upload passed.');
|
console.log('TEST SUCCESS: All assertions for successful upload passed.');
|
||||||
});
|
});
|
||||||
@@ -350,7 +350,7 @@ describe('AdminBrandManager', () => {
|
|||||||
// Brand 2 should still have original logo
|
// Brand 2 should still have original logo
|
||||||
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
|
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
'http://example.com/compliments.png',
|
'https://example.com/compliments.png',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'tes
|
|||||||
const mockAddressId = 123;
|
const mockAddressId = 123;
|
||||||
const authenticatedProfile = createMockUserProfile({
|
const authenticatedProfile = createMockUserProfile({
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: 'http://example.com/avatar.png',
|
avatar_url: 'https://example.com/avatar.png',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
points: 100,
|
points: 100,
|
||||||
preferences: {
|
preferences: {
|
||||||
@@ -264,6 +264,7 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error if trying to save profile when not logged in', async () => {
|
it('should show an error if trying to save profile when not logged in', async () => {
|
||||||
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||||
// This is an edge case, but good to test the safeguard
|
// This is an edge case, but good to test the safeguard
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||||
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
||||||
@@ -271,6 +272,7 @@ describe('ProfileManager', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
|
||||||
});
|
});
|
||||||
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -496,6 +498,23 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show an error when trying to link a GitHub account', async () => {
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /link github account/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /link github account/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith(
|
||||||
|
'Account linking with github is not yet implemented.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should switch between all tabs correctly', async () => {
|
it('should switch between all tabs correctly', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
@@ -804,6 +823,63 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow changing unit system when preferences are initially null', async () => {
|
||||||
|
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
||||||
|
const { rerender } = render(
|
||||||
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
|
||||||
|
const imperialRadio = await screen.findByLabelText(/imperial/i);
|
||||||
|
const metricRadio = screen.getByLabelText(/metric/i);
|
||||||
|
|
||||||
|
// With null preferences, neither should be checked.
|
||||||
|
expect(imperialRadio).not.toBeChecked();
|
||||||
|
expect(metricRadio).not.toBeChecked();
|
||||||
|
|
||||||
|
// Mock the API response for the update
|
||||||
|
const updatedProfileWithPrefs = {
|
||||||
|
...profileWithoutPrefs,
|
||||||
|
preferences: { darkMode: false, unitSystem: 'metric' as const },
|
||||||
|
};
|
||||||
|
mockedApiClient.updateUserPreferences.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(updatedProfileWithPrefs),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
fireEvent.click(metricRadio);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
||||||
|
{ unitSystem: 'metric' },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rerender with the new profile to check the UI update
|
||||||
|
rerender(
|
||||||
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
expect(await screen.findByLabelText(/metric/i)).toBeChecked();
|
||||||
|
expect(screen.getByLabelText(/imperial/i)).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onProfileUpdate if updating unit system fails', async () => {
|
||||||
|
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||||
|
const metricRadio = await screen.findByLabelText(/metric/i);
|
||||||
|
fireEvent.click(metricRadio);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith('API failed');
|
||||||
|
});
|
||||||
|
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should only call updateProfile when only profile data has changed', async () => {
|
it('should only call updateProfile when only profile data has changed', async () => {
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -1004,5 +1080,19 @@ describe('ProfileManager', () => {
|
|||||||
expect(notifyError).toHaveBeenCalledWith('Permission denied');
|
expect(notifyError).toHaveBeenCalledWith('Permission denied');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not trigger OAuth link if user profile is missing', async () => {
|
||||||
|
// This is an edge case to test the guard clause in handleOAuthLink
|
||||||
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||||
|
|
||||||
|
const linkButton = await screen.findByRole('button', { name: /link google account/i });
|
||||||
|
fireEvent.click(linkButton);
|
||||||
|
|
||||||
|
// The function should just return, so nothing should happen.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,6 +250,17 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body.message).toBe('Correction with ID 999 not found');
|
expect(response.body.message).toBe('Correction with ID 999 not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(
|
||||||
|
new Error('Generic DB Error'),
|
||||||
|
);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/admin/corrections/101')
|
||||||
|
.send({ suggested_value: 'new value' });
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Generic DB Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Flyer Review Routes', () => {
|
describe('Flyer Review Routes', () => {
|
||||||
@@ -294,6 +305,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.body).toEqual(mockBrands);
|
expect(response.body).toEqual(mockBrands);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /brands should return 500 on DB error', async () => {
|
||||||
|
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/brands');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||||
const brandId = 55;
|
const brandId = 55;
|
||||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
||||||
@@ -500,6 +518,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
|
||||||
|
const flyerId = 42;
|
||||||
|
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
|
||||||
|
new Error('Generic DB Error'),
|
||||||
|
);
|
||||||
|
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Generic DB Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
||||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ vi.mock('../services/workers.server', () => ({
|
|||||||
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
|
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the monitoring service directly to test route error handling
|
||||||
|
vi.mock('../services/monitoringService.server', () => ({
|
||||||
|
monitoringService: {
|
||||||
|
getWorkerStatuses: vi.fn(),
|
||||||
|
getQueueStatuses: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
|
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
|
||||||
vi.mock('../services/db/flyer.db');
|
vi.mock('../services/db/flyer.db');
|
||||||
vi.mock('../services/db/recipe.db');
|
vi.mock('../services/db/recipe.db');
|
||||||
@@ -78,11 +86,8 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
import adminRouter from './admin.routes';
|
import adminRouter from './admin.routes';
|
||||||
|
|
||||||
// Import the mocked modules to control them
|
// Import the mocked modules to control them
|
||||||
import * as queueService from '../services/queueService.server';
|
import { monitoringService } from '../services/monitoringService.server';
|
||||||
import * as workerService from '../services/workers.server';
|
|
||||||
import { adminRepo } from '../services/db/index.db';
|
import { adminRepo } from '../services/db/index.db';
|
||||||
const mockedQueueService = queueService as Mocked<typeof queueService>;
|
|
||||||
const mockedWorkerService = workerService as Mocked<typeof workerService>;
|
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', () => ({
|
||||||
@@ -146,16 +151,26 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
expect(response.body.errors).toBeDefined();
|
expect(response.body.errors).toBeDefined();
|
||||||
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 500 if fetching activity log fails', async () => {
|
||||||
|
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/activity-log');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('DB Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /workers/status', () => {
|
describe('GET /workers/status', () => {
|
||||||
it('should return the status of all registered workers', async () => {
|
it('should return the status of all registered workers', async () => {
|
||||||
// Arrange: Set the mock status for each worker
|
// Arrange: Set the mock status for each worker
|
||||||
vi.mocked(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
|
const mockStatuses = [
|
||||||
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
|
{ name: 'flyer-processing', isRunning: true },
|
||||||
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
{ name: 'email-sending', isRunning: true },
|
||||||
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
|
{ name: 'analytics-reporting', isRunning: false },
|
||||||
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
|
{ name: 'file-cleanup', isRunning: true },
|
||||||
|
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||||
|
];
|
||||||
|
vi.mocked(monitoringService.getWorkerStatuses).mockResolvedValue(mockStatuses);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/admin/workers/status');
|
const response = await supertest(app).get('/api/admin/workers/status');
|
||||||
@@ -170,51 +185,41 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
{ name: 'weekly-analytics-reporting', isRunning: true },
|
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 500 if fetching worker statuses fails', async () => {
|
||||||
|
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
|
||||||
|
const response = await supertest(app).get('/api/admin/workers/status');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Worker Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /queues/status', () => {
|
describe('GET /queues/status', () => {
|
||||||
it('should return job counts for all registered queues', async () => {
|
it('should return job counts for all registered queues', async () => {
|
||||||
// Arrange: Set the mock job counts for each queue
|
// Arrange: Set the mock job counts for each queue
|
||||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({
|
const mockStatuses = [
|
||||||
waiting: 5,
|
{
|
||||||
active: 1,
|
name: 'flyer-processing',
|
||||||
completed: 100,
|
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||||
failed: 2,
|
},
|
||||||
delayed: 0,
|
{
|
||||||
paused: 0,
|
name: 'email-sending',
|
||||||
});
|
counts: { waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 },
|
||||||
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({
|
},
|
||||||
waiting: 0,
|
{
|
||||||
active: 0,
|
name: 'analytics-reporting',
|
||||||
completed: 50,
|
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
|
||||||
failed: 0,
|
},
|
||||||
delayed: 0,
|
{
|
||||||
paused: 0,
|
name: 'file-cleanup',
|
||||||
});
|
counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 },
|
||||||
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({
|
},
|
||||||
waiting: 0,
|
{
|
||||||
active: 1,
|
name: 'weekly-analytics-reporting',
|
||||||
completed: 10,
|
counts: { waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 },
|
||||||
failed: 1,
|
},
|
||||||
delayed: 0,
|
];
|
||||||
paused: 0,
|
vi.mocked(monitoringService.getQueueStatuses).mockResolvedValue(mockStatuses);
|
||||||
});
|
|
||||||
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({
|
|
||||||
waiting: 2,
|
|
||||||
active: 0,
|
|
||||||
completed: 25,
|
|
||||||
failed: 0,
|
|
||||||
delayed: 0,
|
|
||||||
paused: 0,
|
|
||||||
});
|
|
||||||
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({
|
|
||||||
waiting: 1,
|
|
||||||
active: 0,
|
|
||||||
completed: 5,
|
|
||||||
failed: 0,
|
|
||||||
delayed: 0,
|
|
||||||
paused: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/admin/queues/status');
|
const response = await supertest(app).get('/api/admin/queues/status');
|
||||||
@@ -246,7 +251,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if fetching queue counts fails', async () => {
|
it('should return 500 if fetching queue counts fails', async () => {
|
||||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(
|
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
|
||||||
new Error('Redis is down'),
|
new Error('Redis is down'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
113
src/routes/admin.routes.test.ts
Normal file
113
src/routes/admin.routes.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
|
// Mock dependencies required by admin.routes.ts
|
||||||
|
vi.mock('../services/db/index.db', () => ({
|
||||||
|
adminRepo: {},
|
||||||
|
flyerRepo: {},
|
||||||
|
recipeRepo: {},
|
||||||
|
userRepo: {},
|
||||||
|
personalizationRepo: {},
|
||||||
|
notificationRepo: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/backgroundJobService', () => ({
|
||||||
|
backgroundJobService: {
|
||||||
|
runDailyDealCheck: vi.fn(),
|
||||||
|
triggerAnalyticsReport: vi.fn(),
|
||||||
|
triggerWeeklyAnalyticsReport: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/queueService.server', () => ({
|
||||||
|
flyerQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
emailQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
analyticsQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
cleanupQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
weeklyAnalyticsQueue: { add: vi.fn(), getJob: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/geocodingService.server', () => ({
|
||||||
|
geocodingService: { clearGeocodeCache: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/logger.server', async () => ({
|
||||||
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bull-board/api');
|
||||||
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
|
vi.mock('@bull-board/express', () => ({
|
||||||
|
ExpressAdapter: class {
|
||||||
|
setBasePath() {}
|
||||||
|
getRouter() { return (req: any, res: any, next: any) => next(); }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
|
// Mock Passport to allow admin access
|
||||||
|
vi.mock('./passport.routes', () => ({
|
||||||
|
default: {
|
||||||
|
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
||||||
|
req.user = createMockUserProfile({ role: 'admin' });
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
isAdmin: (req: any, res: any, next: any) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import adminRouter from './admin.routes';
|
||||||
|
|
||||||
|
describe('Admin Routes Rate Limiting', () => {
|
||||||
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger Rate Limiting', () => {
|
||||||
|
it('should block requests to /trigger/daily-deal-check after exceeding limit', async () => {
|
||||||
|
const limit = 30; // Matches adminTriggerLimiter config
|
||||||
|
|
||||||
|
// Make requests up to the limit
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
await supertest(app)
|
||||||
|
.post('/api/admin/trigger/daily-deal-check')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should be blocked
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/admin/trigger/daily-deal-check')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many administrative triggers');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Rate Limiting', () => {
|
||||||
|
it('should block requests to /brands/:id/logo after exceeding limit', async () => {
|
||||||
|
const limit = 20; // Matches adminUploadLimiter config
|
||||||
|
const brandId = 1;
|
||||||
|
|
||||||
|
// Make requests up to the limit
|
||||||
|
// Note: We don't need to attach a file to test the rate limiter, as it runs before multer
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
await supertest(app)
|
||||||
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many file uploads');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ import { monitoringService } from '../services/monitoringService.server';
|
|||||||
import { userService } from '../services/userService';
|
import { userService } from '../services/userService';
|
||||||
import { cleanupUploadedFile } from '../utils/fileUtils';
|
import { cleanupUploadedFile } from '../utils/fileUtils';
|
||||||
import { brandService } from '../services/brandService';
|
import { brandService } from '../services/brandService';
|
||||||
|
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const updateCorrectionSchema = numericIdParam('id').extend({
|
const updateCorrectionSchema = numericIdParam('id').extend({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
@@ -242,6 +243,7 @@ router.put(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/brands/:id/logo',
|
'/brands/:id/logo',
|
||||||
|
adminUploadLimiter,
|
||||||
validateRequest(numericIdParam('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
brandLogoUpload.single('logoImage'),
|
brandLogoUpload.single('logoImage'),
|
||||||
requireFileUpload('logoImage'),
|
requireFileUpload('logoImage'),
|
||||||
@@ -421,6 +423,7 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/trigger/daily-deal-check',
|
'/trigger/daily-deal-check',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -449,6 +452,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/trigger/analytics-report',
|
'/trigger/analytics-report',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -474,6 +478,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/flyers/:flyerId/cleanup',
|
'/flyers/:flyerId/cleanup',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(numericIdParam('flyerId')),
|
validateRequest(numericIdParam('flyerId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -502,6 +507,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/trigger/failing-job',
|
'/trigger/failing-job',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -528,6 +534,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/system/clear-geocode-cache',
|
'/system/clear-geocode-cache',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -580,6 +587,7 @@ router.get('/queues/status', validateRequest(emptySchema), async (req: Request,
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/jobs/:queueName/:jobId/retry',
|
'/jobs/:queueName/:jobId/retry',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(jobRetrySchema),
|
validateRequest(jobRetrySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -606,6 +614,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/trigger/weekly-analytics',
|
'/trigger/weekly-analytics',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
|
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
|
||||||
|
|||||||
@@ -318,6 +318,76 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
|
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /upload-legacy', () => {
|
||||||
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||||
|
const mockUser = createMockUserProfile({
|
||||||
|
user: { user_id: 'legacy-user-1', email: 'legacy-user@test.com' },
|
||||||
|
});
|
||||||
|
// This route requires authentication, so we create an app instance with a user.
|
||||||
|
const authenticatedApp = createTestApp({
|
||||||
|
router: aiRouter,
|
||||||
|
basePath: '/api/ai',
|
||||||
|
authenticatedUser: mockUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process a legacy flyer and return 200 on success', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockFlyer = createMockFlyer({ flyer_id: 10 });
|
||||||
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await supertest(authenticatedApp)
|
||||||
|
.post('/api/ai/upload-legacy')
|
||||||
|
.field('some_legacy_field', 'value') // simulate some body data
|
||||||
|
.attach('flyerFile', imagePath);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockFlyer);
|
||||||
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object), // req.file
|
||||||
|
expect.any(Object), // req.body
|
||||||
|
mockUser,
|
||||||
|
expect.any(Object), // req.log
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if no flyer file is uploaded', async () => {
|
||||||
|
const response = await supertest(authenticatedApp)
|
||||||
|
.post('/api/ai/upload-legacy')
|
||||||
|
.field('some_legacy_field', 'value');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('No flyer file uploaded.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
|
||||||
|
const duplicateError = new aiService.DuplicateFlyerError('Duplicate legacy flyer.', 101);
|
||||||
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
||||||
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(response.body.message).toBe('Duplicate legacy flyer.');
|
||||||
|
expect(response.body.flyerId).toBe(101);
|
||||||
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 500 and cleanup file on a generic service error', async () => {
|
||||||
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new Error('Internal service failure'));
|
||||||
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body.message).toBe('Internal service failure');
|
||||||
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /flyers/process (Legacy)', () => {
|
describe('POST /flyers/process (Legacy)', () => {
|
||||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||||
const mockDataPayload = {
|
const mockDataPayload = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { validateRequest } from '../middleware/validation.middleware';
|
|||||||
import { requiredString } from '../utils/zodUtils';
|
import { requiredString } from '../utils/zodUtils';
|
||||||
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
|
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
|
||||||
import { monitoringService } from '../services/monitoringService.server';
|
import { monitoringService } from '../services/monitoringService.server';
|
||||||
|
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const uploadAndProcessSchema = z.object({
|
|||||||
.length(64, 'Checksum must be 64 characters long.')
|
.length(64, 'Checksum must be 64 characters long.')
|
||||||
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
|
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
|
||||||
),
|
),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,6 +167,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/upload-and-process',
|
'/upload-and-process',
|
||||||
|
aiUploadLimiter,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.single('flyerFile'),
|
uploadToDisk.single('flyerFile'),
|
||||||
// Validation is now handled inside the route to ensure file cleanup on failure.
|
// Validation is now handled inside the route to ensure file cleanup on failure.
|
||||||
@@ -196,6 +199,7 @@ router.post(
|
|||||||
userProfile,
|
userProfile,
|
||||||
req.ip ?? 'unknown',
|
req.ip ?? 'unknown',
|
||||||
req.log,
|
req.log,
|
||||||
|
body.baseUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Respond immediately to the client with 202 Accepted
|
// Respond immediately to the client with 202 Accepted
|
||||||
@@ -221,6 +225,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/upload-legacy',
|
'/upload-legacy',
|
||||||
|
aiUploadLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
uploadToDisk.single('flyerFile'),
|
uploadToDisk.single('flyerFile'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -271,6 +276,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/flyers/process',
|
'/flyers/process',
|
||||||
|
aiUploadLimiter,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.single('flyerImage'),
|
uploadToDisk.single('flyerImage'),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -306,6 +312,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/check-flyer',
|
'/check-flyer',
|
||||||
|
aiUploadLimiter,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.single('image'),
|
uploadToDisk.single('image'),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -325,6 +332,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/extract-address',
|
'/extract-address',
|
||||||
|
aiUploadLimiter,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.single('image'),
|
uploadToDisk.single('image'),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -344,6 +352,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/extract-logo',
|
'/extract-logo',
|
||||||
|
aiUploadLimiter,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
uploadToDisk.array('images'),
|
uploadToDisk.array('images'),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -363,6 +372,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/quick-insights',
|
'/quick-insights',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(insightsSchema),
|
validateRequest(insightsSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -379,6 +389,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/deep-dive',
|
'/deep-dive',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(insightsSchema),
|
validateRequest(insightsSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -395,6 +406,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/search-web',
|
'/search-web',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(searchWebSchema),
|
validateRequest(searchWebSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -409,6 +421,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/compare-prices',
|
'/compare-prices',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(comparePricesSchema),
|
validateRequest(comparePricesSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -427,6 +440,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/plan-trip',
|
'/plan-trip',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(planTripSchema),
|
validateRequest(planTripSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
@@ -446,6 +460,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/generate-image',
|
'/generate-image',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(generateImageSchema),
|
validateRequest(generateImageSchema),
|
||||||
(req: Request, res: Response) => {
|
(req: Request, res: Response) => {
|
||||||
@@ -458,6 +473,7 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/generate-speech',
|
'/generate-speech',
|
||||||
|
aiGenerationLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(generateSpeechSchema),
|
validateRequest(generateSpeechSchema),
|
||||||
(req: Request, res: Response) => {
|
(req: Request, res: Response) => {
|
||||||
@@ -474,6 +490,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/rescan-area',
|
'/rescan-area',
|
||||||
|
aiUploadLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
uploadToDisk.single('image'),
|
uploadToDisk.single('image'),
|
||||||
validateRequest(rescanAreaSchema),
|
validateRequest(rescanAreaSchema),
|
||||||
|
|||||||
@@ -197,6 +197,33 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow registration with an empty string for full_name', async () => {
|
||||||
|
// Arrange
|
||||||
|
const email = 'empty-name@test.com';
|
||||||
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||||
|
newUserProfile: createMockUserProfile({ user: { email } }),
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'token',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await supertest(app).post('/api/auth/register').send({
|
||||||
|
email,
|
||||||
|
password: strongPassword,
|
||||||
|
full_name: '', // Send an empty string
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||||
|
email,
|
||||||
|
strongPassword,
|
||||||
|
undefined, // The preprocess step in the Zod schema should convert '' to undefined
|
||||||
|
undefined,
|
||||||
|
mockLogger,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set a refresh token cookie on successful registration', async () => {
|
it('should set a refresh token cookie on successful registration', async () => {
|
||||||
const mockNewUser = createMockUserProfile({
|
const mockNewUser = createMockUserProfile({
|
||||||
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
|
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
|
||||||
@@ -396,6 +423,24 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
const setCookieHeader = response.headers['set-cookie'];
|
const setCookieHeader = response.headers['set-cookie'];
|
||||||
expect(setCookieHeader[0]).toContain('Max-Age=2592000'); // 30 days in seconds
|
expect(setCookieHeader[0]).toContain('Max-Age=2592000'); // 30 days in seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 400 for an invalid email format', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: 'not-an-email', password: 'password123' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.errors[0].message).toBe('A valid email is required.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if password is missing', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ email: 'test@test.com' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.errors[0].message).toBe('Password is required.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /forgot-password', () => {
|
describe('POST /forgot-password', () => {
|
||||||
@@ -550,12 +595,15 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
it('should still return 200 OK and log an error if deleting the refresh token from DB fails', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const dbError = new Error('DB connection lost');
|
const dbError = new Error('DB connection lost');
|
||||||
mockedAuthService.logout.mockRejectedValue(dbError);
|
mockedAuthService.logout.mockRejectedValue(dbError);
|
||||||
const { logger } = await import('../services/logger.server');
|
const { logger } = await import('../services/logger.server');
|
||||||
|
|
||||||
|
// Spy on logger.error to ensure it's called
|
||||||
|
const errorSpy = vi.spyOn(logger, 'error');
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/auth/logout')
|
.post('/api/auth/logout')
|
||||||
@@ -563,7 +611,12 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
|
// Because authService.logout is fire-and-forget (not awaited), we need to
|
||||||
|
// give the event loop a moment to process the rejected promise and trigger the .catch() block.
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ error: dbError }),
|
expect.objectContaining({ error: dbError }),
|
||||||
'Logout token invalidation failed in background.',
|
'Logout token invalidation failed in background.',
|
||||||
);
|
);
|
||||||
@@ -578,4 +631,280 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
|
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /forgot-password', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const email = 'rate-limit-test@example.com';
|
||||||
|
const maxRequests = 5; // from the rate limiter config
|
||||||
|
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
|
||||||
|
|
||||||
|
// Act: Make `maxRequests` successful calls with the special header
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/forgot-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter for this test
|
||||||
|
.send({ email });
|
||||||
|
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act: Make one more call, which should be blocked
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/forgot-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ email });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many password reset requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
|
||||||
|
// Arrange
|
||||||
|
const email = 'no-rate-limit-test@example.com';
|
||||||
|
const overLimitRequests = 7; // More than the max of 5
|
||||||
|
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
|
||||||
|
|
||||||
|
// Act: Make more calls than the limit. They should all succeed because the limiter is skipped.
|
||||||
|
for (let i = 0; i < overLimitRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/forgot-password')
|
||||||
|
// NO 'X-Test-Rate-Limit-Enable' header is sent
|
||||||
|
.send({ email });
|
||||||
|
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /reset-password', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 10; // from the rate limiter config in auth.routes.ts
|
||||||
|
const newPassword = 'a-Very-Strong-Password-123!';
|
||||||
|
const token = 'some-token-for-rate-limit-test';
|
||||||
|
|
||||||
|
// Mock the service to return a consistent value for the first `maxRequests` calls.
|
||||||
|
// The endpoint returns 400 for invalid tokens, which is fine for this test.
|
||||||
|
// We just need to ensure it's not a 429.
|
||||||
|
mockedAuthService.updatePassword.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Act: Make `maxRequests` calls. They should not be rate-limited.
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/reset-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter
|
||||||
|
.send({ token, newPassword });
|
||||||
|
// The expected status is 400 because the token is invalid, but not 429.
|
||||||
|
expect(response.status, `Request ${i + 1} should not be rate-limited`).toBe(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act: Make one more call, which should be blocked by the rate limiter.
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/reset-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ token, newPassword });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many password reset attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 12; // Limit is 10
|
||||||
|
const newPassword = 'a-Very-Strong-Password-123!';
|
||||||
|
const token = 'some-token-for-skip-limit-test';
|
||||||
|
|
||||||
|
mockedAuthService.updatePassword.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Act: Make more calls than the limit.
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/reset-password')
|
||||||
|
.send({ token, newPassword });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /register', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 5; // Limit is 5 per hour
|
||||||
|
const newUser = {
|
||||||
|
email: 'rate-limit-reg@test.com',
|
||||||
|
password: 'StrongPassword123!',
|
||||||
|
full_name: 'Rate Limit User',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock success to ensure we are hitting the limiter and not failing early
|
||||||
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||||
|
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act: Make maxRequests calls
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send(newUser);
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act: Make one more call
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send(newUser);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many accounts created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
||||||
|
const maxRequests = 7;
|
||||||
|
const newUser = {
|
||||||
|
email: 'no-limit-reg@test.com',
|
||||||
|
password: 'StrongPassword123!',
|
||||||
|
full_name: 'No Limit User',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||||
|
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app).post('/api/auth/register').send(newUser);
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /login', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 5; // Limit is 5 per 15 mins
|
||||||
|
const credentials = { email: 'rate-limit-login@test.com', password: 'password123' };
|
||||||
|
|
||||||
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send(credentials);
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send(credentials);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
||||||
|
const maxRequests = 7;
|
||||||
|
const credentials = { email: 'no-limit-login@test.com', password: 'password123' };
|
||||||
|
|
||||||
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
||||||
|
accessToken: 'token',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app).post('/api/auth/login').send(credentials);
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /refresh-token', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 20; // Limit is 20 per 15 mins
|
||||||
|
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
|
||||||
|
|
||||||
|
// Act: Make maxRequests calls
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/refresh-token')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act: Make one more call
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/refresh-token')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many token refresh attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
||||||
|
const maxRequests = 22;
|
||||||
|
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/refresh-token')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token');
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /logout', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 10; // Limit is 10 per 15 mins
|
||||||
|
mockedAuthService.logout.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/logout')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedResponse = await supertest(app)
|
||||||
|
.post('/api/auth/logout')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many logout attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
||||||
|
const maxRequests = 12;
|
||||||
|
mockedAuthService.logout.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/auth/logout')
|
||||||
|
.set('Cookie', 'refreshToken=valid-token');
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/routes/auth.routes.ts
|
// src/routes/auth.routes.ts
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import rateLimit from 'express-rate-limit';
|
|
||||||
import passport from './passport.routes';
|
import passport from './passport.routes';
|
||||||
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
|
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
@@ -9,48 +8,36 @@ import { validateRequest } from '../middleware/validation.middleware';
|
|||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { validatePasswordStrength } from '../utils/authUtils';
|
import { validatePasswordStrength } from '../utils/authUtils';
|
||||||
import { requiredString } from '../utils/zodUtils';
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
import {
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
forgotPasswordLimiter,
|
||||||
|
resetPasswordLimiter,
|
||||||
|
refreshTokenLimiter,
|
||||||
|
logoutLimiter,
|
||||||
|
} from '../config/rateLimiters';
|
||||||
|
|
||||||
import { authService } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Conditionally disable rate limiting for the test environment
|
// --- Reusable Schemas ---
|
||||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
|
||||||
|
|
||||||
// --- Rate Limiting Configuration ---
|
const passwordSchema = z
|
||||||
const forgotPasswordLimiter = rateLimit({
|
.string()
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
.trim() // Prevent leading/trailing whitespace in passwords.
|
||||||
max: 5,
|
.min(8, 'Password must be at least 8 characters long.')
|
||||||
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
.superRefine((password, ctx) => {
|
||||||
standardHeaders: true,
|
const strength = validatePasswordStrength(password);
|
||||||
legacyHeaders: false,
|
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
||||||
// Do not skip in test environment so we can write integration tests for it.
|
});
|
||||||
// The limiter uses an in-memory store by default, so counts are reset when the test server restarts.
|
|
||||||
// skip: () => isTestEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetPasswordLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 10,
|
|
||||||
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: () => isTestEnv, // Skip this middleware if in test environment
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
// Sanitize email by trimming and converting to lowercase.
|
// Sanitize email by trimming and converting to lowercase.
|
||||||
email: z.string().trim().toLowerCase().email('A valid email is required.'),
|
email: z.string().trim().toLowerCase().email('A valid email is required.'),
|
||||||
password: z
|
password: passwordSchema,
|
||||||
.string()
|
|
||||||
.trim() // Prevent leading/trailing whitespace in passwords.
|
|
||||||
.min(8, 'Password must be at least 8 characters long.')
|
|
||||||
.superRefine((password, ctx) => {
|
|
||||||
const strength = validatePasswordStrength(password);
|
|
||||||
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
|
||||||
}),
|
|
||||||
// Sanitize optional string inputs.
|
// Sanitize optional string inputs.
|
||||||
full_name: z.string().trim().optional(),
|
full_name: z.preprocess((val) => (val === '' ? undefined : val), z.string().trim().optional()),
|
||||||
// Allow empty string or valid URL. If empty string is received, convert to undefined.
|
// Allow empty string or valid URL. If empty string is received, convert to undefined.
|
||||||
avatar_url: z.preprocess(
|
avatar_url: z.preprocess(
|
||||||
(val) => (val === '' ? undefined : val),
|
(val) => (val === '' ? undefined : val),
|
||||||
@@ -59,6 +46,14 @@ const registerSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
body: z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email('A valid email is required.'),
|
||||||
|
password: requiredString('Password is required.'),
|
||||||
|
rememberMe: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const forgotPasswordSchema = z.object({
|
const forgotPasswordSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
// Sanitize email by trimming and converting to lowercase.
|
// Sanitize email by trimming and converting to lowercase.
|
||||||
@@ -69,14 +64,7 @@ const forgotPasswordSchema = z.object({
|
|||||||
const resetPasswordSchema = z.object({
|
const resetPasswordSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
token: requiredString('Token is required.'),
|
token: requiredString('Token is required.'),
|
||||||
newPassword: z
|
newPassword: passwordSchema,
|
||||||
.string()
|
|
||||||
.trim() // Prevent leading/trailing whitespace in passwords.
|
|
||||||
.min(8, 'Password must be at least 8 characters long.')
|
|
||||||
.superRefine((password, ctx) => {
|
|
||||||
const strength = validatePasswordStrength(password);
|
|
||||||
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +73,7 @@ const resetPasswordSchema = z.object({
|
|||||||
// Registration Route
|
// Registration Route
|
||||||
router.post(
|
router.post(
|
||||||
'/register',
|
'/register',
|
||||||
|
registerLimiter,
|
||||||
validateRequest(registerSchema),
|
validateRequest(registerSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
type RegisterRequest = z.infer<typeof registerSchema>;
|
type RegisterRequest = z.infer<typeof registerSchema>;
|
||||||
@@ -122,52 +111,57 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Login Route
|
// Login Route
|
||||||
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
router.post(
|
||||||
passport.authenticate(
|
'/login',
|
||||||
'local',
|
loginLimiter,
|
||||||
{ session: false },
|
validateRequest(loginSchema),
|
||||||
async (err: Error, user: Express.User | false, info: { message: string }) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
// --- LOGIN ROUTE DEBUG LOGGING ---
|
passport.authenticate(
|
||||||
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
|
'local',
|
||||||
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
|
{ session: false },
|
||||||
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
|
async (err: Error, user: Express.User | false, info: { message: string }) => {
|
||||||
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
|
// --- LOGIN ROUTE DEBUG LOGGING ---
|
||||||
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
|
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
|
||||||
|
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
|
||||||
|
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
|
||||||
|
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
|
||||||
|
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
req.log.error(
|
req.log.error(
|
||||||
{ error: err },
|
{ error: err },
|
||||||
`Login authentication error in /login route for email: ${req.body.email}`,
|
`Login authentication error in /login route for email: ${req.body.email}`,
|
||||||
);
|
);
|
||||||
return next(err);
|
return next(err);
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ message: info.message || 'Login failed' });
|
return res.status(401).json({ message: info.message || 'Login failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rememberMe } = req.body;
|
const { rememberMe } = req.body;
|
||||||
const userProfile = user as UserProfile;
|
const userProfile = user as UserProfile;
|
||||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
|
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
|
||||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
|
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined, // 30 days
|
||||||
};
|
};
|
||||||
|
|
||||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||||
// Return the full user profile object on login to avoid a second fetch on the client.
|
// Return the full user profile object on login to avoid a second fetch on the client.
|
||||||
return res.json({ userprofile: userProfile, token: accessToken });
|
return res.json({ userprofile: userProfile, token: accessToken });
|
||||||
} catch (tokenErr) {
|
} catch (tokenErr) {
|
||||||
const email = (user as UserProfile)?.user?.email || req.body.email;
|
const email = (user as UserProfile)?.user?.email || req.body.email;
|
||||||
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
|
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
|
||||||
return next(tokenErr);
|
return next(tokenErr);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)(req, res, next);
|
)(req, res, next);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Route to request a password reset
|
// Route to request a password reset
|
||||||
router.post(
|
router.post(
|
||||||
@@ -224,7 +218,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// New Route to refresh the access token
|
// New Route to refresh the access token
|
||||||
router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const { refreshToken } = req.cookies;
|
const { refreshToken } = req.cookies;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||||
@@ -247,7 +241,7 @@ router.post('/refresh-token', async (req: Request, res: Response, next: NextFunc
|
|||||||
* It clears the refresh token from the database and instructs the client to
|
* It clears the refresh token from the database and instructs the client to
|
||||||
* expire the `refreshToken` cookie.
|
* expire the `refreshToken` cookie.
|
||||||
*/
|
*/
|
||||||
router.post('/logout', async (req: Request, res: Response) => {
|
router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
|
||||||
const { refreshToken } = req.cookies;
|
const { refreshToken } = req.cookies;
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
// Invalidate the token in the database so it cannot be used again.
|
// Invalidate the token in the database so it cannot be used again.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { budgetRepo } from '../services/db/index.db';
|
|||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||||
|
import { budgetUpdateLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ const spendingAnalysisSchema = z.object({
|
|||||||
// Middleware to ensure user is authenticated for all budget routes
|
// Middleware to ensure user is authenticated for all budget routes
|
||||||
router.use(passport.authenticate('jwt', { session: false }));
|
router.use(passport.authenticate('jwt', { session: false }));
|
||||||
|
|
||||||
|
// Apply rate limiting to all subsequent budget routes
|
||||||
|
router.use(budgetUpdateLimiter);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/budgets - Get all budgets for the authenticated user.
|
* GET /api/budgets - Get all budgets for the authenticated user.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -103,4 +103,18 @@ describe('Deals Routes (/api/users/deals)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply userReadLimiter to GET /best-watched-prices', async () => {
|
||||||
|
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await supertest(authenticatedApp)
|
||||||
|
.get('/api/users/deals/best-watched-prices')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import passport from './passport.routes';
|
|||||||
import { dealsRepo } from '../services/db/deals.db';
|
import { dealsRepo } from '../services/db/deals.db';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { userReadLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ router.use(passport.authenticate('jwt', { session: false }));
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/best-watched-prices',
|
'/best-watched-prices',
|
||||||
|
userReadLimiter,
|
||||||
validateRequest(bestWatchedPricesSchema),
|
validateRequest(bestWatchedPricesSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
getFlyerItems: vi.fn(),
|
getFlyerItems: vi.fn(),
|
||||||
getFlyerItemsForFlyers: vi.fn(),
|
getFlyerItemsForFlyers: vi.fn(),
|
||||||
countFlyerItemsForFlyers: vi.fn(),
|
countFlyerItemsForFlyers: vi.fn(),
|
||||||
trackFlyerItemInteraction: vi.fn(),
|
trackFlyerItemInteraction: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,6 +50,8 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockFlyers);
|
expect(response.body).toEqual(mockFlyers);
|
||||||
|
// Also assert that the default limit and offset were used.
|
||||||
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass limit and offset query parameters to the db function', async () => {
|
it('should pass limit and offset query parameters to the db function', async () => {
|
||||||
@@ -58,6 +60,18 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use default for offset when only limit is provided', async () => {
|
||||||
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||||
|
await supertest(app).get('/api/flyers?limit=5');
|
||||||
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 5, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default for limit when only offset is provided', async () => {
|
||||||
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||||
|
await supertest(app).get('/api/flyers?offset=10');
|
||||||
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 10);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 500 if the database call fails', async () => {
|
it('should return 500 if the database call fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
||||||
@@ -151,7 +165,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('DB Error');
|
expect(response.body.message).toBe('DB Error');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ error: dbError },
|
{ error: dbError, flyerId: 123 },
|
||||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -276,5 +290,75 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
.send({ type: 'invalid' });
|
.send({ type: 'invalid' });
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 202 and log an error if the tracking function fails', async () => {
|
||||||
|
const trackingError = new Error('Tracking DB is down');
|
||||||
|
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockRejectedValue(trackingError);
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/flyers/items/99/track')
|
||||||
|
.send({ type: 'click' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
|
||||||
|
// Allow the event loop to process the unhandled promise rejection from the fire-and-forget call
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: trackingError, itemId: 99 },
|
||||||
|
'Flyer item interaction tracking failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /', async () => {
|
||||||
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/flyers')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
|
||||||
|
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/flyers/items/batch-fetch')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ flyerIds: [1] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply batchLimiter to POST /items/batch-count', async () => {
|
||||||
|
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(0);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/flyers/items/batch-count')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ flyerIds: [1] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
|
||||||
|
// Mock fire-and-forget promise
|
||||||
|
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/flyers/items/1/track')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ type: 'view' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import * as db from '../services/db/index.db';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { optionalNumeric } from '../utils/zodUtils';
|
import { optionalNumeric } from '../utils/zodUtils';
|
||||||
|
import {
|
||||||
|
publicReadLimiter,
|
||||||
|
batchLimiter,
|
||||||
|
trackingLimiter,
|
||||||
|
} from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -48,12 +53,12 @@ const trackItemSchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* GET /api/flyers - Get a paginated list of all flyers.
|
* GET /api/flyers - Get a paginated list of all flyers.
|
||||||
*/
|
*/
|
||||||
type GetFlyersRequest = z.infer<typeof getFlyersSchema>;
|
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
||||||
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
|
||||||
const { query } = req as unknown as GetFlyersRequest;
|
|
||||||
try {
|
try {
|
||||||
const limit = query.limit ? Number(query.limit) : 20;
|
// The `validateRequest` middleware ensures `req.query` is valid.
|
||||||
const offset = query.offset ? Number(query.offset) : 0;
|
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||||
|
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
|
||||||
|
|
||||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||||
res.json(flyers);
|
res.json(flyers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,14 +70,14 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promis
|
|||||||
/**
|
/**
|
||||||
* GET /api/flyers/:id - Get a single flyer by its ID.
|
* GET /api/flyers/:id - Get a single flyer by its ID.
|
||||||
*/
|
*/
|
||||||
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
||||||
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
|
||||||
const { params } = req as unknown as GetFlyerByIdRequest;
|
|
||||||
try {
|
try {
|
||||||
const flyer = await db.flyerRepo.getFlyerById(params.id);
|
// Explicitly parse to get the coerced number type for `id`.
|
||||||
|
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||||
|
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||||
res.json(flyer);
|
res.json(flyer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.log.error({ error, flyerId: params.id }, 'Error fetching flyer by ID:');
|
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -82,14 +87,17 @@ router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next):
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/items',
|
'/:id/items',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(flyerIdParamSchema),
|
validateRequest(flyerIdParamSchema),
|
||||||
async (req, res, next): Promise<void> => {
|
async (req, res, next): Promise<void> => {
|
||||||
const { params } = req as unknown as GetFlyerByIdRequest;
|
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
||||||
try {
|
try {
|
||||||
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
|
// Explicitly parse to get the coerced number type for `id`.
|
||||||
|
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||||
|
const items = await db.flyerRepo.getFlyerItems(id, req.log);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -101,10 +109,13 @@ router.get(
|
|||||||
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
|
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
|
||||||
router.post(
|
router.post(
|
||||||
'/items/batch-fetch',
|
'/items/batch-fetch',
|
||||||
|
batchLimiter,
|
||||||
validateRequest(batchFetchSchema),
|
validateRequest(batchFetchSchema),
|
||||||
async (req, res, next): Promise<void> => {
|
async (req, res, next): Promise<void> => {
|
||||||
const { body } = req as unknown as BatchFetchRequest;
|
const { body } = req as unknown as BatchFetchRequest;
|
||||||
try {
|
try {
|
||||||
|
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
|
||||||
|
// and `express.json()` has parsed it. There's no type coercion to apply.
|
||||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,12 +131,14 @@ router.post(
|
|||||||
type BatchCountRequest = z.infer<typeof batchCountSchema>;
|
type BatchCountRequest = z.infer<typeof batchCountSchema>;
|
||||||
router.post(
|
router.post(
|
||||||
'/items/batch-count',
|
'/items/batch-count',
|
||||||
|
batchLimiter,
|
||||||
validateRequest(batchCountSchema),
|
validateRequest(batchCountSchema),
|
||||||
async (req, res, next): Promise<void> => {
|
async (req, res, next): Promise<void> => {
|
||||||
const { body } = req as unknown as BatchCountRequest;
|
const { body } = req as unknown as BatchCountRequest;
|
||||||
try {
|
try {
|
||||||
// The DB function handles an empty array, so we can simplify.
|
// The schema ensures flyerIds is an array of numbers.
|
||||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
|
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
|
||||||
|
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||||
res.json({ count });
|
res.json({ count });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.log.error({ error }, 'Error counting batch flyer items');
|
req.log.error({ error }, 'Error counting batch flyer items');
|
||||||
@@ -137,11 +150,22 @@ router.post(
|
|||||||
/**
|
/**
|
||||||
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
|
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
|
||||||
*/
|
*/
|
||||||
type TrackItemRequest = z.infer<typeof trackItemSchema>;
|
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
|
||||||
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res): void => {
|
try {
|
||||||
const { params, body } = req as unknown as TrackItemRequest;
|
// Explicitly parse to get coerced types.
|
||||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log);
|
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||||
res.status(202).send();
|
|
||||||
|
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||||
|
// We add a .catch to log any potential errors without crashing the server process.
|
||||||
|
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||||
|
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(202).send();
|
||||||
|
} catch (error) {
|
||||||
|
// This will catch Zod parsing errors if they occur.
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -336,4 +336,50 @@ describe('Gamification Routes (/api/achievements)', () => {
|
|||||||
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
|
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /', async () => {
|
||||||
|
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue([]);
|
||||||
|
const response = await supertest(unauthenticatedApp)
|
||||||
|
.get('/api/achievements')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userReadLimiter to GET /me', async () => {
|
||||||
|
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
req.user = mockUserProfile;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue([]);
|
||||||
|
const response = await supertest(authenticatedApp)
|
||||||
|
.get('/api/achievements/me')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply adminTriggerLimiter to POST /award', async () => {
|
||||||
|
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
req.user = mockAdminProfile;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
|
||||||
|
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await supertest(adminApp)
|
||||||
|
.post('/api/achievements/award')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ userId: 'some-user', achievementName: 'some-achievement' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { logger } from '../services/logger.server';
|
|||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { requiredString, optionalNumeric } from '../utils/zodUtils';
|
import { requiredString, optionalNumeric } from '../utils/zodUtils';
|
||||||
|
import {
|
||||||
|
publicReadLimiter,
|
||||||
|
userReadLimiter,
|
||||||
|
adminTriggerLimiter,
|
||||||
|
} from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
||||||
@@ -34,7 +39,7 @@ const awardAchievementSchema = z.object({
|
|||||||
* GET /api/achievements - Get the master list of all available achievements.
|
* GET /api/achievements - Get the master list of all available achievements.
|
||||||
* This is a public endpoint.
|
* This is a public endpoint.
|
||||||
*/
|
*/
|
||||||
router.get('/', async (req, res, next: NextFunction) => {
|
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const achievements = await gamificationService.getAllAchievements(req.log);
|
const achievements = await gamificationService.getAllAchievements(req.log);
|
||||||
res.json(achievements);
|
res.json(achievements);
|
||||||
@@ -50,6 +55,7 @@ router.get('/', async (req, res, next: NextFunction) => {
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/leaderboard',
|
'/leaderboard',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(leaderboardSchema),
|
validateRequest(leaderboardSchema),
|
||||||
async (req, res, next: NextFunction): Promise<void> => {
|
async (req, res, next: NextFunction): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +80,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/me',
|
'/me',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
userReadLimiter,
|
||||||
async (req, res, next: NextFunction): Promise<void> => {
|
async (req, res, next: NextFunction): Promise<void> => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +110,7 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
|
|||||||
*/
|
*/
|
||||||
adminGamificationRouter.post(
|
adminGamificationRouter.post(
|
||||||
'/award',
|
'/award',
|
||||||
|
adminTriggerLimiter,
|
||||||
validateRequest(awardAchievementSchema),
|
validateRequest(awardAchievementSchema),
|
||||||
async (req, res, next: NextFunction): Promise<void> => {
|
async (req, res, next: NextFunction): Promise<void> => {
|
||||||
// Infer type and cast request object as per ADR-003
|
// Infer type and cast request object as per ADR-003
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ vi.mock('passport', () => {
|
|||||||
// Now, import the passport configuration which will use our mocks
|
// Now, import the passport configuration which will use our mocks
|
||||||
import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes';
|
import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes';
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
|
import { ForbiddenError } from '../services/db/errors.db';
|
||||||
|
|
||||||
describe('Passport Configuration', () => {
|
describe('Passport Configuration', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -414,6 +415,29 @@ describe('Passport Configuration', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(done).toHaveBeenCalledWith(dbError, false);
|
expect(done).toHaveBeenCalledWith(dbError, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call done(err, false) if jwt_payload is null', async () => {
|
||||||
|
// Arrange
|
||||||
|
const jwtPayload = null;
|
||||||
|
const done = vi.fn();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
// We know the mock setup populates the callback.
|
||||||
|
if (verifyCallbackWrapper.callback) {
|
||||||
|
// The strategy would not even call the callback if the token is invalid/missing.
|
||||||
|
// However, to test the robustness of our callback, we can invoke it directly with null.
|
||||||
|
await verifyCallbackWrapper.callback(jwtPayload as any, done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// The code will throw a TypeError because it tries to access 'user_id' of null.
|
||||||
|
// The catch block in the strategy will catch this and call done(err, false).
|
||||||
|
expect(done).toHaveBeenCalledWith(expect.any(TypeError), false);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: expect.any(TypeError) },
|
||||||
|
'Error during JWT authentication strategy:',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isAdmin Middleware', () => {
|
describe('isAdmin Middleware', () => {
|
||||||
@@ -445,7 +469,7 @@ describe('Passport Configuration', () => {
|
|||||||
expect(mockRes.status).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 Forbidden if user does not have "admin" role', () => {
|
it('should call next with a ForbiddenError if user does not have "admin" role', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq: Partial<Request> = {
|
const mockReq: Partial<Request> = {
|
||||||
user: createMockUserProfile({
|
user: createMockUserProfile({
|
||||||
@@ -458,14 +482,11 @@ describe('Passport Configuration', () => {
|
|||||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({
|
|
||||||
message: 'Forbidden: Administrator access required.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 Forbidden if req.user is missing', () => {
|
it('should call next with a ForbiddenError if req.user is missing', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq = {} as Request; // No req.user
|
const mockReq = {} as Request; // No req.user
|
||||||
|
|
||||||
@@ -473,11 +494,86 @@ describe('Passport Configuration', () => {
|
|||||||
isAdmin(mockReq, mockRes as Response, mockNext);
|
isAdmin(mockReq, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 Forbidden if req.user is not a valid UserProfile object', () => {
|
it('should log a warning when a non-admin user tries to access an admin route', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockReq: Partial<Request> = {
|
||||||
|
user: createMockUserProfile({
|
||||||
|
role: 'user',
|
||||||
|
user: { user_id: 'user-id-123', email: 'user@test.com' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: user-id-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a warning with "unknown" user when req.user is missing', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockReq = {} as Request; // No req.user
|
||||||
|
|
||||||
|
// Act
|
||||||
|
isAdmin(mockReq, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith('Admin access denied for user: unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next with a ForbiddenError for various invalid user object shapes', () => {
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
const mockRes: Partial<Response> = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Case 1: user is not an object (e.g., a string)
|
||||||
|
const req1 = { user: 'not-an-object' } as unknown as Request;
|
||||||
|
isAdmin(req1, mockRes as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Case 2: user is null
|
||||||
|
const req2 = { user: null } as unknown as Request;
|
||||||
|
isAdmin(req2, mockRes as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Case 3: user object is missing 'user' property
|
||||||
|
const req3 = { user: { role: 'admin' } } as unknown as Request;
|
||||||
|
isAdmin(req3, mockRes as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Case 4: user.user is not an object
|
||||||
|
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
|
||||||
|
isAdmin(req4, mockRes as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Case 5: user.user is missing 'user_id'
|
||||||
|
const req5 = {
|
||||||
|
user: { role: 'admin', user: { email: 'test@test.com' } },
|
||||||
|
} as unknown as Request;
|
||||||
|
isAdmin(req5, mockRes as Response, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset the main mockNext for other tests in the suite
|
||||||
|
mockNext.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next with a ForbiddenError if req.user is not a valid UserProfile object', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq: Partial<Request> = {
|
const mockReq: Partial<Request> = {
|
||||||
// An object that is not a valid UserProfile (e.g., missing 'role')
|
// An object that is not a valid UserProfile (e.g., missing 'role')
|
||||||
@@ -490,11 +586,8 @@ describe('Passport Configuration', () => {
|
|||||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({
|
|
||||||
message: 'Forbidden: Administrator access required.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,13 +704,18 @@ describe('Passport Configuration', () => {
|
|||||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
// The new implementation logs a warning and proceeds.
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
{ error: authError },
|
||||||
|
'Optional auth encountered an error, proceeding anonymously.',
|
||||||
|
);
|
||||||
expect(mockReq.user).toBeUndefined();
|
expect(mockReq.user).toBeUndefined();
|
||||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mockAuth Middleware', () => {
|
describe('mockAuth Middleware', () => {
|
||||||
const mockNext: NextFunction = vi.fn();
|
const mockNext: NextFunction = vi.fn(); // This was a duplicate, fixed.
|
||||||
const mockRes: Partial<Response> = {
|
const mockRes: Partial<Response> = {
|
||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
json: vi.fn(),
|
json: vi.fn(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as db from '../services/db/index.db';
|
|||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
import { ForbiddenError } from '../services/db/errors.db';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||||
|
|
||||||
@@ -307,7 +308,7 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
|||||||
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
||||||
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
||||||
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
||||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
next(new ForbiddenError('Forbidden: Administrator access required.'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,12 +324,17 @@ export const optionalAuth = (req: Request, res: Response, next: NextFunction) =>
|
|||||||
'jwt',
|
'jwt',
|
||||||
{ session: false },
|
{ session: false },
|
||||||
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
||||||
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
|
if (err) {
|
||||||
|
// An actual error occurred during authentication (e.g., malformed token).
|
||||||
|
// For optional auth, we log this but still proceed without a user.
|
||||||
|
logger.warn({ error: err }, 'Optional auth encountered an error, proceeding anonymously.');
|
||||||
|
return next();
|
||||||
|
}
|
||||||
if (info) {
|
if (info) {
|
||||||
// The patch requested this specific error handling.
|
// The patch requested this specific error handling.
|
||||||
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
||||||
} // The patch requested this specific error handling.
|
}
|
||||||
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
|
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds.
|
||||||
|
|
||||||
next(); // Always proceed to the next middleware
|
next(); // Always proceed to the next middleware
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
|||||||
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
|
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
|
||||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
|
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/personalization/master-items');
|
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockItems);
|
expect(response.body).toEqual(mockItems);
|
||||||
@@ -49,7 +49,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
|||||||
it('should return 500 if the database call fails', async () => {
|
it('should return 500 if the database call fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||||
const response = await supertest(app).get('/api/personalization/master-items');
|
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.message).toBe('DB Error');
|
expect(response.body.message).toBe('DB Error');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
@@ -106,4 +106,16 @@ describe('Personalization Routes (/api/personalization)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /master-items', async () => {
|
||||||
|
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/personalization/master-items')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { publicReadLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const emptySchema = z.object({});
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/master-items',
|
'/master-items',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +41,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/dietary-restrictions',
|
'/dietary-restrictions',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +62,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/appliances',
|
'/appliances',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(emptySchema),
|
validateRequest(emptySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// src/routes/price.routes.test.ts
|
// src/routes/price.routes.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
import { mockLogger } from '../tests/utils/mockLogger';
|
||||||
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
// Mock the price repository
|
// Mock the price repository
|
||||||
vi.mock('../services/db/price.db', () => ({
|
vi.mock('../services/db/price.db', () => ({
|
||||||
@@ -17,12 +19,29 @@ vi.mock('../services/logger.server', async () => ({
|
|||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the passport middleware
|
||||||
|
vi.mock('./passport.routes', () => ({
|
||||||
|
default: {
|
||||||
|
authenticate: vi.fn(
|
||||||
|
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
// If req.user is set, proceed as an authenticated user.
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Import the router AFTER other setup.
|
// Import the router AFTER other setup.
|
||||||
import priceRouter from './price.routes';
|
import priceRouter from './price.routes';
|
||||||
import { priceRepo } from '../services/db/price.db';
|
import { priceRepo } from '../services/db/price.db';
|
||||||
|
|
||||||
describe('Price Routes (/api/price-history)', () => {
|
describe('Price Routes (/api/price-history)', () => {
|
||||||
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' });
|
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
|
||||||
|
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history', authenticatedUser: mockUser });
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -130,4 +149,18 @@ describe('Price Routes (/api/price-history)', () => {
|
|||||||
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
|
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply priceHistoryLimiter to POST /', async () => {
|
||||||
|
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/price-history')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ masterItemIds: [1, 2] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// src/routes/price.routes.ts
|
// src/routes/price.routes.ts
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import passport from './passport.routes';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { priceRepo } from '../services/db/price.db';
|
import { priceRepo } from '../services/db/price.db';
|
||||||
import { optionalNumeric } from '../utils/zodUtils';
|
import { optionalNumeric } from '../utils/zodUtils';
|
||||||
|
import { priceHistoryLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -26,21 +28,27 @@ type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
|
|||||||
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
|
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
|
||||||
* This endpoint retrieves price points over time for specified master grocery items.
|
* This endpoint retrieves price points over time for specified master grocery items.
|
||||||
*/
|
*/
|
||||||
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.post(
|
||||||
// Cast 'req' to the inferred type for full type safety.
|
'/',
|
||||||
const {
|
passport.authenticate('jwt', { session: false }),
|
||||||
body: { masterItemIds, limit, offset },
|
priceHistoryLimiter,
|
||||||
} = req as unknown as PriceHistoryRequest;
|
validateRequest(priceHistorySchema),
|
||||||
req.log.info(
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
{ itemCount: masterItemIds.length, limit, offset },
|
// Cast 'req' to the inferred type for full type safety.
|
||||||
'[API /price-history] Received request for historical price data.',
|
const {
|
||||||
);
|
body: { masterItemIds, limit, offset },
|
||||||
try {
|
} = req as unknown as PriceHistoryRequest;
|
||||||
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
|
req.log.info(
|
||||||
res.status(200).json(priceHistory);
|
{ itemCount: masterItemIds.length, limit, offset },
|
||||||
} catch (error) {
|
'[API /price-history] Received request for historical price data.',
|
||||||
next(error);
|
);
|
||||||
}
|
try {
|
||||||
});
|
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
|
||||||
|
res.status(200).json(priceHistory);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -208,4 +208,36 @@ describe('Reaction Routes (/api/reactions)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /', async () => {
|
||||||
|
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||||
|
vi.mocked(reactionRepo.getReactions).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/reactions')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userUpdateLimiter to POST /toggle', async () => {
|
||||||
|
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
|
||||||
|
const app = createTestApp({
|
||||||
|
router: reactionsRouter,
|
||||||
|
basePath: '/api/reactions',
|
||||||
|
authenticatedUser: mockUser,
|
||||||
|
});
|
||||||
|
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/reactions/toggle')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ entity_type: 'recipe', entity_id: '1', reaction_type: 'like' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import { validateRequest } from '../middleware/validation.middleware';
|
|||||||
import passport from './passport.routes';
|
import passport from './passport.routes';
|
||||||
import { requiredString } from '../utils/zodUtils';
|
import { requiredString } from '../utils/zodUtils';
|
||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
|
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ const getReactionSummarySchema = z.object({
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(getReactionsSchema),
|
validateRequest(getReactionsSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +64,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/summary',
|
'/summary',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(getReactionSummarySchema),
|
validateRequest(getReactionSummarySchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +84,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/toggle',
|
'/toggle',
|
||||||
|
reactionToggleLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(toggleReactionSchema),
|
validateRequest(toggleReactionSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -318,4 +318,65 @@ describe('Recipe Routes (/api/recipes)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /suggest', () => {
|
||||||
|
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-user' } });
|
||||||
|
const authApp = createTestApp({
|
||||||
|
router: recipeRouter,
|
||||||
|
basePath: '/api/recipes',
|
||||||
|
authenticatedUser: mockUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
// Arrange
|
||||||
|
const maxRequests = 20; // Limit is 20 per 15 mins
|
||||||
|
const ingredients = ['chicken', 'rice'];
|
||||||
|
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('A tasty suggestion');
|
||||||
|
|
||||||
|
// Act: Make maxRequests calls
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(authApp)
|
||||||
|
.post('/api/recipes/suggest')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ ingredients });
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act: Make one more call
|
||||||
|
const blockedResponse = await supertest(authApp)
|
||||||
|
.post('/api/recipes/suggest')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ ingredients });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many AI generation requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
||||||
|
const maxRequests = 22;
|
||||||
|
const ingredients = ['beef', 'potatoes'];
|
||||||
|
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Another suggestion');
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRequests; i++) {
|
||||||
|
const response = await supertest(authApp)
|
||||||
|
.post('/api/recipes/suggest')
|
||||||
|
.send({ ingredients });
|
||||||
|
expect(response.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on Public Routes', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /:recipeId', async () => {
|
||||||
|
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(createMockRecipe({}));
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/recipes/1')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { aiService } from '../services/aiService.server';
|
|||||||
import passport from './passport.routes';
|
import passport from './passport.routes';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
|
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
|
||||||
|
import { publicReadLimiter, suggestionLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ const suggestRecipeSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/by-sale-percentage',
|
'/by-sale-percentage',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(bySalePercentageSchema),
|
validateRequest(bySalePercentageSchema),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +62,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/by-sale-ingredients',
|
'/by-sale-ingredients',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(bySaleIngredientsSchema),
|
validateRequest(bySaleIngredientsSchema),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +85,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/by-ingredient-and-tag',
|
'/by-ingredient-and-tag',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(byIngredientAndTagSchema),
|
validateRequest(byIngredientAndTagSchema),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -102,7 +106,7 @@ router.get(
|
|||||||
/**
|
/**
|
||||||
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
|
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
|
||||||
*/
|
*/
|
||||||
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
router.get('/:recipeId/comments', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Explicitly parse req.params to coerce recipeId to a number
|
// Explicitly parse req.params to coerce recipeId to a number
|
||||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||||
@@ -117,7 +121,7 @@ router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (
|
|||||||
/**
|
/**
|
||||||
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
|
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
|
||||||
*/
|
*/
|
||||||
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
router.get('/:recipeId', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Explicitly parse req.params to coerce recipeId to a number
|
// Explicitly parse req.params to coerce recipeId to a number
|
||||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||||
@@ -135,6 +139,7 @@ router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res,
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/suggest',
|
'/suggest',
|
||||||
|
suggestionLimiter,
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
validateRequest(suggestRecipeSchema),
|
validateRequest(suggestRecipeSchema),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
|
|||||||
@@ -66,4 +66,16 @@ describe('Stats Routes (/api/stats)', () => {
|
|||||||
expect(response.body.errors.length).toBe(2);
|
expect(response.body.errors.length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should apply publicReadLimiter to GET /most-frequent-sales', async () => {
|
||||||
|
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockResolvedValue([]);
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/stats/most-frequent-sales')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import { optionalNumeric } from '../utils/zodUtils';
|
import { optionalNumeric } from '../utils/zodUtils';
|
||||||
|
import { publicReadLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const mostFrequentSalesSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/most-frequent-sales',
|
'/most-frequent-sales',
|
||||||
|
publicReadLimiter,
|
||||||
validateRequest(mostFrequentSalesSchema),
|
validateRequest(mostFrequentSalesSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -156,4 +156,25 @@ describe('System Routes (/api/system)', () => {
|
|||||||
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
|
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on /geocode', () => {
|
||||||
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||||
|
const limit = 100; // Matches geocodeLimiter config
|
||||||
|
const address = '123 Test St';
|
||||||
|
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue({ lat: 0, lng: 0 });
|
||||||
|
|
||||||
|
// We only need to verify it blocks eventually.
|
||||||
|
// Instead of running 100 requests, we check for the headers which confirm the middleware is active.
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/system/geocode')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ address });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-remaining');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(limit);
|
||||||
|
expect(parseInt(response.headers['ratelimit-remaining'])).toBeLessThan(limit);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { validateRequest } from '../middleware/validation.middleware';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requiredString } from '../utils/zodUtils';
|
import { requiredString } from '../utils/zodUtils';
|
||||||
import { systemService } from '../services/systemService';
|
import { systemService } from '../services/systemService';
|
||||||
|
import { geocodeLimiter } from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/geocode',
|
'/geocode',
|
||||||
|
geocodeLimiter,
|
||||||
validateRequest(geocodeSchema),
|
validateRequest(geocodeSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Infer type and cast request object as per ADR-003
|
// Infer type and cast request object as per ADR-003
|
||||||
|
|||||||
@@ -1030,7 +1030,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
it('should upload an avatar and update the user profile', async () => {
|
it('should upload an avatar and update the user profile', async () => {
|
||||||
const mockUpdatedProfile = createMockUserProfile({
|
const mockUpdatedProfile = createMockUserProfile({
|
||||||
...mockUserProfile,
|
...mockUserProfile,
|
||||||
avatar_url: 'http://localhost:3001/uploads/avatars/new-avatar.png',
|
avatar_url: 'https://example.com/uploads/avatars/new-avatar.png',
|
||||||
});
|
});
|
||||||
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
|
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
|
||||||
|
|
||||||
@@ -1042,7 +1042,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.avatar_url).toContain('http://localhost:3001/uploads/avatars/');
|
expect(response.body.avatar_url).toContain('https://example.com/uploads/avatars/');
|
||||||
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
|
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
|
||||||
mockUserProfile.user.user_id,
|
mockUserProfile.user.user_id,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -1140,6 +1140,19 @@ describe('User Routes (/api/users)', () => {
|
|||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||||
|
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new NotFoundError('Recipe not found'));
|
||||||
|
const response = await supertest(app).delete('/api/users/recipes/999');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.message).toBe('Recipe not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||||
|
const response = await supertest(app).delete('/api/users/recipes/abc');
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.errors[0].message).toContain('received NaN');
|
||||||
|
});
|
||||||
|
|
||||||
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
|
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
|
||||||
const updates = { description: 'A new delicious description.' };
|
const updates = { description: 'A new delicious description.' };
|
||||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
|
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
|
||||||
@@ -1181,6 +1194,14 @@ describe('User Routes (/api/users)', () => {
|
|||||||
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PUT /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/users/recipes/abc')
|
||||||
|
.send({ name: 'New Name' });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.errors[0].message).toContain('received NaN');
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
||||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(
|
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(
|
||||||
new NotFoundError('Shopping list not found'),
|
new NotFoundError('Shopping list not found'),
|
||||||
@@ -1214,5 +1235,96 @@ describe('User Routes (/api/users)', () => {
|
|||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
}); // End of Recipe Routes
|
}); // End of Recipe Routes
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Advance time to ensure rate limits are reset between tests
|
||||||
|
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userUpdateLimiter to PUT /profile', async () => {
|
||||||
|
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUserProfile);
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/users/profile')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ full_name: 'Rate Limit Test' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userSensitiveUpdateLimiter to PUT /profile/password and block after limit', async () => {
|
||||||
|
const limit = 5;
|
||||||
|
vi.mocked(userService.updateUserPassword).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Consume the limit
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/users/profile/password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ newPassword: 'StrongPassword123!' });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next request should be blocked
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put('/api/users/profile/password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ newPassword: 'StrongPassword123!' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many sensitive requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userUploadLimiter to POST /profile/avatar', async () => {
|
||||||
|
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUserProfile);
|
||||||
|
const dummyImagePath = 'test-avatar.png';
|
||||||
|
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/users/profile/avatar')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||||
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
|
||||||
|
// Explicitly advance time to ensure the rate limiter window has reset from previous tests
|
||||||
|
vi.advanceTimersByTime(60 * 60 * 1000 + 5000);
|
||||||
|
|
||||||
|
const limit = 5;
|
||||||
|
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Consume the limit
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete('/api/users/account')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ password: 'correct-password' });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next request should be blocked
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete('/api/users/account')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ password: 'correct-password' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.text).toContain('Too many sensitive requests');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
} from '../utils/zodUtils';
|
} from '../utils/zodUtils';
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { cleanupUploadedFile } from '../utils/fileUtils';
|
import { cleanupUploadedFile } from '../utils/fileUtils';
|
||||||
|
import {
|
||||||
|
userUpdateLimiter,
|
||||||
|
userSensitiveUpdateLimiter,
|
||||||
|
userUploadLimiter,
|
||||||
|
} from '../config/rateLimiters';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -95,6 +100,7 @@ const avatarUpload = createUploadMiddleware({
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/profile/avatar',
|
'/profile/avatar',
|
||||||
|
userUploadLimiter,
|
||||||
avatarUpload.single('avatar'),
|
avatarUpload.single('avatar'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// The try-catch block was already correct here.
|
// The try-catch block was already correct here.
|
||||||
@@ -215,6 +221,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
|
|||||||
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
|
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/profile',
|
'/profile',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(updateProfileSchema),
|
validateRequest(updateProfileSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
|
||||||
@@ -241,6 +248,7 @@ router.put(
|
|||||||
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
|
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/profile/password',
|
'/profile/password',
|
||||||
|
userSensitiveUpdateLimiter,
|
||||||
validateRequest(updatePasswordSchema),
|
validateRequest(updatePasswordSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
||||||
@@ -264,6 +272,7 @@ router.put(
|
|||||||
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
|
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
|
||||||
router.delete(
|
router.delete(
|
||||||
'/account',
|
'/account',
|
||||||
|
userSensitiveUpdateLimiter,
|
||||||
validateRequest(deleteAccountSchema),
|
validateRequest(deleteAccountSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
||||||
@@ -302,6 +311,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
|
|||||||
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
|
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
|
||||||
router.post(
|
router.post(
|
||||||
'/watched-items',
|
'/watched-items',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(addWatchedItemSchema),
|
validateRequest(addWatchedItemSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
||||||
@@ -333,6 +343,7 @@ const watchedItemIdSchema = numericIdParam('masterItemId');
|
|||||||
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
|
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
|
||||||
router.delete(
|
router.delete(
|
||||||
'/watched-items/:masterItemId',
|
'/watched-items/:masterItemId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(watchedItemIdSchema),
|
validateRequest(watchedItemIdSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
||||||
@@ -407,6 +418,7 @@ router.get(
|
|||||||
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
|
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
|
||||||
router.post(
|
router.post(
|
||||||
'/shopping-lists',
|
'/shopping-lists',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(createShoppingListSchema),
|
validateRequest(createShoppingListSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
||||||
@@ -435,6 +447,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/shopping-lists/:listId',
|
'/shopping-lists/:listId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(shoppingListIdSchema),
|
validateRequest(shoppingListIdSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
||||||
@@ -475,6 +488,7 @@ const addShoppingListItemSchema = shoppingListIdSchema.extend({
|
|||||||
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
|
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
|
||||||
router.post(
|
router.post(
|
||||||
'/shopping-lists/:listId/items',
|
'/shopping-lists/:listId/items',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(addShoppingListItemSchema),
|
validateRequest(addShoppingListItemSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
|
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
|
||||||
@@ -515,6 +529,7 @@ const updateShoppingListItemSchema = numericIdParam('itemId').extend({
|
|||||||
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
|
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/shopping-lists/items/:itemId',
|
'/shopping-lists/items/:itemId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(updateShoppingListItemSchema),
|
validateRequest(updateShoppingListItemSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||||
@@ -546,6 +561,7 @@ const shoppingListItemIdSchema = numericIdParam('itemId');
|
|||||||
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
|
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
|
||||||
router.delete(
|
router.delete(
|
||||||
'/shopping-lists/items/:itemId',
|
'/shopping-lists/items/:itemId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(shoppingListItemIdSchema),
|
validateRequest(shoppingListItemIdSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
|
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||||
@@ -574,6 +590,7 @@ const updatePreferencesSchema = z.object({
|
|||||||
type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
|
type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/profile/preferences',
|
'/profile/preferences',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(updatePreferencesSchema),
|
validateRequest(updatePreferencesSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
||||||
@@ -619,6 +636,7 @@ const setUserRestrictionsSchema = z.object({
|
|||||||
type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
|
type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/me/dietary-restrictions',
|
'/me/dietary-restrictions',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(setUserRestrictionsSchema),
|
validateRequest(setUserRestrictionsSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
||||||
@@ -663,6 +681,7 @@ const setUserAppliancesSchema = z.object({
|
|||||||
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
|
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/me/appliances',
|
'/me/appliances',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(setUserAppliancesSchema),
|
validateRequest(setUserAppliancesSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
||||||
@@ -730,6 +749,7 @@ const updateUserAddressSchema = z.object({
|
|||||||
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
|
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/profile/address',
|
'/profile/address',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(updateUserAddressSchema),
|
validateRequest(updateUserAddressSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
@@ -756,6 +776,7 @@ const recipeIdSchema = numericIdParam('recipeId');
|
|||||||
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
|
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
|
||||||
router.delete(
|
router.delete(
|
||||||
'/recipes/:recipeId',
|
'/recipes/:recipeId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(recipeIdSchema),
|
validateRequest(recipeIdSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
||||||
@@ -794,6 +815,7 @@ const updateRecipeSchema = recipeIdSchema.extend({
|
|||||||
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
|
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
|
||||||
router.put(
|
router.put(
|
||||||
'/recipes/:recipeId',
|
'/recipes/:recipeId',
|
||||||
|
userUpdateLimiter,
|
||||||
validateRequest(updateRecipeSchema),
|
validateRequest(updateRecipeSchema),
|
||||||
async (req, res, next: NextFunction) => {
|
async (req, res, next: NextFunction) => {
|
||||||
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ import { logger as mockLoggerInstance } from './logger.server';
|
|||||||
// Explicitly unmock the service under test to ensure we import the real implementation.
|
// Explicitly unmock the service under test to ensure we import the real implementation.
|
||||||
vi.unmock('./aiService.server');
|
vi.unmock('./aiService.server');
|
||||||
|
|
||||||
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
|
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity } = vi.hoisted(() => {
|
||||||
const mockGenerateContent = vi.fn();
|
const mockGenerateContent = vi.fn();
|
||||||
const mockToBuffer = vi.fn();
|
const mockToBuffer = vi.fn();
|
||||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||||
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp };
|
const mockAdminLogActivity = vi.fn();
|
||||||
|
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock sharp, as it's a direct dependency of the service.
|
// Mock sharp, as it's a direct dependency of the service.
|
||||||
@@ -65,6 +66,7 @@ vi.mock('./db/index.db', () => ({
|
|||||||
adminRepo: {
|
adminRepo: {
|
||||||
logActivity: vi.fn(),
|
logActivity: vi.fn(),
|
||||||
},
|
},
|
||||||
|
withTransaction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./queueService.server', () => ({
|
vi.mock('./queueService.server', () => ({
|
||||||
@@ -79,13 +81,21 @@ vi.mock('./db/flyer.db', () => ({
|
|||||||
|
|
||||||
vi.mock('../utils/imageProcessor', () => ({
|
vi.mock('../utils/imageProcessor', () => ({
|
||||||
generateFlyerIcon: vi.fn(),
|
generateFlyerIcon: vi.fn(),
|
||||||
|
processAndSaveImage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./db/admin.db', () => ({
|
||||||
|
AdminRepository: vi.fn().mockImplementation(function () {
|
||||||
|
return { logActivity: mockAdminLogActivity };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import mocked modules to assert on them
|
// Import mocked modules to assert on them
|
||||||
import * as dbModule from './db/index.db';
|
import * as dbModule from './db/index.db';
|
||||||
import { flyerQueue } from './queueService.server';
|
import { flyerQueue } from './queueService.server';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
|
||||||
|
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
|
||||||
|
|
||||||
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
|
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
|
||||||
// This helps ensure type safety in mocks without relying on 'any'.
|
// This helps ensure type safety in mocks without relying on 'any'.
|
||||||
@@ -106,7 +116,7 @@ interface MockFlyer {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = 'http://localhost:3001';
|
const baseUrl = 'https://example.com';
|
||||||
|
|
||||||
describe('AI Service (Server)', () => {
|
describe('AI Service (Server)', () => {
|
||||||
// Create mock dependencies that will be injected into the service
|
// Create mock dependencies that will be injected into the service
|
||||||
@@ -121,12 +131,16 @@ describe('AI Service (Server)', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGenerateContent.mockReset();
|
mockGenerateContent.mockReset();
|
||||||
|
mockAdminLogActivity.mockClear();
|
||||||
// Reset modules to ensure the service re-initializes with the mocks
|
// Reset modules to ensure the service re-initializes with the mocks
|
||||||
|
|
||||||
mockAiClient.generateContent.mockResolvedValue({
|
mockAiClient.generateContent.mockResolvedValue({
|
||||||
text: '[]',
|
text: '[]',
|
||||||
candidates: [],
|
candidates: [],
|
||||||
});
|
});
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
||||||
|
return callback({}); // Mock client
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AiFlyerDataSchema', () => {
|
describe('AiFlyerDataSchema', () => {
|
||||||
@@ -336,8 +350,6 @@ describe('AI Service (Server)', () => {
|
|||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-2.5-flash'
|
{ error: nonRetriableError }, // The first model in the list is now 'gemini-2.5-flash'
|
||||||
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
||||||
{ error: nonRetriableError }, // The first model in the list
|
|
||||||
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -616,11 +628,8 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
|
||||||
expect(result.store_name).toBe('Test Store');
|
// With normalization removed from this service, the result should match the raw AI response.
|
||||||
expect(result.items).toHaveLength(2);
|
expect(result).toEqual(mockAiResponse);
|
||||||
expect(result.items[1].price_display).toBe('');
|
|
||||||
expect(result.items[1].quantity).toBe('');
|
|
||||||
expect(result.items[1].category_name).toBe('Other/Miscellaneous');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if the AI response is not a valid JSON object', async () => {
|
it('should throw an error if the AI response is not a valid JSON object', async () => {
|
||||||
@@ -800,9 +809,11 @@ describe('AI Service (Server)', () => {
|
|||||||
expect(
|
expect(
|
||||||
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
|
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
|
||||||
).toBeNull(); // This was a duplicate, fixed.
|
).toBeNull(); // This was a duplicate, fixed.
|
||||||
|
// The code now fails earlier because it can't find the closing brace.
|
||||||
|
// We need to update the assertion to match the actual error log.
|
||||||
expect(localLogger.error).toHaveBeenCalledWith(
|
expect(localLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
|
{ responseText }, // The log includes the full response text.
|
||||||
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
|
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1004,7 +1015,7 @@ describe('AI Service (Server)', () => {
|
|||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
submitterIp: '127.0.0.1',
|
submitterIp: '127.0.0.1',
|
||||||
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'https://example.com',
|
||||||
});
|
});
|
||||||
expect(result.id).toBe('job123');
|
expect(result.id).toBe('job123');
|
||||||
});
|
});
|
||||||
@@ -1026,7 +1037,7 @@ describe('AI Service (Server)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
userProfileAddress: undefined,
|
userProfileAddress: undefined,
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'https://example.com',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1044,6 +1055,7 @@ describe('AI Service (Server)', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Default success mocks. Use createMockFlyer for a more complete mock.
|
// Default success mocks. Use createMockFlyer for a more complete mock.
|
||||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
|
||||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
|
||||||
vi.mocked(createFlyerAndItems).mockResolvedValue({
|
vi.mocked(createFlyerAndItems).mockResolvedValue({
|
||||||
flyer: {
|
flyer: {
|
||||||
@@ -1117,6 +1129,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
|
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1143,6 +1156,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
[], // No items
|
[], // No items
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1174,6 +1188,7 @@ describe('AI Service (Server)', () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('extractedData.store_name missing'),
|
expect.stringContaining('extractedData.store_name missing'),
|
||||||
@@ -1190,7 +1205,7 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toHaveProperty('flyer_id', 100);
|
expect(result).toHaveProperty('flyer_id', 100);
|
||||||
expect(dbModule.adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(mockAdminLogActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
userId: mockProfile.user.user_id,
|
userId: mockProfile.user.user_id,
|
||||||
@@ -1220,6 +1235,29 @@ describe('AI Service (Server)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log and re-throw the original error if the database transaction fails', async () => {
|
||||||
|
const body = { checksum: 'legacy-fail-checksum', extractedData: { store_name: 'Fail Store' } };
|
||||||
|
const dbError = new Error('DB transaction failed');
|
||||||
|
|
||||||
|
// Mock withTransaction to fail
|
||||||
|
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
aiServiceInstance.processLegacyFlyerUpload(
|
||||||
|
mockFile,
|
||||||
|
body,
|
||||||
|
mockProfile,
|
||||||
|
mockLoggerInstance,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(dbError);
|
||||||
|
|
||||||
|
// Verify the service-level error logging
|
||||||
|
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||||
|
{ err: dbError, checksum: 'legacy-fail-checksum' },
|
||||||
|
'Legacy flyer upload database transaction failed.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle body as a string', async () => {
|
it('should handle body as a string', async () => {
|
||||||
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
|
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
@@ -1235,6 +1273,7 @@ describe('AI Service (Server)', () => {
|
|||||||
expect.objectContaining({ checksum: 'str-body' }),
|
expect.objectContaining({ checksum: 'str-body' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
mockLoggerInstance,
|
mockLoggerInstance,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1244,56 +1283,4 @@ describe('AI Service (Server)', () => {
|
|||||||
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_normalizeExtractedItems (private method)', () => {
|
|
||||||
it('should correctly normalize items with null or undefined price_in_cents', () => {
|
|
||||||
const rawItems: RawFlyerItem[] = [
|
|
||||||
{
|
|
||||||
item: 'Valid Item',
|
|
||||||
price_display: '$1.99',
|
|
||||||
price_in_cents: 199,
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category A',
|
|
||||||
master_item_id: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: 'Item with Null Price',
|
|
||||||
price_display: null,
|
|
||||||
price_in_cents: null, // Test case for null
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category B',
|
|
||||||
master_item_id: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: 'Item with Undefined Price',
|
|
||||||
price_display: '$2.99',
|
|
||||||
price_in_cents: undefined, // Test case for undefined
|
|
||||||
quantity: '1',
|
|
||||||
category_name: 'Category C',
|
|
||||||
master_item_id: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item: null, // Test null item name
|
|
||||||
price_display: undefined, // Test undefined display price
|
|
||||||
price_in_cents: 50,
|
|
||||||
quantity: null, // Test null quantity
|
|
||||||
category_name: undefined, // Test undefined category
|
|
||||||
master_item_id: null, // Test null master_item_id
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Access the private method for testing
|
|
||||||
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems);
|
|
||||||
|
|
||||||
expect(normalized).toHaveLength(4);
|
|
||||||
expect(normalized[0].price_in_cents).toBe(199);
|
|
||||||
expect(normalized[1].price_in_cents).toBe(null); // null should remain null
|
|
||||||
expect(normalized[2].price_in_cents).toBe(null); // undefined should become null
|
|
||||||
expect(normalized[3].item).toBe('Unknown Item');
|
|
||||||
expect(normalized[3].quantity).toBe('');
|
|
||||||
expect(normalized[3].category_name).toBe('Other/Miscellaneous');
|
|
||||||
expect(normalized[3].master_item_id).toBeUndefined(); // nullish coalescing to undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import type {
|
|||||||
FlyerInsert,
|
FlyerInsert,
|
||||||
Flyer,
|
Flyer,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { FlyerProcessingError } from './processingErrors';
|
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||||
import * as db from './db/index.db';
|
import * as db from './db/index.db';
|
||||||
import { flyerQueue } from './queueService.server';
|
import { flyerQueue } from './queueService.server';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
import { getBaseUrl } from '../utils/serverUtils';
|
import { getBaseUrl } from '../utils/serverUtils'; // This was a duplicate, fixed.
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
|
||||||
|
import { AdminRepository } from './db/admin.db';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
||||||
import {
|
import {
|
||||||
@@ -72,14 +73,7 @@ interface IAiClient {
|
|||||||
* This type is intentionally loose to accommodate potential null/undefined values
|
* This type is intentionally loose to accommodate potential null/undefined values
|
||||||
* from the AI before they are cleaned and normalized.
|
* from the AI before they are cleaned and normalized.
|
||||||
*/
|
*/
|
||||||
export type RawFlyerItem = {
|
export type RawFlyerItem = z.infer<typeof ExtractedFlyerItemSchema>;
|
||||||
item: string | null;
|
|
||||||
price_display: string | null | undefined;
|
|
||||||
price_in_cents: number | null | undefined;
|
|
||||||
quantity: string | null | undefined;
|
|
||||||
category_name: string | null | undefined;
|
|
||||||
master_item_id?: number | null | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class DuplicateFlyerError extends FlyerProcessingError {
|
export class DuplicateFlyerError extends FlyerProcessingError {
|
||||||
constructor(message: string, public flyerId: number) {
|
constructor(message: string, public flyerId: number) {
|
||||||
@@ -214,7 +208,11 @@ export class AIService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'[AIService] Mock generateContent called. This should only happen in tests when no API key is available.',
|
'[AIService] Mock generateContent called. This should only happen in tests when no API key is available.',
|
||||||
);
|
);
|
||||||
return { text: '[]' } as unknown as GenerateContentResponse;
|
// Return a minimal valid JSON object structure to prevent downstream parsing errors.
|
||||||
|
const mockResponse = { store_name: 'Mock Store', items: [] };
|
||||||
|
return {
|
||||||
|
text: JSON.stringify(mockResponse),
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -368,62 +366,43 @@ export class AIService {
|
|||||||
* @returns The parsed JSON object, or null if parsing fails.
|
* @returns The parsed JSON object, or null if parsing fails.
|
||||||
*/
|
*/
|
||||||
private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
|
private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
|
||||||
// --- START HYPER-DIAGNOSTIC LOGGING ---
|
// --- START EXTENSIVE DEBUG LOGGING ---
|
||||||
console.log('\n--- DIAGNOSING _parseJsonFromAiResponse ---');
|
logger.debug(
|
||||||
console.log(
|
{
|
||||||
`1. Initial responseText (Type: ${typeof responseText}):`,
|
responseText_type: typeof responseText,
|
||||||
JSON.stringify(responseText),
|
responseText_length: responseText?.length,
|
||||||
|
responseText_preview: responseText?.substring(0, 200),
|
||||||
|
},
|
||||||
|
'[_parseJsonFromAiResponse] Starting JSON parsing.',
|
||||||
);
|
);
|
||||||
// --- END HYPER-DIAGNOSTIC LOGGING ---
|
|
||||||
|
|
||||||
if (!responseText) {
|
if (!responseText) {
|
||||||
logger.warn(
|
logger.warn('[_parseJsonFromAiResponse] Response text is empty or undefined. Aborting parsing.');
|
||||||
'[_parseJsonFromAiResponse] Response text is empty or undefined. Returning null.',
|
|
||||||
);
|
|
||||||
console.log('2. responseText is falsy. ABORTING.');
|
|
||||||
console.log('--- END DIAGNOSIS ---\n');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the start of the JSON, which can be inside a markdown block
|
// Find the start of the JSON, which can be inside a markdown block
|
||||||
const markdownRegex = /```(json)?\s*([\s\S]*?)\s*```/;
|
const markdownRegex = /```(json)?\s*([\s\S]*?)\s*```/;
|
||||||
const markdownMatch = responseText.match(markdownRegex);
|
const markdownMatch = responseText.match(markdownRegex);
|
||||||
console.log('2. Regex Result (markdownMatch):', markdownMatch);
|
|
||||||
|
|
||||||
let jsonString;
|
let jsonString;
|
||||||
if (markdownMatch && markdownMatch[2] !== undefined) {
|
if (markdownMatch && markdownMatch[2] !== undefined) {
|
||||||
// Check for capture group
|
|
||||||
console.log('3. Regex matched. Processing Captured Group.');
|
|
||||||
console.log(
|
|
||||||
` - Captured content (Type: ${typeof markdownMatch[2]}, Length: ${markdownMatch[2].length}):`,
|
|
||||||
JSON.stringify(markdownMatch[2]),
|
|
||||||
);
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ rawCapture: markdownMatch[2] },
|
{ capturedLength: markdownMatch[2].length },
|
||||||
'[_parseJsonFromAiResponse] Found JSON content within markdown code block.',
|
'[_parseJsonFromAiResponse] Found JSON content within markdown code block.',
|
||||||
);
|
);
|
||||||
|
|
||||||
jsonString = markdownMatch[2].trim();
|
jsonString = markdownMatch[2].trim();
|
||||||
console.log(
|
|
||||||
`4. After trimming, jsonString is (Type: ${typeof jsonString}, Length: ${jsonString.length}):`,
|
|
||||||
JSON.stringify(jsonString),
|
|
||||||
);
|
|
||||||
logger.debug(
|
|
||||||
{ trimmedJsonString: jsonString },
|
|
||||||
'[_parseJsonFromAiResponse] Trimmed extracted JSON string.',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
logger.debug('[_parseJsonFromAiResponse] No markdown code block found. Using raw response text.');
|
||||||
'3. Regex did NOT match or capture group 2 is undefined. Will attempt to parse entire responseText.',
|
|
||||||
);
|
|
||||||
jsonString = responseText;
|
jsonString = responseText;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first '{' or '[' and the last '}' or ']' to isolate the JSON object.
|
// Find the first '{' or '[' and the last '}' or ']' to isolate the JSON object.
|
||||||
const firstBrace = jsonString.indexOf('{');
|
const firstBrace = jsonString.indexOf('{');
|
||||||
const firstBracket = jsonString.indexOf('[');
|
const firstBracket = jsonString.indexOf('[');
|
||||||
console.log(
|
logger.debug(
|
||||||
`5. Index search on jsonString: firstBrace=${firstBrace}, firstBracket=${firstBracket}`,
|
{ firstBrace, firstBracket },
|
||||||
|
'[_parseJsonFromAiResponse] Searching for start of JSON.',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine the starting point of the JSON content
|
// Determine the starting point of the JSON content
|
||||||
@@ -431,37 +410,44 @@ export class AIService {
|
|||||||
firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)
|
firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)
|
||||||
? firstBracket
|
? firstBracket
|
||||||
: firstBrace;
|
: firstBrace;
|
||||||
console.log('6. Calculated startIndex:', startIndex);
|
|
||||||
|
|
||||||
if (startIndex === -1) {
|
if (startIndex === -1) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ responseText },
|
{ responseText },
|
||||||
"[_parseJsonFromAiResponse] Could not find starting '{' or '[' in response.",
|
"[_parseJsonFromAiResponse] Could not find starting '{' or '[' in response.",
|
||||||
);
|
);
|
||||||
console.log('7. startIndex is -1. ABORTING.');
|
|
||||||
console.log('--- END DIAGNOSIS ---\n');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonSlice = jsonString.substring(startIndex);
|
// Find the last brace or bracket to gracefully handle trailing text.
|
||||||
console.log(
|
// This is a robust way to handle cases where the AI might add trailing text after the JSON.
|
||||||
`8. Sliced string to be parsed (jsonSlice) (Length: ${jsonSlice.length}):`,
|
const lastBrace = jsonString.lastIndexOf('}');
|
||||||
JSON.stringify(jsonSlice),
|
const lastBracket = jsonString.lastIndexOf(']');
|
||||||
|
const endIndex = Math.max(lastBrace, lastBracket);
|
||||||
|
|
||||||
|
if (endIndex === -1) {
|
||||||
|
logger.error(
|
||||||
|
{ responseText },
|
||||||
|
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonSlice = jsonString.substring(startIndex, endIndex + 1);
|
||||||
|
logger.debug(
|
||||||
|
{ sliceLength: jsonSlice.length },
|
||||||
|
'[_parseJsonFromAiResponse] Extracted JSON slice for parsing.',
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('9. Attempting JSON.parse on jsonSlice...');
|
|
||||||
const parsed = JSON.parse(jsonSlice) as T;
|
const parsed = JSON.parse(jsonSlice) as T;
|
||||||
console.log('10. SUCCESS: JSON.parse succeeded.');
|
logger.info('[_parseJsonFromAiResponse] Successfully parsed JSON from AI response.');
|
||||||
console.log('--- END DIAGNOSIS (SUCCESS) ---\n');
|
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ jsonSlice, error: e, errorMessage: (e as Error).message, stack: (e as Error).stack },
|
{ jsonSlice, error: e, errorMessage: (e as Error).message, stack: (e as Error).stack },
|
||||||
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
|
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
|
||||||
);
|
);
|
||||||
console.error('10. FAILURE: JSON.parse FAILED. Error:', e);
|
|
||||||
console.log('--- END DIAGNOSIS (FAILURE) ---\n');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,12 +524,8 @@ export class AIService {
|
|||||||
userProfileAddress?: string,
|
userProfileAddress?: string,
|
||||||
logger: Logger = this.logger,
|
logger: Logger = this.logger,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
store_name: string | null;
|
store_name: string | null; valid_from: string | null; valid_to: string | null; store_address: string | null; items: z.infer<typeof ExtractedFlyerItemSchema>[];
|
||||||
valid_from: string | null;
|
} & z.infer<typeof AiFlyerDataSchema>> {
|
||||||
valid_to: string | null;
|
|
||||||
store_address: string | null;
|
|
||||||
items: ExtractedFlyerItem[];
|
|
||||||
}> {
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`,
|
`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`,
|
||||||
);
|
);
|
||||||
@@ -599,50 +581,22 @@ export class AIService {
|
|||||||
throw new Error('AI response did not contain a valid JSON object.');
|
throw new Error('AI response did not contain a valid JSON object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize the items to create a clean data structure.
|
// The FlyerDataTransformer is now responsible for all normalization.
|
||||||
logger.debug('[extractCoreDataFromFlyerImage] Normalizing extracted items.');
|
// We return the raw items as parsed from the AI response.
|
||||||
const normalizedItems = Array.isArray(extractedData.items)
|
if (!Array.isArray(extractedData.items)) {
|
||||||
? this._normalizeExtractedItems(extractedData.items)
|
extractedData.items = [];
|
||||||
: [];
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[extractCoreDataFromFlyerImage] Successfully processed flyer data for store: ${extractedData.store_name}. Exiting method.`,
|
`[extractCoreDataFromFlyerImage] Successfully processed flyer data for store: ${extractedData.store_name}. Exiting method.`,
|
||||||
);
|
);
|
||||||
return { ...extractedData, items: normalizedItems };
|
return extractedData;
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
logger.error({ err: apiError }, '[extractCoreDataFromFlyerImage] The entire process failed.');
|
logger.error({ err: apiError }, '[extractCoreDataFromFlyerImage] The entire process failed.');
|
||||||
throw apiError;
|
throw apiError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the raw items returned by the AI, ensuring fields are in the correct format.
|
|
||||||
* @param items An array of raw flyer items from the AI.
|
|
||||||
* @returns A normalized array of flyer items.
|
|
||||||
*/
|
|
||||||
private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
|
|
||||||
return items.map((item: RawFlyerItem) => ({
|
|
||||||
...item,
|
|
||||||
// Ensure 'item' is always a string, defaulting to 'Unknown Item' if null/undefined.
|
|
||||||
item:
|
|
||||||
item.item === null || item.item === undefined || String(item.item).trim() === ''
|
|
||||||
? 'Unknown Item'
|
|
||||||
: String(item.item),
|
|
||||||
price_display:
|
|
||||||
item.price_display === null || item.price_display === undefined
|
|
||||||
? ''
|
|
||||||
: String(item.price_display),
|
|
||||||
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
|
|
||||||
category_name:
|
|
||||||
item.category_name === null || item.category_name === undefined
|
|
||||||
? 'Other/Miscellaneous'
|
|
||||||
: String(item.category_name),
|
|
||||||
// Ensure undefined is converted to null to match the Zod schema.
|
|
||||||
price_in_cents: item.price_in_cents ?? null,
|
|
||||||
master_item_id: item.master_item_id ?? undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SERVER-SIDE FUNCTION
|
* SERVER-SIDE FUNCTION
|
||||||
* Extracts a specific piece of text from a cropped area of an image.
|
* Extracts a specific piece of text from a cropped area of an image.
|
||||||
@@ -799,6 +753,7 @@ async enqueueFlyerProcessing(
|
|||||||
userProfile: UserProfile | undefined,
|
userProfile: UserProfile | undefined,
|
||||||
submitterIp: string,
|
submitterIp: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
baseUrlOverride?: string,
|
||||||
): Promise<Job> {
|
): Promise<Job> {
|
||||||
// 1. Check for duplicate flyer
|
// 1. Check for duplicate flyer
|
||||||
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
|
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
|
||||||
@@ -825,7 +780,19 @@ async enqueueFlyerProcessing(
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(logger);
|
const baseUrl = baseUrlOverride || getBaseUrl(logger);
|
||||||
|
// --- START DEBUGGING ---
|
||||||
|
// Add a fail-fast check to ensure the baseUrl is a valid URL before enqueuing.
|
||||||
|
// This will make the test fail at the upload step if the URL is the problem,
|
||||||
|
// which is easier to debug than a worker failure.
|
||||||
|
if (!baseUrl || !baseUrl.startsWith('http')) {
|
||||||
|
const errorMessage = `[aiService] FATAL: The generated baseUrl is not a valid absolute URL. Value: "${baseUrl}". This will cause the flyer processing worker to fail. Check the FRONTEND_URL environment variable.`;
|
||||||
|
logger.error(errorMessage);
|
||||||
|
// Throw a standard error that the calling route can handle.
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
logger.info({ baseUrl }, '[aiService] Enqueuing job with valid baseUrl.');
|
||||||
|
// --- END DEBUGGING ---
|
||||||
|
|
||||||
// 3. Add job to the queue
|
// 3. Add job to the queue
|
||||||
const job = await flyerQueue.add('process-flyer', {
|
const job = await flyerQueue.add('process-flyer', {
|
||||||
@@ -849,6 +816,7 @@ async enqueueFlyerProcessing(
|
|||||||
body: any,
|
body: any,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
|
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
|
||||||
|
logger.debug({ body, type: typeof body }, '[AIService] Starting _parseLegacyPayload');
|
||||||
let parsed: FlyerProcessPayload = {};
|
let parsed: FlyerProcessPayload = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -857,6 +825,7 @@ async enqueueFlyerProcessing(
|
|||||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
|
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
|
||||||
return { parsed: {}, extractedData: {} };
|
return { parsed: {}, extractedData: {} };
|
||||||
}
|
}
|
||||||
|
logger.debug({ parsed }, '[AIService] Parsed top-level body');
|
||||||
|
|
||||||
// If the real payload is nested inside a 'data' property (which could be a string),
|
// If the real payload is nested inside a 'data' property (which could be a string),
|
||||||
// we parse it out but keep the original `parsed` object for top-level properties like checksum.
|
// we parse it out but keep the original `parsed` object for top-level properties like checksum.
|
||||||
@@ -872,13 +841,16 @@ async enqueueFlyerProcessing(
|
|||||||
potentialPayload = parsed.data;
|
potentialPayload = parsed.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.debug({ potentialPayload }, '[AIService] Potential payload after checking "data" property');
|
||||||
|
|
||||||
// The extracted data is either in an `extractedData` key or is the payload itself.
|
// The extracted data is either in an `extractedData` key or is the payload itself.
|
||||||
const extractedData = potentialPayload.extractedData ?? potentialPayload;
|
const extractedData = potentialPayload.extractedData ?? potentialPayload;
|
||||||
|
logger.debug({ extractedData: !!extractedData }, '[AIService] Extracted data object');
|
||||||
|
|
||||||
// Merge for checksum lookup: properties in the outer `parsed` object (like a top-level checksum)
|
// Merge for checksum lookup: properties in the outer `parsed` object (like a top-level checksum)
|
||||||
// take precedence over any same-named properties inside `potentialPayload`.
|
// take precedence over any same-named properties inside `potentialPayload`.
|
||||||
const finalParsed = { ...potentialPayload, ...parsed };
|
const finalParsed = { ...potentialPayload, ...parsed };
|
||||||
|
logger.debug({ finalParsed }, '[AIService] Final parsed object for checksum lookup');
|
||||||
|
|
||||||
return { parsed: finalParsed, extractedData };
|
return { parsed: finalParsed, extractedData };
|
||||||
}
|
}
|
||||||
@@ -889,10 +861,12 @@ async enqueueFlyerProcessing(
|
|||||||
userProfile: UserProfile | undefined,
|
userProfile: UserProfile | undefined,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<Flyer> {
|
): Promise<Flyer> {
|
||||||
|
logger.debug({ body, file }, '[AIService] Starting processLegacyFlyerUpload');
|
||||||
const { parsed, extractedData: initialExtractedData } = this._parseLegacyPayload(body, logger);
|
const { parsed, extractedData: initialExtractedData } = this._parseLegacyPayload(body, logger);
|
||||||
let extractedData = initialExtractedData;
|
let extractedData = initialExtractedData;
|
||||||
|
|
||||||
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
||||||
|
logger.debug({ checksum, parsed }, '[AIService] Extracted checksum from legacy payload');
|
||||||
if (!checksum) {
|
if (!checksum) {
|
||||||
throw new ValidationError([], 'Checksum is required.');
|
throw new ValidationError([], 'Checksum is required.');
|
||||||
}
|
}
|
||||||
@@ -927,12 +901,24 @@ async enqueueFlyerProcessing(
|
|||||||
logger.warn('extractedData.store_name missing; using fallback store name.');
|
logger.warn('extractedData.store_name missing; using fallback store name.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconsDir = path.join(path.dirname(file.path), 'icons');
|
// Process the uploaded image to strip metadata and optimize it.
|
||||||
const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger);
|
const flyerImageDir = path.dirname(file.path);
|
||||||
|
const processedImageFileName = await processAndSaveImage(
|
||||||
|
file.path,
|
||||||
|
flyerImageDir,
|
||||||
|
originalFileName,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const processedImagePath = path.join(flyerImageDir, processedImageFileName);
|
||||||
|
|
||||||
|
// Generate the icon from the newly processed (and cleaned) image.
|
||||||
|
const iconsDir = path.join(flyerImageDir, 'icons');
|
||||||
|
const iconFileName = await generateFlyerIcon(processedImagePath, iconsDir, logger);
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(logger);
|
const baseUrl = getBaseUrl(logger);
|
||||||
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
|
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
|
||||||
const imageUrl = `${baseUrl}/flyer-images/${file.filename}`;
|
const imageUrl = `${baseUrl}/flyer-images/${processedImageFileName}`;
|
||||||
|
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs for legacy upload');
|
||||||
|
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = {
|
||||||
file_name: originalFileName,
|
file_name: originalFileName,
|
||||||
@@ -948,18 +934,28 @@ async enqueueFlyerProcessing(
|
|||||||
uploaded_by: userProfile?.user.user_id,
|
uploaded_by: userProfile?.user.user_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(flyerData, itemsForDb, logger);
|
return db.withTransaction(async (client) => {
|
||||||
|
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||||
|
|
||||||
logger.info(`Successfully processed legacy flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`);
|
logger.info(
|
||||||
|
`Successfully processed legacy flyer: ${flyer.file_name} (ID: ${flyer.flyer_id}) with ${items.length} items.`,
|
||||||
|
);
|
||||||
|
|
||||||
await db.adminRepo.logActivity({
|
const transactionalAdminRepo = new AdminRepository(client);
|
||||||
userId: userProfile?.user.user_id,
|
await transactionalAdminRepo.logActivity(
|
||||||
action: 'flyer_processed',
|
{
|
||||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
userId: userProfile?.user.user_id,
|
||||||
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
|
action: 'flyer_processed',
|
||||||
}, logger);
|
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||||
|
details: { flyerId: flyer.flyer_id, storeName: flyerData.store_name },
|
||||||
return newFlyer;
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
return flyer;
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error({ err: error, checksum }, 'Legacy flyer upload database transaction failed.');
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ const joinUrl = (base: string, path: string): string => {
|
|||||||
* A promise that holds the in-progress token refresh operation.
|
* A promise that holds the in-progress token refresh operation.
|
||||||
* This prevents multiple parallel refresh requests.
|
* This prevents multiple parallel refresh requests.
|
||||||
*/
|
*/
|
||||||
let refreshTokenPromise: Promise<string> | null = null;
|
let performTokenRefreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to refresh the access token using the HttpOnly refresh token cookie.
|
* Attempts to refresh the access token using the HttpOnly refresh token cookie.
|
||||||
* @returns A promise that resolves to the new access token.
|
* @returns A promise that resolves to the new access token.
|
||||||
*/
|
*/
|
||||||
const refreshToken = async (): Promise<string> => {
|
const _performTokenRefresh = async (): Promise<string> => {
|
||||||
logger.info('Attempting to refresh access token...');
|
logger.info('Attempting to refresh access token...');
|
||||||
try {
|
try {
|
||||||
// Use the joinUrl helper for consistency, though usually this is a relative fetch in browser
|
// Use the joinUrl helper for consistency, though usually this is a relative fetch in browser
|
||||||
@@ -75,11 +75,15 @@ const refreshToken = async (): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom fetch wrapper that handles automatic token refreshing.
|
* A custom fetch wrapper that handles automatic token refreshing for authenticated API calls.
|
||||||
* All authenticated API calls should use this function.
|
* If a request fails with a 401 Unauthorized status, it attempts to refresh the access token
|
||||||
* @param url The URL to fetch.
|
* using the refresh token cookie. If successful, it retries the original request with the new token.
|
||||||
* @param options The fetch options.
|
* All authenticated API calls should use this function or one of its helpers (e.g., `authedGet`).
|
||||||
* @returns A promise that resolves to the fetch Response.
|
*
|
||||||
|
* @param url The endpoint path (e.g., '/users/profile') or a full URL.
|
||||||
|
* @param options Standard `fetch` options (method, body, etc.).
|
||||||
|
* @param apiOptions Custom options for the API client, such as `tokenOverride` for testing or an `AbortSignal`.
|
||||||
|
* @returns A promise that resolves to the final `Response` object from the fetch call.
|
||||||
*/
|
*/
|
||||||
export const apiFetch = async (
|
export const apiFetch = async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -122,12 +126,12 @@ export const apiFetch = async (
|
|||||||
try {
|
try {
|
||||||
logger.info(`apiFetch: Received 401 for ${fullUrl}. Attempting token refresh.`);
|
logger.info(`apiFetch: Received 401 for ${fullUrl}. Attempting token refresh.`);
|
||||||
// If no refresh is in progress, start one.
|
// If no refresh is in progress, start one.
|
||||||
if (!refreshTokenPromise) {
|
if (!performTokenRefreshPromise) {
|
||||||
refreshTokenPromise = refreshToken();
|
performTokenRefreshPromise = _performTokenRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the existing or new refresh operation to complete.
|
// Wait for the existing or new refresh operation to complete.
|
||||||
const newToken = await refreshTokenPromise;
|
const newToken = await performTokenRefreshPromise;
|
||||||
|
|
||||||
logger.info(`apiFetch: Token refreshed. Retrying original request to ${fullUrl}.`);
|
logger.info(`apiFetch: Token refreshed. Retrying original request to ${fullUrl}.`);
|
||||||
// Retry the original request with the new token.
|
// Retry the original request with the new token.
|
||||||
@@ -138,7 +142,7 @@ export const apiFetch = async (
|
|||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
} finally {
|
} finally {
|
||||||
// Clear the promise so the next 401 will trigger a new refresh.
|
// Clear the promise so the next 401 will trigger a new refresh.
|
||||||
refreshTokenPromise = null;
|
performTokenRefreshPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +772,25 @@ export const triggerFailingJob = (tokenOverride?: string): Promise<Response> =>
|
|||||||
export const getJobStatus = (jobId: string, tokenOverride?: string): Promise<Response> =>
|
export const getJobStatus = (jobId: string, tokenOverride?: string): Promise<Response> =>
|
||||||
authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
|
authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes an access token using a refresh token cookie.
|
||||||
|
* This is intended for use in Node.js test environments where cookies must be set manually.
|
||||||
|
* @param cookie The full 'Cookie' header string (e.g., "refreshToken=...").
|
||||||
|
* @returns A promise that resolves to the fetch Response.
|
||||||
|
*/
|
||||||
|
export async function refreshToken(cookie: string) {
|
||||||
|
const url = joinUrl(API_BASE_URL, '/auth/refresh-token');
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// The browser would handle this automatically, but in Node.js tests we must set it manually.
|
||||||
|
Cookie: cookie,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers the clearing of the geocoding cache on the server.
|
* Triggers the clearing of the geocoding cache on the server.
|
||||||
* Requires admin privileges.
|
* Requires admin privileges.
|
||||||
|
|||||||
@@ -3,6 +3,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import type * as jsonwebtoken from 'jsonwebtoken';
|
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
transactionalUserRepoMocks: {
|
||||||
|
updateUserPassword: vi.fn(),
|
||||||
|
deleteResetToken: vi.fn(),
|
||||||
|
createPasswordResetToken: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
},
|
||||||
|
transactionalAdminRepoMocks: {
|
||||||
|
logActivity: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./db/user.db', () => ({
|
||||||
|
UserRepository: vi.fn().mockImplementation(function () { return transactionalUserRepoMocks }),
|
||||||
|
}));
|
||||||
|
vi.mock('./db/admin.db', () => ({
|
||||||
|
AdminRepository: vi.fn().mockImplementation(function () { return transactionalAdminRepoMocks }),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: typeof import('./authService').authService;
|
let authService: typeof import('./authService').authService;
|
||||||
let bcrypt: typeof import('bcrypt');
|
let bcrypt: typeof import('bcrypt');
|
||||||
@@ -11,7 +32,10 @@ describe('AuthService', () => {
|
|||||||
let adminRepo: typeof import('./db/index.db').adminRepo;
|
let adminRepo: typeof import('./db/index.db').adminRepo;
|
||||||
let logger: typeof import('./logger.server').logger;
|
let logger: typeof import('./logger.server').logger;
|
||||||
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
||||||
|
let DatabaseError: typeof import('./processingErrors').DatabaseError;
|
||||||
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
||||||
|
let RepositoryError: typeof import('./db/errors.db').RepositoryError;
|
||||||
|
let withTransaction: typeof import('./db/index.db').withTransaction;
|
||||||
|
|
||||||
const reqLog = {}; // Mock request logger object
|
const reqLog = {}; // Mock request logger object
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
@@ -35,12 +59,13 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
// Set environment variables before any modules are imported
|
// Set environment variables before any modules are imported
|
||||||
vi.stubEnv('JWT_SECRET', 'test-secret');
|
vi.stubEnv('JWT_SECRET', 'test-secret');
|
||||||
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
|
||||||
// Mock all dependencies before dynamically importing the service
|
// Mock all dependencies before dynamically importing the service
|
||||||
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
||||||
vi.mock('bcrypt');
|
vi.mock('bcrypt');
|
||||||
vi.mock('./db/index.db', () => ({
|
vi.mock('./db/index.db', () => ({
|
||||||
|
withTransaction: vi.fn(),
|
||||||
userRepo: {
|
userRepo: {
|
||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
saveRefreshToken: vi.fn(),
|
saveRefreshToken: vi.fn(),
|
||||||
@@ -74,8 +99,16 @@ describe('AuthService', () => {
|
|||||||
userRepo = dbModule.userRepo;
|
userRepo = dbModule.userRepo;
|
||||||
adminRepo = dbModule.adminRepo;
|
adminRepo = dbModule.adminRepo;
|
||||||
logger = (await import('./logger.server')).logger;
|
logger = (await import('./logger.server')).logger;
|
||||||
|
withTransaction = (await import('./db/index.db')).withTransaction;
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
||||||
|
return callback({}); // Mock client
|
||||||
|
});
|
||||||
|
const { validatePasswordStrength } = await import('../utils/authUtils');
|
||||||
|
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: true, feedback: '' });
|
||||||
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
||||||
|
DatabaseError = (await import('./processingErrors')).DatabaseError;
|
||||||
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
||||||
|
RepositoryError = (await import('./db/errors.db')).RepositoryError;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -85,7 +118,7 @@ describe('AuthService', () => {
|
|||||||
describe('registerUser', () => {
|
describe('registerUser', () => {
|
||||||
it('should successfully register a new user', async () => {
|
it('should successfully register a new user', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);
|
||||||
|
|
||||||
const result = await authService.registerUser(
|
const result = await authService.registerUser(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -96,13 +129,13 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
||||||
expect(userRepo.createUser).toHaveBeenCalledWith(
|
expect(transactionalUserRepoMocks.createUser).toHaveBeenCalledWith(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
'hashed-password',
|
'hashed-password',
|
||||||
{ full_name: 'Test User', avatar_url: undefined },
|
{ full_name: 'Test User', avatar_url: undefined },
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
expect(adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'user_registered',
|
action: 'user_registered',
|
||||||
userId: 'user-123',
|
userId: 'user-123',
|
||||||
@@ -115,25 +148,25 @@ describe('AuthService', () => {
|
|||||||
it('should throw UniqueConstraintError if email already exists', async () => {
|
it('should throw UniqueConstraintError if email already exists', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
const error = new UniqueConstraintError('Email exists');
|
const error = new UniqueConstraintError('Email exists');
|
||||||
vi.mocked(userRepo.createUser).mockRejectedValue(error);
|
vi.mocked(withTransaction).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||||
).rejects.toThrow(UniqueConstraintError);
|
).rejects.toThrow(UniqueConstraintError);
|
||||||
|
|
||||||
expect(logger.error).not.toHaveBeenCalled(); // Should not log expected unique constraint errors as system errors
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw other errors', async () => {
|
it('should log and re-throw generic errors on registration failure', async () => {
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
const error = new Error('Database failed');
|
const error = new Error('Database failed');
|
||||||
vi.mocked(userRepo.createUser).mockRejectedValue(error);
|
vi.mocked(withTransaction).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||||
).rejects.toThrow('Database failed');
|
).rejects.toThrow(DatabaseError);
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalledWith({ error, email: 'test@example.com' }, `User registration failed with an unexpected error.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +174,7 @@ describe('AuthService', () => {
|
|||||||
it('should register user and return tokens', async () => {
|
it('should register user and return tokens', async () => {
|
||||||
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
|
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);
|
||||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||||
// We must mock `jwt.default.sign` to affect the code under test.
|
// We must mock `jwt.default.sign` to affect the code under test.
|
||||||
@@ -199,17 +232,13 @@ describe('AuthService', () => {
|
|||||||
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
|
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw error on failure', async () => {
|
it('should propagate the error from the repository on failure', async () => {
|
||||||
const error = new Error('DB Error');
|
const error = new Error('DB Error');
|
||||||
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
|
// The service method now directly propagates the error from the repo.
|
||||||
'DB Error',
|
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(error);
|
||||||
);
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ error }),
|
|
||||||
expect.stringContaining('Failed to save refresh token'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,11 +249,12 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.resetPassword('test@example.com', reqLog);
|
const result = await authService.resetPassword('test@example.com', reqLog);
|
||||||
|
|
||||||
expect(userRepo.createPasswordResetToken).toHaveBeenCalledWith(
|
expect(transactionalUserRepoMocks.createPasswordResetToken).toHaveBeenCalledWith(
|
||||||
'user-123',
|
'user-123',
|
||||||
'hashed-token',
|
'hashed-token',
|
||||||
expect.any(Date),
|
expect.any(Date),
|
||||||
reqLog,
|
reqLog,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
|
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -258,36 +288,50 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('updatePassword', () => {
|
describe('updatePassword', () => {
|
||||||
it('should update password if token is valid', async () => {
|
it('should update password if token is valid and wrap operations in a transaction', async () => {
|
||||||
const mockTokenRecord = {
|
const mockTokenRecord = {
|
||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
token_hash: 'hashed-token',
|
token_hash: 'hashed-token',
|
||||||
};
|
};
|
||||||
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
||||||
vi.mocked(bcrypt.compare).mockImplementation(async () => true); // Match found
|
vi.mocked(bcrypt.compare).mockImplementation(async () => true);
|
||||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
|
||||||
|
|
||||||
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
|
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
|
||||||
|
|
||||||
expect(userRepo.updateUserPassword).toHaveBeenCalledWith(
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(transactionalUserRepoMocks.updateUserPassword).toHaveBeenCalledWith(
|
||||||
'user-123',
|
'user-123',
|
||||||
'new-hashed-password',
|
'new-hashed-password',
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
expect(userRepo.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
||||||
expect(adminRepo.logActivity).toHaveBeenCalledWith(
|
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'password_reset' }),
|
expect.objectContaining({ action: 'password_reset' }),
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log and re-throw an error if the transaction fails', async () => {
|
||||||
|
const mockTokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' };
|
||||||
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
|
||||||
|
vi.mocked(bcrypt.compare).mockImplementation(async () => true);
|
||||||
|
const dbError = new Error('Transaction failed');
|
||||||
|
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(authService.updatePassword('valid-token', 'newPassword', reqLog)).rejects.toThrow(DatabaseError);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `An unexpected error occurred during password update.`);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return null if token is invalid or not found', async () => {
|
it('should return null if token is invalid or not found', async () => {
|
||||||
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
|
vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
|
const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);
|
||||||
|
|
||||||
expect(userRepo.updateUserPassword).not.toHaveBeenCalled();
|
expect(transactionalUserRepoMocks.updateUserPassword).not.toHaveBeenCalled();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -309,6 +353,37 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw a DatabaseError if finding the user fails with a generic error', async () => {
|
||||||
|
const dbError = new Error('DB connection failed');
|
||||||
|
vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
// Use a try-catch to assert on the error instance properties, which is more robust
|
||||||
|
// than `toBeInstanceOf` in some complex module mocking scenarios in Vitest.
|
||||||
|
try {
|
||||||
|
await authService.getUserByRefreshToken('any-token', reqLog);
|
||||||
|
expect.fail('Expected an error to be thrown');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('DatabaseError');
|
||||||
|
expect(error.message).toBe('DB connection failed');
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ error: dbError, refreshToken: 'any-token' },
|
||||||
|
'An unexpected error occurred while fetching user by refresh token.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw a RepositoryError if finding the user fails with a known error', async () => {
|
||||||
|
const repoError = new RepositoryError('Some repo error', 500);
|
||||||
|
vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(repoError);
|
||||||
|
|
||||||
|
await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(repoError);
|
||||||
|
// The original error is re-thrown, so the generic wrapper log should not be called.
|
||||||
|
expect(logger.error).not.toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
'An unexpected error occurred while fetching user by refresh token.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
@@ -317,12 +392,12 @@ describe('AuthService', () => {
|
|||||||
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
|
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and throw on error', async () => {
|
it('should propagate the error from the repository on failure', async () => {
|
||||||
const error = new Error('DB Error');
|
const error = new Error('DB Error');
|
||||||
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
|
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(authService.logout('token', reqLog)).rejects.toThrow('DB Error');
|
await expect(authService.logout('token', reqLog)).rejects.toThrow(error);
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,5 +420,13 @@ describe('AuthService', () => {
|
|||||||
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should propagate errors from getUserByRefreshToken', async () => {
|
||||||
|
const dbError = new DatabaseError('Underlying DB call failed');
|
||||||
|
// We mock the service's own method since refreshAccessToken calls it directly.
|
||||||
|
vi.spyOn(authService, 'getUserByRefreshToken').mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
await expect(authService.refreshAccessToken('any-token', reqLog)).rejects.toThrow(dbError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { userRepo, adminRepo } from './db/index.db';
|
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||||
import { UniqueConstraintError } from './db/errors.db';
|
import { withTransaction, userRepo } from './db/index.db';
|
||||||
import { getPool } from './db/connection.db';
|
import { RepositoryError, ValidationError } from './db/errors.db';
|
||||||
import { logger } from './logger.server';
|
import { logger } from './logger.server';
|
||||||
import { sendPasswordResetEmail } from './emailService.server';
|
import { sendPasswordResetEmail } from './emailService.server';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
@@ -20,44 +20,47 @@ class AuthService {
|
|||||||
avatarUrl: string | undefined,
|
avatarUrl: string | undefined,
|
||||||
reqLog: any,
|
reqLog: any,
|
||||||
) {
|
) {
|
||||||
try {
|
const strength = validatePasswordStrength(password);
|
||||||
|
if (!strength.isValid) {
|
||||||
|
throw new ValidationError([], strength.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap user creation and activity logging in a transaction for atomicity.
|
||||||
|
// The `createUser` method is now designed to be composed within other transactions.
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
logger.info(`Hashing password for new user: ${email}`);
|
logger.info(`Hashing password for new user: ${email}`);
|
||||||
|
|
||||||
// The createUser method in UserRepository now handles its own transaction.
|
const newUser = await transactionalUserRepo.createUser(
|
||||||
const newUser = await userRepo.createUser(
|
|
||||||
email,
|
email,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
{ full_name: fullName, avatar_url: avatarUrl },
|
{ full_name: fullName, avatar_url: avatarUrl },
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userEmail = newUser.user.email;
|
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
||||||
const userId = newUser.user.user_id;
|
|
||||||
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
|
|
||||||
|
|
||||||
// Use the new standardized logging function
|
|
||||||
await adminRepo.logActivity(
|
await adminRepo.logActivity(
|
||||||
{
|
{ userId: newUser.user.user_id, action: 'user_registered', displayText: `${email} has registered.`, icon: 'user-plus' },
|
||||||
userId: newUser.user.user_id,
|
|
||||||
action: 'user_registered',
|
|
||||||
displayText: `${userEmail} has registered.`,
|
|
||||||
icon: 'user-plus',
|
|
||||||
},
|
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
} catch (error: unknown) {
|
}).catch((error: unknown) => {
|
||||||
if (error instanceof UniqueConstraintError) {
|
// Re-throw known repository errors (like UniqueConstraintError) to allow for specific handling upstream.
|
||||||
// If the email is a duplicate, return a 409 Conflict status.
|
if (error instanceof RepositoryError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
logger.error({ error }, `User registration route failed for email: ${email}.`);
|
// For unknown errors, log them and wrap them in a generic DatabaseError
|
||||||
// Pass the error to the centralized handler
|
// to standardize the error contract of the service layer.
|
||||||
throw error;
|
const message = error instanceof Error ? error.message : 'An unknown error occurred during registration.';
|
||||||
}
|
logger.error({ error, email }, `User registration failed with an unexpected error.`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerAndLoginUser(
|
async registerAndLoginUser(
|
||||||
@@ -91,15 +94,9 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
||||||
try {
|
// The repository method `saveRefreshToken` already includes robust error handling
|
||||||
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||||
} catch (tokenErr) {
|
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
||||||
logger.error(
|
|
||||||
{ error: tokenErr },
|
|
||||||
`Failed to save refresh token during login for user: ${userId}`,
|
|
||||||
);
|
|
||||||
throw tokenErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
||||||
@@ -124,7 +121,11 @@ class AuthService {
|
|||||||
const tokenHash = await bcrypt.hash(token, saltRounds);
|
const tokenHash = await bcrypt.hash(token, saltRounds);
|
||||||
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
||||||
|
|
||||||
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog);
|
// Wrap the token creation in a transaction to ensure atomicity of the DELETE and INSERT operations.
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
await transactionalUserRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog, client);
|
||||||
|
});
|
||||||
|
|
||||||
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
||||||
|
|
||||||
@@ -139,13 +140,29 @@ class AuthService {
|
|||||||
|
|
||||||
return token;
|
return token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error, email }, `An unexpected error occurred during password reset for email: ${email}`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
||||||
try {
|
const strength = validatePasswordStrength(newPassword);
|
||||||
|
if (!strength.isValid) {
|
||||||
|
throw new ValidationError([], strength.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap all database operations in a transaction to ensure atomicity.
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||||
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
||||||
|
|
||||||
|
// This read can happen outside the transaction if we use the non-transactional repo.
|
||||||
const validTokens = await userRepo.getValidResetTokens(reqLog);
|
const validTokens = await userRepo.getValidResetTokens(reqLog);
|
||||||
let tokenRecord;
|
let tokenRecord;
|
||||||
for (const record of validTokens) {
|
for (const record of validTokens) {
|
||||||
@@ -157,32 +174,31 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return null;
|
return null; // Token is invalid or expired, not an error.
|
||||||
}
|
}
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
// These three writes are now atomic.
|
||||||
await userRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
await transactionalUserRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
||||||
|
await transactionalUserRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
||||||
// Log this security event after a successful password reset.
|
|
||||||
await adminRepo.logActivity(
|
await adminRepo.logActivity(
|
||||||
{
|
{ userId: tokenRecord.user_id, action: 'password_reset', displayText: `User ID ${tokenRecord.user_id} has reset their password.`, icon: 'key' },
|
||||||
userId: tokenRecord.user_id,
|
|
||||||
action: 'password_reset',
|
|
||||||
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
|
|
||||||
icon: 'key',
|
|
||||||
details: { source_ip: null },
|
|
||||||
},
|
|
||||||
reqLog,
|
reqLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
}).catch((error) => {
|
||||||
logger.error({ error }, `An error occurred during password reset.`);
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error }, `An unexpected error occurred during password update.`);
|
||||||
|
throw new DatabaseError(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
||||||
@@ -194,18 +210,22 @@ class AuthService {
|
|||||||
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
|
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
|
||||||
return userProfile;
|
return userProfile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'An error occurred during /refresh-token.');
|
// Re-throw known repository errors to allow for specific handling upstream.
|
||||||
throw error;
|
if (error instanceof RepositoryError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ error, refreshToken }, 'An unexpected error occurred while fetching user by refresh token.');
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(refreshToken: string, reqLog: any) {
|
async logout(refreshToken: string, reqLog: any) {
|
||||||
try {
|
// The repository method `deleteRefreshToken` now includes robust error handling
|
||||||
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||||
} catch (err: any) {
|
// The original implementation also swallowed errors, which is now fixed.
|
||||||
logger.error({ error: err }, 'Failed to delete refresh token from DB during logout.');
|
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ vi.mock('../services/logger.server', () => ({
|
|||||||
// Mock the date utility to control the output for the weekly analytics job
|
// Mock the date utility to control the output for the weekly analytics job
|
||||||
vi.mock('../utils/dateUtils', () => ({
|
vi.mock('../utils/dateUtils', () => ({
|
||||||
getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
|
getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
|
||||||
|
getCurrentDateISOString: vi.fn(() => '2024-10-18'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/queueService.server', () => ({
|
||||||
|
analyticsQueue: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
|
weeklyAnalyticsQueue: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
|
emailQueue: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
|
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
|
||||||
@@ -32,6 +45,7 @@ import type { PersonalizationRepository } from './db/personalization.db';
|
|||||||
import type { NotificationRepository } from './db/notification.db';
|
import type { NotificationRepository } from './db/notification.db';
|
||||||
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||||
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger
|
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger
|
||||||
|
import { analyticsQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
|
||||||
|
|
||||||
describe('Background Job Service', () => {
|
describe('Background Job Service', () => {
|
||||||
// Create mock dependencies that will be injected into the service
|
// Create mock dependencies that will be injected into the service
|
||||||
@@ -118,6 +132,37 @@ describe('Background Job Service', () => {
|
|||||||
mockServiceLogger,
|
mockServiceLogger,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
describe('Manual Triggers', () => {
|
||||||
|
it('triggerAnalyticsReport should add a daily report job to the queue', async () => {
|
||||||
|
// The mock should return the jobId passed to it to simulate bullmq's behavior
|
||||||
|
vi.mocked(analyticsQueue.add).mockImplementation(async (name, data, opts) => ({ id: opts?.jobId }) as any);
|
||||||
|
const jobId = await service.triggerAnalyticsReport();
|
||||||
|
|
||||||
|
expect(jobId).toContain('manual-report-');
|
||||||
|
expect(analyticsQueue.add).toHaveBeenCalledWith(
|
||||||
|
'generate-daily-report',
|
||||||
|
{ reportDate: '2024-10-18' },
|
||||||
|
{ jobId: expect.stringContaining('manual-report-') },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggerWeeklyAnalyticsReport should add a weekly report job to the queue', async () => {
|
||||||
|
// The mock should return the jobId passed to it
|
||||||
|
vi.mocked(weeklyAnalyticsQueue.add).mockImplementation(async (name, data, opts) => ({ id: opts?.jobId }) as any);
|
||||||
|
const jobId = await service.triggerWeeklyAnalyticsReport();
|
||||||
|
|
||||||
|
expect(jobId).toContain('manual-weekly-report-');
|
||||||
|
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith(
|
||||||
|
'generate-weekly-report',
|
||||||
|
{
|
||||||
|
reportYear: 2024, // From mocked dateUtils
|
||||||
|
reportWeek: 42, // From mocked dateUtils
|
||||||
|
},
|
||||||
|
{ jobId: expect.stringContaining('manual-weekly-report-') },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should do nothing if no deals are found for any user', async () => {
|
it('should do nothing if no deals are found for any user', async () => {
|
||||||
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
|
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
|
||||||
await service.runDailyDealCheck();
|
await service.runDailyDealCheck();
|
||||||
@@ -153,24 +198,27 @@ describe('Background Job Service', () => {
|
|||||||
// Check that in-app notifications were created for both users
|
// Check that in-app notifications were created for both users
|
||||||
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
|
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
|
||||||
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
|
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
|
||||||
expect(notificationPayload).toHaveLength(2);
|
|
||||||
// Use expect.arrayContaining to be order-agnostic.
|
// Sort by user_id to ensure a consistent order for a direct `toEqual` comparison.
|
||||||
expect(notificationPayload).toEqual(
|
// This provides a clearer diff on failure than `expect.arrayContaining`.
|
||||||
expect.arrayContaining([
|
const sortedPayload = [...notificationPayload].sort((a, b) =>
|
||||||
{
|
a.user_id.localeCompare(b.user_id),
|
||||||
user_id: 'user-1',
|
|
||||||
content: 'You have 1 new deal(s) on your watched items!',
|
|
||||||
link_url: '/dashboard/deals',
|
|
||||||
updated_at: expect.any(String),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user_id: 'user-2',
|
|
||||||
content: 'You have 2 new deal(s) on your watched items!',
|
|
||||||
link_url: '/dashboard/deals',
|
|
||||||
updated_at: expect.any(String),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(sortedPayload).toEqual([
|
||||||
|
{
|
||||||
|
user_id: 'user-1',
|
||||||
|
content: 'You have 1 new deal(s) on your watched items!',
|
||||||
|
link_url: '/dashboard/deals',
|
||||||
|
updated_at: expect.any(String),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'user-2',
|
||||||
|
content: 'You have 2 new deal(s) on your watched items!',
|
||||||
|
link_url: '/dashboard/deals',
|
||||||
|
updated_at: expect.any(String),
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle and log errors for individual users without stopping the process', async () => {
|
it('should handle and log errors for individual users without stopping the process', async () => {
|
||||||
@@ -252,7 +300,7 @@ describe('Background Job Service', () => {
|
|||||||
vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
|
vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should schedule three cron jobs with the correct schedules', () => {
|
it('should schedule four cron jobs with the correct schedules', () => {
|
||||||
startBackgroundJobs(
|
startBackgroundJobs(
|
||||||
mockBackgroundJobService,
|
mockBackgroundJobService,
|
||||||
mockAnalyticsQueue,
|
mockAnalyticsQueue,
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import type { Queue } from 'bullmq';
|
import type { Queue } from 'bullmq';
|
||||||
import { Notification, WatchedItemDeal } from '../types';
|
import { formatCurrency } from '../utils/formatUtils';
|
||||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
import { getSimpleWeekAndYear, getCurrentDateISOString } from '../utils/dateUtils';
|
||||||
|
import type { Notification, WatchedItemDeal } from '../types';
|
||||||
// Import types for repositories from their source files
|
// Import types for repositories from their source files
|
||||||
import type { PersonalizationRepository } from './db/personalization.db';
|
import type { PersonalizationRepository } from './db/personalization.db';
|
||||||
import type { NotificationRepository } from './db/notification.db';
|
import type { NotificationRepository } from './db/notification.db';
|
||||||
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
|
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
|
||||||
|
|
||||||
|
type UserDealGroup = {
|
||||||
|
userProfile: { user_id: string; email: string; full_name: string | null };
|
||||||
|
deals: WatchedItemDeal[];
|
||||||
|
};
|
||||||
|
|
||||||
interface EmailJobData {
|
interface EmailJobData {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -25,7 +31,7 @@ export class BackgroundJobService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async triggerAnalyticsReport(): Promise<string> {
|
public async triggerAnalyticsReport(): Promise<string> {
|
||||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
const reportDate = getCurrentDateISOString(); // YYYY-MM-DD
|
||||||
const jobId = `manual-report-${reportDate}-${Date.now()}`;
|
const jobId = `manual-report-${reportDate}-${Date.now()}`;
|
||||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||||
return job.id!;
|
return job.id!;
|
||||||
@@ -57,14 +63,16 @@ export class BackgroundJobService {
|
|||||||
const dealsListHtml = deals
|
const dealsListHtml = deals
|
||||||
.map(
|
.map(
|
||||||
(deal) =>
|
(deal) =>
|
||||||
`<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`,
|
`<li><strong>${deal.item_name}</strong> is on sale for <strong>${formatCurrency(
|
||||||
|
deal.best_price_in_cents,
|
||||||
|
)}</strong> at ${deal.store_name}!</li>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
|
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
|
||||||
const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`;
|
const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`;
|
||||||
|
|
||||||
// Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day.
|
// Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day.
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = getCurrentDateISOString();
|
||||||
const jobId = `deal-email-${userProfile.user_id}-${today}`;
|
const jobId = `deal-email-${userProfile.user_id}-${today}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -82,15 +90,41 @@ export class BackgroundJobService {
|
|||||||
private _prepareInAppNotification(
|
private _prepareInAppNotification(
|
||||||
userId: string,
|
userId: string,
|
||||||
dealCount: number,
|
dealCount: number,
|
||||||
): Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> {
|
): Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> {
|
||||||
return {
|
return {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
content: `You have ${dealCount} new deal(s) on your watched items!`,
|
content: `You have ${dealCount} new deal(s) on your watched items!`,
|
||||||
link_url: '/dashboard/deals', // A link to the future "My Deals" page
|
link_url: '/dashboard/deals', // A link to the future "My Deals" page
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _processDealsForUser({
|
||||||
|
userProfile,
|
||||||
|
deals,
|
||||||
|
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
|
||||||
|
try {
|
||||||
|
this.logger.info(
|
||||||
|
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare in-app and email notifications.
|
||||||
|
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
|
||||||
|
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
|
||||||
|
|
||||||
|
// Enqueue an email notification job.
|
||||||
|
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
||||||
|
|
||||||
|
// Return the notification to be collected for bulk insertion.
|
||||||
|
return notification;
|
||||||
|
} catch (userError) {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: userError },
|
||||||
|
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
|
||||||
|
);
|
||||||
|
return null; // Return null on error for this user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks for new deals on watched items for all users and sends notifications.
|
* Checks for new deals on watched items for all users and sends notifications.
|
||||||
* This function is designed to be run periodically (e.g., daily).
|
* This function is designed to be run periodically (e.g., daily).
|
||||||
@@ -110,70 +144,47 @@ export class BackgroundJobService {
|
|||||||
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
|
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
|
||||||
|
|
||||||
// 2. Group deals by user in memory.
|
// 2. Group deals by user in memory.
|
||||||
const dealsByUser = allDeals.reduce<
|
const dealsByUser = new Map<string, UserDealGroup>();
|
||||||
Record<
|
for (const deal of allDeals) {
|
||||||
string,
|
let userGroup = dealsByUser.get(deal.user_id);
|
||||||
{
|
if (!userGroup) {
|
||||||
userProfile: { user_id: string; email: string; full_name: string | null };
|
userGroup = {
|
||||||
deals: WatchedItemDeal[];
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>((acc, deal) => {
|
|
||||||
if (!acc[deal.user_id]) {
|
|
||||||
acc[deal.user_id] = {
|
|
||||||
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
|
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
|
||||||
deals: [],
|
deals: [],
|
||||||
};
|
};
|
||||||
|
dealsByUser.set(deal.user_id, userGroup);
|
||||||
}
|
}
|
||||||
acc[deal.user_id].deals.push(deal);
|
userGroup.deals.push(deal);
|
||||||
return acc;
|
}
|
||||||
}, {});
|
|
||||||
|
|
||||||
const allNotifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[] =
|
|
||||||
[];
|
|
||||||
|
|
||||||
// 3. Process each user's deals in parallel.
|
// 3. Process each user's deals in parallel.
|
||||||
const userProcessingPromises = Object.values(dealsByUser).map(
|
const userProcessingPromises = Array.from(dealsByUser.values()).map((userGroup) =>
|
||||||
async ({ userProfile, deals }) => {
|
this._processDealsForUser(userGroup),
|
||||||
try {
|
|
||||||
this.logger.info(
|
|
||||||
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Prepare in-app and email notifications.
|
|
||||||
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
|
|
||||||
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
|
|
||||||
|
|
||||||
// 5. Enqueue an email notification job.
|
|
||||||
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
|
||||||
|
|
||||||
// Return the notification to be collected for bulk insertion.
|
|
||||||
return notification;
|
|
||||||
} catch (userError) {
|
|
||||||
this.logger.error(
|
|
||||||
{ err: userError },
|
|
||||||
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
|
|
||||||
);
|
|
||||||
return null; // Return null on error for this user.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for all user processing to complete.
|
// Wait for all user processing to complete.
|
||||||
const results = await Promise.allSettled(userProcessingPromises);
|
const results = await Promise.allSettled(userProcessingPromises);
|
||||||
|
|
||||||
// 6. Collect all successfully created notifications.
|
// 6. Collect all successfully created notifications.
|
||||||
results.forEach((result) => {
|
const successfulNotifications = results
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
.filter(
|
||||||
allNotifications.push(result.value);
|
(
|
||||||
}
|
result,
|
||||||
});
|
): result is PromiseFulfilledResult<
|
||||||
|
Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'>
|
||||||
|
> => result.status === 'fulfilled' && !!result.value,
|
||||||
|
)
|
||||||
|
.map((result) => result.value);
|
||||||
|
|
||||||
// 7. Bulk insert all in-app notifications in a single query.
|
// 7. Bulk insert all in-app notifications in a single query.
|
||||||
if (allNotifications.length > 0) {
|
if (successfulNotifications.length > 0) {
|
||||||
await this.notificationRepo.createBulkNotifications(allNotifications, this.logger);
|
const notificationsForDb = successfulNotifications.map((n) => ({
|
||||||
|
...n,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
await this.notificationRepo.createBulkNotifications(notificationsForDb, this.logger);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`,
|
`[BackgroundJob] Successfully created ${successfulNotifications.length} in-app notifications.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +255,7 @@ export function startBackgroundJobs(
|
|||||||
(async () => {
|
(async () => {
|
||||||
logger.info('[BackgroundJob] Enqueuing daily analytics report generation job.');
|
logger.info('[BackgroundJob] Enqueuing daily analytics report generation job.');
|
||||||
try {
|
try {
|
||||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
const reportDate = getCurrentDateISOString(); // YYYY-MM-DD
|
||||||
// We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts.
|
// We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts.
|
||||||
await analyticsQueue.add(
|
await analyticsQueue.add(
|
||||||
'generate-daily-report',
|
'generate-daily-report',
|
||||||
|
|||||||
@@ -106,7 +106,13 @@ describe('Address DB Service', () => {
|
|||||||
'An identical address already exists.',
|
'An identical address already exists.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, address: addressData },
|
{
|
||||||
|
err: dbError,
|
||||||
|
address: addressData,
|
||||||
|
code: '23505',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in upsertAddress',
|
'Database error in upsertAddress',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -715,7 +715,14 @@ describe('Admin DB Service', () => {
|
|||||||
adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger),
|
adminRepo.updateUserRole('non-existent-user', 'admin', mockLogger),
|
||||||
).rejects.toThrow('The specified user does not exist.');
|
).rejects.toThrow('The specified user does not exist.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: 'non-existent-user', role: 'admin' },
|
{
|
||||||
|
err: dbError,
|
||||||
|
userId: 'non-existent-user',
|
||||||
|
role: 'admin',
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in updateUserRole',
|
'Database error in updateUserRole',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
RepositoryError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
ForbiddenError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
FileUploadError,
|
FileUploadError,
|
||||||
NotNullConstraintError,
|
NotNullConstraintError,
|
||||||
@@ -18,17 +19,17 @@ import {
|
|||||||
vi.mock('./logger.server');
|
vi.mock('./logger.server');
|
||||||
|
|
||||||
describe('Custom Database and Application Errors', () => {
|
describe('Custom Database and Application Errors', () => {
|
||||||
describe('DatabaseError', () => {
|
describe('RepositoryError', () => {
|
||||||
it('should create a generic database error with a message and status', () => {
|
it('should create a generic database error with a message and status', () => {
|
||||||
const message = 'Generic DB Error';
|
const message = 'Generic DB Error';
|
||||||
const status = 500;
|
const status = 500;
|
||||||
const error = new DatabaseError(message, status);
|
const error = new RepositoryError(message, status);
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe(message);
|
expect(error.message).toBe(message);
|
||||||
expect(error.status).toBe(status);
|
expect(error.status).toBe(status);
|
||||||
expect(error.name).toBe('DatabaseError');
|
expect(error.name).toBe('RepositoryError');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new UniqueConstraintError();
|
const error = new UniqueConstraintError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(UniqueConstraintError);
|
expect(error).toBeInstanceOf(UniqueConstraintError);
|
||||||
expect(error.message).toBe('The record already exists.');
|
expect(error.message).toBe('The record already exists.');
|
||||||
expect(error.status).toBe(409);
|
expect(error.status).toBe(409);
|
||||||
@@ -56,7 +57,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new ForeignKeyConstraintError();
|
const error = new ForeignKeyConstraintError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(ForeignKeyConstraintError);
|
expect(error).toBeInstanceOf(ForeignKeyConstraintError);
|
||||||
expect(error.message).toBe('The referenced record does not exist.');
|
expect(error.message).toBe('The referenced record does not exist.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
@@ -75,7 +76,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
const error = new NotFoundError();
|
const error = new NotFoundError();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(NotFoundError);
|
expect(error).toBeInstanceOf(NotFoundError);
|
||||||
expect(error.message).toBe('The requested resource was not found.');
|
expect(error.message).toBe('The requested resource was not found.');
|
||||||
expect(error.status).toBe(404);
|
expect(error.status).toBe(404);
|
||||||
@@ -89,13 +90,32 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ForbiddenError', () => {
|
||||||
|
it('should create an error with a default message and status 403', () => {
|
||||||
|
const error = new ForbiddenError();
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
|
expect(error).toBeInstanceOf(ForbiddenError);
|
||||||
|
expect(error.message).toBe('Access denied.');
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
expect(error.name).toBe('ForbiddenError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an error with a custom message', () => {
|
||||||
|
const message = 'You shall not pass.';
|
||||||
|
const error = new ForbiddenError(message);
|
||||||
|
expect(error.message).toBe(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ValidationError', () => {
|
describe('ValidationError', () => {
|
||||||
it('should create an error with a default message, status 400, and validation errors array', () => {
|
it('should create an error with a default message, status 400, and validation errors array', () => {
|
||||||
const validationIssues = [{ path: ['email'], message: 'Invalid email' }];
|
const validationIssues = [{ path: ['email'], message: 'Invalid email' }];
|
||||||
const error = new ValidationError(validationIssues);
|
const error = new ValidationError(validationIssues);
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error).toBeInstanceOf(ValidationError);
|
expect(error).toBeInstanceOf(ValidationError);
|
||||||
expect(error.message).toBe('The request data is invalid.');
|
expect(error.message).toBe('The request data is invalid.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
@@ -126,7 +146,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('NotNullConstraintError', () => {
|
describe('NotNullConstraintError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new NotNullConstraintError();
|
const error = new NotNullConstraintError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A required field was left null.');
|
expect(error.message).toBe('A required field was left null.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('NotNullConstraintError');
|
expect(error.name).toBe('NotNullConstraintError');
|
||||||
@@ -142,7 +162,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('CheckConstraintError', () => {
|
describe('CheckConstraintError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new CheckConstraintError();
|
const error = new CheckConstraintError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A check constraint was violated.');
|
expect(error.message).toBe('A check constraint was violated.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('CheckConstraintError');
|
expect(error.name).toBe('CheckConstraintError');
|
||||||
@@ -158,7 +178,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('InvalidTextRepresentationError', () => {
|
describe('InvalidTextRepresentationError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new InvalidTextRepresentationError();
|
const error = new InvalidTextRepresentationError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A value has an invalid format for its data type.');
|
expect(error.message).toBe('A value has an invalid format for its data type.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('InvalidTextRepresentationError');
|
expect(error.name).toBe('InvalidTextRepresentationError');
|
||||||
@@ -174,7 +194,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
describe('NumericValueOutOfRangeError', () => {
|
describe('NumericValueOutOfRangeError', () => {
|
||||||
it('should create an error with a default message and status 400', () => {
|
it('should create an error with a default message and status 400', () => {
|
||||||
const error = new NumericValueOutOfRangeError();
|
const error = new NumericValueOutOfRangeError();
|
||||||
expect(error).toBeInstanceOf(DatabaseError);
|
expect(error).toBeInstanceOf(RepositoryError);
|
||||||
expect(error.message).toBe('A numeric value is out of the allowed range.');
|
expect(error.message).toBe('A numeric value is out of the allowed range.');
|
||||||
expect(error.status).toBe(400);
|
expect(error.status).toBe(400);
|
||||||
expect(error.name).toBe('NumericValueOutOfRangeError');
|
expect(error.name).toBe('NumericValueOutOfRangeError');
|
||||||
@@ -196,7 +216,7 @@ describe('Custom Database and Application Errors', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-throw existing DatabaseError instances without logging', () => {
|
it('should re-throw existing RepositoryError instances without logging', () => {
|
||||||
const notFound = new NotFoundError('Test not found');
|
const notFound = new NotFoundError('Test not found');
|
||||||
expect(() => handleDbError(notFound, mockLogger, 'msg', {})).toThrow(notFound);
|
expect(() => handleDbError(notFound, mockLogger, 'msg', {})).toThrow(notFound);
|
||||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// src/services/db/errors.db.ts
|
// src/services/db/errors.db.ts
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
import { DatabaseError as ProcessingDatabaseError } from '../processingErrors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for custom database errors to ensure they have a status property.
|
* Base class for custom repository-level errors to ensure they have a status property.
|
||||||
*/
|
*/
|
||||||
export class DatabaseError extends Error {
|
export class RepositoryError extends Error {
|
||||||
public status: number;
|
public status: number;
|
||||||
|
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
@@ -20,7 +21,7 @@ export class DatabaseError extends Error {
|
|||||||
* Thrown when a unique constraint is violated (e.g., trying to register an existing email).
|
* Thrown when a unique constraint is violated (e.g., trying to register an existing email).
|
||||||
* Corresponds to PostgreSQL error code '23505'.
|
* Corresponds to PostgreSQL error code '23505'.
|
||||||
*/
|
*/
|
||||||
export class UniqueConstraintError extends DatabaseError {
|
export class UniqueConstraintError extends RepositoryError {
|
||||||
constructor(message = 'The record already exists.') {
|
constructor(message = 'The record already exists.') {
|
||||||
super(message, 409); // 409 Conflict
|
super(message, 409); // 409 Conflict
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ export class UniqueConstraintError extends DatabaseError {
|
|||||||
* Thrown when a foreign key constraint is violated (e.g., trying to reference a non-existent record).
|
* Thrown when a foreign key constraint is violated (e.g., trying to reference a non-existent record).
|
||||||
* Corresponds to PostgreSQL error code '23503'.
|
* Corresponds to PostgreSQL error code '23503'.
|
||||||
*/
|
*/
|
||||||
export class ForeignKeyConstraintError extends DatabaseError {
|
export class ForeignKeyConstraintError extends RepositoryError {
|
||||||
constructor(message = 'The referenced record does not exist.') {
|
constructor(message = 'The referenced record does not exist.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export class ForeignKeyConstraintError extends DatabaseError {
|
|||||||
* Thrown when a 'not null' constraint is violated.
|
* Thrown when a 'not null' constraint is violated.
|
||||||
* Corresponds to PostgreSQL error code '23502'.
|
* Corresponds to PostgreSQL error code '23502'.
|
||||||
*/
|
*/
|
||||||
export class NotNullConstraintError extends DatabaseError {
|
export class NotNullConstraintError extends RepositoryError {
|
||||||
constructor(message = 'A required field was left null.') {
|
constructor(message = 'A required field was left null.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ export class NotNullConstraintError extends DatabaseError {
|
|||||||
* Thrown when a 'check' constraint is violated.
|
* Thrown when a 'check' constraint is violated.
|
||||||
* Corresponds to PostgreSQL error code '23514'.
|
* Corresponds to PostgreSQL error code '23514'.
|
||||||
*/
|
*/
|
||||||
export class CheckConstraintError extends DatabaseError {
|
export class CheckConstraintError extends RepositoryError {
|
||||||
constructor(message = 'A check constraint was violated.') {
|
constructor(message = 'A check constraint was violated.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ export class CheckConstraintError extends DatabaseError {
|
|||||||
* Thrown when a value has an invalid text representation for its data type (e.g., 'abc' for an integer).
|
* Thrown when a value has an invalid text representation for its data type (e.g., 'abc' for an integer).
|
||||||
* Corresponds to PostgreSQL error code '22P02'.
|
* Corresponds to PostgreSQL error code '22P02'.
|
||||||
*/
|
*/
|
||||||
export class InvalidTextRepresentationError extends DatabaseError {
|
export class InvalidTextRepresentationError extends RepositoryError {
|
||||||
constructor(message = 'A value has an invalid format for its data type.') {
|
constructor(message = 'A value has an invalid format for its data type.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ export class InvalidTextRepresentationError extends DatabaseError {
|
|||||||
* Thrown when a numeric value is out of range for its data type (e.g., too large for an integer).
|
* Thrown when a numeric value is out of range for its data type (e.g., too large for an integer).
|
||||||
* Corresponds to PostgreSQL error code '22003'.
|
* Corresponds to PostgreSQL error code '22003'.
|
||||||
*/
|
*/
|
||||||
export class NumericValueOutOfRangeError extends DatabaseError {
|
export class NumericValueOutOfRangeError extends RepositoryError {
|
||||||
constructor(message = 'A numeric value is out of the allowed range.') {
|
constructor(message = 'A numeric value is out of the allowed range.') {
|
||||||
super(message, 400); // 400 Bad Request
|
super(message, 400); // 400 Bad Request
|
||||||
}
|
}
|
||||||
@@ -79,12 +80,22 @@ export class NumericValueOutOfRangeError extends DatabaseError {
|
|||||||
/**
|
/**
|
||||||
* Thrown when a specific record is not found in the database.
|
* Thrown when a specific record is not found in the database.
|
||||||
*/
|
*/
|
||||||
export class NotFoundError extends DatabaseError {
|
export class NotFoundError extends RepositoryError {
|
||||||
constructor(message = 'The requested resource was not found.') {
|
constructor(message = 'The requested resource was not found.') {
|
||||||
super(message, 404); // 404 Not Found
|
super(message, 404); // 404 Not Found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the user does not have permission to access the resource.
|
||||||
|
*/
|
||||||
|
export class ForbiddenError extends RepositoryError {
|
||||||
|
constructor(message = 'Access denied.') {
|
||||||
|
super(message, 403); // 403 Forbidden
|
||||||
|
this.name = 'ForbiddenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the structure for a single validation issue, often from a library like Zod.
|
* Defines the structure for a single validation issue, often from a library like Zod.
|
||||||
*/
|
*/
|
||||||
@@ -97,7 +108,7 @@ export interface ValidationIssue {
|
|||||||
/**
|
/**
|
||||||
* Thrown when request validation fails (e.g., missing body fields or invalid params).
|
* Thrown when request validation fails (e.g., missing body fields or invalid params).
|
||||||
*/
|
*/
|
||||||
export class ValidationError extends DatabaseError {
|
export class ValidationError extends RepositoryError {
|
||||||
public validationErrors: ValidationIssue[];
|
public validationErrors: ValidationIssue[];
|
||||||
|
|
||||||
constructor(errors: ValidationIssue[], message = 'The request data is invalid.') {
|
constructor(errors: ValidationIssue[], message = 'The request data is invalid.') {
|
||||||
@@ -126,6 +137,15 @@ export interface HandleDbErrorOptions {
|
|||||||
defaultMessage?: string;
|
defaultMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type guard to check if an error object is a PostgreSQL error with a code.
|
||||||
|
*/
|
||||||
|
function isPostgresError(
|
||||||
|
error: unknown,
|
||||||
|
): error is { code: string; constraint?: string; detail?: string } {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized error handler for database repositories.
|
* Centralized error handler for database repositories.
|
||||||
* Logs the error and throws appropriate custom errors based on PostgreSQL error codes.
|
* Logs the error and throws appropriate custom errors based on PostgreSQL error codes.
|
||||||
@@ -138,26 +158,42 @@ export function handleDbError(
|
|||||||
options: HandleDbErrorOptions = {},
|
options: HandleDbErrorOptions = {},
|
||||||
): never {
|
): never {
|
||||||
// If it's already a known domain error (like NotFoundError thrown manually), rethrow it.
|
// If it's already a known domain error (like NotFoundError thrown manually), rethrow it.
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof RepositoryError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the raw error
|
if (isPostgresError(error)) {
|
||||||
logger.error({ err: error, ...logContext }, logMessage);
|
const { code, constraint, detail } = error;
|
||||||
|
const enhancedLogContext = { err: error, code, constraint, detail, ...logContext };
|
||||||
|
|
||||||
if (error instanceof Error && 'code' in error) {
|
// Log the detailed error first
|
||||||
const code = (error as any).code;
|
logger.error(enhancedLogContext, logMessage);
|
||||||
|
|
||||||
if (code === '23505') throw new UniqueConstraintError(options.uniqueMessage);
|
// Now, throw the appropriate custom error
|
||||||
if (code === '23503') throw new ForeignKeyConstraintError(options.fkMessage);
|
switch (code) {
|
||||||
if (code === '23502') throw new NotNullConstraintError(options.notNullMessage);
|
case '23505': // unique_violation
|
||||||
if (code === '23514') throw new CheckConstraintError(options.checkMessage);
|
throw new UniqueConstraintError(options.uniqueMessage);
|
||||||
if (code === '22P02') throw new InvalidTextRepresentationError(options.invalidTextMessage);
|
case '23503': // foreign_key_violation
|
||||||
if (code === '22003') throw new NumericValueOutOfRangeError(options.numericOutOfRangeMessage);
|
throw new ForeignKeyConstraintError(options.fkMessage);
|
||||||
|
case '23502': // not_null_violation
|
||||||
|
throw new NotNullConstraintError(options.notNullMessage);
|
||||||
|
case '23514': // check_violation
|
||||||
|
throw new CheckConstraintError(options.checkMessage);
|
||||||
|
case '22P02': // invalid_text_representation
|
||||||
|
throw new InvalidTextRepresentationError(options.invalidTextMessage);
|
||||||
|
case '22003': // numeric_value_out_of_range
|
||||||
|
throw new NumericValueOutOfRangeError(options.numericOutOfRangeMessage);
|
||||||
|
default:
|
||||||
|
// If it's a PG error but not one we handle specifically, fall through to the generic error.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Log the error if it wasn't a recognized Postgres error
|
||||||
|
logger.error({ err: error, ...logContext }, logMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback generic error
|
// Fallback generic error
|
||||||
throw new Error(
|
// Use the consistent DatabaseError from the processing errors module for the fallback.
|
||||||
options.defaultMessage || `Failed to perform operation on ${options.entityName || 'database'}.`,
|
const errorMessage = options.defaultMessage || `Failed to perform operation on ${options.entityName || 'database'}.`;
|
||||||
);
|
throw new ProcessingDatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
CheckConstraintError,
|
CheckConstraintError,
|
||||||
} from './errors.db';
|
} from './errors.db';
|
||||||
|
import { DatabaseError } from '../processingErrors';
|
||||||
import type {
|
import type {
|
||||||
FlyerInsert,
|
FlyerInsert,
|
||||||
FlyerItemInsert,
|
FlyerItemInsert,
|
||||||
@@ -131,8 +132,8 @@ describe('Flyer DB Service', () => {
|
|||||||
it('should execute an INSERT query and return the new flyer', async () => {
|
it('should execute an INSERT query and return the new flyer', async () => {
|
||||||
const flyerData: FlyerDbInsert = {
|
const flyerData: FlyerDbInsert = {
|
||||||
file_name: 'test.jpg',
|
file_name: 'test.jpg',
|
||||||
image_url: 'http://localhost:3001/images/test.jpg',
|
image_url: 'https://example.com/images/test.jpg',
|
||||||
icon_url: 'http://localhost:3001/images/icons/test.jpg',
|
icon_url: 'https://example.com/images/icons/test.jpg',
|
||||||
checksum: 'checksum123',
|
checksum: 'checksum123',
|
||||||
store_id: 1,
|
store_id: 1,
|
||||||
valid_from: '2024-01-01',
|
valid_from: '2024-01-01',
|
||||||
@@ -154,8 +155,8 @@ describe('Flyer DB Service', () => {
|
|||||||
expect.stringContaining('INSERT INTO flyers'),
|
expect.stringContaining('INSERT INTO flyers'),
|
||||||
[
|
[
|
||||||
'test.jpg',
|
'test.jpg',
|
||||||
'http://localhost:3001/images/test.jpg',
|
'https://example.com/images/test.jpg',
|
||||||
'http://localhost:3001/images/icons/test.jpg',
|
'https://example.com/images/icons/test.jpg',
|
||||||
'checksum123',
|
'checksum123',
|
||||||
1,
|
1,
|
||||||
'2024-01-01',
|
'2024-01-01',
|
||||||
@@ -183,7 +184,13 @@ describe('Flyer DB Service', () => {
|
|||||||
'A flyer with this checksum already exists.',
|
'A flyer with this checksum already exists.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, flyerData },
|
{
|
||||||
|
err: dbError,
|
||||||
|
flyerData,
|
||||||
|
code: '23505',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in insertFlyer',
|
'Database error in insertFlyer',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -330,7 +337,13 @@ describe('Flyer DB Service', () => {
|
|||||||
'The specified flyer, category, master item, or product does not exist.',
|
'The specified flyer, category, master item, or product does not exist.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, flyerId: 999 },
|
{
|
||||||
|
err: dbError,
|
||||||
|
flyerId: 999,
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in insertFlyerItems',
|
'Database error in insertFlyerItems',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -350,8 +363,8 @@ describe('Flyer DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createFlyerAndItems', () => {
|
describe('createFlyerAndItems', () => {
|
||||||
it('should use withTransaction to create a flyer and items', async () => {
|
it('should execute find/create store, insert flyer, and insert items using the provided client', async () => {
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = { // This was a duplicate, fixed.
|
||||||
file_name: 'transact.jpg',
|
file_name: 'transact.jpg',
|
||||||
store_name: 'Transaction Store',
|
store_name: 'Transaction Store',
|
||||||
} as FlyerInsert;
|
} as FlyerInsert;
|
||||||
@@ -374,41 +387,31 @@ describe('Flyer DB Service', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock the withTransaction to execute the callback with a mock client
|
// Mock the sequence of 4 calls on the client
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
const mockClient = { query: vi.fn() };
|
||||||
const mockClient = { query: vi.fn() };
|
mockClient.query
|
||||||
// Mock the sequence of 4 calls within the transaction
|
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
||||||
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
// 2. findOrCreateStore: SELECT store_id
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||||
// 2. findOrCreateStore: SELECT store_id
|
// 3. insertFlyer
|
||||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
||||||
// 3. insertFlyer
|
// 4. insertFlyerItems
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
.mockResolvedValueOnce({ rows: mockItems });
|
||||||
// 4. insertFlyerItems
|
|
||||||
.mockResolvedValueOnce({ rows: mockItems });
|
|
||||||
return callback(mockClient as unknown as PoolClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await createFlyerAndItems(flyerData, itemsData, mockLogger);
|
const result = await createFlyerAndItems(
|
||||||
|
flyerData,
|
||||||
|
itemsData,
|
||||||
|
mockLogger,
|
||||||
|
mockClient as unknown as PoolClient,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
flyer: mockFlyer,
|
flyer: mockFlyer,
|
||||||
items: mockItems,
|
items: mockItems,
|
||||||
});
|
});
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Verify the individual functions were called with the client
|
// Verify the individual functions were called with the client
|
||||||
const callback = (vi.mocked(withTransaction) as Mock).mock.calls[0][0];
|
|
||||||
const mockClient = { query: vi.fn() };
|
|
||||||
// Set up the same mock sequence for verification
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore 1
|
|
||||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore 2
|
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
|
|
||||||
.mockResolvedValueOnce({ rows: mockItems });
|
|
||||||
await callback(mockClient as unknown as PoolClient);
|
|
||||||
|
|
||||||
// findOrCreateStore assertions
|
// findOrCreateStore assertions
|
||||||
expect(mockClient.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||||
@@ -430,28 +433,27 @@ describe('Flyer DB Service', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log and re-throw an error if the transaction fails', async () => {
|
it('should propagate an error if any step fails', async () => {
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = {
|
||||||
file_name: 'fail.jpg',
|
file_name: 'fail.jpg',
|
||||||
store_name: 'Fail Store',
|
store_name: 'Fail Store',
|
||||||
} as FlyerInsert;
|
} as FlyerInsert;
|
||||||
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
|
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
|
||||||
const transactionError = new Error('Underlying transaction failed');
|
const dbError = new Error('Underlying DB call failed');
|
||||||
|
|
||||||
// Mock withTransaction to reject directly
|
// Mock the client to fail on the insertFlyer step
|
||||||
vi.mocked(withTransaction).mockRejectedValue(transactionError);
|
const mockClient = { query: vi.fn() };
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
||||||
|
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||||
|
.mockRejectedValueOnce(dbError); // insertFlyer fails
|
||||||
|
|
||||||
// Expect the createFlyerAndItems function to reject with the same error
|
// The calling service's withTransaction would catch this.
|
||||||
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow(
|
// Here, we just expect it to be thrown.
|
||||||
transactionError,
|
await expect(
|
||||||
);
|
createFlyerAndItems(flyerData, itemsData, mockLogger, mockClient as unknown as PoolClient),
|
||||||
|
// The error is wrapped by handleDbError, so we check for the wrapped error.
|
||||||
// Verify that the error was logged before being re-thrown
|
).rejects.toThrow(new DatabaseError('Failed to insert flyer into database.'));
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
||||||
{ err: transactionError },
|
|
||||||
'Database transaction error in createFlyerAndItems',
|
|
||||||
);
|
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -379,27 +379,23 @@ export async function createFlyerAndItems(
|
|||||||
flyerData: FlyerInsert,
|
flyerData: FlyerInsert,
|
||||||
itemsForDb: FlyerItemInsert[],
|
itemsForDb: FlyerItemInsert[],
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
client: PoolClient,
|
||||||
) {
|
) {
|
||||||
try {
|
// The calling service is now responsible for managing the transaction.
|
||||||
return await withTransaction(async (client) => {
|
// This function assumes it is being run within a transaction via the provided client.
|
||||||
const flyerRepo = new FlyerRepository(client);
|
const flyerRepo = new FlyerRepository(client);
|
||||||
|
|
||||||
// 1. Find or create the store to get the store_id
|
// 1. Find or create the store to get the store_id
|
||||||
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
|
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
|
||||||
|
|
||||||
// 2. Prepare the data for the flyer table, replacing store_name with store_id
|
// 2. Prepare the data for the flyer table, replacing store_name with store_id
|
||||||
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
|
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
|
||||||
|
|
||||||
// 3. Insert the flyer record
|
// 3. Insert the flyer record
|
||||||
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
|
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
|
||||||
|
|
||||||
// 4. Insert the associated flyer items
|
// 4. Insert the associated flyer items
|
||||||
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
|
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
|
||||||
|
|
||||||
return { flyer: newFlyer, items: newItems };
|
return { flyer: newFlyer, items: newItems };
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, 'Database transaction error in createFlyerAndItems');
|
|
||||||
throw error; // Re-throw the error to be handled by the calling service.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,14 @@ describe('Gamification DB Service', () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow('The specified user or achievement does not exist.');
|
).rejects.toThrow('The specified user or achievement does not exist.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: 'non-existent-user', achievementName: 'Non-existent Achievement' },
|
{
|
||||||
|
err: dbError,
|
||||||
|
userId: 'non-existent-user',
|
||||||
|
achievementName: 'Non-existent Achievement',
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in awardAchievement',
|
'Database error in awardAchievement',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
64
src/services/db/index.db.test.ts
Normal file
64
src/services/db/index.db.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/services/db/index.db.test.ts
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock all the repository classes to be simple classes/functions
|
||||||
|
// This prevents their constructors from running real database connection logic.
|
||||||
|
vi.mock('./user.db', () => ({ UserRepository: class UserRepository {} }));
|
||||||
|
vi.mock('./flyer.db', () => ({ FlyerRepository: class FlyerRepository {} }));
|
||||||
|
vi.mock('./address.db', () => ({ AddressRepository: class AddressRepository {} }));
|
||||||
|
vi.mock('./shopping.db', () => ({ ShoppingRepository: class ShoppingRepository {} }));
|
||||||
|
vi.mock('./personalization.db', () => ({
|
||||||
|
PersonalizationRepository: class PersonalizationRepository {},
|
||||||
|
}));
|
||||||
|
vi.mock('./recipe.db', () => ({ RecipeRepository: class RecipeRepository {} }));
|
||||||
|
vi.mock('./notification.db', () => ({
|
||||||
|
NotificationRepository: class NotificationRepository {},
|
||||||
|
}));
|
||||||
|
vi.mock('./budget.db', () => ({ BudgetRepository: class BudgetRepository {} }));
|
||||||
|
vi.mock('./gamification.db', () => ({
|
||||||
|
GamificationRepository: class GamificationRepository {},
|
||||||
|
}));
|
||||||
|
vi.mock('./admin.db', () => ({ AdminRepository: class AdminRepository {} }));
|
||||||
|
|
||||||
|
// These modules export an already-instantiated object, so we mock the object.
|
||||||
|
vi.mock('./reaction.db', () => ({ reactionRepo: {} }));
|
||||||
|
vi.mock('./conversion.db', () => ({ conversionRepo: {} }));
|
||||||
|
|
||||||
|
// Mock the re-exported function.
|
||||||
|
vi.mock('./connection.db', () => ({ withTransaction: vi.fn() }));
|
||||||
|
|
||||||
|
// We must un-mock the file we are testing so we get the actual implementation.
|
||||||
|
vi.unmock('./index.db');
|
||||||
|
|
||||||
|
// Import the module to be tested AFTER setting up the mocks.
|
||||||
|
import * as db from './index.db';
|
||||||
|
|
||||||
|
// Import the mocked classes to check `instanceof`.
|
||||||
|
import { UserRepository } from './user.db';
|
||||||
|
import { FlyerRepository } from './flyer.db';
|
||||||
|
import { AddressRepository } from './address.db';
|
||||||
|
import { ShoppingRepository } from './shopping.db';
|
||||||
|
import { PersonalizationRepository } from './personalization.db';
|
||||||
|
import { RecipeRepository } from './recipe.db';
|
||||||
|
import { NotificationRepository } from './notification.db';
|
||||||
|
import { BudgetRepository } from './budget.db';
|
||||||
|
import { GamificationRepository } from './gamification.db';
|
||||||
|
import { AdminRepository } from './admin.db';
|
||||||
|
|
||||||
|
describe('DB Index', () => {
|
||||||
|
it('should instantiate and export all repositories and functions', () => {
|
||||||
|
expect(db.userRepo).toBeInstanceOf(UserRepository);
|
||||||
|
expect(db.flyerRepo).toBeInstanceOf(FlyerRepository);
|
||||||
|
expect(db.addressRepo).toBeInstanceOf(AddressRepository);
|
||||||
|
expect(db.shoppingRepo).toBeInstanceOf(ShoppingRepository);
|
||||||
|
expect(db.personalizationRepo).toBeInstanceOf(PersonalizationRepository);
|
||||||
|
expect(db.recipeRepo).toBeInstanceOf(RecipeRepository);
|
||||||
|
expect(db.notificationRepo).toBeInstanceOf(NotificationRepository);
|
||||||
|
expect(db.budgetRepo).toBeInstanceOf(BudgetRepository);
|
||||||
|
expect(db.gamificationRepo).toBeInstanceOf(GamificationRepository);
|
||||||
|
expect(db.adminRepo).toBeInstanceOf(AdminRepository);
|
||||||
|
expect(db.reactionRepo).toBeDefined();
|
||||||
|
expect(db.conversionRepo).toBeDefined();
|
||||||
|
expect(db.withTransaction).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,7 +150,15 @@ describe('Notification DB Service', () => {
|
|||||||
notificationRepo.createNotification('non-existent-user', 'Test', mockLogger),
|
notificationRepo.createNotification('non-existent-user', 'Test', mockLogger),
|
||||||
).rejects.toThrow('The specified user does not exist.');
|
).rejects.toThrow('The specified user does not exist.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: 'non-existent-user', content: 'Test', linkUrl: undefined },
|
{
|
||||||
|
err: dbError,
|
||||||
|
userId: 'non-existent-user',
|
||||||
|
content: 'Test',
|
||||||
|
linkUrl: undefined,
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in createNotification',
|
'Database error in createNotification',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -195,7 +203,13 @@ describe('Notification DB Service', () => {
|
|||||||
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
||||||
).rejects.toThrow(ForeignKeyConstraintError);
|
).rejects.toThrow(ForeignKeyConstraintError);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, notifications: notificationsToCreate },
|
{
|
||||||
|
err: dbError,
|
||||||
|
notifications: notificationsToCreate,
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in createBulkNotifications',
|
'Database error in createBulkNotifications',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,7 +173,14 @@ describe('Recipe DB Service', () => {
|
|||||||
'The specified user or recipe does not exist.',
|
'The specified user or recipe does not exist.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: 'user-123', recipeId: 999 },
|
{
|
||||||
|
err: dbError,
|
||||||
|
userId: 'user-123',
|
||||||
|
recipeId: 999,
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in addFavoriteRecipe',
|
'Database error in addFavoriteRecipe',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -414,7 +421,15 @@ describe('Recipe DB Service', () => {
|
|||||||
recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger),
|
recipeRepo.addRecipeComment(999, 'user-123', 'Fail', mockLogger),
|
||||||
).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
|
).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, recipeId: 999, userId: 'user-123', parentCommentId: undefined },
|
{
|
||||||
|
err: dbError,
|
||||||
|
recipeId: 999,
|
||||||
|
userId: 'user-123',
|
||||||
|
parentCommentId: undefined,
|
||||||
|
code: '23503',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
'Database error in addRecipeComment',
|
'Database error in addRecipeComment',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ describe('Shopping DB Service', () => {
|
|||||||
const mockReceipt = {
|
const mockReceipt = {
|
||||||
receipt_id: 1,
|
receipt_id: 1,
|
||||||
user_id: 'user-1',
|
user_id: 'user-1',
|
||||||
receipt_image_url: 'http://example.com/receipt.jpg',
|
receipt_image_url: 'https://example.com/receipt.jpg',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
};
|
};
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
|||||||
import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories';
|
import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories';
|
||||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||||
import type { Profile, ActivityLogItem, SearchQuery, UserProfile, User } from '../../types';
|
import type { Profile, ActivityLogItem, SearchQuery, UserProfile, User } from '../../types';
|
||||||
|
import { ShoppingRepository } from './shopping.db';
|
||||||
|
import { PersonalizationRepository } from './personalization.db';
|
||||||
|
|
||||||
// Mock other db services that are used by functions in user.db.ts
|
// Mock other db services that are used by functions in user.db.ts
|
||||||
// Update mocks to put methods on prototype so spyOn works in exportUserData tests
|
// Update mocks to put methods on prototype so spyOn works in exportUserData tests
|
||||||
@@ -115,14 +117,14 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createUser', () => {
|
describe('createUser', () => {
|
||||||
it('should execute a transaction to create a user and profile', async () => {
|
it('should create a user and profile successfully', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
user_id: 'new-user-id',
|
user_id: 'new-user-id',
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
// This is the flat structure returned by the DB query inside createUser
|
|
||||||
const mockDbProfile = {
|
const mockDbProfile = {
|
||||||
user_id: 'new-user-id',
|
user_id: 'new-user-id',
|
||||||
email: 'new@example.com',
|
email: 'new@example.com',
|
||||||
@@ -136,7 +138,7 @@ describe('User DB Service', () => {
|
|||||||
user_created_at: new Date().toISOString(),
|
user_created_at: new Date().toISOString(),
|
||||||
user_updated_at: new Date().toISOString(),
|
user_updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
// This is the nested structure the function is expected to return
|
|
||||||
const expectedProfile: UserProfile = {
|
const expectedProfile: UserProfile = {
|
||||||
user: {
|
user: {
|
||||||
user_id: mockDbProfile.user_id,
|
user_id: mockDbProfile.user_id,
|
||||||
@@ -153,14 +155,11 @@ describe('User DB Service', () => {
|
|||||||
updated_at: mockDbProfile.updated_at,
|
updated_at: mockDbProfile.updated_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
// Mock the sequence of queries on the main pool instance
|
||||||
const mockClient = { query: vi.fn(), release: vi.fn() };
|
(mockPoolInstance.query as Mock)
|
||||||
(mockClient.query as Mock)
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
||||||
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
|
||||||
return callback(mockClient as unknown as PoolClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await userRepo.createUser(
|
const result = await userRepo.createUser(
|
||||||
'new@example.com',
|
'new@example.com',
|
||||||
@@ -169,52 +168,73 @@ describe('User DB Service', () => {
|
|||||||
mockLogger,
|
mockLogger,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use objectContaining because the real implementation might have other DB-generated fields.
|
|
||||||
// We can't do a deep equality check on the user object because the mock factory will generate different timestamps.
|
|
||||||
expect(result.user.user_id).toEqual(expectedProfile.user.user_id);
|
expect(result.user.user_id).toEqual(expectedProfile.user.user_id);
|
||||||
expect(result.full_name).toEqual(expectedProfile.full_name);
|
expect(result.full_name).toEqual(expectedProfile.full_name);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should rollback the transaction if creating the user fails', async () => {
|
it('should create a user with a null password hash (e.g. OAuth)', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
user_id: 'oauth-user-id',
|
||||||
|
email: 'oauth@example.com',
|
||||||
|
};
|
||||||
|
const mockDbProfile = {
|
||||||
|
user_id: 'oauth-user-id',
|
||||||
|
email: 'oauth@example.com',
|
||||||
|
role: 'user',
|
||||||
|
full_name: 'OAuth User',
|
||||||
|
user_created_at: new Date().toISOString(),
|
||||||
|
user_updated_at: new Date().toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockPoolInstance.query as Mock)
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
|
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
||||||
|
|
||||||
|
const result = await userRepo.createUser(
|
||||||
|
'oauth@example.com',
|
||||||
|
null, // Pass null for passwordHash
|
||||||
|
{ full_name: 'OAuth User' },
|
||||||
|
mockLogger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.user.email).toBe('oauth@example.com');
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||||
|
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||||
|
['oauth@example.com', null],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if creating the user fails', async () => {
|
||||||
const dbError = new Error('User insert failed');
|
const dbError = new Error('User insert failed');
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
const mockClient = { query: vi.fn() };
|
|
||||||
mockClient.query.mockRejectedValueOnce(dbError); // set_config or INSERT fails
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
|
||||||
throw dbError;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger),
|
userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger),
|
||||||
).rejects.toThrow('Failed to create user in database.');
|
).rejects.toThrow('Failed to create user in database.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, email: 'fail@example.com' },
|
{ err: dbError, email: 'fail@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should rollback the transaction if fetching the final profile fails', async () => {
|
it('should throw an error if fetching the final profile fails', async () => {
|
||||||
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
||||||
const dbError = new Error('Profile fetch failed');
|
const dbError = new Error('Profile fetch failed');
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
(mockPoolInstance.query as Mock)
|
||||||
const mockClient = { query: vi.fn() };
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockRejectedValueOnce(dbError); // SELECT profile fails
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
|
||||||
.mockRejectedValueOnce(dbError); // SELECT profile fails
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(dbError);
|
|
||||||
throw dbError;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow(
|
await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow(
|
||||||
'Failed to create user in database.',
|
'Failed to create user in database.',
|
||||||
);
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, email: 'fail@example.com' },
|
{ err: dbError, email: 'fail@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,50 +242,135 @@ describe('User DB Service', () => {
|
|||||||
const dbError = new Error('duplicate key value violates unique constraint');
|
const dbError = new Error('duplicate key value violates unique constraint');
|
||||||
(dbError as Error & { code: string }).code = '23505';
|
(dbError as Error & { code: string }).code = '23505';
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
(mockPoolInstance.query as Mock).mockRejectedValue(dbError);
|
||||||
|
|
||||||
try {
|
await expect(
|
||||||
await userRepo.createUser('exists@example.com', 'pass', {}, mockLogger);
|
userRepo.createUser('exists@example.com', 'pass', {}, mockLogger),
|
||||||
expect.fail('Expected createUser to throw UniqueConstraintError');
|
).rejects.toThrow(UniqueConstraintError);
|
||||||
} catch (error: unknown) {
|
|
||||||
expect(error).toBeInstanceOf(UniqueConstraintError);
|
|
||||||
// After confirming the error type, we can safely access its properties.
|
|
||||||
// This satisfies TypeScript's type checker for the 'unknown' type.
|
|
||||||
if (error instanceof Error) {
|
|
||||||
expect(error.message).toBe('A user with this email address already exists.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
await expect(
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`);
|
userRepo.createUser('exists@example.com', 'pass', {}, mockLogger),
|
||||||
|
).rejects.toThrow('A user with this email address already exists.');
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
err: dbError,
|
||||||
|
email: 'exists@example.com',
|
||||||
|
code: '23505',
|
||||||
|
constraint: undefined,
|
||||||
|
detail: undefined,
|
||||||
|
},
|
||||||
|
'Error during createUser',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if profile is not found after user creation', async () => {
|
it('should throw an error if profile is not found after user creation', async () => {
|
||||||
const mockUser = { user_id: 'new-user-id', email: 'no-profile@example.com' };
|
const mockUser = { user_id: 'new-user-id', email: 'no-profile@example.com' };
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
(mockPoolInstance.query as Mock)
|
||||||
const mockClient = { query: vi.fn() };
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
mockClient.query
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds
|
||||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
||||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds
|
|
||||||
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
|
||||||
// The callback will throw, which is caught and re-thrown by withTransaction
|
|
||||||
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
|
|
||||||
'Failed to create or retrieve user profile after registration.',
|
|
||||||
);
|
|
||||||
throw new Error('Internal failure'); // Simulate re-throw from withTransaction
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger),
|
userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger),
|
||||||
).rejects.toThrow('Failed to create user in database.');
|
).rejects.toThrow('Failed to create user in database.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: expect.any(Error), email: 'no-profile@example.com' },
|
{ err: expect.any(Error), email: 'no-profile@example.com' },
|
||||||
'Error during createUser transaction',
|
'Error during createUser',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('_createUser (private)', () => {
|
||||||
|
it('should execute queries in order and return a full user profile', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
user_id: 'private-user-id',
|
||||||
|
email: 'private@example.com',
|
||||||
|
};
|
||||||
|
const mockDbProfile = {
|
||||||
|
user_id: 'private-user-id',
|
||||||
|
email: 'private@example.com',
|
||||||
|
role: 'user',
|
||||||
|
full_name: 'Private User',
|
||||||
|
avatar_url: null,
|
||||||
|
points: 0,
|
||||||
|
preferences: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
user_created_at: new Date().toISOString(),
|
||||||
|
user_updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const expectedProfile: UserProfile = {
|
||||||
|
user: {
|
||||||
|
user_id: mockDbProfile.user_id,
|
||||||
|
email: mockDbProfile.email,
|
||||||
|
created_at: mockDbProfile.user_created_at,
|
||||||
|
updated_at: mockDbProfile.user_updated_at,
|
||||||
|
},
|
||||||
|
full_name: 'Private User',
|
||||||
|
avatar_url: null,
|
||||||
|
role: 'user',
|
||||||
|
points: 0,
|
||||||
|
preferences: null,
|
||||||
|
created_at: mockDbProfile.created_at,
|
||||||
|
updated_at: mockDbProfile.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the sequence of queries on the client
|
||||||
|
(mockPoolInstance.query as Mock)
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
|
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
||||||
|
|
||||||
|
// Access private method for testing
|
||||||
|
const result = await (userRepo as any)._createUser(
|
||||||
|
mockPoolInstance, // Pass the mock client
|
||||||
|
'private@example.com',
|
||||||
|
'hashedpass',
|
||||||
|
{ full_name: 'Private User' },
|
||||||
|
mockLogger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(expectedProfile);
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"SELECT set_config('my_app.user_metadata', $1, true)",
|
||||||
|
[JSON.stringify({ full_name: 'Private User' })],
|
||||||
|
);
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||||
|
['private@example.com', 'hashedpass'],
|
||||||
|
);
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.stringContaining('FROM public.users u'),
|
||||||
|
['private-user-id'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if profile is not found after user creation', async () => {
|
||||||
|
const mockUser = { user_id: 'no-profile-user', email: 'no-profile@example.com' };
|
||||||
|
|
||||||
|
(mockPoolInstance.query as Mock)
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||||
|
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(userRepo as any)._createUser(
|
||||||
|
mockPoolInstance,
|
||||||
|
'no-profile@example.com',
|
||||||
|
'pass',
|
||||||
|
{},
|
||||||
|
mockLogger,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Failed to create or retrieve user profile after registration.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findUserWithProfileByEmail', () => {
|
describe('findUserWithProfileByEmail', () => {
|
||||||
it('should query for a user and their profile by email', async () => {
|
it('should query for a user and their profile by email', async () => {
|
||||||
const mockDbResult: any = {
|
const mockDbResult: any = {
|
||||||
@@ -669,23 +774,12 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteRefreshToken', () => {
|
describe('deleteRefreshToken', () => {
|
||||||
it('should execute an UPDATE query to set the refresh token to NULL', async () => {
|
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
|
||||||
await userRepo.deleteRefreshToken('a-token', mockLogger);
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
|
||||||
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1',
|
|
||||||
['a-token'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error but not throw if the database query fails', async () => {
|
it('should log an error but not throw if the database query fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
|
||||||
// The function is designed to swallow errors, so we expect it to resolve.
|
|
||||||
await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined();
|
await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined();
|
||||||
|
|
||||||
// We can still check that the query was attempted.
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalled();
|
expect(mockPoolInstance.query).toHaveBeenCalled();
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError },
|
{ err: dbError },
|
||||||
@@ -696,14 +790,14 @@ describe('User DB Service', () => {
|
|||||||
|
|
||||||
describe('createPasswordResetToken', () => {
|
describe('createPasswordResetToken', () => {
|
||||||
it('should execute DELETE and INSERT queries', async () => {
|
it('should execute DELETE and INSERT queries', async () => {
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [] });
|
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger);
|
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
'DELETE FROM public.password_reset_tokens WHERE user_id = $1',
|
'DELETE FROM public.password_reset_tokens WHERE user_id = $1',
|
||||||
['123'],
|
['123'],
|
||||||
);
|
);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockClient.query).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('INSERT INTO public.password_reset_tokens'),
|
expect.stringContaining('INSERT INTO public.password_reset_tokens'),
|
||||||
['123', 'token-hash', expires],
|
['123', 'token-hash', expires],
|
||||||
);
|
);
|
||||||
@@ -712,18 +806,18 @@ describe('User DB Service', () => {
|
|||||||
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
|
||||||
const dbError = new Error('violates foreign key constraint');
|
const dbError = new Error('violates foreign key constraint');
|
||||||
(dbError as Error & { code: string }).code = '23503';
|
(dbError as Error & { code: string }).code = '23503';
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger),
|
userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger, mockClient as unknown as PoolClient),
|
||||||
).rejects.toThrow(ForeignKeyConstraintError);
|
).rejects.toThrow(ForeignKeyConstraintError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a generic error if the database query fails', async () => {
|
it('should throw a generic error if the database query fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
await expect(
|
await expect(
|
||||||
userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger),
|
userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient),
|
||||||
).rejects.toThrow('Failed to create password reset token.');
|
).rejects.toThrow('Failed to create password reset token.');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: dbError, userId: '123' },
|
{ err: dbError, userId: '123' },
|
||||||
@@ -764,10 +858,13 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error if the database query fails', async () => {
|
it('should log an error if the database query fails', async () => {
|
||||||
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
|
const dbError = new Error('DB Error');
|
||||||
await userRepo.deleteResetToken('token-hash', mockLogger);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
await expect(userRepo.deleteResetToken('token-hash', mockLogger)).rejects.toThrow(
|
||||||
|
'Failed to delete password reset token.',
|
||||||
|
);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
{ err: expect.any(Error), tokenHash: 'token-hash' },
|
{ err: dbError, tokenHash: 'token-hash' },
|
||||||
'Database error in deleteResetToken',
|
'Database error in deleteResetToken',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -800,18 +897,7 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('exportUserData', () => {
|
describe('exportUserData', () => {
|
||||||
// Import the mocked withTransaction helper
|
|
||||||
let withTransaction: Mock;
|
|
||||||
beforeEach(async () => {
|
|
||||||
const connDb = await import('./connection.db');
|
|
||||||
// Cast to Mock for type-safe access to mock properties
|
|
||||||
withTransaction = connDb.withTransaction as Mock;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call profile, watched items, and shopping list functions', async () => {
|
it('should call profile, watched items, and shopping list functions', async () => {
|
||||||
const { ShoppingRepository } = await import('./shopping.db');
|
|
||||||
const { PersonalizationRepository } = await import('./personalization.db');
|
|
||||||
|
|
||||||
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
||||||
findProfileSpy.mockResolvedValue(
|
findProfileSpy.mockResolvedValue(
|
||||||
createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }) }),
|
createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }) }),
|
||||||
@@ -1007,6 +1093,32 @@ describe('User DB Service', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw ForeignKeyConstraintError if the user_id does not exist', async () => {
|
||||||
|
const dbError = new Error('violates foreign key constraint');
|
||||||
|
(dbError as Error & { code: string }).code = '23503';
|
||||||
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
const queryData = {
|
||||||
|
user_id: 'non-existent-user',
|
||||||
|
query_text: 'search text',
|
||||||
|
result_count: 0,
|
||||||
|
was_successful: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(userRepo.logSearchQuery(queryData, mockLogger)).rejects.toThrow(
|
||||||
|
ForeignKeyConstraintError,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(userRepo.logSearchQuery(queryData, mockLogger)).rejects.toThrow(
|
||||||
|
'The specified user does not exist.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: dbError, queryData }),
|
||||||
|
'Database error in logSearchQuery',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw a generic error if the database query fails', async () => {
|
it('should throw a generic error if the database query fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||||
|
|||||||
@@ -61,6 +61,64 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The internal logic for creating a user. This method assumes it is being run
|
||||||
|
* within a database transaction and operates on a single PoolClient.
|
||||||
|
*/
|
||||||
|
private async _createUser(
|
||||||
|
dbClient: PoolClient,
|
||||||
|
email: string,
|
||||||
|
passwordHash: string | null,
|
||||||
|
profileData: { full_name?: string; avatar_url?: string },
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<UserProfile> {
|
||||||
|
logger.debug(`[DB _createUser] Starting user creation for email: ${email}`);
|
||||||
|
|
||||||
|
await dbClient.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||||
|
JSON.stringify(profileData ?? {}),
|
||||||
|
]);
|
||||||
|
logger.debug(`[DB _createUser] Session metadata set for ${email}.`);
|
||||||
|
|
||||||
|
const userInsertRes = await dbClient.query<{ user_id: string; email: string }>(
|
||||||
|
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||||
|
[email, passwordHash],
|
||||||
|
);
|
||||||
|
const newUserId = userInsertRes.rows[0].user_id;
|
||||||
|
logger.debug(`[DB _createUser] Inserted into users table. New user ID: ${newUserId}`);
|
||||||
|
|
||||||
|
const profileQuery = `
|
||||||
|
SELECT u.user_id, u.email, u.created_at as user_created_at, u.updated_at as user_updated_at, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.created_at, p.updated_at
|
||||||
|
FROM public.users u
|
||||||
|
JOIN public.profiles p ON u.user_id = p.user_id
|
||||||
|
WHERE u.user_id = $1;
|
||||||
|
`;
|
||||||
|
const finalProfileRes = await dbClient.query(profileQuery, [newUserId]);
|
||||||
|
const flatProfile = finalProfileRes.rows[0];
|
||||||
|
|
||||||
|
if (!flatProfile) {
|
||||||
|
throw new Error('Failed to create or retrieve user profile after registration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullUserProfile: UserProfile = {
|
||||||
|
user: {
|
||||||
|
user_id: flatProfile.user_id,
|
||||||
|
email: flatProfile.email,
|
||||||
|
created_at: flatProfile.user_created_at,
|
||||||
|
updated_at: flatProfile.user_updated_at,
|
||||||
|
},
|
||||||
|
full_name: flatProfile.full_name,
|
||||||
|
avatar_url: flatProfile.avatar_url,
|
||||||
|
role: flatProfile.role,
|
||||||
|
points: flatProfile.points,
|
||||||
|
preferences: flatProfile.preferences,
|
||||||
|
created_at: flatProfile.created_at,
|
||||||
|
updated_at: flatProfile.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug({ user: fullUserProfile }, `[DB _createUser] Fetched full profile for new user:`);
|
||||||
|
return fullUserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user in the public.users table.
|
* Creates a new user in the public.users table.
|
||||||
* This method expects to be run within a transaction, so it requires a PoolClient.
|
* This method expects to be run within a transaction, so it requires a PoolClient.
|
||||||
@@ -75,71 +133,26 @@ export class UserRepository {
|
|||||||
profileData: { full_name?: string; avatar_url?: string },
|
profileData: { full_name?: string; avatar_url?: string },
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
return withTransaction(async (client: PoolClient) => {
|
// This method is now a wrapper that ensures the core logic runs within a transaction.
|
||||||
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
|
try {
|
||||||
|
// If this.db has a 'connect' method, it's a Pool. We must start a transaction.
|
||||||
// Use 'set_config' to safely pass parameters to a configuration variable.
|
if ('connect' in this.db) {
|
||||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
return await withTransaction(async (client) => {
|
||||||
JSON.stringify(profileData),
|
return this._createUser(client, email, passwordHash, profileData, logger);
|
||||||
]);
|
});
|
||||||
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
|
} else {
|
||||||
|
// If this.db is already a PoolClient, we're inside a transaction. Use it directly.
|
||||||
// Insert the new user into the 'users' table. This will fire the trigger.
|
return await this._createUser(this.db as PoolClient, email, passwordHash, profileData, logger);
|
||||||
const userInsertRes = await client.query<{ user_id: string }>(
|
|
||||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
|
||||||
[email, passwordHash],
|
|
||||||
);
|
|
||||||
const newUserId = userInsertRes.rows[0].user_id;
|
|
||||||
logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`);
|
|
||||||
|
|
||||||
// After the trigger has run, fetch the complete profile data.
|
|
||||||
const profileQuery = `
|
|
||||||
SELECT u.user_id, u.email, u.created_at as user_created_at, u.updated_at as user_updated_at, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.created_at, p.updated_at
|
|
||||||
FROM public.users u
|
|
||||||
JOIN public.profiles p ON u.user_id = p.user_id
|
|
||||||
WHERE u.user_id = $1;
|
|
||||||
`;
|
|
||||||
const finalProfileRes = await client.query(profileQuery, [newUserId]);
|
|
||||||
const flatProfile = finalProfileRes.rows[0];
|
|
||||||
|
|
||||||
if (!flatProfile) {
|
|
||||||
throw new Error('Failed to create or retrieve user profile after registration.');
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Construct the nested UserProfile object to match the type definition.
|
handleDbError(error, logger, 'Error during createUser', { email }, {
|
||||||
const fullUserProfile: UserProfile = {
|
|
||||||
// user_id is now correctly part of the nested user object, not at the top level.
|
|
||||||
user: {
|
|
||||||
user_id: flatProfile.user_id,
|
|
||||||
email: flatProfile.email,
|
|
||||||
created_at: flatProfile.user_created_at,
|
|
||||||
updated_at: flatProfile.user_updated_at,
|
|
||||||
},
|
|
||||||
full_name: flatProfile.full_name,
|
|
||||||
avatar_url: flatProfile.avatar_url,
|
|
||||||
role: flatProfile.role,
|
|
||||||
points: flatProfile.points,
|
|
||||||
preferences: flatProfile.preferences,
|
|
||||||
created_at: flatProfile.created_at,
|
|
||||||
updated_at: flatProfile.updated_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
|
||||||
return fullUserProfile;
|
|
||||||
}).catch((error) => {
|
|
||||||
// Specific handling for unique constraint violation on user creation
|
|
||||||
if (error instanceof Error && 'code' in error && (error as any).code === '23505') {
|
|
||||||
logger.warn(`Attempted to create a user with an existing email: ${email}`);
|
|
||||||
throw new UniqueConstraintError('A user with this email address already exists.');
|
|
||||||
}
|
|
||||||
// Fallback to generic handler for all other errors
|
|
||||||
handleDbError(error, logger, 'Error during createUser transaction', { email }, {
|
|
||||||
uniqueMessage: 'A user with this email address already exists.',
|
uniqueMessage: 'A user with this email address already exists.',
|
||||||
defaultMessage: 'Failed to create user in database.',
|
defaultMessage: 'Failed to create user in database.',
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a user by their email and joins their profile data.
|
* Finds a user by their email and joins their profile data.
|
||||||
* This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query.
|
* This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query.
|
||||||
@@ -464,6 +477,7 @@ export class UserRepository {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// This is a non-critical operation, so we just log the error and continue.
|
||||||
logger.error({ err: error }, 'Database error in deleteRefreshToken');
|
logger.error({ err: error }, 'Database error in deleteRefreshToken');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,10 +489,11 @@ export class UserRepository {
|
|||||||
* @param expiresAt The timestamp when the token expires.
|
* @param expiresAt The timestamp when the token expires.
|
||||||
*/
|
*/
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger): Promise<void> {
|
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger, client: PoolClient): Promise<void> {
|
||||||
const client = this.db as PoolClient;
|
|
||||||
try {
|
try {
|
||||||
|
// First, remove any existing tokens for the user to ensure they can only have one active reset request.
|
||||||
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
||||||
|
// Then, insert the new token.
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||||
[userId, tokenHash, expiresAt]
|
[userId, tokenHash, expiresAt]
|
||||||
@@ -519,10 +534,9 @@ export class UserRepository {
|
|||||||
try {
|
try {
|
||||||
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
|
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
handleDbError(error, logger, 'Database error in deleteResetToken', { tokenHash }, {
|
||||||
{ err: error, tokenHash },
|
defaultMessage: 'Failed to delete password reset token.',
|
||||||
'Database error in deleteResetToken',
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/services/flyerAiProcessor.server.test.ts
|
// src/services/flyerAiProcessor.server.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import { AiDataValidationError } from './processingErrors';
|
import { AiDataValidationError } from './processingErrors';
|
||||||
import { logger } from './logger.server'; // Keep this import for the logger instance
|
import { logger } from './logger.server'; // Keep this import for the logger instance
|
||||||
@@ -21,7 +21,7 @@ const createMockJobData = (data: Partial<FlyerJobData>): FlyerJobData => ({
|
|||||||
filePath: '/tmp/flyer.jpg',
|
filePath: '/tmp/flyer.jpg',
|
||||||
originalFileName: 'flyer.jpg',
|
originalFileName: 'flyer.jpg',
|
||||||
checksum: 'checksum-123',
|
checksum: 'checksum-123',
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'https://example.com',
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ describe('FlyerAiProcessor', () => {
|
|||||||
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
|
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Ensure env stubs are cleaned up after each test
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('should call AI service and return validated data on success', async () => {
|
it('should call AI service and return validated data on success', async () => {
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
const mockAiResponse = {
|
const mockAiResponse = {
|
||||||
@@ -73,64 +78,230 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.needsReview).toBe(false);
|
expect(result.needsReview).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw AiDataValidationError if AI response has incorrect data structure', async () => {
|
it('should throw an error if getAllMasterItems fails', async () => {
|
||||||
|
// Arrange
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
// Mock AI to return a structurally invalid response (e.g., items is not an array)
|
const dbError = new Error('Database connection failed');
|
||||||
const invalidResponse = {
|
vi.mocked(mockPersonalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||||
store_name: 'Invalid Store',
|
|
||||||
items: 'not-an-array',
|
|
||||||
valid_from: null,
|
|
||||||
valid_to: null,
|
|
||||||
store_address: null,
|
|
||||||
};
|
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
|
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
|
|
||||||
AiDataValidationError,
|
// Act & Assert
|
||||||
);
|
await expect(
|
||||||
|
service.extractAndValidateData(imagePaths, jobData, logger),
|
||||||
|
).rejects.toThrow(dbError);
|
||||||
|
|
||||||
|
// Verify that the process stops before calling the AI service
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass validation even if store_name is missing', async () => {
|
describe('Validation and Quality Checks', () => {
|
||||||
const jobData = createMockJobData({});
|
it('should pass validation and not flag for review with good quality data', async () => {
|
||||||
const mockAiResponse = {
|
const jobData = createMockJobData({});
|
||||||
store_name: null, // Missing store name
|
const mockAiResponse = {
|
||||||
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }],
|
store_name: 'Good Store',
|
||||||
// ADDED to satisfy AiFlyerDataSchema
|
valid_from: '2024-01-01',
|
||||||
valid_from: null,
|
valid_to: '2024-01-07',
|
||||||
valid_to: null,
|
store_address: '123 Good St',
|
||||||
store_address: null,
|
items: [
|
||||||
};
|
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
|
||||||
const { logger } = await import('./logger.server');
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
// It should not throw, but return the data and log a warning.
|
// With all data present and correct, it should not need a review.
|
||||||
expect(result.data).toEqual(mockAiResponse);
|
expect(result.needsReview).toBe(false);
|
||||||
expect(result.needsReview).toBe(true);
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('missing a store name. The transformer will use a fallback. Flagging for review.'));
|
});
|
||||||
|
|
||||||
|
it('should throw AiDataValidationError if AI response has incorrect data structure', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
// Mock AI to return a structurally invalid response (e.g., items is not an array)
|
||||||
|
const invalidResponse = {
|
||||||
|
store_name: 'Invalid Store',
|
||||||
|
items: 'not-an-array',
|
||||||
|
valid_from: null,
|
||||||
|
valid_to: null,
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
|
||||||
|
AiDataValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if store_name is missing', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: null, // Missing store name
|
||||||
|
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }],
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Missing store name'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if items array is empty', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
items: [], // Empty items array
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['No items were extracted'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if item price quality is low', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Priced Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
|
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'B' },
|
||||||
|
{ item: 'Unpriced Item 2', price_in_cents: null, price_display: 'FREE', quantity: '1', category_name: 'C' },
|
||||||
|
], // 1/3 = 33% have price, which is < 50%
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Low price quality (33% of items have a price)'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use a custom price quality threshold from an environment variable', async () => {
|
||||||
|
// Arrange
|
||||||
|
vi.stubEnv('AI_PRICE_QUALITY_THRESHOLD', '0.8'); // Set a stricter threshold (80%)
|
||||||
|
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
|
||||||
|
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
|
||||||
|
{ item: 'Priced Item 3', price_in_cents: 399, price_display: '$3.99', quantity: '1', category_name: 'C' },
|
||||||
|
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'D' },
|
||||||
|
], // 3/4 = 75% have price. This is > 50% (default) but < 80% (custom).
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Because 75% < 80%, it should be flagged for review.
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Low price quality (75% of items have a price)'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag for review if validity dates are missing', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: null, // Missing date
|
||||||
|
valid_to: null, // Missing date
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ qualityIssues: ['Missing both valid_from and valid_to dates'] }),
|
||||||
|
expect.stringContaining('AI response has quality issues.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine multiple quality issues in the log', async () => {
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: null, // Issue 1
|
||||||
|
items: [], // Issue 2
|
||||||
|
valid_from: null, // Issue 3
|
||||||
|
valid_to: null,
|
||||||
|
store_address: null,
|
||||||
|
};
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
|
||||||
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
expect(result.needsReview).toBe(true);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
{ rawData: mockAiResponse, qualityIssues: ['Missing store name', 'No items were extracted', 'Missing both valid_from and valid_to dates'] },
|
||||||
|
'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing both valid_from and valid_to dates',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass validation even if items array is empty', async () => {
|
it('should pass the userProfileAddress from jobData to the AI service', async () => {
|
||||||
const jobData = createMockJobData({});
|
// Arrange
|
||||||
|
const jobData = createMockJobData({ userProfileAddress: '456 Fallback Ave' });
|
||||||
const mockAiResponse = {
|
const mockAiResponse = {
|
||||||
store_name: 'Test Store',
|
store_name: 'Test Store',
|
||||||
items: [], // Empty items array
|
valid_from: '2024-01-01',
|
||||||
// ADDED to satisfy AiFlyerDataSchema
|
valid_to: '2024-01-07',
|
||||||
valid_from: null,
|
store_address: '123 Test St',
|
||||||
valid_to: null,
|
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
|
||||||
store_address: null,
|
|
||||||
};
|
};
|
||||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||||
const { logger } = await import('./logger.server');
|
|
||||||
|
|
||||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||||
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
expect(result.data).toEqual(mockAiResponse);
|
|
||||||
expect(result.needsReview).toBe(true);
|
// Assert
|
||||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('contains no items. The flyer will be saved with an item_count of 0. Flagging for review.'));
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
|
||||||
|
imagePaths, [], undefined, '456 Fallback Ave', logger
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Batching Logic', () => {
|
describe('Batching Logic', () => {
|
||||||
@@ -201,6 +372,46 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.needsReview).toBe(false);
|
expect(result.needsReview).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle an empty object response from a batch without crashing', async () => {
|
||||||
|
// Arrange
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
const imagePaths = [
|
||||||
|
{ path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAiResponseBatch1 = {
|
||||||
|
store_name: 'Good Store',
|
||||||
|
valid_from: '2025-01-01',
|
||||||
|
valid_to: '2025-01-07',
|
||||||
|
store_address: '123 Good St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The AI returns an empty object for the second batch.
|
||||||
|
const mockAiResponseBatch2 = {};
|
||||||
|
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage)
|
||||||
|
.mockResolvedValueOnce(mockAiResponseBatch1)
|
||||||
|
.mockResolvedValueOnce(mockAiResponseBatch2 as any); // Use `as any` to bypass strict type check for the test mock
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 1. AI service was called twice.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// 2. The final data should only contain data from the first batch.
|
||||||
|
expect(result.data.store_name).toBe('Good Store');
|
||||||
|
expect(result.data.items).toHaveLength(1);
|
||||||
|
expect(result.data.items[0].item).toBe('Item A');
|
||||||
|
|
||||||
|
// 3. The process should complete without errors and not be flagged for review if the first batch was good.
|
||||||
|
expect(result.needsReview).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fill in missing metadata from subsequent batches', async () => {
|
it('should fill in missing metadata from subsequent batches', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const jobData = createMockJobData({});
|
const jobData = createMockJobData({});
|
||||||
@@ -226,4 +437,40 @@ describe('FlyerAiProcessor', () => {
|
|||||||
expect(result.data.items).toHaveLength(2);
|
expect(result.data.items).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle a single batch correctly when image count is less than BATCH_SIZE', async () => {
|
||||||
|
// Arrange
|
||||||
|
const jobData = createMockJobData({});
|
||||||
|
// 2 images, which is less than the BATCH_SIZE of 4.
|
||||||
|
const imagePaths = [
|
||||||
|
{ path: 'page1.jpg', mimetype: 'image/jpeg' },
|
||||||
|
{ path: 'page2.jpg', mimetype: 'image/jpeg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAiResponse = {
|
||||||
|
store_name: 'Single Batch Store',
|
||||||
|
valid_from: '2025-02-01',
|
||||||
|
valid_to: '2025-02-07',
|
||||||
|
store_address: '789 Single St',
|
||||||
|
items: [
|
||||||
|
{ item: 'Item X', price_display: '$10', price_in_cents: 1000, quantity: '1', category_name: 'Cat X', master_item_id: 10 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the AI service to be called only once.
|
||||||
|
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValueOnce(mockAiResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// 1. AI service was called only once.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 2. Check the arguments for the single call.
|
||||||
|
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(imagePaths, [], undefined, undefined, logger);
|
||||||
|
|
||||||
|
// 3. Check that the final data matches the single batch's data.
|
||||||
|
expect(result.data).toEqual(mockAiResponse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -46,26 +46,52 @@ export class FlyerAiProcessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW QUALITY CHECK ---
|
// --- Data Quality Checks ---
|
||||||
// After structural validation, perform semantic quality checks.
|
// After structural validation, perform semantic quality checks to flag low-quality
|
||||||
const { store_name, items } = validationResult.data;
|
// extractions for manual review.
|
||||||
let needsReview = false;
|
const { store_name, items, valid_from, valid_to } = validationResult.data;
|
||||||
|
const qualityIssues: string[] = [];
|
||||||
|
|
||||||
// 1. Check for a valid store name, but don't fail the job.
|
// 1. Check for a store name.
|
||||||
// The data transformer will handle this by assigning a fallback name.
|
|
||||||
if (!store_name || store_name.trim() === '') {
|
if (!store_name || store_name.trim() === '') {
|
||||||
logger.warn({ rawData: extractedData }, 'AI response is missing a store name. The transformer will use a fallback. Flagging for review.');
|
qualityIssues.push('Missing store name');
|
||||||
needsReview = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check that at least one item was extracted, but don't fail the job.
|
// 2. Check that items were extracted.
|
||||||
// An admin can review a flyer with 0 items.
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
logger.warn({ rawData: extractedData }, 'AI response contains no items. The flyer will be saved with an item_count of 0. Flagging for review.');
|
qualityIssues.push('No items were extracted');
|
||||||
needsReview = true;
|
} else {
|
||||||
|
// 3. If items exist, check their quality (e.g., missing prices).
|
||||||
|
// The threshold is configurable via an environment variable, defaulting to 0.5 (50%).
|
||||||
|
const priceQualityThreshold = parseFloat(process.env.AI_PRICE_QUALITY_THRESHOLD || '0.5');
|
||||||
|
|
||||||
|
const itemsWithPrice = items.filter(
|
||||||
|
(item) => item.price_in_cents != null && item.price_in_cents > 0,
|
||||||
|
).length;
|
||||||
|
const priceQualityRatio = itemsWithPrice / items.length;
|
||||||
|
|
||||||
|
if (priceQualityRatio < priceQualityThreshold) {
|
||||||
|
// If the ratio of items with a valid price is below the threshold, flag for review.
|
||||||
|
qualityIssues.push(
|
||||||
|
`Low price quality (${(priceQualityRatio * 100).toFixed(0)}% of items have a price)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`AI extracted ${validationResult.data.items.length} items.`);
|
// 4. Check for flyer validity dates.
|
||||||
|
if (!valid_from && !valid_to) {
|
||||||
|
qualityIssues.push('Missing both valid_from and valid_to dates');
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReview = qualityIssues.length > 0;
|
||||||
|
if (needsReview) {
|
||||||
|
logger.warn(
|
||||||
|
{ rawData: extractedData, qualityIssues },
|
||||||
|
`AI response has quality issues. Flagging for review. Issues: ${qualityIssues.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`AI extracted ${validationResult.data.items.length} items. Needs Review: ${needsReview}`);
|
||||||
return { data: validationResult.data, needsReview };
|
return { data: validationResult.data, needsReview };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +155,7 @@ export class FlyerAiProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Items: Append all found items to the master list.
|
// 2. Items: Append all found items to the master list.
|
||||||
mergedData.items.push(...batchResult.items);
|
mergedData.items.push(...(batchResult.items || []));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
|
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { logger as mockLogger } from './logger.server';
|
|||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
import type { AiProcessorResult } from './flyerAiProcessor.server';
|
import type { AiProcessorResult } from './flyerAiProcessor.server';
|
||||||
import type { FlyerItemInsert } from '../types';
|
import type { FlyerItemInsert } from '../types';
|
||||||
|
import { getBaseUrl } from '../utils/serverUtils';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock('../utils/imageProcessor', () => ({
|
vi.mock('../utils/imageProcessor', () => ({
|
||||||
@@ -15,6 +16,10 @@ vi.mock('./logger.server', () => ({
|
|||||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/serverUtils', () => ({
|
||||||
|
getBaseUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('FlyerDataTransformer', () => {
|
describe('FlyerDataTransformer', () => {
|
||||||
let transformer: FlyerDataTransformer;
|
let transformer: FlyerDataTransformer;
|
||||||
|
|
||||||
@@ -23,12 +28,13 @@ describe('FlyerDataTransformer', () => {
|
|||||||
transformer = new FlyerDataTransformer();
|
transformer = new FlyerDataTransformer();
|
||||||
// Stub environment variables to ensure consistency and predictability.
|
// Stub environment variables to ensure consistency and predictability.
|
||||||
// Prioritize FRONTEND_URL to match the updated service logic.
|
// Prioritize FRONTEND_URL to match the updated service logic.
|
||||||
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
|
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
|
||||||
vi.stubEnv('PORT', ''); // Ensure this is not used
|
vi.stubEnv('PORT', ''); // Ensure this is not used
|
||||||
|
|
||||||
// Provide a default mock implementation for generateFlyerIcon
|
// Provide a default mock implementation for generateFlyerIcon
|
||||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
|
||||||
|
vi.mocked(getBaseUrl).mockReturnValue('https://example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transform AI data into database-ready format with a user ID', async () => {
|
it('should transform AI data into database-ready format with a user ID', async () => {
|
||||||
@@ -60,7 +66,6 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
|
||||||
const originalFileName = 'my-flyer.pdf';
|
const originalFileName = 'my-flyer.pdf';
|
||||||
const checksum = 'checksum-abc-123';
|
const checksum = 'checksum-abc-123';
|
||||||
const userId = 'user-xyz-456';
|
const userId = 'user-xyz-456';
|
||||||
@@ -69,8 +74,9 @@ describe('FlyerDataTransformer', () => {
|
|||||||
// Act
|
// Act
|
||||||
const { flyerData, itemsForDb } = await transformer.transform(
|
const { flyerData, itemsForDb } = await transformer.transform(
|
||||||
aiResult,
|
aiResult,
|
||||||
imagePaths,
|
|
||||||
originalFileName,
|
originalFileName,
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
checksum,
|
checksum,
|
||||||
userId,
|
userId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
@@ -121,12 +127,6 @@ describe('FlyerDataTransformer', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Check that generateFlyerIcon was called correctly
|
|
||||||
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
|
||||||
'/uploads/flyer-page-1.jpg',
|
|
||||||
'/uploads/icons',
|
|
||||||
mockLogger,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing optional data gracefully', async () => {
|
it('should handle missing optional data gracefully', async () => {
|
||||||
@@ -141,7 +141,6 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: true,
|
needsReview: true,
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/another.png', mimetype: 'image/png' }];
|
|
||||||
const originalFileName = 'another.png';
|
const originalFileName = 'another.png';
|
||||||
const checksum = 'checksum-def-456';
|
const checksum = 'checksum-def-456';
|
||||||
// No userId provided
|
// No userId provided
|
||||||
@@ -151,8 +150,9 @@ describe('FlyerDataTransformer', () => {
|
|||||||
// Act
|
// Act
|
||||||
const { flyerData, itemsForDb } = await transformer.transform(
|
const { flyerData, itemsForDb } = await transformer.transform(
|
||||||
aiResult,
|
aiResult,
|
||||||
imagePaths,
|
|
||||||
originalFileName,
|
originalFileName,
|
||||||
|
'another.png',
|
||||||
|
'icon-another.webp',
|
||||||
checksum,
|
checksum,
|
||||||
undefined,
|
undefined,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
@@ -219,13 +219,13 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const { itemsForDb } = await transformer.transform(
|
const { itemsForDb } = await transformer.transform(
|
||||||
aiResult,
|
aiResult,
|
||||||
imagePaths,
|
|
||||||
'file.pdf',
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
'checksum',
|
'checksum',
|
||||||
'user-1',
|
'user-1',
|
||||||
mockLogger,
|
mockLogger,
|
||||||
@@ -250,7 +250,7 @@ describe('FlyerDataTransformer', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use fallback baseUrl if none is provided and log a warning', async () => {
|
it('should use fallback baseUrl from getBaseUrl if none is provided', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const aiResult: AiProcessorResult = {
|
const aiResult: AiProcessorResult = {
|
||||||
data: {
|
data: {
|
||||||
@@ -262,18 +262,17 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
|
||||||
const baseUrl = undefined; // Explicitly pass undefined for this test
|
const baseUrl = undefined; // Explicitly pass undefined for this test
|
||||||
|
|
||||||
// The fallback logic uses process.env.PORT || 3000.
|
const expectedFallbackUrl = 'http://fallback-url.com';
|
||||||
// The beforeEach sets PORT to '', so it should fallback to 3000.
|
vi.mocked(getBaseUrl).mockReturnValue(expectedFallbackUrl);
|
||||||
const expectedFallbackUrl = 'http://localhost:3000';
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const { flyerData } = await transformer.transform(
|
const { flyerData } = await transformer.transform(
|
||||||
aiResult,
|
aiResult,
|
||||||
imagePaths,
|
|
||||||
'my-flyer.pdf',
|
'my-flyer.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
'checksum-abc-123',
|
'checksum-abc-123',
|
||||||
'user-xyz-456',
|
'user-xyz-456',
|
||||||
mockLogger,
|
mockLogger,
|
||||||
@@ -281,10 +280,8 @@ describe('FlyerDataTransformer', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// 1. Check that a warning was logged
|
// 1. Check that getBaseUrl was called
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(getBaseUrl).toHaveBeenCalledWith(mockLogger);
|
||||||
`Base URL not provided in job data. Falling back to default local URL: ${expectedFallbackUrl}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Check that the URLs were constructed with the fallback
|
// 2. Check that the URLs were constructed with the fallback
|
||||||
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);
|
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);
|
||||||
@@ -292,4 +289,227 @@ describe('FlyerDataTransformer', () => {
|
|||||||
`${expectedFallbackUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
`${expectedFallbackUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('_normalizeItem price parsing', () => {
|
||||||
|
it('should use price_in_cents from AI if it is valid, ignoring price_display', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: '$4.99', // Parsable, but should be ignored
|
||||||
|
price_in_cents: 399, // AI provides a specific (maybe wrong) value
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBe(399); // AI's value should be prioritized
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use parsePriceToCents as a fallback if AI price_in_cents is null', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: '$4.99', // Parsable value
|
||||||
|
price_in_cents: null, // AI fails to provide a value
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBe(499); // Should be parsed from price_display
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should result in null if both AI price and display price are unparsable', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 'Milk',
|
||||||
|
price_display: 'FREE', // Unparsable
|
||||||
|
price_in_cents: null, // AI provides null
|
||||||
|
quantity: '1L',
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb[0].price_in_cents).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-string values for string fields gracefully by converting them', async () => {
|
||||||
|
// This test verifies that if data with incorrect types bypasses earlier validation,
|
||||||
|
// the transformer is robust enough to convert them to strings instead of crashing.
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Type-Unsafe Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: 12345 as any, // Simulate AI returning a number instead of a string
|
||||||
|
price_display: 3.99 as any, // Simulate a number for a string field
|
||||||
|
price_in_cents: 399,
|
||||||
|
quantity: 5 as any, // Simulate a number
|
||||||
|
category_name: 'Dairy',
|
||||||
|
master_item_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
needsReview: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { itemsForDb } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://robust.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(itemsForDb).toHaveLength(1);
|
||||||
|
expect(itemsForDb[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
item: '12345', // Should be converted to string
|
||||||
|
price_display: '3.99', // Should be converted to string
|
||||||
|
quantity: '5', // Should be converted to string
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('needsReview flag handling', () => {
|
||||||
|
it('should set status to "processed" when needsReview is false', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
needsReview: false, // Key part of this test
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { flyerData } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(flyerData.status).toBe('processed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set status to "needs_review" when needsReview is true', async () => {
|
||||||
|
// Arrange
|
||||||
|
const aiResult: AiProcessorResult = {
|
||||||
|
data: {
|
||||||
|
store_name: 'Test Store',
|
||||||
|
valid_from: '2024-01-01',
|
||||||
|
valid_to: '2024-01-07',
|
||||||
|
store_address: '123 Test St',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
needsReview: true, // Key part of this test
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { flyerData } = await transformer.transform(
|
||||||
|
aiResult,
|
||||||
|
'file.pdf',
|
||||||
|
'flyer-page-1.jpg',
|
||||||
|
'icon-flyer-page-1.webp',
|
||||||
|
'checksum',
|
||||||
|
'user-1',
|
||||||
|
mockLogger,
|
||||||
|
'http://test.host',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(flyerData.status).toBe('needs_review');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import type { Logger } from 'pino';
|
|||||||
import type { FlyerInsert, FlyerItemInsert } from '../types';
|
import type { FlyerInsert, FlyerItemInsert } from '../types';
|
||||||
import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult
|
import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult
|
||||||
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
|
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
|
||||||
import { TransformationError } from './processingErrors';
|
import { TransformationError } from './processingErrors';
|
||||||
|
import { parsePriceToCents } from '../utils/priceParser';
|
||||||
|
import { getBaseUrl } from '../utils/serverUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for transforming the validated data from the AI service
|
* This class is responsible for transforming the validated data from the AI service
|
||||||
@@ -21,16 +22,25 @@ export class FlyerDataTransformer {
|
|||||||
private _normalizeItem(
|
private _normalizeItem(
|
||||||
item: z.infer<typeof AiFlyerDataSchema>['items'][number],
|
item: z.infer<typeof AiFlyerDataSchema>['items'][number],
|
||||||
): FlyerItemInsert {
|
): FlyerItemInsert {
|
||||||
|
// If the AI fails to provide `price_in_cents` but provides a parsable `price_display`,
|
||||||
|
// we can use our own parser as a fallback to improve data quality.
|
||||||
|
const priceFromDisplay = parsePriceToCents(item.price_display ?? '');
|
||||||
|
|
||||||
|
// Prioritize the AI's direct `price_in_cents` value, but use the parsed value if the former is null.
|
||||||
|
const finalPriceInCents = item.price_in_cents ?? priceFromDisplay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
// Use nullish coalescing and trim for robustness.
|
// Use nullish coalescing and trim for robustness.
|
||||||
// An empty or whitespace-only name falls back to 'Unknown Item'.
|
// An empty or whitespace-only name falls back to 'Unknown Item'.
|
||||||
item: (item.item ?? '').trim() || 'Unknown Item',
|
item: (String(item.item ?? '')).trim() || 'Unknown Item',
|
||||||
// Default null/undefined to an empty string and trim.
|
// Default null/undefined to an empty string and trim.
|
||||||
price_display: (item.price_display ?? '').trim(),
|
price_display: (String(item.price_display ?? '')).trim(),
|
||||||
quantity: (item.quantity ?? '').trim(),
|
quantity: (String(item.quantity ?? '')).trim(),
|
||||||
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
|
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
|
||||||
category_name: (item.category_name ?? '').trim() || 'Other/Miscellaneous',
|
category_name: (String(item.category_name ?? '')).trim() || 'Other/Miscellaneous',
|
||||||
|
// Overwrite price_in_cents with our calculated value.
|
||||||
|
price_in_cents: finalPriceInCents,
|
||||||
// Use nullish coalescing to convert null to undefined for the database.
|
// Use nullish coalescing to convert null to undefined for the database.
|
||||||
master_item_id: item.master_item_id ?? undefined,
|
master_item_id: item.master_item_id ?? undefined,
|
||||||
view_count: 0,
|
view_count: 0,
|
||||||
@@ -38,6 +48,28 @@ export class FlyerDataTransformer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the full public URLs for the flyer image and its icon.
|
||||||
|
* @param imageFileName The filename of the main processed image.
|
||||||
|
* @param iconFileName The filename of the generated icon.
|
||||||
|
* @param baseUrl The base URL from the job payload.
|
||||||
|
* @param logger The logger instance.
|
||||||
|
* @returns An object containing the full image_url and icon_url.
|
||||||
|
*/
|
||||||
|
private _buildUrls(
|
||||||
|
imageFileName: string,
|
||||||
|
iconFileName: string,
|
||||||
|
baseUrl: string | undefined,
|
||||||
|
logger: Logger,
|
||||||
|
): { imageUrl: string; iconUrl: string } {
|
||||||
|
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
|
||||||
|
const finalBaseUrl = baseUrl || getBaseUrl(logger);
|
||||||
|
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
|
||||||
|
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||||
|
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');
|
||||||
|
return { imageUrl, iconUrl };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms AI-extracted data into database-ready flyer and item records.
|
* Transforms AI-extracted data into database-ready flyer and item records.
|
||||||
* @param extractedData The validated data from the AI.
|
* @param extractedData The validated data from the AI.
|
||||||
@@ -50,8 +82,9 @@ export class FlyerDataTransformer {
|
|||||||
*/
|
*/
|
||||||
async transform(
|
async transform(
|
||||||
aiResult: AiProcessorResult,
|
aiResult: AiProcessorResult,
|
||||||
imagePaths: { path: string; mimetype: string }[],
|
|
||||||
originalFileName: string,
|
originalFileName: string,
|
||||||
|
imageFileName: string,
|
||||||
|
iconFileName: string,
|
||||||
checksum: string,
|
checksum: string,
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
@@ -62,12 +95,7 @@ export class FlyerDataTransformer {
|
|||||||
try {
|
try {
|
||||||
const { data: extractedData, needsReview } = aiResult;
|
const { data: extractedData, needsReview } = aiResult;
|
||||||
|
|
||||||
const firstImage = imagePaths[0].path;
|
const { imageUrl, iconUrl } = this._buildUrls(imageFileName, iconFileName, baseUrl, logger);
|
||||||
const iconFileName = await generateFlyerIcon(
|
|
||||||
firstImage,
|
|
||||||
path.join(path.dirname(firstImage), 'icons'),
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
||||||
|
|
||||||
@@ -76,23 +104,10 @@ export class FlyerDataTransformer {
|
|||||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The baseUrl is passed from the job payload to ensure the worker has the correct environment context.
|
|
||||||
// If it's missing for any reason, we fall back to a sensible default for local development.
|
|
||||||
let finalBaseUrl = baseUrl;
|
|
||||||
if (!finalBaseUrl) {
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
finalBaseUrl = `http://localhost:${port}`;
|
|
||||||
logger.warn(
|
|
||||||
`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
|
||||||
|
|
||||||
const flyerData: FlyerInsert = {
|
const flyerData: FlyerInsert = {
|
||||||
file_name: originalFileName,
|
file_name: originalFileName,
|
||||||
image_url: `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`,
|
image_url: imageUrl,
|
||||||
icon_url: `${finalBaseUrl}/flyer-images/icons/${iconFileName}`,
|
icon_url: iconUrl,
|
||||||
checksum,
|
checksum,
|
||||||
store_name: storeName,
|
store_name: storeName,
|
||||||
valid_from: extractedData.valid_from,
|
valid_from: extractedData.valid_from,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
unlink: vi.fn(),
|
unlink: vi.fn(),
|
||||||
readdir: vi.fn(),
|
readdir: vi.fn(),
|
||||||
execAsync: vi.fn(),
|
execAsync: vi.fn(),
|
||||||
|
mockAdminLogActivity: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 2. Mock modules using the hoisted variables
|
// 2. Mock modules using the hoisted variables
|
||||||
@@ -34,12 +35,21 @@ import {
|
|||||||
AiDataValidationError,
|
AiDataValidationError,
|
||||||
PdfConversionError,
|
PdfConversionError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
|
TransformationError,
|
||||||
|
DatabaseError,
|
||||||
} from './processingErrors';
|
} from './processingErrors';
|
||||||
|
import { NotFoundError } from './db/errors.db';
|
||||||
import { FlyerFileHandler } from './flyerFileHandler.server';
|
import { FlyerFileHandler } from './flyerFileHandler.server';
|
||||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||||
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
import type { AIService } from './aiService.server';
|
import type { AIService } from './aiService.server';
|
||||||
|
|
||||||
|
// Mock image processor functions
|
||||||
|
vi.mock('../utils/imageProcessor', () => ({
|
||||||
|
generateFlyerIcon: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('./aiService.server', () => ({
|
vi.mock('./aiService.server', () => ({
|
||||||
aiService: {
|
aiService: {
|
||||||
@@ -52,6 +62,13 @@ vi.mock('./db/flyer.db', () => ({
|
|||||||
vi.mock('./db/index.db', () => ({
|
vi.mock('./db/index.db', () => ({
|
||||||
personalizationRepo: { getAllMasterItems: vi.fn() },
|
personalizationRepo: { getAllMasterItems: vi.fn() },
|
||||||
adminRepo: { logActivity: vi.fn() },
|
adminRepo: { logActivity: vi.fn() },
|
||||||
|
flyerRepo: { getFlyerById: vi.fn() },
|
||||||
|
withTransaction: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('./db/admin.db', () => ({
|
||||||
|
AdminRepository: vi.fn().mockImplementation(function () {
|
||||||
|
return { logActivity: mocks.mockAdminLogActivity };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -78,13 +95,17 @@ describe('FlyerProcessingService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Provide a default mock implementation for withTransaction that just executes the callback.
|
||||||
|
// This is needed for the happy path tests. Tests for transaction failures will override this.
|
||||||
|
vi.mocked(mockedDb.withTransaction).mockImplementation(async (callback: any) => callback({}));
|
||||||
|
|
||||||
// Spy on the real transformer's method and provide a mock implementation.
|
// Spy on the real transformer's method and provide a mock implementation.
|
||||||
// This is more robust than mocking the entire class constructor.
|
// This is more robust than mocking the entire class constructor.
|
||||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
||||||
flyerData: {
|
flyerData: {
|
||||||
file_name: 'test.jpg',
|
file_name: 'test.jpg',
|
||||||
image_url: 'http://example.com/test.jpg',
|
image_url: 'https://example.com/test.jpg',
|
||||||
icon_url: 'http://example.com/icon.webp',
|
icon_url: 'https://example.com/icon.webp',
|
||||||
store_name: 'Mock Store',
|
store_name: 'Mock Store',
|
||||||
// Add required fields for FlyerInsert type
|
// Add required fields for FlyerInsert type
|
||||||
status: 'processed',
|
status: 'processed',
|
||||||
@@ -114,7 +135,6 @@ describe('FlyerProcessingService', () => {
|
|||||||
service = new FlyerProcessingService(
|
service = new FlyerProcessingService(
|
||||||
mockFileHandler,
|
mockFileHandler,
|
||||||
mockAiProcessor,
|
mockAiProcessor,
|
||||||
mockedDb,
|
|
||||||
mockFs,
|
mockFs,
|
||||||
mockCleanupQueue,
|
mockCleanupQueue,
|
||||||
new FlyerDataTransformer(),
|
new FlyerDataTransformer(),
|
||||||
@@ -149,7 +169,7 @@ describe('FlyerProcessingService', () => {
|
|||||||
flyer: createMockFlyer({
|
flyer: createMockFlyer({
|
||||||
flyer_id: 1,
|
flyer_id: 1,
|
||||||
file_name: 'test.jpg',
|
file_name: 'test.jpg',
|
||||||
image_url: 'http://example.com/test.jpg',
|
image_url: 'https://example.com/test.jpg',
|
||||||
item_count: 1,
|
item_count: 1,
|
||||||
}),
|
}),
|
||||||
items: [],
|
items: [],
|
||||||
@@ -158,6 +178,9 @@ describe('FlyerProcessingService', () => {
|
|||||||
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
|
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
|
||||||
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
|
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
|
||||||
|
});
|
||||||
|
|
||||||
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
|
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
|
||||||
return {
|
return {
|
||||||
@@ -166,7 +189,7 @@ describe('FlyerProcessingService', () => {
|
|||||||
filePath: '/tmp/flyer.jpg',
|
filePath: '/tmp/flyer.jpg',
|
||||||
originalFileName: 'flyer.jpg',
|
originalFileName: 'flyer.jpg',
|
||||||
checksum: 'checksum-123',
|
checksum: 'checksum-123',
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'https://example.com',
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
updateProgress: vi.fn(),
|
updateProgress: vi.fn(),
|
||||||
@@ -189,16 +212,54 @@ describe('FlyerProcessingService', () => {
|
|||||||
it('should process an image file successfully and enqueue a cleanup job', async () => {
|
it('should process an image file successfully and enqueue a cleanup job', async () => {
|
||||||
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
|
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
|
||||||
|
|
||||||
|
// Arrange: Mock dependencies to simulate a successful run
|
||||||
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
||||||
|
imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
|
||||||
|
createdImagePaths: ['/tmp/flyer-processed.jpeg'],
|
||||||
|
});
|
||||||
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
|
||||||
|
|
||||||
const result = await service.processJob(job);
|
const result = await service.processJob(job);
|
||||||
|
|
||||||
expect(result).toEqual({ flyerId: 1 });
|
expect(result).toEqual({ flyerId: 1 });
|
||||||
|
|
||||||
|
// 1. File handler was called
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
||||||
|
|
||||||
|
// 2. AI processor was called
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 3. Icon was generated from the processed image
|
||||||
|
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp/icons', expect.any(Object));
|
||||||
|
|
||||||
|
// 4. Transformer was called with the correct filenames
|
||||||
|
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object), // aiResult
|
||||||
|
'flyer.jpg', // originalFileName
|
||||||
|
'flyer-processed.jpeg', // imageFileName
|
||||||
|
'icon-flyer.webp', // iconFileName
|
||||||
|
'checksum-123', // checksum
|
||||||
|
undefined, // userId
|
||||||
|
expect.any(Object), // logger
|
||||||
|
'https://example.com', // baseUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. DB transaction was initiated
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
|
expect(mocks.mockAdminLogActivity).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 6. Cleanup job was enqueued with all generated files
|
||||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||||
'cleanup-flyer-files',
|
'cleanup-flyer-files',
|
||||||
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
|
{
|
||||||
|
flyerId: 1,
|
||||||
|
paths: [
|
||||||
|
'/tmp/flyer.jpg', // original job path
|
||||||
|
'/tmp/flyer-processed.jpeg', // from prepareImageInputs
|
||||||
|
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
|
||||||
|
],
|
||||||
|
},
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -209,24 +270,33 @@ describe('FlyerProcessingService', () => {
|
|||||||
// Mock the file handler to return multiple created paths
|
// Mock the file handler to return multiple created paths
|
||||||
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
|
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
|
||||||
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
||||||
imagePaths: createdPaths.map(p => ({ path: p, mimetype: 'image/jpeg' })),
|
imagePaths: [
|
||||||
|
{ path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' },
|
||||||
|
{ path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' },
|
||||||
|
],
|
||||||
createdImagePaths: createdPaths,
|
createdImagePaths: createdPaths,
|
||||||
});
|
});
|
||||||
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-1.webp');
|
||||||
|
|
||||||
await service.processJob(job);
|
await service.processJob(job);
|
||||||
|
|
||||||
|
// Verify transaction and inner calls
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
// Verify cleanup job includes original PDF and both generated images
|
// Verify icon generation was called for the first page
|
||||||
|
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-1.jpg', '/tmp/icons', expect.any(Object));
|
||||||
|
// Verify cleanup job includes original PDF and all generated/processed images
|
||||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||||
'cleanup-flyer-files',
|
'cleanup-flyer-files',
|
||||||
{
|
{
|
||||||
flyerId: 1,
|
flyerId: 1,
|
||||||
paths: [
|
paths: [
|
||||||
'/tmp/flyer.pdf',
|
'/tmp/flyer.pdf', // original job path
|
||||||
'/tmp/flyer-1.jpg',
|
'/tmp/flyer-1.jpg', // from prepareImageInputs
|
||||||
'/tmp/flyer-2.jpg',
|
'/tmp/flyer-2.jpg', // from prepareImageInputs
|
||||||
|
'/tmp/icons/icon-flyer-1.webp', // from generateFlyerIcon
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -359,14 +429,26 @@ describe('FlyerProcessingService', () => {
|
|||||||
imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
|
imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
|
||||||
createdImagePaths: [convertedPath],
|
createdImagePaths: [convertedPath],
|
||||||
});
|
});
|
||||||
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-converted.webp');
|
||||||
|
|
||||||
await service.processJob(job);
|
await service.processJob(job);
|
||||||
|
|
||||||
|
// Verify transaction and inner calls
|
||||||
|
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
|
||||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify icon generation was called for the converted image
|
||||||
|
expect(generateFlyerIcon).toHaveBeenCalledWith(convertedPath, '/tmp/icons', expect.any(Object));
|
||||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||||
'cleanup-flyer-files',
|
'cleanup-flyer-files',
|
||||||
{ flyerId: 1, paths: ['/tmp/flyer.gif', convertedPath] },
|
{
|
||||||
|
flyerId: 1,
|
||||||
|
paths: [
|
||||||
|
'/tmp/flyer.gif', // original job path
|
||||||
|
convertedPath, // from prepareImageInputs
|
||||||
|
'/tmp/icons/icon-flyer-converted.webp', // from generateFlyerIcon
|
||||||
|
],
|
||||||
|
},
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -375,20 +457,25 @@ describe('FlyerProcessingService', () => {
|
|||||||
const job = createMockJob({});
|
const job = createMockJob({});
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
const dbError = new Error('Database transaction failed');
|
const dbError = new Error('Database transaction failed');
|
||||||
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
|
|
||||||
|
|
||||||
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
|
// To test the DB failure, we make the transaction itself fail when called.
|
||||||
|
// This is more realistic than mocking the inner function `createFlyerAndItems`.
|
||||||
|
vi.mocked(mockedDb.withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
// The service wraps the generic DB error in a DatabaseError.
|
||||||
errorCode: 'UNKNOWN_ERROR',
|
await expect(service.processJob(job)).rejects.toThrow(DatabaseError);
|
||||||
message: 'Database transaction failed',
|
|
||||||
|
// The final progress update should reflect the structured DatabaseError.
|
||||||
|
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
||||||
|
errorCode: 'DATABASE_ERROR',
|
||||||
|
message: 'A database operation failed. Please try again later.',
|
||||||
stages: [
|
stages: [
|
||||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||||
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
||||||
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
||||||
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'Database transaction failed' },
|
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'A database operation failed. Please try again later.' },
|
||||||
],
|
],
|
||||||
}); // This was a duplicate, fixed.
|
});
|
||||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||||
@@ -418,17 +505,14 @@ describe('FlyerProcessingService', () => {
|
|||||||
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
||||||
const job = createMockJob({});
|
const job = createMockJob({});
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
const iconError = new Error('Icon generation failed.');
|
const iconGenError = new Error('Icon generation failed.');
|
||||||
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
|
vi.mocked(generateFlyerIcon).mockRejectedValue(iconGenError);
|
||||||
// to always succeed. For this test, we override that mock to simulate a failure
|
|
||||||
// bubbling up from the icon generation step.
|
|
||||||
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(iconError);
|
|
||||||
|
|
||||||
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
||||||
|
|
||||||
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
||||||
|
|
||||||
expect(reportErrorSpy).toHaveBeenCalledWith(iconError, job, expect.any(Object), expect.any(Array));
|
expect(reportErrorSpy).toHaveBeenCalledWith(iconGenError, job, expect.any(Object), expect.any(Array));
|
||||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||||
@@ -589,14 +673,48 @@ describe('FlyerProcessingService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip processing and return "skipped" if paths array is empty', async () => {
|
it('should skip processing and return "skipped" if paths array is empty and paths cannot be derived', async () => {
|
||||||
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
|
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
|
||||||
|
// Mock that the flyer cannot be found in the DB, so paths cannot be derived.
|
||||||
|
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Not found'));
|
||||||
|
|
||||||
const result = await service.processCleanupJob(job);
|
const result = await service.processCleanupJob(job);
|
||||||
|
|
||||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ status: 'skipped', reason: 'no paths' });
|
expect(result).toEqual({ status: 'skipped', reason: 'no paths derived' });
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('Job received no paths to clean. Skipping.');
|
// Check for both warnings: the attempt to derive, and the final skip message.
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Job received no paths and could not derive any from the database. Skipping.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive paths from DB and delete files if job paths are empty', async () => {
|
||||||
|
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
|
||||||
|
const mockFlyer = createMockFlyer({
|
||||||
|
image_url: 'https://example.com/flyer-images/flyer-abc.jpg',
|
||||||
|
icon_url: 'https://example.com/flyer-images/icons/icon-flyer-abc.webp',
|
||||||
|
});
|
||||||
|
// Mock DB call to return a flyer
|
||||||
|
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
|
||||||
|
mocks.unlink.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Mock process.env.STORAGE_PATH
|
||||||
|
vi.stubEnv('STORAGE_PATH', '/var/www/app/flyer-images');
|
||||||
|
|
||||||
|
const result = await service.processCleanupJob(job);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
||||||
|
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/flyer-abc.jpg');
|
||||||
|
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/icons/icon-flyer-abc.webp');
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// src/services/flyerProcessingService.server.ts
|
// src/services/flyerProcessingService.server.ts
|
||||||
import type { Job, Queue } from 'bullmq';
|
import { UnrecoverableError, type Job, type Queue } from 'bullmq';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import path from 'path';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import type { FlyerFileHandler, IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
import type { FlyerFileHandler, IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||||
import type { FlyerAiProcessor } from './flyerAiProcessor.server';
|
import type { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||||
import type * as Db from './db/index.db';
|
import * as db from './db/index.db';
|
||||||
import type { AdminRepository } from './db/admin.db';
|
import { AdminRepository } from './db/admin.db';
|
||||||
import { FlyerDataTransformer } from './flyerDataTransformer';
|
import { FlyerDataTransformer } from './flyerDataTransformer';
|
||||||
import type { FlyerJobData, CleanupJobData } from '../types/job-data';
|
import type { FlyerJobData, CleanupJobData } from '../types/job-data';
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +13,12 @@ import {
|
|||||||
PdfConversionError,
|
PdfConversionError,
|
||||||
AiDataValidationError,
|
AiDataValidationError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
|
DatabaseError, // This is from processingErrors
|
||||||
} from './processingErrors';
|
} from './processingErrors';
|
||||||
|
import { NotFoundError } from './db/errors.db';
|
||||||
import { createFlyerAndItems } from './db/flyer.db';
|
import { createFlyerAndItems } from './db/flyer.db';
|
||||||
import { logger as globalLogger } from './logger.server';
|
import { logger as globalLogger } from './logger.server'; // This was a duplicate, fixed.
|
||||||
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
|
|
||||||
// Define ProcessingStage locally as it's not exported from the types file.
|
// Define ProcessingStage locally as it's not exported from the types file.
|
||||||
export type ProcessingStage = {
|
export type ProcessingStage = {
|
||||||
@@ -34,9 +37,6 @@ export class FlyerProcessingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private fileHandler: FlyerFileHandler,
|
private fileHandler: FlyerFileHandler,
|
||||||
private aiProcessor: FlyerAiProcessor,
|
private aiProcessor: FlyerAiProcessor,
|
||||||
// This service only needs the `logActivity` method from the `adminRepo`.
|
|
||||||
// By using `Pick`, we create a more focused and testable dependency.
|
|
||||||
private db: { adminRepo: Pick<AdminRepository, 'logActivity'> },
|
|
||||||
private fs: IFileSystem,
|
private fs: IFileSystem,
|
||||||
// By depending on `Pick<Queue, 'add'>`, we specify that this service only needs
|
// By depending on `Pick<Queue, 'add'>`, we specify that this service only needs
|
||||||
// an object with an `add` method that matches the Queue's `add` method signature.
|
// an object with an `add` method that matches the Queue's `add` method signature.
|
||||||
@@ -92,10 +92,22 @@ export class FlyerProcessingService {
|
|||||||
stages[2].status = 'in-progress';
|
stages[2].status = 'in-progress';
|
||||||
await job.updateProgress({ stages });
|
await job.updateProgress({ stages });
|
||||||
|
|
||||||
|
// The fileHandler has already prepared the primary image (e.g., by stripping EXIF data).
|
||||||
|
// We now generate an icon from it and prepare the filenames for the transformer.
|
||||||
|
const primaryImagePath = imagePaths[0].path;
|
||||||
|
const imageFileName = path.basename(primaryImagePath);
|
||||||
|
const iconsDir = path.join(path.dirname(primaryImagePath), 'icons');
|
||||||
|
const iconFileName = await generateFlyerIcon(primaryImagePath, iconsDir, logger);
|
||||||
|
|
||||||
|
// Add the newly generated icon to the list of files to be cleaned up.
|
||||||
|
// The main processed image path is already in `allFilePaths` via `createdImagePaths`.
|
||||||
|
allFilePaths.push(path.join(iconsDir, iconFileName));
|
||||||
|
|
||||||
const { flyerData, itemsForDb } = await this.transformer.transform(
|
const { flyerData, itemsForDb } = await this.transformer.transform(
|
||||||
aiResult,
|
aiResult,
|
||||||
imagePaths,
|
|
||||||
job.data.originalFileName,
|
job.data.originalFileName,
|
||||||
|
imageFileName,
|
||||||
|
iconFileName,
|
||||||
job.data.checksum,
|
job.data.checksum,
|
||||||
job.data.userId,
|
job.data.userId,
|
||||||
logger,
|
logger,
|
||||||
@@ -108,30 +120,45 @@ export class FlyerProcessingService {
|
|||||||
stages[3].status = 'in-progress';
|
stages[3].status = 'in-progress';
|
||||||
await job.updateProgress({ stages });
|
await job.updateProgress({ stages });
|
||||||
|
|
||||||
const { flyer } = await createFlyerAndItems(flyerData, itemsForDb, logger);
|
let flyerId: number;
|
||||||
|
try {
|
||||||
|
const { flyer } = await db.withTransaction(async (client) => {
|
||||||
|
// This assumes createFlyerAndItems is refactored to accept a transactional client.
|
||||||
|
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||||
|
|
||||||
|
// Instantiate a new AdminRepository with the transactional client to ensure
|
||||||
|
// the activity log is part of the same transaction.
|
||||||
|
const transactionalAdminRepo = new AdminRepository(client);
|
||||||
|
await transactionalAdminRepo.logActivity(
|
||||||
|
{
|
||||||
|
action: 'flyer_processed',
|
||||||
|
displayText: `Processed flyer for ${flyerData.store_name}`,
|
||||||
|
details: { flyer_id: newFlyer.flyer_id, store_name: flyerData.store_name },
|
||||||
|
userId: job.data.userId,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { flyer: newFlyer };
|
||||||
|
});
|
||||||
|
flyerId = flyer.flyer_id;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FlyerProcessingError) throw error;
|
||||||
|
throw new DatabaseError(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
|
||||||
stages[3].status = 'completed';
|
stages[3].status = 'completed';
|
||||||
await job.updateProgress({ stages });
|
await job.updateProgress({ stages });
|
||||||
|
|
||||||
// Stage 5: Log Activity
|
|
||||||
await this.db.adminRepo.logActivity(
|
|
||||||
{
|
|
||||||
action: 'flyer_processed',
|
|
||||||
displayText: `Processed flyer for ${flyerData.store_name}`,
|
|
||||||
details: { flyer_id: flyer.flyer_id, store_name: flyerData.store_name },
|
|
||||||
userId: job.data.userId,
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enqueue a job to clean up the original and any generated files.
|
// Enqueue a job to clean up the original and any generated files.
|
||||||
await this.cleanupQueue.add(
|
await this.cleanupQueue.add(
|
||||||
'cleanup-flyer-files',
|
'cleanup-flyer-files',
|
||||||
{ flyerId: flyer.flyer_id, paths: allFilePaths },
|
{ flyerId, paths: allFilePaths },
|
||||||
{ removeOnComplete: true },
|
{ removeOnComplete: true },
|
||||||
);
|
);
|
||||||
logger.info(`Successfully processed job and enqueued cleanup for flyer ID: ${flyer.flyer_id}`);
|
logger.info(`Successfully processed job and enqueued cleanup for flyer ID: ${flyerId}`);
|
||||||
|
|
||||||
return { flyerId: flyer.flyer_id };
|
return { flyerId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.');
|
logger.warn('Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.');
|
||||||
// Add detailed logging of the raw error object
|
// Add detailed logging of the raw error object
|
||||||
@@ -157,14 +184,52 @@ export class FlyerProcessingService {
|
|||||||
const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data });
|
const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data });
|
||||||
logger.info('Picked up file cleanup job.');
|
logger.info('Picked up file cleanup job.');
|
||||||
|
|
||||||
const { paths } = job.data;
|
const { flyerId, paths } = job.data;
|
||||||
if (!paths || paths.length === 0) {
|
let pathsToDelete = paths;
|
||||||
logger.warn('Job received no paths to clean. Skipping.');
|
|
||||||
return { status: 'skipped', reason: 'no paths' };
|
// If no paths are provided (e.g., from a manual trigger), attempt to derive them from the database.
|
||||||
|
if (!pathsToDelete || pathsToDelete.length === 0) {
|
||||||
|
logger.warn(`Cleanup job for flyer ${flyerId} received no paths. Attempting to derive paths from DB.`);
|
||||||
|
try {
|
||||||
|
const flyer = await db.flyerRepo.getFlyerById(flyerId);
|
||||||
|
const derivedPaths: string[] = [];
|
||||||
|
// This path needs to be configurable and match where multer saves files.
|
||||||
|
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||||
|
|
||||||
|
if (flyer.image_url) {
|
||||||
|
try {
|
||||||
|
const imageName = path.basename(new URL(flyer.image_url).pathname);
|
||||||
|
derivedPaths.push(path.join(storagePath, imageName));
|
||||||
|
} catch (urlError) {
|
||||||
|
logger.error({ err: urlError, url: flyer.image_url }, 'Failed to parse flyer.image_url to derive file path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flyer.icon_url) {
|
||||||
|
try {
|
||||||
|
const iconName = path.basename(new URL(flyer.icon_url).pathname);
|
||||||
|
derivedPaths.push(path.join(storagePath, 'icons', iconName));
|
||||||
|
} catch (urlError) {
|
||||||
|
logger.error({ err: urlError, url: flyer.icon_url }, 'Failed to parse flyer.icon_url to derive file path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathsToDelete = derivedPaths;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError) {
|
||||||
|
logger.error({ flyerId }, 'Cannot derive cleanup paths because flyer was not found in DB.');
|
||||||
|
// Do not throw. Allow the job to be marked as skipped if no paths are found.
|
||||||
|
} else {
|
||||||
|
throw error; // Re-throw other DB errors to allow for retries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathsToDelete || pathsToDelete.length === 0) {
|
||||||
|
logger.warn('Job received no paths and could not derive any from the database. Skipping.');
|
||||||
|
return { status: 'skipped', reason: 'no paths derived' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
paths.map(async (filePath) => {
|
pathsToDelete.map(async (filePath) => {
|
||||||
try {
|
try {
|
||||||
await this.fs.unlink(filePath);
|
await this.fs.unlink(filePath);
|
||||||
logger.info(`Successfully deleted temporary file: ${filePath}`);
|
logger.info(`Successfully deleted temporary file: ${filePath}`);
|
||||||
@@ -183,12 +248,12 @@ export class FlyerProcessingService {
|
|||||||
|
|
||||||
const failedDeletions = results.filter((r) => r.status === 'rejected');
|
const failedDeletions = results.filter((r) => r.status === 'rejected');
|
||||||
if (failedDeletions.length > 0) {
|
if (failedDeletions.length > 0) {
|
||||||
const failedPaths = paths.filter((_, i) => results[i].status === 'rejected');
|
const failedPaths = pathsToDelete.filter((_, i) => results[i].status === 'rejected');
|
||||||
throw new Error(`Failed to delete ${failedDeletions.length} file(s): ${failedPaths.join(', ')}`);
|
throw new Error(`Failed to delete ${failedDeletions.length} file(s): ${failedPaths.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Successfully deleted all ${paths.length} temporary files.`);
|
logger.info(`Successfully deleted all ${pathsToDelete.length} temporary files.`);
|
||||||
return { status: 'success', deletedCount: paths.length };
|
return { status: 'success', deletedCount: pathsToDelete.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +275,8 @@ export class FlyerProcessingService {
|
|||||||
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
|
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
|
||||||
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
|
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
|
||||||
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
|
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
|
||||||
['TRANSFORMATION_FAILED', 'Transforming AI Data'], // Add new mapping
|
['TRANSFORMATION_FAILED', 'Transforming AI Data'],
|
||||||
|
['DATABASE_ERROR', 'Saving to Database'],
|
||||||
]);
|
]);
|
||||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||||
let errorPayload: { errorCode: string; message: string; [key: string]: any };
|
let errorPayload: { errorCode: string; message: string; [key: string]: any };
|
||||||
@@ -227,15 +293,6 @@ export class FlyerProcessingService {
|
|||||||
const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
|
const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
|
||||||
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
|
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
|
||||||
|
|
||||||
// Fallback for generic errors not in the map. This is less robust and relies on string matching.
|
|
||||||
// A future improvement would be to wrap these in specific FlyerProcessingError subclasses.
|
|
||||||
if (errorStageIndex === -1 && errorPayload.message.includes('Icon generation failed')) {
|
|
||||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Transforming AI Data');
|
|
||||||
}
|
|
||||||
if (errorStageIndex === -1 && errorPayload.message.includes('Database transaction failed')) {
|
|
||||||
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Saving to Database');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If not mapped, find the currently running stage
|
// 2. If not mapped, find the currently running stage
|
||||||
if (errorStageIndex === -1) {
|
if (errorStageIndex === -1) {
|
||||||
errorStageIndex = stagesToReport.findIndex(s => s.status === 'in-progress');
|
errorStageIndex = stagesToReport.findIndex(s => s.status === 'in-progress');
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/services/gamificationService.ts
|
// src/services/gamificationService.ts
|
||||||
|
|
||||||
import { gamificationRepo } from './db/index.db';
|
import { gamificationRepo } from './db/index.db';
|
||||||
import { ForeignKeyConstraintError } from './db/errors.db';
|
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
import { ForeignKeyConstraintError } from './db/errors.db';
|
||||||
|
|
||||||
class GamificationService {
|
class GamificationService {
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +16,12 @@ class GamificationService {
|
|||||||
await gamificationRepo.awardAchievement(userId, achievementName, log);
|
await gamificationRepo.awardAchievement(userId, achievementName, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
|
// This is an expected error (e.g., achievement name doesn't exist),
|
||||||
|
// which the repository layer should have already logged with appropriate context.
|
||||||
|
// We re-throw it so the calling layer (e.g., an admin route) can handle it.
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// For unexpected, generic errors, we log them at the service level before re-throwing.
|
||||||
log.error(
|
log.error(
|
||||||
{ error, userId, achievementName },
|
{ error, userId, achievementName },
|
||||||
'Error awarding achievement via admin endpoint:',
|
'Error awarding achievement via admin endpoint:',
|
||||||
@@ -45,10 +49,6 @@ class GamificationService {
|
|||||||
* @param log The logger instance.
|
* @param log The logger instance.
|
||||||
*/
|
*/
|
||||||
async getLeaderboard(limit: number, log: Logger) {
|
async getLeaderboard(limit: number, log: Logger) {
|
||||||
// The test failures point to an issue in the underlying repository method,
|
|
||||||
// where the database query is not being executed. This service method is a simple
|
|
||||||
// pass-through, so the root cause is likely in `gamification.db.ts`.
|
|
||||||
// Adding robust error handling here is a good practice regardless.
|
|
||||||
try {
|
try {
|
||||||
return await gamificationRepo.getLeaderboard(limit, log);
|
return await gamificationRepo.getLeaderboard(limit, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,10 +63,6 @@ class GamificationService {
|
|||||||
* @param log The logger instance.
|
* @param log The logger instance.
|
||||||
*/
|
*/
|
||||||
async getUserAchievements(userId: string, log: Logger) {
|
async getUserAchievements(userId: string, log: Logger) {
|
||||||
// The test failures point to an issue in the underlying repository method,
|
|
||||||
// where the database query is not being executed. This service method is a simple
|
|
||||||
// pass-through, so the root cause is likely in `gamification.db.ts`.
|
|
||||||
// Adding robust error handling here is a good practice regardless.
|
|
||||||
try {
|
try {
|
||||||
return await gamificationRepo.getUserAchievements(userId, log);
|
return await gamificationRepo.getUserAchievements(userId, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ export class TransformationError extends FlyerProcessingError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a database operation fails during processing.
|
||||||
|
*/
|
||||||
|
export class DatabaseError extends FlyerProcessingError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
'DATABASE_ERROR',
|
||||||
|
'A database operation failed. Please try again later.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Error thrown when an image conversion fails (e.g., using sharp).
|
* Error thrown when an image conversion fails (e.g., using sharp).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { Address, UserProfile } from '../types';
|
|||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { ValidationError, NotFoundError } from './db/errors.db';
|
import { ValidationError, NotFoundError } from './db/errors.db';
|
||||||
|
import { DatabaseError } from './processingErrors';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import type { TokenCleanupJobData } from '../types/job-data';
|
import type { TokenCleanupJobData } from '../types/job-data';
|
||||||
|
import { getTestBaseUrl } from '../tests/utils/testHelpers';
|
||||||
|
|
||||||
// Un-mock the service under test to ensure we are testing the real implementation,
|
// Un-mock the service under test to ensure we are testing the real implementation,
|
||||||
// not the global mock from `tests/setup/tests-setup-unit.ts`.
|
// not the global mock from `tests/setup/tests-setup-unit.ts`.
|
||||||
@@ -176,6 +178,29 @@ describe('UserService', () => {
|
|||||||
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
||||||
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw a DatabaseError if the transaction fails', async () => {
|
||||||
|
const { logger } = await import('./logger.server');
|
||||||
|
const user = createMockUserProfile({
|
||||||
|
user: { user_id: 'user-123' },
|
||||||
|
address_id: null,
|
||||||
|
});
|
||||||
|
const addressData: Partial<Address> = { address_line_1: '123 Fail St' };
|
||||||
|
const dbError = new Error('DB connection lost');
|
||||||
|
|
||||||
|
// Simulate a failure within the transaction (e.g., upsertAddress fails)
|
||||||
|
mocks.mockUpsertAddress.mockRejectedValue(dbError);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
// The service should wrap the generic error in a `DatabaseError`.
|
||||||
|
await expect(userService.upsertUserAddress(user, addressData, logger)).rejects.toBeInstanceOf(DatabaseError);
|
||||||
|
|
||||||
|
// Assert that the error was logged correctly
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ err: dbError, userId: user.user.user_id },
|
||||||
|
`Transaction to upsert user address failed: ${dbError.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('processTokenCleanupJob', () => {
|
describe('processTokenCleanupJob', () => {
|
||||||
@@ -208,7 +233,7 @@ describe('UserService', () => {
|
|||||||
await expect(userService.processTokenCleanupJob(job)).rejects.toThrow('DB Error');
|
await expect(userService.processTokenCleanupJob(job)).rejects.toThrow('DB Error');
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ err: error }),
|
expect.objectContaining({ err: error }),
|
||||||
'Expired token cleanup job failed.',
|
`Expired token cleanup job failed: ${error.message}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -216,12 +241,12 @@ describe('UserService', () => {
|
|||||||
describe('updateUserAvatar', () => {
|
describe('updateUserAvatar', () => {
|
||||||
it('should construct avatar URL and update profile', async () => {
|
it('should construct avatar URL and update profile', async () => {
|
||||||
const { logger } = await import('./logger.server');
|
const { logger } = await import('./logger.server');
|
||||||
const testBaseUrl = 'http://localhost:3001';
|
const testBaseUrl = getTestBaseUrl();
|
||||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||||
|
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
|
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
|
||||||
const expectedUrl = `${testBaseUrl}/uploads/avatars/avatar.jpg`;
|
const expectedUrl = `${testBaseUrl}/uploads/avatars/${file.filename}`;
|
||||||
|
|
||||||
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
|
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { AddressRepository } from './db/address.db';
|
|||||||
import { UserRepository } from './db/user.db';
|
import { UserRepository } from './db/user.db';
|
||||||
import type { Address, Profile, UserProfile } from '../types';
|
import type { Address, Profile, UserProfile } from '../types';
|
||||||
import { ValidationError, NotFoundError } from './db/errors.db';
|
import { ValidationError, NotFoundError } from './db/errors.db';
|
||||||
|
import { DatabaseError } from './processingErrors';
|
||||||
import { logger as globalLogger } from './logger.server';
|
import { logger as globalLogger } from './logger.server';
|
||||||
import type { TokenCleanupJobData } from '../types/job-data';
|
import type { TokenCleanupJobData } from '../types/job-data';
|
||||||
|
import { getBaseUrl } from '../utils/serverUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates user-related business logic that may involve multiple repository calls.
|
* Encapsulates user-related business logic that may involve multiple repository calls.
|
||||||
@@ -27,27 +29,26 @@ class UserService {
|
|||||||
addressData: Partial<Address>,
|
addressData: Partial<Address>,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return db.withTransaction(async (client) => {
|
return db
|
||||||
// Instantiate repositories with the transactional client
|
.withTransaction(async (client) => {
|
||||||
const addressRepo = new AddressRepository(client);
|
const addressRepo = new AddressRepository(client);
|
||||||
const userRepo = new UserRepository(client);
|
const userRepo = new UserRepository(client);
|
||||||
|
const addressId = await addressRepo.upsertAddress(
|
||||||
const addressId = await addressRepo.upsertAddress(
|
{ ...addressData, address_id: userprofile.address_id ?? undefined },
|
||||||
{ ...addressData, address_id: userprofile.address_id ?? undefined },
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the user didn't have an address_id before, update their profile to link it.
|
|
||||||
if (!userprofile.address_id) {
|
|
||||||
await userRepo.updateUserProfile(
|
|
||||||
userprofile.user.user_id,
|
|
||||||
{ address_id: addressId },
|
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
}
|
if (!userprofile.address_id) {
|
||||||
|
await userRepo.updateUserProfile(userprofile.user.user_id, { address_id: addressId }, logger);
|
||||||
return addressId;
|
}
|
||||||
});
|
return addressId;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId: userprofile.user.user_id }, `Transaction to upsert user address failed: ${errorMessage}`);
|
||||||
|
// Wrap the original error in a service-level DatabaseError to standardize the error contract,
|
||||||
|
// as this is an unexpected failure within the transaction boundary.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,27 +56,21 @@ class UserService {
|
|||||||
* @param job The BullMQ job object.
|
* @param job The BullMQ job object.
|
||||||
* @returns An object containing the count of deleted tokens.
|
* @returns An object containing the count of deleted tokens.
|
||||||
*/
|
*/
|
||||||
async processTokenCleanupJob(
|
async processTokenCleanupJob(job: Job<TokenCleanupJobData>): Promise<{ deletedCount: number }> {
|
||||||
job: Job<TokenCleanupJobData>,
|
|
||||||
): Promise<{ deletedCount: number }> {
|
|
||||||
const logger = globalLogger.child({
|
const logger = globalLogger.child({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
jobName: job.name,
|
jobName: job.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Picked up expired token cleanup job.');
|
logger.info('Picked up expired token cleanup job.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
|
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
|
||||||
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
|
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
|
||||||
return { deletedCount };
|
return { deletedCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wrappedError = error instanceof Error ? error : new Error(String(error));
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
logger.error(
|
logger.error({ err: error, attemptsMade: job.attemptsMade }, `Expired token cleanup job failed: ${errorMessage}`);
|
||||||
{ err: wrappedError, attemptsMade: job.attemptsMade },
|
// This is a background job, but wrapping in a standard error type is good practice.
|
||||||
'Expired token cleanup job failed.',
|
throw new DatabaseError(errorMessage);
|
||||||
);
|
|
||||||
throw wrappedError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,26 +82,20 @@ class UserService {
|
|||||||
* @returns The updated user profile.
|
* @returns The updated user profile.
|
||||||
*/
|
*/
|
||||||
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
|
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
|
||||||
// Construct proper URLs including protocol and host to satisfy DB constraints.
|
try {
|
||||||
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
|
const baseUrl = getBaseUrl(logger);
|
||||||
if (!baseUrl || !baseUrl.startsWith('http')) {
|
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
|
||||||
const port = process.env.PORT || 3000;
|
return await db.userRepo.updateUserProfile(userId, { avatar_url: avatarUrl }, logger);
|
||||||
const fallbackUrl = `http://localhost:${port}`;
|
} catch (error) {
|
||||||
if (baseUrl) {
|
// Re-throw known application errors without logging them as system errors.
|
||||||
logger.warn(
|
if (error instanceof NotFoundError) {
|
||||||
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
|
throw error;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
baseUrl = fallbackUrl;
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to update user avatar: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
||||||
|
|
||||||
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
|
|
||||||
return db.userRepo.updateUserProfile(
|
|
||||||
userId,
|
|
||||||
{ avatar_url: avatarUrl },
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Updates a user's password after hashing it.
|
* Updates a user's password after hashing it.
|
||||||
@@ -115,9 +104,16 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
*/
|
*/
|
||||||
async updateUserPassword(userId: string, newPassword: string, logger: Logger): Promise<void> {
|
async updateUserPassword(userId: string, newPassword: string, logger: Logger): Promise<void> {
|
||||||
const saltRounds = 10;
|
try {
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
const saltRounds = 10;
|
||||||
await db.userRepo.updateUserPassword(userId, hashedPassword, logger);
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
await db.userRepo.updateUserPassword(userId, hashedPassword, logger);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to update user password: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,19 +123,25 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
*/
|
*/
|
||||||
async deleteUserAccount(userId: string, password: string, logger: Logger): Promise<void> {
|
async deleteUserAccount(userId: string, password: string, logger: Logger): Promise<void> {
|
||||||
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userId, logger);
|
try {
|
||||||
if (!userWithHash || !userWithHash.password_hash) {
|
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userId, logger);
|
||||||
// This case should be rare for a logged-in user but is a good safeguard.
|
if (!userWithHash || !userWithHash.password_hash) {
|
||||||
throw new NotFoundError('User not found or password not set.');
|
throw new NotFoundError('User not found or password not set.');
|
||||||
|
}
|
||||||
|
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new ValidationError([], 'Incorrect password.');
|
||||||
|
}
|
||||||
|
await db.userRepo.deleteUserById(userId, logger);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId }, `Failed to delete user account: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
|
||||||
if (!isMatch) {
|
|
||||||
// Use ValidationError for a 400-level response in the route
|
|
||||||
throw new ValidationError([], 'Incorrect password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.userRepo.deleteUserById(userId, logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,18 +151,21 @@ class UserService {
|
|||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
* @returns The address object.
|
* @returns The address object.
|
||||||
*/
|
*/
|
||||||
async getUserAddress(
|
async getUserAddress(userProfile: UserProfile, addressId: number, logger: Logger): Promise<Address> {
|
||||||
userProfile: UserProfile,
|
|
||||||
addressId: number,
|
|
||||||
logger: Logger,
|
|
||||||
): Promise<Address> {
|
|
||||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
|
||||||
if (userProfile.address_id !== addressId) {
|
if (userProfile.address_id !== addressId) {
|
||||||
// Use ValidationError to trigger a 403 Forbidden response in the route handler.
|
|
||||||
throw new ValidationError([], 'Forbidden: You can only access your own address.');
|
throw new ValidationError([], 'Forbidden: You can only access your own address.');
|
||||||
}
|
}
|
||||||
// The repo method will throw a NotFoundError if the address doesn't exist.
|
try {
|
||||||
return db.addressRepo.getAddressById(addressId, logger);
|
return await db.addressRepo.getAddressById(addressId, logger);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
logger.error({ err: error, userId: userProfile.user.user_id, addressId }, `Failed to get user address: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +179,17 @@ class UserService {
|
|||||||
if (deleterId === userToDeleteId) {
|
if (deleterId === userToDeleteId) {
|
||||||
throw new ValidationError([], 'Admins cannot delete their own account.');
|
throw new ValidationError([], 'Admins cannot delete their own account.');
|
||||||
}
|
}
|
||||||
await db.userRepo.deleteUserById(userToDeleteId, log);
|
try {
|
||||||
|
await db.userRepo.deleteUserById(userToDeleteId, log);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
|
log.error({ err: error, deleterId, userToDeleteId }, `Admin failed to delete user account: ${errorMessage}`);
|
||||||
|
// Wrap unexpected errors.
|
||||||
|
throw new DatabaseError(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ const fsAdapter: IFileSystem = {
|
|||||||
const flyerProcessingService = new FlyerProcessingService(
|
const flyerProcessingService = new FlyerProcessingService(
|
||||||
new FlyerFileHandler(fsAdapter, execAsync),
|
new FlyerFileHandler(fsAdapter, execAsync),
|
||||||
new FlyerAiProcessor(aiService, db.personalizationRepo),
|
new FlyerAiProcessor(aiService, db.personalizationRepo),
|
||||||
db,
|
|
||||||
fsAdapter,
|
fsAdapter,
|
||||||
cleanupQueue,
|
cleanupQueue,
|
||||||
new FlyerDataTransformer(),
|
new FlyerDataTransformer(),
|
||||||
|
|||||||
51
src/tests/e2e/admin-authorization.e2e.test.ts
Normal file
51
src/tests/e2e/admin-authorization.e2e.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// src/tests/e2e/admin-authorization.e2e.test.ts
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import * as apiClient from '../../services/apiClient';
|
||||||
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
|
import type { UserProfile } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
describe('Admin Route Authorization', () => {
|
||||||
|
let regularUser: UserProfile;
|
||||||
|
let regularUserAuthToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a standard user for testing authorization
|
||||||
|
const { user, token } = await createAndLoginUser({
|
||||||
|
email: `e2e-authz-user-${Date.now()}@example.com`,
|
||||||
|
fullName: 'E2E AuthZ User',
|
||||||
|
});
|
||||||
|
regularUser = user;
|
||||||
|
regularUserAuthToken = token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup the created user
|
||||||
|
if (regularUser?.user.user_id) {
|
||||||
|
await cleanupDb({ userIds: [regularUser.user.user_id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define a list of admin-only endpoints to test
|
||||||
|
const adminEndpoints = [
|
||||||
|
{ method: 'GET', path: '/admin/stats', action: (token: string) => apiClient.getApplicationStats(token) },
|
||||||
|
{ method: 'GET', path: '/admin/users', action: (token: string) => apiClient.authedGet('/admin/users', { tokenOverride: token }) },
|
||||||
|
{ method: 'GET', path: '/admin/corrections', action: (token: string) => apiClient.getSuggestedCorrections(token) },
|
||||||
|
{ method: 'POST', path: '/admin/corrections/1/approve', action: (token: string) => apiClient.approveCorrection(1, token) },
|
||||||
|
{ method: 'POST', path: '/admin/trigger/daily-deal-check', action: (token: string) => apiClient.authedPostEmpty('/admin/trigger/daily-deal-check', { tokenOverride: token }) },
|
||||||
|
{ method: 'GET', path: '/admin/queues/status', action: (token: string) => apiClient.authedGet('/admin/queues/status', { tokenOverride: token }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(adminEndpoints)('should return 403 Forbidden for a regular user trying to access $method $path', async ({ action }) => {
|
||||||
|
// Act: Attempt to access the admin endpoint with the regular user's token
|
||||||
|
const response = await action(regularUserAuthToken);
|
||||||
|
|
||||||
|
// Assert: The request should be forbidden
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
// src/tests/e2e/admin-dashboard.e2e.test.ts
|
// src/tests/e2e/admin-dashboard.e2e.test.ts
|
||||||
import { describe, it, expect, afterAll } from 'vitest';
|
import { describe, it, expect, afterAll } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import app from '../../../server';
|
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('E2E Admin Dashboard Flow', () => {
|
describe('E2E Admin Dashboard Flow', () => {
|
||||||
// Use a unique email for every run to avoid collisions
|
// Use a unique email for every run to avoid collisions
|
||||||
const uniqueId = Date.now();
|
const uniqueId = Date.now();
|
||||||
@@ -21,25 +20,18 @@ describe('E2E Admin Dashboard Flow', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way.
|
// Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way.
|
||||||
if (adminUserId) {
|
await cleanupDb({
|
||||||
try {
|
userIds: [adminUserId],
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUserId]);
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error('Error cleaning up E2E admin user:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow an admin to log in and access dashboard features', async () => {
|
it('should allow an admin to log in and access dashboard features', async () => {
|
||||||
// 1. Register a new user (initially a regular user)
|
// 1. Register a new user (initially a regular user)
|
||||||
const registerResponse = await request.post('/api/auth/register').send({
|
const registerResponse = await apiClient.registerUser(adminEmail, adminPassword, 'E2E Admin User');
|
||||||
email: adminEmail,
|
|
||||||
password: adminPassword,
|
|
||||||
full_name: 'E2E Admin User',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(registerResponse.status).toBe(201);
|
expect(registerResponse.status).toBe(201);
|
||||||
const registeredUser = registerResponse.body.userprofile.user;
|
const registerData = await registerResponse.json();
|
||||||
|
const registeredUser = registerData.userprofile.user;
|
||||||
adminUserId = registeredUser.user_id;
|
adminUserId = registeredUser.user_id;
|
||||||
expect(adminUserId).toBeDefined();
|
expect(adminUserId).toBeDefined();
|
||||||
|
|
||||||
@@ -50,46 +42,55 @@ describe('E2E Admin Dashboard Flow', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// 3. Login to get the access token (now with admin privileges)
|
// 3. Login to get the access token (now with admin privileges)
|
||||||
const loginResponse = await request.post('/api/auth/login').send({
|
// We poll because the direct DB write above runs in a separate transaction
|
||||||
email: adminEmail,
|
// from the login API call. Due to PostgreSQL's `Read Committed` transaction
|
||||||
password: adminPassword,
|
// isolation, the API might read the user's role before the test's update
|
||||||
});
|
// transaction is fully committed and visible. Polling makes the test resilient to this race condition.
|
||||||
|
const { response: loginResponse, data: loginData } = await poll(
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.loginUser(adminEmail, adminPassword, false);
|
||||||
|
// Clone to read body without consuming the original response stream
|
||||||
|
const data = response.ok ? await response.clone().json() : {};
|
||||||
|
return { response, data };
|
||||||
|
},
|
||||||
|
(result) => result.response.ok && result.data?.userprofile?.role === 'admin',
|
||||||
|
{ timeout: 10000, interval: 1000, description: 'user login with admin role' },
|
||||||
|
);
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(200);
|
expect(loginResponse.status).toBe(200);
|
||||||
authToken = loginResponse.body.token;
|
authToken = loginData.token;
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
// Verify the role returned in the login response is now 'admin'
|
// Verify the role returned in the login response is now 'admin'
|
||||||
expect(loginResponse.body.userprofile.role).toBe('admin');
|
expect(loginData.userprofile.role).toBe('admin');
|
||||||
|
|
||||||
// 4. Fetch System Stats (Protected Admin Route)
|
// 4. Fetch System Stats (Protected Admin Route)
|
||||||
const statsResponse = await request
|
const statsResponse = await apiClient.getApplicationStats(authToken);
|
||||||
.get('/api/admin/stats')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
|
||||||
|
|
||||||
expect(statsResponse.status).toBe(200);
|
expect(statsResponse.status).toBe(200);
|
||||||
expect(statsResponse.body).toHaveProperty('userCount');
|
const statsData = await statsResponse.json();
|
||||||
expect(statsResponse.body).toHaveProperty('flyerCount');
|
expect(statsData).toHaveProperty('userCount');
|
||||||
|
expect(statsData).toHaveProperty('flyerCount');
|
||||||
|
|
||||||
// 5. Fetch User List (Protected Admin Route)
|
// 5. Fetch User List (Protected Admin Route)
|
||||||
const usersResponse = await request
|
const usersResponse = await apiClient.authedGet('/admin/users', { tokenOverride: authToken });
|
||||||
.get('/api/admin/users')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
|
||||||
|
|
||||||
expect(usersResponse.status).toBe(200);
|
expect(usersResponse.status).toBe(200);
|
||||||
expect(Array.isArray(usersResponse.body)).toBe(true);
|
const usersData = await usersResponse.json();
|
||||||
|
expect(Array.isArray(usersData)).toBe(true);
|
||||||
// The list should contain the admin user we just created
|
// The list should contain the admin user we just created
|
||||||
const self = usersResponse.body.find((u: any) => u.user_id === adminUserId);
|
const self = usersData.find((u: any) => u.user_id === adminUserId);
|
||||||
expect(self).toBeDefined();
|
expect(self).toBeDefined();
|
||||||
|
|
||||||
// 6. Check Queue Status (Protected Admin Route)
|
// 6. Check Queue Status (Protected Admin Route)
|
||||||
const queueResponse = await request
|
const queueResponse = await apiClient.authedGet('/admin/queues/status', {
|
||||||
.get('/api/admin/queues/status')
|
tokenOverride: authToken,
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
});
|
||||||
|
|
||||||
expect(queueResponse.status).toBe(200);
|
expect(queueResponse.status).toBe(200);
|
||||||
expect(Array.isArray(queueResponse.body)).toBe(true);
|
const queueData = await queueResponse.json();
|
||||||
|
expect(Array.isArray(queueData)).toBe(true);
|
||||||
// Verify that the 'flyer-processing' queue is present in the status report
|
// Verify that the 'flyer-processing' queue is present in the status report
|
||||||
const flyerQueue = queueResponse.body.find((q: any) => q.name === 'flyer-processing');
|
const flyerQueue = queueData.find((q: any) => q.name === 'flyer-processing');
|
||||||
expect(flyerQueue).toBeDefined();
|
expect(flyerQueue).toBeDefined();
|
||||||
expect(flyerQueue.counts).toBeDefined();
|
expect(flyerQueue.counts).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
|
|
||||||
@@ -11,15 +12,17 @@ import type { UserProfile } from '../../types';
|
|||||||
|
|
||||||
describe('Authentication E2E Flow', () => {
|
describe('Authentication E2E Flow', () => {
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
|
let testUserAuthToken: string;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Create a user that can be used for login-related tests in this suite.
|
// Create a user that can be used for login-related tests in this suite.
|
||||||
try {
|
try {
|
||||||
const { user } = await createAndLoginUser({
|
const { user, token } = await createAndLoginUser({
|
||||||
email: `e2e-login-user-${Date.now()}@example.com`,
|
email: `e2e-login-user-${Date.now()}@example.com`,
|
||||||
fullName: 'E2E Login User',
|
fullName: 'E2E Login User',
|
||||||
});
|
});
|
||||||
|
testUserAuthToken = token;
|
||||||
testUser = user;
|
testUser = user;
|
||||||
createdUserIds.push(user.user.user_id);
|
createdUserIds.push(user.user.user_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -118,12 +121,8 @@ describe('Authentication E2E Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to access a protected route after logging in', async () => {
|
it('should be able to access a protected route after logging in', async () => {
|
||||||
// Arrange: Log in to get a token
|
// Arrange: Use the token from the beforeAll hook
|
||||||
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
|
const token = testUserAuthToken;
|
||||||
const loginData = await loginResponse.json();
|
|
||||||
const token = loginData.token;
|
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(200);
|
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
|
|
||||||
// Act: Use the token to access a protected route
|
// Act: Use the token to access a protected route
|
||||||
@@ -139,11 +138,9 @@ describe('Authentication E2E Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow an authenticated user to update their profile', async () => {
|
it('should allow an authenticated user to update their profile', async () => {
|
||||||
// Arrange: Log in to get a token
|
// Arrange: Use the token from the beforeAll hook
|
||||||
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
|
const token = testUserAuthToken;
|
||||||
const loginData = await loginResponse.json();
|
expect(token).toBeDefined();
|
||||||
const token = loginData.token;
|
|
||||||
expect(loginResponse.status).toBe(200);
|
|
||||||
|
|
||||||
const profileUpdates = {
|
const profileUpdates = {
|
||||||
full_name: 'E2E Updated Name',
|
full_name: 'E2E Updated Name',
|
||||||
@@ -178,34 +175,26 @@ describe('Authentication E2E Flow', () => {
|
|||||||
expect(registerResponse.status).toBe(201);
|
expect(registerResponse.status).toBe(201);
|
||||||
createdUserIds.push(registerData.userprofile.user.user_id);
|
createdUserIds.push(registerData.userprofile.user.user_id);
|
||||||
|
|
||||||
// Instead of a fixed delay, poll by attempting to log in. This is more robust
|
// Poll until the user can log in, confirming the record has propagated.
|
||||||
// and confirms the user record is committed and readable by subsequent transactions.
|
await poll(
|
||||||
let loginSuccess = false;
|
() => apiClient.loginUser(email, TEST_PASSWORD, false),
|
||||||
for (let i = 0; i < 10; i++) {
|
(response) => response.ok,
|
||||||
// Poll for up to 10 seconds
|
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
|
||||||
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
);
|
||||||
if (loginResponse.ok) {
|
|
||||||
loginSuccess = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
expect(loginSuccess, 'User should be able to log in after registration. DB might be lagging.').toBe(true);
|
|
||||||
|
|
||||||
// Act 1: Request a password reset
|
// Poll for the password reset token.
|
||||||
const forgotResponse = await apiClient.requestPasswordReset(email);
|
const { response: forgotResponse, token: resetToken } = await poll(
|
||||||
const forgotData = await forgotResponse.json();
|
async () => {
|
||||||
const resetToken = forgotData.token;
|
const response = await apiClient.requestPasswordReset(email);
|
||||||
|
// Clone to read body without consuming the original response stream
|
||||||
// --- DEBUG SECTION FOR FAILURE ---
|
const data = response.ok ? await response.clone().json() : {};
|
||||||
if (!resetToken) {
|
return { response, token: data.token };
|
||||||
console.error(' [DEBUG FAILURE] Token missing in response:', JSON.stringify(forgotData, null, 2));
|
},
|
||||||
console.error(' [DEBUG FAILURE] This usually means the backend hit a DB error or is not in NODE_ENV=test mode.');
|
(result) => !!result.token,
|
||||||
}
|
{ timeout: 10000, interval: 1000, description: 'password reset token generation' },
|
||||||
// ---------------------------------
|
);
|
||||||
|
|
||||||
// Assert 1: Check that we received a token.
|
// Assert 1: Check that we received a token.
|
||||||
expect(forgotResponse.status).toBe(200);
|
|
||||||
expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
|
expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
|
||||||
expect(resetToken).toBeTypeOf('string');
|
expect(resetToken).toBeTypeOf('string');
|
||||||
|
|
||||||
@@ -236,4 +225,47 @@ describe('Authentication E2E Flow', () => {
|
|||||||
expect(data.token).toBeUndefined();
|
expect(data.token).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Token Refresh Flow', () => {
|
||||||
|
it('should allow an authenticated user to refresh their access token and use it', async () => {
|
||||||
|
// 1. Log in to get the refresh token cookie and an initial access token.
|
||||||
|
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
|
||||||
|
expect(loginResponse.status).toBe(200);
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
const initialAccessToken = loginData.token;
|
||||||
|
|
||||||
|
// 2. Extract the refresh token from the 'set-cookie' header.
|
||||||
|
const setCookieHeader = loginResponse.headers.get('set-cookie');
|
||||||
|
expect(setCookieHeader, 'Set-Cookie header should be present in login response').toBeDefined();
|
||||||
|
// A typical Set-Cookie header might be 'refreshToken=...; Path=/; HttpOnly; Max-Age=...'. We just need the 'refreshToken=...' part.
|
||||||
|
const refreshTokenCookie = setCookieHeader!.split(';')[0];
|
||||||
|
|
||||||
|
// 3. Call the refresh token endpoint, passing the cookie.
|
||||||
|
// This assumes a new method in apiClient to handle this specific request.
|
||||||
|
const refreshResponse = await apiClient.refreshToken(refreshTokenCookie);
|
||||||
|
|
||||||
|
// 4. Assert the refresh was successful and we got a new token.
|
||||||
|
expect(refreshResponse.status).toBe(200);
|
||||||
|
const refreshData = await refreshResponse.json();
|
||||||
|
const newAccessToken = refreshData.token;
|
||||||
|
expect(newAccessToken).toBeDefined();
|
||||||
|
expect(newAccessToken).not.toBe(initialAccessToken);
|
||||||
|
|
||||||
|
// 5. Use the new access token to access a protected route.
|
||||||
|
const profileResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: newAccessToken });
|
||||||
|
expect(profileResponse.status).toBe(200);
|
||||||
|
const profileData = await profileResponse.json();
|
||||||
|
expect(profileData.user.user_id).toBe(testUser.user.user_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to refresh with an invalid or missing token', async () => {
|
||||||
|
// Case 1: No cookie provided. This assumes refreshToken can handle an empty string.
|
||||||
|
const noCookieResponse = await apiClient.refreshToken('');
|
||||||
|
expect(noCookieResponse.status).toBe(401);
|
||||||
|
|
||||||
|
// Case 2: Invalid cookie provided
|
||||||
|
const invalidCookieResponse = await apiClient.refreshToken('refreshToken=invalid-garbage-token');
|
||||||
|
expect(invalidCookieResponse.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
// src/tests/e2e/flyer-upload.e2e.test.ts
|
// src/tests/e2e/flyer-upload.e2e.test.ts
|
||||||
import { describe, it, expect, afterAll } from 'vitest';
|
import { describe, it, expect, afterAll } from 'vitest';
|
||||||
import supertest from 'supertest';
|
|
||||||
import app from '../../../server';
|
|
||||||
import { getPool } from '../../services/db/connection.db';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import * as apiClient from '../../services/apiClient';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('E2E Flyer Upload and Processing Workflow', () => {
|
describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||||
const uniqueId = Date.now();
|
const uniqueId = Date.now();
|
||||||
const userEmail = `e2e-uploader-${uniqueId}@example.com`;
|
const userEmail = `e2e-uploader-${uniqueId}@example.com`;
|
||||||
@@ -23,33 +21,24 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
|||||||
let flyerId: number | null = null;
|
let flyerId: number | null = null;
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Cleanup: Delete the flyer and user created during the test
|
// Use the centralized cleanup utility for robustness.
|
||||||
const pool = getPool();
|
await cleanupDb({
|
||||||
if (flyerId) {
|
userIds: [userId],
|
||||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
|
flyerIds: [flyerId],
|
||||||
}
|
});
|
||||||
if (userId) {
|
|
||||||
await pool.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a user to upload a flyer and wait for processing to complete', async () => {
|
it('should allow a user to upload a flyer and wait for processing to complete', async () => {
|
||||||
// 1. Register a new user
|
// 1. Register a new user
|
||||||
const registerResponse = await request.post('/api/auth/register').send({
|
const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'E2E Flyer Uploader');
|
||||||
email: userEmail,
|
|
||||||
password: userPassword,
|
|
||||||
full_name: 'E2E Flyer Uploader',
|
|
||||||
});
|
|
||||||
expect(registerResponse.status).toBe(201);
|
expect(registerResponse.status).toBe(201);
|
||||||
|
|
||||||
// 2. Login to get the access token
|
// 2. Login to get the access token
|
||||||
const loginResponse = await request.post('/api/auth/login').send({
|
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
||||||
email: userEmail,
|
|
||||||
password: userPassword,
|
|
||||||
});
|
|
||||||
expect(loginResponse.status).toBe(200);
|
expect(loginResponse.status).toBe(200);
|
||||||
authToken = loginResponse.body.token;
|
const loginData = await loginResponse.json();
|
||||||
userId = loginResponse.body.userprofile.user.user_id;
|
authToken = loginData.token;
|
||||||
|
userId = loginData.userprofile.user.user_id;
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
|
|
||||||
// 3. Prepare the flyer file
|
// 3. Prepare the flyer file
|
||||||
@@ -73,34 +62,37 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a File object for the apiClient
|
||||||
|
// FIX: The Node.js `Buffer` type can be incompatible with the web `File` API's
|
||||||
|
// expected `BlobPart` type in some TypeScript configurations. Explicitly creating
|
||||||
|
// a `Uint8Array` from the buffer ensures compatibility and resolves the type error.
|
||||||
|
// `Uint8Array` is a valid `BufferSource`, which is a valid `BlobPart`.
|
||||||
|
const flyerFile = new File([new Uint8Array(fileBuffer)], fileName, { type: 'image/jpeg' });
|
||||||
|
|
||||||
// Calculate checksum (required by the API)
|
// Calculate checksum (required by the API)
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
// 4. Upload the flyer
|
// 4. Upload the flyer
|
||||||
const uploadResponse = await request
|
const uploadResponse = await apiClient.uploadAndProcessFlyer(flyerFile, checksum, authToken);
|
||||||
.post('/api/ai/upload-and-process')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
.field('checksum', checksum)
|
|
||||||
.attach('flyerFile', fileBuffer, fileName);
|
|
||||||
|
|
||||||
expect(uploadResponse.status).toBe(202);
|
expect(uploadResponse.status).toBe(202);
|
||||||
const jobId = uploadResponse.body.jobId;
|
const uploadData = await uploadResponse.json();
|
||||||
|
const jobId = uploadData.jobId;
|
||||||
expect(jobId).toBeDefined();
|
expect(jobId).toBeDefined();
|
||||||
|
|
||||||
// 5. Poll for job completion
|
// 5. Poll for job completion using the new utility
|
||||||
let jobStatus;
|
const jobStatus = await poll(
|
||||||
const maxRetries = 60; // Poll for up to 180 seconds
|
async () => {
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
const statusResponse = await apiClient.getJobStatus(jobId, authToken);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3s
|
return statusResponse.json();
|
||||||
|
},
|
||||||
const statusResponse = await request
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
.get(`/api/ai/jobs/${jobId}/status`)
|
{ timeout: 180000, interval: 3000, description: 'flyer processing job completion' },
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
);
|
||||||
|
|
||||||
jobStatus = statusResponse.body;
|
if (jobStatus.state === 'failed') {
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
// Log the failure reason for easier debugging in CI/CD environments.
|
||||||
break;
|
console.error('E2E flyer processing job failed. Reason:', jobStatus.failedReason);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(jobStatus.state).toBe('completed');
|
expect(jobStatus.state).toBe('completed');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user