Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce5f481a8 | ||
|
|
e0120d38fd | ||
| 6b2079ef2c | |||
|
|
0478e176d5 | ||
| 47f7f97cd9 | |||
|
|
b0719d1e39 | ||
| 0039ac3752 | |||
|
|
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 |
@@ -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.23",
|
"version": "0.9.41",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.23",
|
"version": "0.9.41",
|
||||||
"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.23",
|
"version": "0.9.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS public.profiles (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
||||||
CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
|
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
||||||
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
||||||
-- This index is crucial for the gamification leaderboard feature.
|
-- This index is crucial for the gamification leaderboard feature.
|
||||||
@@ -108,9 +108,9 @@ CREATE TABLE IF NOT EXISTS public.stores (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
||||||
CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
|
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
||||||
|
|
||||||
-- 5. The 'categories' table for normalized category data.
|
-- 5. The 'categories' table for normalized category data.
|
||||||
@@ -141,10 +141,10 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
||||||
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
||||||
CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https://?.*'),
|
|
||||||
CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
|
|
||||||
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https://?.*'),
|
||||||
|
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url IS NULL OR icon_url ~* '^https://?.*'),
|
||||||
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
||||||
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
||||||
@@ -198,9 +198,9 @@ CREATE TABLE IF NOT EXISTS public.brands (
|
|||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT brands_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT brands_name_check CHECK (TRIM(name) <> '')
|
||||||
CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
@@ -464,9 +464,9 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
@@ -521,9 +521,9 @@ CREATE TABLE IF NOT EXISTS public.recipes (
|
|||||||
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> '')
|
||||||
CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
||||||
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
||||||
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
||||||
@@ -920,9 +920,9 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
raw_text TEXT,
|
raw_text TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ CREATE TABLE IF NOT EXISTS public.profiles (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
CONSTRAINT profiles_full_name_check CHECK (full_name IS NULL OR TRIM(full_name) <> ''),
|
||||||
CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https?://.*'),
|
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL,
|
||||||
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT profiles_avatar_url_check CHECK (avatar_url IS NULL OR avatar_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
|
||||||
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
|
||||||
-- This index is crucial for the gamification leaderboard feature.
|
-- This index is crucial for the gamification leaderboard feature.
|
||||||
@@ -124,9 +124,9 @@ CREATE TABLE IF NOT EXISTS public.stores (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT stores_name_check CHECK (TRIM(name) <> ''),
|
||||||
CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*'),
|
|
||||||
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT stores_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
|
||||||
|
|
||||||
-- 5. The 'categories' table for normalized category data.
|
-- 5. The 'categories' table for normalized category data.
|
||||||
@@ -157,10 +157,10 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
CONSTRAINT flyers_valid_dates_check CHECK (valid_to >= valid_from),
|
||||||
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
CONSTRAINT flyers_file_name_check CHECK (TRIM(file_name) <> ''),
|
||||||
CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https?://.*'),
|
|
||||||
CONSTRAINT flyers_icon_url_check CHECK (icon_url ~* '^https?://.*'),
|
|
||||||
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
CONSTRAINT flyers_checksum_check CHECK (checksum IS NULL OR length(checksum) = 64)
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT flyers_image_url_check CHECK (image_url ~* '^https?://.*'),
|
||||||
|
-- CONSTRAINT flyers_icon_url_check CHECK (icon_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
CREATE INDEX IF NOT EXISTS idx_flyers_store_id ON public.flyers(store_id);
|
||||||
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
COMMENT ON COLUMN public.flyers.file_name IS 'The original name of the uploaded flyer file (e.g., "flyer_week_1.pdf").';
|
||||||
@@ -214,9 +214,9 @@ CREATE TABLE IF NOT EXISTS public.brands (
|
|||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT brands_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT brands_name_check CHECK (TRIM(name) <> '')
|
||||||
CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT brands_logo_url_check CHECK (logo_url IS NULL OR logo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
@@ -481,9 +481,9 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
downvotes INTEGER DEFAULT 0 NOT NULL CHECK (downvotes >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT user_submitted_prices_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
|
||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
@@ -538,9 +538,9 @@ CREATE TABLE IF NOT EXISTS public.recipes (
|
|||||||
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
fork_count INTEGER DEFAULT 0 NOT NULL CHECK (fork_count >= 0),
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> ''),
|
CONSTRAINT recipes_name_check CHECK (TRIM(name) <> '')
|
||||||
CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT recipes_photo_url_check CHECK (photo_url IS NULL OR photo_url ~* '^https?://.*')
|
||||||
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
|
||||||
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
COMMENT ON COLUMN public.recipes.servings IS 'The number of servings this recipe yields.';
|
||||||
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
COMMENT ON COLUMN public.recipes.original_recipe_id IS 'If this recipe is a variation of another, this points to the original.';
|
||||||
@@ -940,9 +940,9 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
raw_text TEXT,
|
raw_text TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||||
|
|||||||
@@ -628,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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -638,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 () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
||||||
@@ -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',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
@@ -359,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) => {
|
||||||
|
|||||||
@@ -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,14 +59,14 @@ 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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -586,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),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -165,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, flyerId: '123' },
|
{ error: dbError, flyerId: 123 },
|
||||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -310,4 +310,55 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,7 +53,7 @@ const trackItemSchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* GET /api/flyers - Get a paginated list of all flyers.
|
* GET /api/flyers - Get a paginated list of all flyers.
|
||||||
*/
|
*/
|
||||||
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// The `validateRequest` middleware ensures `req.query` is valid.
|
// The `validateRequest` middleware ensures `req.query` is valid.
|
||||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||||
@@ -65,7 +70,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Explicitly parse to get the coerced number type for `id`.
|
// Explicitly parse to get the coerced number type for `id`.
|
||||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||||
@@ -82,6 +87,7 @@ 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> => {
|
||||||
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
||||||
@@ -103,6 +109,7 @@ 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;
|
||||||
@@ -124,6 +131,7 @@ 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;
|
||||||
@@ -142,7 +150,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res, next): void => {
|
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
|
||||||
try {
|
try {
|
||||||
// Explicitly parse to get coerced types.
|
// Explicitly parse to get coerced types.
|
||||||
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -468,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({
|
||||||
@@ -481,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
|
||||||
|
|
||||||
@@ -496,11 +494,38 @@ 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 for various invalid user object shapes', () => {
|
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 mockNext = vi.fn();
|
||||||
const mockRes: Partial<Response> = {
|
const mockRes: Partial<Response> = {
|
||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
@@ -510,29 +535,29 @@ describe('Passport Configuration', () => {
|
|||||||
// Case 1: user is not an object (e.g., a string)
|
// Case 1: user is not an object (e.g., a string)
|
||||||
const req1 = { user: 'not-an-object' } as unknown as Request;
|
const req1 = { user: 'not-an-object' } as unknown as Request;
|
||||||
isAdmin(req1, mockRes as Response, mockNext);
|
isAdmin(req1, mockRes as Response, mockNext);
|
||||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Case 2: user is null
|
// Case 2: user is null
|
||||||
const req2 = { user: null } as unknown as Request;
|
const req2 = { user: null } as unknown as Request;
|
||||||
isAdmin(req2, mockRes as Response, mockNext);
|
isAdmin(req2, mockRes as Response, mockNext);
|
||||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Case 3: user object is missing 'user' property
|
// Case 3: user object is missing 'user' property
|
||||||
const req3 = { user: { role: 'admin' } } as unknown as Request;
|
const req3 = { user: { role: 'admin' } } as unknown as Request;
|
||||||
isAdmin(req3, mockRes as Response, mockNext);
|
isAdmin(req3, mockRes as Response, mockNext);
|
||||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Case 4: user.user is not an object
|
// Case 4: user.user is not an object
|
||||||
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
|
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
|
||||||
isAdmin(req4, mockRes as Response, mockNext);
|
isAdmin(req4, mockRes as Response, mockNext);
|
||||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Case 5: user.user is missing 'user_id'
|
// Case 5: user.user is missing 'user_id'
|
||||||
@@ -540,15 +565,15 @@ describe('Passport Configuration', () => {
|
|||||||
user: { role: 'admin', user: { email: 'test@test.com' } },
|
user: { role: 'admin', user: { email: 'test@test.com' } },
|
||||||
} as unknown as Request;
|
} as unknown as Request;
|
||||||
isAdmin(req5, mockRes as Response, mockNext);
|
isAdmin(req5, mockRes as Response, mockNext);
|
||||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Reset the main mockNext for other tests in the suite
|
// Reset the main mockNext for other tests in the suite
|
||||||
mockNext.mockClear();
|
mockNext.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 Forbidden if req.user is not a valid UserProfile object', () => {
|
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')
|
||||||
@@ -561,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.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -1235,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`);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ 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', () => ({
|
vi.mock('./db/admin.db', () => ({
|
||||||
@@ -93,8 +94,8 @@ vi.mock('./db/admin.db', () => ({
|
|||||||
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 { withTransaction } from './db/index.db';
|
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
|
||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
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'.
|
||||||
@@ -115,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
|
||||||
@@ -808,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.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1012,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');
|
||||||
});
|
});
|
||||||
@@ -1034,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',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1052,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: {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ 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 { 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
|
||||||
@@ -73,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) {
|
||||||
@@ -215,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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -369,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
|
||||||
@@ -432,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,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);
|
||||||
@@ -794,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', {
|
||||||
@@ -818,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 {
|
||||||
@@ -826,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.
|
||||||
@@ -841,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 };
|
||||||
}
|
}
|
||||||
@@ -858,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.');
|
||||||
}
|
}
|
||||||
@@ -896,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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ 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
|
||||||
@@ -134,7 +134,6 @@ describe('AuthService', () => {
|
|||||||
'hashed-password',
|
'hashed-password',
|
||||||
{ full_name: 'Test User', avatar_url: undefined },
|
{ full_name: 'Test User', avatar_url: undefined },
|
||||||
reqLog,
|
reqLog,
|
||||||
{},
|
|
||||||
);
|
);
|
||||||
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class AuthService {
|
|||||||
hashedPassword,
|
hashedPassword,
|
||||||
{ full_name: fullName, avatar_url: avatarUrl },
|
{ full_name: fullName, avatar_url: avatarUrl },
|
||||||
reqLog,
|
reqLog,
|
||||||
client, // Pass the transactional client
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
ForeignKeyConstraintError,
|
ForeignKeyConstraintError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
ForbiddenError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
FileUploadError,
|
FileUploadError,
|
||||||
NotNullConstraintError,
|
NotNullConstraintError,
|
||||||
@@ -89,6 +90,25 @@ 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' }];
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ export class NotFoundError extends RepositoryError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -132,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',
|
||||||
@@ -155,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',
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -282,6 +282,95 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -74,60 +132,18 @@ export class UserRepository {
|
|||||||
passwordHash: string | null,
|
passwordHash: string | null,
|
||||||
profileData: { full_name?: string; avatar_url?: string },
|
profileData: { full_name?: string; avatar_url?: string },
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
// Allow passing a transactional client
|
|
||||||
client: Pool | PoolClient = this.db,
|
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
|
// This method is now a wrapper that ensures the core logic runs within a transaction.
|
||||||
try {
|
try {
|
||||||
logger.debug(`[DB createUser] Starting user creation for email: ${email}`);
|
// If this.db has a 'connect' method, it's a Pool. We must start a transaction.
|
||||||
|
if ('connect' in this.db) {
|
||||||
// Use 'set_config' to safely pass parameters to a configuration variable.
|
return await withTransaction(async (client) => {
|
||||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
return this._createUser(client, email, passwordHash, profileData, logger);
|
||||||
JSON.stringify(profileData),
|
});
|
||||||
]);
|
} else {
|
||||||
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
|
// If this.db is already a PoolClient, we're inside a transaction. Use it directly.
|
||||||
|
return await this._createUser(this.db as PoolClient, email, passwordHash, profileData, logger);
|
||||||
// Insert the new user into the 'users' table. This will fire the trigger.
|
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the nested UserProfile object to match the type definition.
|
|
||||||
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) {
|
} catch (error) {
|
||||||
handleDbError(error, logger, 'Error during createUser', { email }, {
|
handleDbError(error, logger, 'Error during createUser', { email }, {
|
||||||
uniqueMessage: 'A user with this email address already exists.',
|
uniqueMessage: 'A user with this email address already exists.',
|
||||||
@@ -136,6 +152,7 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +66,17 @@ 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';
|
||||||
const baseUrl = 'http://test.host';
|
const baseUrl = 'https://example.com';
|
||||||
|
|
||||||
// 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 = ''; // Explicitly pass '' 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`);
|
||||||
@@ -315,13 +312,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,
|
||||||
@@ -353,13 +350,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,
|
||||||
@@ -391,13 +388,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,
|
||||||
@@ -432,13 +429,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,
|
||||||
@@ -469,13 +466,13 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: false, // Key part of this test
|
needsReview: false, // Key part of this test
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const { flyerData } = await transformer.transform(
|
const { flyerData } = 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,
|
||||||
@@ -498,13 +495,13 @@ describe('FlyerDataTransformer', () => {
|
|||||||
},
|
},
|
||||||
needsReview: true, // Key part of this test
|
needsReview: true, // Key part of this test
|
||||||
};
|
};
|
||||||
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const { flyerData } = await transformer.transform(
|
const { flyerData } = 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,
|
||||||
|
|||||||
@@ -5,9 +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 { 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
|
||||||
@@ -48,44 +48,25 @@ export class FlyerDataTransformer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a 64x64 icon for the flyer's first page.
|
|
||||||
* @param firstImage The path to the first image of the flyer.
|
|
||||||
* @param logger The logger instance.
|
|
||||||
* @returns The filename of the generated icon.
|
|
||||||
*/
|
|
||||||
private async _generateIcon(firstImage: string, logger: Logger): Promise<string> {
|
|
||||||
const iconFileName = await generateFlyerIcon(
|
|
||||||
firstImage,
|
|
||||||
path.join(path.dirname(firstImage), 'icons'),
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
return iconFileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs the full public URLs for the flyer image and its icon.
|
* Constructs the full public URLs for the flyer image and its icon.
|
||||||
* @param firstImage The path to the first image of the flyer.
|
* @param imageFileName The filename of the main processed image.
|
||||||
* @param iconFileName The filename of the generated icon.
|
* @param iconFileName The filename of the generated icon.
|
||||||
* @param baseUrl The base URL from the job payload.
|
* @param baseUrl The base URL from the job payload.
|
||||||
* @param logger The logger instance.
|
* @param logger The logger instance.
|
||||||
* @returns An object containing the full image_url and icon_url.
|
* @returns An object containing the full image_url and icon_url.
|
||||||
*/
|
*/
|
||||||
private _buildUrls(
|
private _buildUrls(
|
||||||
firstImage: string,
|
imageFileName: string,
|
||||||
iconFileName: string,
|
iconFileName: string,
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): { imageUrl: string; iconUrl: string } {
|
): { imageUrl: string; iconUrl: string } {
|
||||||
let finalBaseUrl = baseUrl;
|
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
|
||||||
if (!finalBaseUrl) {
|
const finalBaseUrl = baseUrl || getBaseUrl(logger);
|
||||||
const port = process.env.PORT || 3000;
|
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
|
||||||
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 imageUrl = `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`;
|
|
||||||
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||||
|
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');
|
||||||
return { imageUrl, iconUrl };
|
return { imageUrl, iconUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,21 +82,20 @@ 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,
|
||||||
baseUrl?: string,
|
baseUrl: string,
|
||||||
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
|
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
|
||||||
logger.info('Starting data transformation from AI output to database format.');
|
logger.info('Starting data transformation from AI output to database format.');
|
||||||
|
|
||||||
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 this._generateIcon(firstImage, logger);
|
|
||||||
const { imageUrl, iconUrl } = this._buildUrls(firstImage, iconFileName, baseUrl, logger);
|
|
||||||
|
|
||||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,14 @@ 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: {
|
||||||
@@ -98,8 +104,8 @@ describe('FlyerProcessingService', () => {
|
|||||||
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',
|
||||||
@@ -163,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: [],
|
||||||
@@ -172,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 {
|
||||||
@@ -180,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(),
|
||||||
@@ -203,19 +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);
|
||||||
// Verify that the transaction function was called.
|
|
||||||
|
// 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(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
||||||
// Verify that the functions inside the transaction were called.
|
|
||||||
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.mockAdminLogActivity).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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -226,9 +270,13 @@ 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);
|
||||||
|
|
||||||
@@ -237,15 +285,18 @@ describe('FlyerProcessingService', () => {
|
|||||||
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),
|
||||||
@@ -378,6 +429,7 @@ 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);
|
||||||
|
|
||||||
@@ -385,9 +437,18 @@ describe('FlyerProcessingService', () => {
|
|||||||
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
|
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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -444,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 transformationError = new TransformationError('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(transformationError);
|
|
||||||
|
|
||||||
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(transformationError, 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.',
|
||||||
@@ -633,5 +691,30 @@ describe('FlyerProcessingService', () => {
|
|||||||
'Job received no paths and could not derive any from the database. Skipping.',
|
'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,6 +1,5 @@
|
|||||||
// 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 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';
|
||||||
@@ -18,7 +17,8 @@ import {
|
|||||||
} from './processingErrors';
|
} from './processingErrors';
|
||||||
import { NotFoundError } from './db/errors.db';
|
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 = {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ValidationError, NotFoundError } from './db/errors.db';
|
|||||||
import { DatabaseError } from './processingErrors';
|
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`.
|
||||||
@@ -240,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);
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
// src/tests/e2e/user-journey.e2e.test.ts
|
// src/tests/e2e/user-journey.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 { cleanupDb } from '../utils/cleanup';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { poll } from '../utils/poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('E2E User Journey', () => {
|
describe('E2E User Journey', () => {
|
||||||
// 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();
|
||||||
@@ -23,65 +21,64 @@ describe('E2E User Journey', () => {
|
|||||||
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 the test succeeds, the user deletes their own account, so this acts as a fallback.
|
// If the test succeeds, the user deletes their own account, so this acts as a fallback.
|
||||||
if (userId) {
|
await cleanupDb({
|
||||||
try {
|
userIds: [userId],
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]);
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error('Error cleaning up E2E test user:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', async () => {
|
it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', 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 Traveler');
|
||||||
email: userEmail,
|
|
||||||
password: userPassword,
|
|
||||||
full_name: 'E2E Traveler',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(registerResponse.status).toBe(201);
|
expect(registerResponse.status).toBe(201);
|
||||||
expect(registerResponse.body.message).toBe('User registered successfully!');
|
const registerData = await registerResponse.json();
|
||||||
|
expect(registerData.message).toBe('User registered successfully!');
|
||||||
|
|
||||||
// 2. Login to get the access token
|
// 2. Login to get the access token.
|
||||||
const loginResponse = await request.post('/api/auth/login').send({
|
// We poll here because even between two API calls (register and login),
|
||||||
email: userEmail,
|
// there can be a small delay before the newly created user record is visible
|
||||||
password: userPassword,
|
// to the transaction started by the login request. This prevents flaky test failures.
|
||||||
});
|
const { response: loginResponse, data: loginData } = await poll(
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.loginUser(userEmail, userPassword, false);
|
||||||
|
const data = response.ok ? await response.clone().json() : {};
|
||||||
|
return { response, data };
|
||||||
|
},
|
||||||
|
(result) => result.response.ok,
|
||||||
|
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
|
||||||
|
);
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(200);
|
expect(loginResponse.status).toBe(200);
|
||||||
authToken = loginResponse.body.token;
|
authToken = loginData.token;
|
||||||
userId = loginResponse.body.userprofile.user.user_id;
|
userId = loginData.userprofile.user.user_id;
|
||||||
|
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
expect(userId).toBeDefined();
|
expect(userId).toBeDefined();
|
||||||
|
|
||||||
// 3. Create a Shopping List
|
// 3. Create a Shopping List
|
||||||
const createListResponse = await request
|
const createListResponse = await apiClient.createShoppingList('E2E Party List', authToken);
|
||||||
.post('/api/users/shopping-lists')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
.send({ name: 'E2E Party List' });
|
|
||||||
|
|
||||||
expect(createListResponse.status).toBe(201);
|
expect(createListResponse.status).toBe(201);
|
||||||
shoppingListId = createListResponse.body.shopping_list_id;
|
const createListData = await createListResponse.json();
|
||||||
|
shoppingListId = createListData.shopping_list_id;
|
||||||
expect(shoppingListId).toBeDefined();
|
expect(shoppingListId).toBeDefined();
|
||||||
|
|
||||||
// 4. Add an item to the list
|
// 4. Add an item to the list
|
||||||
const addItemResponse = await request
|
const addItemResponse = await apiClient.addShoppingListItem(
|
||||||
.post(`/api/users/shopping-lists/${shoppingListId}/items`)
|
shoppingListId,
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
{ customItemName: 'Chips' },
|
||||||
.send({ customItemName: 'Chips' });
|
authToken,
|
||||||
|
);
|
||||||
|
|
||||||
expect(addItemResponse.status).toBe(201);
|
expect(addItemResponse.status).toBe(201);
|
||||||
expect(addItemResponse.body.custom_item_name).toBe('Chips');
|
const addItemData = await addItemResponse.json();
|
||||||
|
expect(addItemData.custom_item_name).toBe('Chips');
|
||||||
|
|
||||||
// 5. Verify the list and item exist via GET
|
// 5. Verify the list and item exist via GET
|
||||||
const getListsResponse = await request
|
const getListsResponse = await apiClient.fetchShoppingLists(authToken);
|
||||||
.get('/api/users/shopping-lists')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
|
||||||
|
|
||||||
expect(getListsResponse.status).toBe(200);
|
expect(getListsResponse.status).toBe(200);
|
||||||
const myLists = getListsResponse.body;
|
const myLists = await getListsResponse.json();
|
||||||
const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId);
|
const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId);
|
||||||
|
|
||||||
expect(targetList).toBeDefined();
|
expect(targetList).toBeDefined();
|
||||||
@@ -89,19 +86,16 @@ describe('E2E User Journey', () => {
|
|||||||
expect(targetList.items[0].custom_item_name).toBe('Chips');
|
expect(targetList.items[0].custom_item_name).toBe('Chips');
|
||||||
|
|
||||||
// 6. Delete the User Account (Self-Service)
|
// 6. Delete the User Account (Self-Service)
|
||||||
const deleteAccountResponse = await request
|
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
|
||||||
.delete('/api/users/account')
|
tokenOverride: authToken,
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
});
|
||||||
.send({ password: userPassword });
|
|
||||||
|
|
||||||
expect(deleteAccountResponse.status).toBe(200);
|
expect(deleteAccountResponse.status).toBe(200);
|
||||||
expect(deleteAccountResponse.body.message).toBe('Account deleted successfully.');
|
const deleteData = await deleteAccountResponse.json();
|
||||||
|
expect(deleteData.message).toBe('Account deleted successfully.');
|
||||||
|
|
||||||
// 7. Verify Login is no longer possible
|
// 7. Verify Login is no longer possible
|
||||||
const failLoginResponse = await request.post('/api/auth/login').send({
|
const failLoginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
||||||
email: userEmail,
|
|
||||||
password: userPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(failLoginResponse.status).toBe(401);
|
expect(failLoginResponse.status).toBe(401);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
// src/tests/integration/admin.integration.test.ts
|
// src/tests/integration/admin.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Admin API Routes Integration Tests', () => {
|
describe('Admin API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let adminToken: string;
|
let adminToken: string;
|
||||||
let adminUser: UserProfile;
|
let adminUser: UserProfile;
|
||||||
let regularUser: UserProfile;
|
let regularUser: UserProfile;
|
||||||
@@ -21,6 +20,10 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
const createdStoreIds: number[] = [];
|
const createdStoreIds: number[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Create a fresh admin user and a regular user for this test suite
|
// Create a fresh admin user and a regular user for this test suite
|
||||||
// Using unique emails to prevent test pollution from other integration test files.
|
// Using unique emails to prevent test pollution from other integration test files.
|
||||||
({ user: adminUser, token: adminToken } = await createAndLoginUser({
|
({ user: adminUser, token: adminToken } = await createAndLoginUser({
|
||||||
@@ -40,6 +43,7 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: createdUserIds,
|
userIds: createdUserIds,
|
||||||
storeIds: createdStoreIds,
|
storeIds: createdStoreIds,
|
||||||
@@ -164,7 +168,7 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const flyerRes = await getPool().query(
|
const flyerRes = await getPool().query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
||||||
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 'https://example.com/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
|
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/asdmin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||||
// The checksum must be a unique 64-character string to satisfy the DB constraint.
|
// The checksum must be a unique 64-character string to satisfy the DB constraint.
|
||||||
// We generate a dynamic string and pad it to 64 characters.
|
// We generate a dynamic string and pad it to 64 characters.
|
||||||
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],
|
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/ai.integration.test.ts
|
// src/tests/integration/ai.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
@@ -12,8 +11,6 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
interface TestGeolocationCoordinates {
|
interface TestGeolocationCoordinates {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -26,10 +23,15 @@ interface TestGeolocationCoordinates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AI API Routes Integration Tests', () => {
|
describe('AI API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
let testUserId: string;
|
let testUserId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Create and log in as a new user for authenticated tests.
|
// Create and log in as a new user for authenticated tests.
|
||||||
const { token, user } = await createAndLoginUser({ fullName: 'AI Tester', request });
|
const { token, user } = await createAndLoginUser({ fullName: 'AI Tester', request });
|
||||||
authToken = token;
|
authToken = token;
|
||||||
@@ -37,6 +39,7 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
// 1. Clean up database records
|
// 1. Clean up database records
|
||||||
await cleanupDb({ userIds: [testUserId] });
|
await cleanupDb({ userIds: [testUserId] });
|
||||||
|
|
||||||
@@ -193,4 +196,31 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
.send({ text: 'a test prompt' });
|
.send({ text: 'a test prompt' });
|
||||||
expect(response.status).toBe(501);
|
expect(response.status).toBe(501);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting', () => {
|
||||||
|
it('should block requests to /api/ai/quick-insights after exceeding the limit', async () => {
|
||||||
|
const limit = 20; // Matches aiGenerationLimiter config
|
||||||
|
const items = [{ item: 'test' }];
|
||||||
|
|
||||||
|
// Send requests up to the limit
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const response = await request
|
||||||
|
.post('/api/ai/quick-insights')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ items });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should be blocked
|
||||||
|
const blockedResponse = await request
|
||||||
|
.post('/api/ai/quick-insights')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ items });
|
||||||
|
|
||||||
|
expect(blockedResponse.status).toBe(429);
|
||||||
|
expect(blockedResponse.text).toContain('Too many AI generation requests');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/auth.integration.test.ts
|
// src/tests/integration/auth.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
@@ -10,8 +9,6 @@ import type { UserProfile } from '../../types';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are integration tests that verify the authentication flow against a running backend server.
|
* These are integration tests that verify the authentication flow against a running backend server.
|
||||||
* Make sure your Express server is running before executing these tests.
|
* Make sure your Express server is running before executing these tests.
|
||||||
@@ -19,11 +16,16 @@ const request = supertest(app);
|
|||||||
* To run only these tests: `vitest run src/tests/auth.integration.test.ts`
|
* To run only these tests: `vitest run src/tests/auth.integration.test.ts`
|
||||||
*/
|
*/
|
||||||
describe('Authentication API Integration', () => {
|
describe('Authentication API Integration', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUserEmail: string;
|
let testUserEmail: string;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Use a unique email for this test suite to prevent collisions with other tests.
|
// Use a unique email for this test suite to prevent collisions with other tests.
|
||||||
const email = `auth-integration-test-${Date.now()}@example.com`;
|
const email = `auth-integration-test-${Date.now()}@example.com`;
|
||||||
({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request }));
|
({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request }));
|
||||||
@@ -32,6 +34,7 @@ describe('Authentication API Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({ userIds: createdUserIds });
|
await cleanupDb({ userIds: createdUserIds });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,22 +175,26 @@ describe('Authentication API Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Rate Limiting', () => {
|
describe('Rate Limiting', () => {
|
||||||
// This test requires the `skip: () => isTestEnv` line in the `forgotPasswordLimiter`
|
|
||||||
// configuration within `src/routes/auth.routes.ts` to be commented out or removed.
|
|
||||||
it('should block requests to /forgot-password after exceeding the limit', async () => {
|
it('should block requests to /forgot-password after exceeding the limit', async () => {
|
||||||
const email = testUserEmail; // Use the user created in beforeAll
|
const email = testUserEmail; // Use the user created in beforeAll
|
||||||
const limit = 5; // Based on the configuration in auth.routes.ts
|
const limit = 5; // Based on the configuration in auth.routes.ts
|
||||||
|
|
||||||
// Send requests up to the limit. These should all pass.
|
// Send requests up to the limit. These should all pass.
|
||||||
for (let i = 0; i < limit; i++) {
|
for (let i = 0; i < limit; i++) {
|
||||||
const response = await request.post('/api/auth/forgot-password').send({ email });
|
const response = await request
|
||||||
|
.post('/api/auth/forgot-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ email });
|
||||||
|
|
||||||
// The endpoint returns 200 even for non-existent users to prevent email enumeration.
|
// The endpoint returns 200 even for non-existent users to prevent email enumeration.
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The next request (the 6th one) should be blocked.
|
// The next request (the 6th one) should be blocked.
|
||||||
const blockedResponse = await request.post('/api/auth/forgot-password').send({ email });
|
const blockedResponse = await request
|
||||||
|
.post('/api/auth/forgot-password')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||||
|
.send({ email });
|
||||||
|
|
||||||
expect(blockedResponse.status).toBe(429);
|
expect(blockedResponse.status).toBe(429);
|
||||||
expect(blockedResponse.text).toContain(
|
expect(blockedResponse.text).toContain(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/budget.integration.test.ts
|
// src/tests/integration/budget.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import type { UserProfile, Budget } from '../../types';
|
import type { UserProfile, Budget } from '../../types';
|
||||||
@@ -11,9 +10,8 @@ import { getPool } from '../../services/db/connection.db';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Budget API Routes Integration Tests', () => {
|
describe('Budget API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
let testBudget: Budget;
|
let testBudget: Budget;
|
||||||
@@ -21,6 +19,10 @@ describe('Budget API Routes Integration Tests', () => {
|
|||||||
const createdBudgetIds: number[] = [];
|
const createdBudgetIds: number[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// 1. Create a user for the tests
|
// 1. Create a user for the tests
|
||||||
const { user, token } = await createAndLoginUser({
|
const { user, token } = await createAndLoginUser({
|
||||||
email: `budget-user-${Date.now()}@example.com`,
|
email: `budget-user-${Date.now()}@example.com`,
|
||||||
@@ -50,6 +52,7 @@ describe('Budget API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
// Clean up all created resources
|
// Clean up all created resources
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: createdUserIds,
|
userIds: createdUserIds,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getPool } from '../../services/db/connection.db';
|
|||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
|
|
||||||
describe('Database Service Integration Tests', () => {
|
describe('Database Service Integration Tests', () => {
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
@@ -26,6 +27,13 @@ describe('Database Service Integration Tests', () => {
|
|||||||
{ full_name: fullName },
|
{ full_name: fullName },
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Poll to ensure the user record is findable before tests run.
|
||||||
|
await poll(
|
||||||
|
() => db.userRepo.findUserByEmail(testUserEmail, logger),
|
||||||
|
(foundUser) => !!foundUser,
|
||||||
|
{ timeout: 5000, interval: 500, description: `user ${testUserEmail} to be findable` },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/flyer-processing.integration.test.ts
|
// src/tests/integration/flyer-processing.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as db from '../../services/db/index.db';
|
import * as db from '../../services/db/index.db';
|
||||||
@@ -9,21 +8,19 @@ import { getPool } from '../../services/db/connection.db';
|
|||||||
import { generateFileChecksum } from '../../utils/checksum';
|
import { generateFileChecksum } from '../../utils/checksum';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import type { UserProfile, ExtractedFlyerItem } from '../../types';
|
import type { UserProfile, ExtractedFlyerItem } from '../../types';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
import { cleanupFiles } from '../utils/cleanupFiles';
|
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||||
import piexif from 'piexifjs';
|
import piexif from 'piexifjs';
|
||||||
import exifParser from 'exif-parser';
|
import exifParser from 'exif-parser';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createFlyerAndItems } from '../../services/db/flyer.db';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
const { mockExtractCoreData } = vi.hoisted(() => ({
|
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||||
mockExtractCoreData: vi.fn(),
|
mockExtractCoreData: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -39,23 +36,38 @@ vi.mock('../../services/aiService.server', async (importOriginal) => {
|
|||||||
return actual;
|
return actual;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the database service to allow for simulating DB failures.
|
// Mock the main DB service to allow for simulating transaction failures.
|
||||||
// By default, it will use the real implementation.
|
// By default, it will use the real implementation.
|
||||||
vi.mock('../../services/db/flyer.db', async (importOriginal) => {
|
vi.mock('../../services/db/index.db', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../../services/db/flyer.db')>();
|
const actual = await importOriginal<typeof import('../../services/db/index.db')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
createFlyerAndItems: vi.fn().mockImplementation(actual.createFlyerAndItems),
|
withTransaction: vi.fn().mockImplementation(actual.withTransaction),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Flyer Processing Background Job Integration Test', () => {
|
describe('Flyer Processing Background Job Integration Test', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
const createdFlyerIds: number[] = [];
|
const createdFlyerIds: number[] = [];
|
||||||
const createdFilePaths: string[] = [];
|
const createdFilePaths: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
// FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated
|
||||||
|
// for the database, satisfying the 'url_check' constraint.
|
||||||
|
// IMPORTANT: This must run BEFORE the app is imported so workers inherit the env var.
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
|
||||||
|
const appModule = await import('../../../server');
|
||||||
|
const app = appModule.default;
|
||||||
|
request = supertest(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIX: Reset mocks before each test to ensure isolation.
|
||||||
|
// This prevents "happy path" mocks from leaking into error handling tests and vice versa.
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 1. Reset AI Service Mock to default success state
|
||||||
|
mockExtractCoreData.mockReset();
|
||||||
mockExtractCoreData.mockResolvedValue({
|
mockExtractCoreData.mockResolvedValue({
|
||||||
store_name: 'Mock Store',
|
store_name: 'Mock Store',
|
||||||
valid_from: null,
|
valid_from: null,
|
||||||
@@ -71,9 +83,18 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. Restore DB Service Mock to real implementation
|
||||||
|
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
|
||||||
|
const { withTransaction } = await import('../../services/db/index.db');
|
||||||
|
const actualDb = await vi.importActual<typeof import('../../services/db/index.db')>('../../services/db/index.db');
|
||||||
|
vi.mocked(withTransaction).mockReset();
|
||||||
|
vi.mocked(withTransaction).mockImplementation(actualDb.withTransaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs(); // Clean up env stubs
|
||||||
|
|
||||||
// Use the centralized cleanup utility.
|
// Use the centralized cleanup utility.
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: createdUserIds,
|
userIds: createdUserIds,
|
||||||
@@ -96,7 +117,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
// This prevents a 409 Conflict error when the second test runs.
|
// This prevents a 409 Conflict error when the second test runs.
|
||||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
||||||
const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`;
|
const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track created files for cleanup
|
// Track created files for cleanup
|
||||||
@@ -110,6 +131,9 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const uploadReq = request
|
const uploadReq = request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
|
// Pass the baseUrl directly in the form data to ensure the worker receives it,
|
||||||
|
// bypassing issues with vi.stubEnv in multi-threaded test environments.
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
if (token) {
|
if (token) {
|
||||||
uploadReq.set('Authorization', `Bearer ${token}`);
|
uploadReq.set('Authorization', `Bearer ${token}`);
|
||||||
@@ -120,25 +144,19 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
// Assert 1: Check that a job ID was returned.
|
// Assert 1: Check that a job ID was returned.
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Act 2: Poll for the job status until it completes.
|
// Act 2: Poll for job completion using the new utility.
|
||||||
let jobStatus;
|
const jobStatus = await poll(
|
||||||
// Poll for up to 210 seconds (70 * 3s). This should be greater than the worker's
|
async () => {
|
||||||
// lockDuration (120s) to patiently wait for long-running jobs.
|
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
const maxRetries = 70;
|
if (token) {
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
statusReq.set('Authorization', `Bearer ${token}`);
|
||||||
console.log(`Polling attempt ${i + 1}...`);
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
const statusResponse = await statusReq;
|
||||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
return statusResponse.body;
|
||||||
if (token) {
|
},
|
||||||
statusReq.set('Authorization', `Bearer ${token}`);
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
}
|
{ timeout: 210000, interval: 3000, description: 'flyer processing' },
|
||||||
const statusResponse = await statusReq;
|
);
|
||||||
jobStatus = statusResponse.body;
|
|
||||||
console.log(`Job status: ${JSON.stringify(jobStatus)}`);
|
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert 2: Check that the job completed successfully.
|
// Assert 2: Check that the job completed successfully.
|
||||||
if (jobStatus?.state === 'failed') {
|
if (jobStatus?.state === 'failed') {
|
||||||
@@ -220,7 +238,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const imageWithExifBuffer = Buffer.from(jpegWithExif, 'binary');
|
const imageWithExifBuffer = Buffer.from(jpegWithExif, 'binary');
|
||||||
|
|
||||||
const uniqueFileName = `test-flyer-with-exif-${Date.now()}.jpg`;
|
const uniqueFileName = `test-flyer-with-exif-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([imageWithExifBuffer], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(imageWithExifBuffer)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track original and derived files for cleanup
|
// Track original and derived files for cleanup
|
||||||
@@ -233,25 +251,24 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const uploadResponse = await request
|
const uploadResponse = await request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
|
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
|
||||||
|
|
||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Poll for job completion
|
// 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 request
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
.get(`/api/ai/jobs/${jobId}/status`)
|
||||||
const statusResponse = await request
|
.set('Authorization', `Bearer ${token}`);
|
||||||
.get(`/api/ai/jobs/${jobId}/status`)
|
return statusResponse.body;
|
||||||
.set('Authorization', `Bearer ${token}`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'EXIF stripping job' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Assert
|
// 3. Assert
|
||||||
if (jobStatus?.state === 'failed') {
|
if (jobStatus?.state === 'failed') {
|
||||||
@@ -306,7 +323,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const uniqueFileName = `test-flyer-with-metadata-${Date.now()}.png`;
|
const uniqueFileName = `test-flyer-with-metadata-${Date.now()}.png`;
|
||||||
const mockImageFile = new File([Buffer.from(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png' });
|
const mockImageFile = new File([new Uint8Array(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track files for cleanup
|
// Track files for cleanup
|
||||||
@@ -319,25 +336,24 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const uploadResponse = await request
|
const uploadResponse = await request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
|
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
|
||||||
|
|
||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Poll for job completion
|
// 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 request
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
.get(`/api/ai/jobs/${jobId}/status`)
|
||||||
const statusResponse = await request
|
.set('Authorization', `Bearer ${token}`);
|
||||||
.get(`/api/ai/jobs/${jobId}/status`)
|
return statusResponse.body;
|
||||||
.set('Authorization', `Bearer ${token}`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'PNG metadata stripping job' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Assert job completion
|
// 3. Assert job completion
|
||||||
if (jobStatus?.state === 'failed') {
|
if (jobStatus?.state === 'failed') {
|
||||||
@@ -369,14 +385,14 @@ it(
|
|||||||
async () => {
|
async () => {
|
||||||
// Arrange: Mock the AI service to throw an error for this specific test.
|
// Arrange: Mock the AI service to throw an error for this specific test.
|
||||||
const aiError = new Error('AI model failed to extract data.');
|
const aiError = new Error('AI model failed to extract data.');
|
||||||
mockExtractCoreData.mockRejectedValueOnce(aiError);
|
mockExtractCoreData.mockRejectedValue(aiError);
|
||||||
|
|
||||||
// Arrange: Prepare a unique flyer file for upload.
|
// Arrange: Prepare a unique flyer file for upload.
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`fail-test-${Date.now()}`)]);
|
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`fail-test-${Date.now()}`)]);
|
||||||
const uniqueFileName = `ai-fail-test-${Date.now()}.jpg`;
|
const uniqueFileName = `ai-fail-test-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track created files for cleanup
|
// Track created files for cleanup
|
||||||
@@ -386,23 +402,22 @@ it(
|
|||||||
// Act 1: Upload the file to start the background job.
|
// Act 1: Upload the file to start the background job.
|
||||||
const uploadResponse = await request
|
const uploadResponse = await request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Act 2: Poll for the job status until it completes or fails.
|
// Act 2: Poll for job completion using the new utility.
|
||||||
let jobStatus;
|
const jobStatus = await poll(
|
||||||
const maxRetries = 60;
|
async () => {
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
return statusResponse.body;
|
||||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'AI failure test job' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert 1: Check that the job failed.
|
// Assert 1: Check that the job failed.
|
||||||
expect(jobStatus?.state).toBe('failed');
|
expect(jobStatus?.state).toBe('failed');
|
||||||
@@ -418,16 +433,18 @@ it(
|
|||||||
it(
|
it(
|
||||||
'should handle a database failure during flyer creation',
|
'should handle a database failure during flyer creation',
|
||||||
async () => {
|
async () => {
|
||||||
// Arrange: Mock the database creation function to throw an error for this specific test.
|
// Arrange: Mock the database transaction function to throw an error.
|
||||||
|
// This is a more realistic simulation of a DB failure than mocking the inner createFlyerAndItems function.
|
||||||
const dbError = new Error('DB transaction failed');
|
const dbError = new Error('DB transaction failed');
|
||||||
vi.mocked(createFlyerAndItems).mockRejectedValueOnce(dbError);
|
const { withTransaction } = await import('../../services/db/index.db');
|
||||||
|
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||||
|
|
||||||
// Arrange: Prepare a unique flyer file for upload.
|
// Arrange: Prepare a unique flyer file for upload.
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`db-fail-test-${Date.now()}`)]);
|
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`db-fail-test-${Date.now()}`)]);
|
||||||
const uniqueFileName = `db-fail-test-${Date.now()}.jpg`;
|
const uniqueFileName = `db-fail-test-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track created files for cleanup
|
// Track created files for cleanup
|
||||||
@@ -437,23 +454,22 @@ it(
|
|||||||
// Act 1: Upload the file to start the background job.
|
// Act 1: Upload the file to start the background job.
|
||||||
const uploadResponse = await request
|
const uploadResponse = await request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Act 2: Poll for the job status until it completes or fails.
|
// Act 2: Poll for job completion using the new utility.
|
||||||
let jobStatus;
|
const jobStatus = await poll(
|
||||||
const maxRetries = 60;
|
async () => {
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
return statusResponse.body;
|
||||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'DB failure test job' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert 1: Check that the job failed.
|
// Assert 1: Check that the job failed.
|
||||||
expect(jobStatus?.state).toBe('failed');
|
expect(jobStatus?.state).toBe('failed');
|
||||||
@@ -471,7 +487,7 @@ it(
|
|||||||
async () => {
|
async () => {
|
||||||
// Arrange: Mock the AI service to throw an error, causing the job to fail.
|
// Arrange: Mock the AI service to throw an error, causing the job to fail.
|
||||||
const aiError = new Error('Simulated AI failure for cleanup test.');
|
const aiError = new Error('Simulated AI failure for cleanup test.');
|
||||||
mockExtractCoreData.mockRejectedValueOnce(aiError);
|
mockExtractCoreData.mockRejectedValue(aiError);
|
||||||
|
|
||||||
// Arrange: Prepare a unique flyer file for upload.
|
// Arrange: Prepare a unique flyer file for upload.
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
@@ -481,7 +497,7 @@ it(
|
|||||||
Buffer.from(`cleanup-fail-test-${Date.now()}`),
|
Buffer.from(`cleanup-fail-test-${Date.now()}`),
|
||||||
]);
|
]);
|
||||||
const uniqueFileName = `cleanup-fail-test-${Date.now()}.jpg`;
|
const uniqueFileName = `cleanup-fail-test-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track the path of the file that will be created in the uploads directory.
|
// Track the path of the file that will be created in the uploads directory.
|
||||||
@@ -492,23 +508,22 @@ it(
|
|||||||
// Act 1: Upload the file to start the background job.
|
// Act 1: Upload the file to start the background job.
|
||||||
const uploadResponse = await request
|
const uploadResponse = await request
|
||||||
.post('/api/ai/upload-and-process')
|
.post('/api/ai/upload-and-process')
|
||||||
|
.field('baseUrl', getTestBaseUrl())
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// Act 2: Poll for the job status until it fails.
|
// Act 2: Poll for job completion using the new utility.
|
||||||
let jobStatus;
|
const jobStatus = await poll(
|
||||||
const maxRetries = 60;
|
async () => {
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
return statusResponse.body;
|
||||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'failed', // We expect this one to fail
|
||||||
if (jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'file cleanup failure test job' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert 1: Check that the job actually failed.
|
// Assert 1: Check that the job actually failed.
|
||||||
expect(jobStatus?.state).toBe('failed');
|
expect(jobStatus?.state).toBe('failed');
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// src/tests/integration/flyer.integration.test.ts
|
// src/tests/integration/flyer.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import app from '../../../server';
|
|
||||||
import type { Flyer, FlyerItem } from '../../types';
|
import type { Flyer, FlyerItem } from '../../types';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -13,12 +13,16 @@ import { cleanupDb } from '../utils/cleanup';
|
|||||||
describe('Public Flyer API Routes Integration Tests', () => {
|
describe('Public Flyer API Routes Integration Tests', () => {
|
||||||
let flyers: Flyer[] = [];
|
let flyers: Flyer[] = [];
|
||||||
// Use a supertest instance for all requests in this file
|
// Use a supertest instance for all requests in this file
|
||||||
const request = supertest(app);
|
let request: ReturnType<typeof supertest>;
|
||||||
let testStoreId: number;
|
let testStoreId: number;
|
||||||
let createdFlyerId: number;
|
let createdFlyerId: number;
|
||||||
|
|
||||||
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Ensure at least one flyer exists
|
// Ensure at least one flyer exists
|
||||||
const storeRes = await getPool().query(
|
const storeRes = await getPool().query(
|
||||||
`INSERT INTO public.stores (name) VALUES ('Integration Test Store') RETURNING store_id`,
|
`INSERT INTO public.stores (name) VALUES ('Integration Test Store') RETURNING store_id`,
|
||||||
@@ -27,7 +31,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
const flyerRes = await getPool().query(
|
const flyerRes = await getPool().query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
||||||
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 'https://example.com/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
|
VALUES ($1, 'integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||||
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||||
);
|
);
|
||||||
createdFlyerId = flyerRes.rows[0].flyer_id;
|
createdFlyerId = flyerRes.rows[0].flyer_id;
|
||||||
@@ -44,6 +48,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
// Clean up the test data created in beforeAll to prevent polluting the test database.
|
// Clean up the test data created in beforeAll to prevent polluting the test database.
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
flyerIds: [createdFlyerId],
|
flyerIds: [createdFlyerId],
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
// src/tests/integration/gamification.integration.test.ts
|
// src/tests/integration/gamification.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
|
||||||
import { generateFileChecksum } from '../../utils/checksum';
|
import { generateFileChecksum } from '../../utils/checksum';
|
||||||
import * as db from '../../services/db/index.db';
|
import * as db from '../../services/db/index.db';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import * as imageProcessor from '../../utils/imageProcessor';
|
import * as imageProcessor from '../../utils/imageProcessor';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
import type {
|
import type {
|
||||||
UserProfile,
|
UserProfile,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
@@ -25,8 +25,6 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
const { mockExtractCoreData } = vi.hoisted(() => ({
|
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||||
mockExtractCoreData: vi.fn(),
|
mockExtractCoreData: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -52,6 +50,7 @@ vi.mock('../../utils/imageProcessor', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Gamification Flow Integration Test', () => {
|
describe('Gamification Flow Integration Test', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
const createdFlyerIds: number[] = [];
|
const createdFlyerIds: number[] = [];
|
||||||
@@ -59,6 +58,12 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
const createdStoreIds: number[] = [];
|
const createdStoreIds: number[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// Stub environment variables for URL generation in the background worker.
|
||||||
|
// This needs to be in beforeAll to ensure it's set before any code that might use it is imported.
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Create a new user specifically for this test suite to ensure a clean slate.
|
// Create a new user specifically for this test suite to ensure a clean slate.
|
||||||
({ user: testUser, token: authToken } = await createAndLoginUser({
|
({ user: testUser, token: authToken } = await createAndLoginUser({
|
||||||
email: `gamification-user-${Date.now()}@example.com`,
|
email: `gamification-user-${Date.now()}@example.com`,
|
||||||
@@ -85,6 +90,7 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: testUser ? [testUser.user.user_id] : [],
|
userIds: testUser ? [testUser.user.user_id] : [],
|
||||||
flyerIds: createdFlyerIds,
|
flyerIds: createdFlyerIds,
|
||||||
@@ -96,16 +102,12 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
it(
|
it(
|
||||||
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
||||||
async () => {
|
async () => {
|
||||||
// --- Arrange: Stub environment variables for URL generation in the background worker ---
|
|
||||||
const testBaseUrl = 'http://localhost:3001'; // Use a fixed port for predictability
|
|
||||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
|
||||||
|
|
||||||
// --- Arrange: Prepare a unique flyer file for upload ---
|
// --- Arrange: Prepare a unique flyer file for upload ---
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
||||||
const uniqueFileName = `gamification-test-flyer-${Date.now()}.jpg`;
|
const uniqueFileName = `gamification-test-flyer-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track created files for cleanup
|
// Track created files for cleanup
|
||||||
@@ -124,20 +126,19 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
const { jobId } = uploadResponse.body;
|
const { jobId } = uploadResponse.body;
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
|
|
||||||
// --- Act 2: Poll for job completion ---
|
// --- Act 2: 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 request
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
.get(`/api/ai/jobs/${jobId}/status`)
|
||||||
const statusResponse = await request
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
.get(`/api/ai/jobs/${jobId}/status`)
|
return statusResponse.body;
|
||||||
.set('Authorization', `Bearer ${authToken}`);
|
},
|
||||||
jobStatus = statusResponse.body;
|
(status) => status.state === 'completed' || status.state === 'failed',
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
{ timeout: 180000, interval: 3000, description: 'gamification flyer processing' },
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
}
|
if (!jobStatus) {
|
||||||
if (!jobStatus) {
|
|
||||||
console.error('[DEBUG] Gamification test job timed out: No job status received.');
|
console.error('[DEBUG] Gamification test job timed out: No job status received.');
|
||||||
throw new Error('Gamification test job timed out: No job status received.');
|
throw new Error('Gamification test job timed out: No job status received.');
|
||||||
}
|
}
|
||||||
@@ -187,8 +188,6 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
firstUploadAchievement!.points_value,
|
firstUploadAchievement!.points_value,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Cleanup ---
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
},
|
},
|
||||||
240000, // Increase timeout to 240s to match other long-running processing tests
|
240000, // Increase timeout to 240s to match other long-running processing tests
|
||||||
);
|
);
|
||||||
@@ -196,10 +195,6 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
describe('Legacy Flyer Upload', () => {
|
describe('Legacy Flyer Upload', () => {
|
||||||
it('should process a legacy upload and save fully qualified URLs to the database', async () => {
|
it('should process a legacy upload and save fully qualified URLs to the database', async () => {
|
||||||
// --- Arrange ---
|
// --- Arrange ---
|
||||||
// 1. Stub environment variables to have a predictable base URL for the test.
|
|
||||||
const testBaseUrl = 'https://cdn.example.com';
|
|
||||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
|
||||||
|
|
||||||
// 2. Mock the icon generator to return a predictable filename.
|
// 2. Mock the icon generator to return a predictable filename.
|
||||||
vi.mocked(imageProcessor.generateFlyerIcon).mockResolvedValue('legacy-icon.webp');
|
vi.mocked(imageProcessor.generateFlyerIcon).mockResolvedValue('legacy-icon.webp');
|
||||||
|
|
||||||
@@ -207,7 +202,7 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const uniqueFileName = `legacy-upload-test-${Date.now()}.jpg`;
|
const uniqueFileName = `legacy-upload-test-${Date.now()}.jpg`;
|
||||||
const mockImageFile = new File([imageBuffer], uniqueFileName, { type: 'image/jpeg' });
|
const mockImageFile = new File([new Uint8Array(imageBuffer)], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Track created files for cleanup.
|
// Track created files for cleanup.
|
||||||
@@ -257,11 +252,10 @@ describe('Gamification Flow Integration Test', () => {
|
|||||||
createdStoreIds.push(savedFlyer.store_id!); // Add for cleanup.
|
createdStoreIds.push(savedFlyer.store_id!); // Add for cleanup.
|
||||||
|
|
||||||
// 8. Assert that the URLs are fully qualified.
|
// 8. Assert that the URLs are fully qualified.
|
||||||
expect(savedFlyer.image_url).to.equal(`${testBaseUrl}/flyer-images/${uniqueFileName}`);
|
expect(savedFlyer.image_url).to.equal(newFlyer.image_url);
|
||||||
expect(savedFlyer.icon_url).to.equal(`${testBaseUrl}/flyer-images/icons/legacy-icon.webp`);
|
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
|
||||||
|
const expectedBaseUrl = getTestBaseUrl();
|
||||||
// --- Cleanup ---
|
expect(newFlyer.image_url).toContain(`${expectedBaseUrl}/flyer-images/`);
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/notification.integration.test.ts
|
// src/tests/integration/notification.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import type { UserProfile, Notification } from '../../types';
|
import type { UserProfile, Notification } from '../../types';
|
||||||
@@ -11,14 +10,17 @@ import { getPool } from '../../services/db/connection.db';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Notification API Routes Integration Tests', () => {
|
describe('Notification API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// 1. Create a user for the tests
|
// 1. Create a user for the tests
|
||||||
const { user, token } = await createAndLoginUser({
|
const { user, token } = await createAndLoginUser({
|
||||||
email: `notification-user-${Date.now()}@example.com`,
|
email: `notification-user-${Date.now()}@example.com`,
|
||||||
@@ -46,6 +48,7 @@ describe('Notification API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
// Notifications are deleted via CASCADE when the user is deleted.
|
// Notifications are deleted via CASCADE when the user is deleted.
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: createdUserIds,
|
userIds: createdUserIds,
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
// src/tests/integration/price.integration.test.ts
|
// src/tests/integration/price.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
|
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Price History API Integration Test (/api/price-history)', () => {
|
describe('Price History API Integration Test (/api/price-history)', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let masterItemId: number;
|
let masterItemId: number;
|
||||||
let storeId: number;
|
let storeId: number;
|
||||||
let flyerId1: number;
|
let flyerId1: number;
|
||||||
@@ -18,6 +17,10 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
let flyerId3: number;
|
let flyerId3: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// 1. Create a master grocery item
|
// 1. Create a master grocery item
|
||||||
@@ -35,21 +38,21 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
// 3. Create two flyers with different dates
|
// 3. Create two flyers with different dates
|
||||||
const flyerRes1 = await pool.query(
|
const flyerRes1 = await pool.query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
||||||
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 'https://example.com/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
|
VALUES ($1, 'price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
|
||||||
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
|
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
|
||||||
);
|
);
|
||||||
flyerId1 = flyerRes1.rows[0].flyer_id;
|
flyerId1 = flyerRes1.rows[0].flyer_id;
|
||||||
|
|
||||||
const flyerRes2 = await pool.query(
|
const flyerRes2 = await pool.query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
||||||
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 'https://example.com/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
|
VALUES ($1, 'price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
|
||||||
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
|
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
|
||||||
);
|
);
|
||||||
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
|
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
|
||||||
|
|
||||||
const flyerRes3 = await pool.query(
|
const flyerRes3 = await pool.query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
|
||||||
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 'https://example.com/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
|
VALUES ($1, 'price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
|
||||||
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
|
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
|
||||||
);
|
);
|
||||||
flyerId3 = flyerRes3.rows[0].flyer_id;
|
flyerId3 = flyerRes3.rows[0].flyer_id;
|
||||||
@@ -70,6 +73,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
// The CASCADE on the tables should handle flyer_items.
|
// The CASCADE on the tables should handle flyer_items.
|
||||||
// The delete on flyers cascades to flyer_items, which fires a trigger `recalculate_price_history_on_flyer_item_delete`.
|
// The delete on flyers cascades to flyer_items, which fires a trigger `recalculate_price_history_on_flyer_item_delete`.
|
||||||
@@ -93,7 +97,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct price history for a given master item ID', async () => {
|
it('should return the correct price history for a given master item ID', async () => {
|
||||||
const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] });
|
const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [masterItemId] });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toBeInstanceOf(Array);
|
expect(response.body).toBeInstanceOf(Array);
|
||||||
@@ -107,6 +111,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
it('should respect the limit parameter', async () => {
|
it('should respect the limit parameter', async () => {
|
||||||
const response = await request
|
const response = await request
|
||||||
.post('/api/price-history')
|
.post('/api/price-history')
|
||||||
|
.set('Authorization', 'Bearer ${token}')
|
||||||
.send({ masterItemIds: [masterItemId], limit: 2 });
|
.send({ masterItemIds: [masterItemId], limit: 2 });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -118,6 +123,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
it('should respect the offset parameter', async () => {
|
it('should respect the offset parameter', async () => {
|
||||||
const response = await request
|
const response = await request
|
||||||
.post('/api/price-history')
|
.post('/api/price-history')
|
||||||
|
.set('Authorization', 'Bearer ${token}')
|
||||||
.send({ masterItemIds: [masterItemId], limit: 2, offset: 1 });
|
.send({ masterItemIds: [masterItemId], limit: 2, offset: 1 });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -127,7 +133,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return price history sorted by date in ascending order', async () => {
|
it('should return price history sorted by date in ascending order', async () => {
|
||||||
const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] });
|
const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [masterItemId] });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const history = response.body;
|
const history = response.body;
|
||||||
@@ -142,7 +148,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return an empty array for a master item ID with no price history', async () => {
|
it('should return an empty array for a master item ID with no price history', async () => {
|
||||||
const response = await request.post('/api/price-history').send({ masterItemIds: [999999] });
|
const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [999999] });
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual([]);
|
expect(response.body).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/public.routes.integration.test.ts
|
// src/tests/integration/public.routes.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import type {
|
import type {
|
||||||
Flyer,
|
Flyer,
|
||||||
FlyerItem,
|
FlyerItem,
|
||||||
@@ -13,22 +12,26 @@ import type {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { poll } from '../utils/poll';
|
||||||
|
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Public API Routes Integration Tests', () => {
|
describe('Public API Routes Integration Tests', () => {
|
||||||
// Shared state for tests
|
// Shared state for tests
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let testRecipe: Recipe;
|
let testRecipe: Recipe;
|
||||||
let testFlyer: Flyer;
|
let testFlyer: Flyer;
|
||||||
let testStoreId: number;
|
let testStoreId: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
// Create a user to own the recipe
|
// Create a user to own the recipe
|
||||||
const userEmail = `public-routes-user-${Date.now()}@example.com`;
|
const userEmail = `public-routes-user-${Date.now()}@example.com`;
|
||||||
@@ -42,27 +45,12 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
testUser = createdUser;
|
testUser = createdUser;
|
||||||
|
|
||||||
// DEBUG: Verify user existence in DB
|
// Poll to ensure the user record has propagated before creating dependent records.
|
||||||
console.log(`[DEBUG] createAndLoginUser returned user ID: ${testUser.user.user_id}`);
|
await poll(
|
||||||
const userCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
() => pool.query('SELECT 1 FROM public.users WHERE user_id = $1', [testUser.user.user_id]),
|
||||||
console.log(`[DEBUG] DB check for user found ${userCheck.rowCount ?? 0} rows.`);
|
(result) => (result.rowCount ?? 0) > 0,
|
||||||
if (!userCheck.rowCount) {
|
{ timeout: 5000, interval: 500, description: `user ${testUser.user.user_id} to persist` },
|
||||||
console.error(`[DEBUG] CRITICAL: User ${testUser.user.user_id} does not exist in public.users table! Attempting to wait...`);
|
);
|
||||||
// Wait loop to ensure user persistence if there's a race condition
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const retryCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
|
||||||
if (retryCheck.rowCount && retryCheck.rowCount > 0) {
|
|
||||||
console.log(`[DEBUG] User found after retry ${i + 1}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Final check before proceeding to avoid FK error
|
|
||||||
const finalCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
|
||||||
if (!finalCheck.rowCount) {
|
|
||||||
throw new Error(`User ${testUser.user.user_id} failed to persist in DB. Cannot continue test.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a recipe
|
// Create a recipe
|
||||||
const recipeRes = await pool.query(
|
const recipeRes = await pool.query(
|
||||||
@@ -78,7 +66,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
testStoreId = storeRes.rows[0].store_id;
|
testStoreId = storeRes.rows[0].store_id;
|
||||||
const flyerRes = await pool.query(
|
const flyerRes = await pool.query(
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
||||||
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 'https://example.com/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
|
VALUES ($1, 'public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
|
||||||
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||||
);
|
);
|
||||||
testFlyer = flyerRes.rows[0];
|
testFlyer = flyerRes.rows[0];
|
||||||
@@ -91,6 +79,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: testUser ? [testUser.user.user_id] : [],
|
userIds: testUser ? [testUser.user.user_id] : [],
|
||||||
recipeIds: testRecipe ? [testRecipe.recipe_id] : [],
|
recipeIds: testRecipe ? [testRecipe.recipe_id] : [],
|
||||||
@@ -235,4 +224,27 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
expect(appliances[0]).toHaveProperty('appliance_id');
|
expect(appliances[0]).toHaveProperty('appliance_id');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rate Limiting on Public Routes', () => {
|
||||||
|
it('should block requests to /api/personalization/master-items after exceeding the limit', async () => {
|
||||||
|
const limit = 100; // Matches publicReadLimiter config
|
||||||
|
// We only need to verify it blocks eventually, but running 100 requests in a test is slow.
|
||||||
|
// Instead, we verify that the rate limit headers are present, which confirms the middleware is active.
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.get('/api/personalization/master-items')
|
||||||
|
.set('X-Test-Rate-Limit-Enable', 'true'); // Opt-in to rate limiting
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers).toHaveProperty('x-ratelimit-limit');
|
||||||
|
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
|
||||||
|
|
||||||
|
// Verify the limit matches our config
|
||||||
|
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
|
||||||
|
|
||||||
|
// Verify we consumed one
|
||||||
|
const remaining = parseInt(response.headers['x-ratelimit-remaining']);
|
||||||
|
expect(remaining).toBeLessThan(limit);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/recipe.integration.test.ts
|
// src/tests/integration/recipe.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
import type { UserProfile, Recipe, RecipeComment } from '../../types';
|
import type { UserProfile, Recipe, RecipeComment } from '../../types';
|
||||||
@@ -13,9 +12,8 @@ import { aiService } from '../../services/aiService.server';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('Recipe API Routes Integration Tests', () => {
|
describe('Recipe API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
let testRecipe: Recipe;
|
let testRecipe: Recipe;
|
||||||
@@ -23,6 +21,10 @@ describe('Recipe API Routes Integration Tests', () => {
|
|||||||
const createdRecipeIds: number[] = [];
|
const createdRecipeIds: number[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Create a user to own the recipe and perform authenticated actions
|
// Create a user to own the recipe and perform authenticated actions
|
||||||
const { user, token } = await createAndLoginUser({
|
const { user, token } = await createAndLoginUser({
|
||||||
email: `recipe-user-${Date.now()}@example.com`,
|
email: `recipe-user-${Date.now()}@example.com`,
|
||||||
@@ -48,6 +50,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
// Clean up all created resources
|
// Clean up all created resources
|
||||||
await cleanupDb({
|
await cleanupDb({
|
||||||
userIds: createdUserIds,
|
userIds: createdUserIds,
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
// src/tests/integration/server.integration.test.ts
|
// src/tests/integration/server.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('Server Initialization Smoke Test', () => {
|
describe('Server Initialization Smoke Test', () => {
|
||||||
|
let app: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
app = (await import('../../../server')).default;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('should import the server app without crashing', () => {
|
it('should import the server app without crashing', () => {
|
||||||
// This test's primary purpose is to ensure that all top-level code in `server.ts`
|
// This test's primary purpose is to ensure that all top-level code in `server.ts`
|
||||||
// can execute without throwing an error. This catches issues like syntax errors,
|
// can execute without throwing an error. This catches issues like syntax errors,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcrypt';
|
|||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { ShoppingList } from '../../types';
|
import type { ShoppingList } from '../../types';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
|
import { poll } from '../utils/poll';
|
||||||
|
|
||||||
describe('Shopping List DB Service Tests', () => {
|
describe('Shopping List DB Service Tests', () => {
|
||||||
it('should create and retrieve a shopping list for a user', async ({ onTestFinished }) => {
|
it('should create and retrieve a shopping list for a user', async ({ onTestFinished }) => {
|
||||||
@@ -19,6 +20,12 @@ describe('Shopping List DB Service Tests', () => {
|
|||||||
);
|
);
|
||||||
const testUserId = userprofile.user.user_id;
|
const testUserId = userprofile.user.user_id;
|
||||||
|
|
||||||
|
// Poll to ensure the user record has propagated before creating dependent records.
|
||||||
|
await poll(
|
||||||
|
() => getPool().query('SELECT 1 FROM public.users WHERE user_id = $1', [testUserId]),
|
||||||
|
(result) => (result.rowCount ?? 0) > 0,
|
||||||
|
{ timeout: 5000, interval: 500, description: `user ${testUserId} to persist` },
|
||||||
|
);
|
||||||
onTestFinished(async () => {
|
onTestFinished(async () => {
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||||
});
|
});
|
||||||
@@ -51,6 +58,13 @@ describe('Shopping List DB Service Tests', () => {
|
|||||||
);
|
);
|
||||||
const testUserId = userprofile.user.user_id;
|
const testUserId = userprofile.user.user_id;
|
||||||
|
|
||||||
|
// Poll to ensure the user record has propagated before creating dependent records.
|
||||||
|
await poll(
|
||||||
|
() => getPool().query('SELECT 1 FROM public.users WHERE user_id = $1', [testUserId]),
|
||||||
|
(result) => (result.rowCount ?? 0) > 0,
|
||||||
|
{ timeout: 5000, interval: 500, description: `user ${testUserId} to persist` },
|
||||||
|
);
|
||||||
|
|
||||||
onTestFinished(async () => {
|
onTestFinished(async () => {
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
// src/tests/integration/system.integration.test.ts
|
// src/tests/integration/system.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('System API Routes Integration Tests', () => {
|
describe('System API Routes Integration Tests', () => {
|
||||||
|
let app: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
app = (await import('../../../server')).default;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /api/system/pm2-status', () => {
|
describe('GET /api/system/pm2-status', () => {
|
||||||
it('should return a status for PM2', async () => {
|
it('should return a status for PM2', async () => {
|
||||||
const request = supertest(app);
|
const request = supertest(app);
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
// src/tests/integration/user.integration.test.ts
|
// src/tests/integration/user.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
import path from 'path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
|
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
|
||||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
|
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('User API Routes Integration Tests', () => {
|
describe('User API Routes Integration Tests', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
@@ -22,6 +23,10 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
// Before any tests run, create a new user and log them in.
|
// Before any tests run, create a new user and log them in.
|
||||||
// The token will be used for all subsequent API calls in this test suite.
|
// The token will be used for all subsequent API calls in this test suite.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
const email = `user-test-${Date.now()}@example.com`;
|
const email = `user-test-${Date.now()}@example.com`;
|
||||||
const { user, token } = await createAndLoginUser({ email, fullName: 'Test User', request });
|
const { user, token } = await createAndLoginUser({ email, fullName: 'Test User', request });
|
||||||
testUser = user;
|
testUser = user;
|
||||||
@@ -32,7 +37,27 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
// After all tests, clean up by deleting the created user.
|
// After all tests, clean up by deleting the created user.
|
||||||
// This now cleans up ALL users created by this test suite to prevent pollution.
|
// This now cleans up ALL users created by this test suite to prevent pollution.
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({ userIds: createdUserIds });
|
await cleanupDb({ userIds: createdUserIds });
|
||||||
|
|
||||||
|
// Safeguard to clean up any avatar files created during tests.
|
||||||
|
const uploadDir = path.resolve(__dirname, '../../../uploads/avatars');
|
||||||
|
try {
|
||||||
|
const allFiles = await fs.readdir(uploadDir);
|
||||||
|
// Filter for any file that contains any of the user IDs created in this test suite.
|
||||||
|
const testFiles = allFiles
|
||||||
|
.filter((f) => createdUserIds.some((userId) => userId && f.includes(userId)))
|
||||||
|
.map((f) => path.join(uploadDir, f));
|
||||||
|
|
||||||
|
if (testFiles.length > 0) {
|
||||||
|
await cleanupFiles(testFiles);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if the directory doesn't exist, but log other errors.
|
||||||
|
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.error('Error during user integration test avatar file cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
||||||
@@ -295,4 +320,64 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow a user to upload an avatar image and update their profile', async () => {
|
||||||
|
// Arrange: Path to a dummy image file
|
||||||
|
const dummyImagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
|
|
||||||
|
// Act: Make the POST request to upload the avatar
|
||||||
|
const response = await request
|
||||||
|
.post('/api/users/profile/avatar')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.attach('avatar', dummyImagePath);
|
||||||
|
|
||||||
|
// Assert: Check the response
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const updatedProfile = response.body;
|
||||||
|
expect(updatedProfile.avatar_url).toBeDefined();
|
||||||
|
expect(updatedProfile.avatar_url).not.toBeNull();
|
||||||
|
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar');
|
||||||
|
|
||||||
|
// Assert (Verification): Fetch the profile again to ensure the change was persisted
|
||||||
|
const verifyResponse = await request
|
||||||
|
.get('/api/users/profile')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
const refetchedProfile = verifyResponse.body;
|
||||||
|
expect(refetchedProfile.avatar_url).toBe(updatedProfile.avatar_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject avatar upload for an invalid file type', async () => {
|
||||||
|
// Arrange: Create a buffer representing a text file.
|
||||||
|
const invalidFileBuffer = Buffer.from('This is not an image file.');
|
||||||
|
const invalidFileName = 'test.txt';
|
||||||
|
|
||||||
|
// Act: Attempt to upload the text file to the avatar endpoint.
|
||||||
|
const response = await request
|
||||||
|
.post('/api/users/profile/avatar')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.attach('avatar', invalidFileBuffer, invalidFileName);
|
||||||
|
|
||||||
|
// Assert: Check for a 400 Bad Request response.
|
||||||
|
// This error comes from the multer fileFilter configuration in the route.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Only image files are allowed!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject avatar upload for a file that is too large', async () => {
|
||||||
|
// Arrange: Create a buffer larger than the configured limit (e.g., > 1MB).
|
||||||
|
// The limit is set in the multer middleware in `user.routes.ts`.
|
||||||
|
// We'll create a 2MB buffer to be safe.
|
||||||
|
const largeFileBuffer = Buffer.alloc(2 * 1024 * 1024, 'a');
|
||||||
|
const largeFileName = 'large-avatar.jpg';
|
||||||
|
|
||||||
|
// Act: Attempt to upload the large file.
|
||||||
|
const response = await request
|
||||||
|
.post('/api/users/profile/avatar')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.attach('avatar', largeFileBuffer, largeFileName);
|
||||||
|
|
||||||
|
// Assert: Check for a 400 Bad Request response from the multer error handler.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('File upload error: File too large');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/integration/user.routes.integration.test.ts
|
// src/tests/integration/user.routes.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import app from '../../../server';
|
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
import { cleanupDb } from '../utils/cleanup';
|
import { cleanupDb } from '../utils/cleanup';
|
||||||
@@ -10,15 +9,18 @@ import { cleanupDb } from '../utils/cleanup';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = supertest(app);
|
|
||||||
|
|
||||||
describe('User Routes Integration Tests (/api/users)', () => {
|
describe('User Routes Integration Tests (/api/users)', () => {
|
||||||
|
let request: ReturnType<typeof supertest>;
|
||||||
let authToken = '';
|
let authToken = '';
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
// Authenticate once before all tests in this suite to get a JWT.
|
// Authenticate once before all tests in this suite to get a JWT.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||||
|
const app = (await import('../../../server')).default;
|
||||||
|
request = supertest(app);
|
||||||
|
|
||||||
// Use the helper to create and log in a user in one step.
|
// Use the helper to create and log in a user in one step.
|
||||||
const { user, token } = await createAndLoginUser({
|
const { user, token } = await createAndLoginUser({
|
||||||
fullName: 'User Routes Test User',
|
fullName: 'User Routes Test User',
|
||||||
@@ -30,6 +32,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await cleanupDb({ userIds: createdUserIds });
|
await cleanupDb({ userIds: createdUserIds });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// src/tests/setup/integration-global-setup.ts
|
// src/tests/setup/integration-global-setup.ts
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import app from '../../../server'; // Import the Express app
|
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
|
|
||||||
@@ -13,6 +12,9 @@ let globalPool: ReturnType<typeof getPool> | null = null;
|
|||||||
export async function setup() {
|
export async function setup() {
|
||||||
// Ensure we are in the correct environment for these tests.
|
// Ensure we are in the correct environment for these tests.
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
// Fix: Set the FRONTEND_URL globally for the test server instance
|
||||||
|
process.env.FRONTEND_URL = 'https://example.com';
|
||||||
|
|
||||||
console.log(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
|
console.log(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
|
||||||
|
|
||||||
// The integration setup is now the single source of truth for preparing the test DB.
|
// The integration setup is now the single source of truth for preparing the test DB.
|
||||||
@@ -30,6 +32,10 @@ export async function setup() {
|
|||||||
console.log(`[PID:${process.pid}] Initializing global database pool...`);
|
console.log(`[PID:${process.pid}] Initializing global database pool...`);
|
||||||
globalPool = getPool();
|
globalPool = getPool();
|
||||||
|
|
||||||
|
// Fix: Dynamic import AFTER env vars are set
|
||||||
|
const appModule = await import('../../../server');
|
||||||
|
const app = appModule.default;
|
||||||
|
|
||||||
// Programmatically start the server within the same process.
|
// Programmatically start the server within the same process.
|
||||||
const port = process.env.PORT || 3001;
|
const port = process.env.PORT || 3001;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -1,85 +1,57 @@
|
|||||||
// src/tests/utils/cleanup.ts
|
// src/tests/utils/cleanup.ts
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export interface TestResourceIds {
|
interface CleanupOptions {
|
||||||
userIds?: string[];
|
userIds?: (string | null | undefined)[];
|
||||||
flyerIds?: number[];
|
flyerIds?: (number | null | undefined)[];
|
||||||
storeIds?: number[];
|
storeIds?: (number | null | undefined)[];
|
||||||
recipeIds?: number[];
|
recipeIds?: (number | null | undefined)[];
|
||||||
masterItemIds?: number[];
|
budgetIds?: (number | null | undefined)[];
|
||||||
budgetIds?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A robust cleanup utility for integration tests.
|
* A centralized utility to clean up database records created during tests.
|
||||||
* It deletes entities in the correct order to avoid foreign key violations.
|
* It deletes records in an order that respects foreign key constraints.
|
||||||
* It's designed to be called in an `afterAll` hook.
|
* It performs operations on a single client connection but does not use a
|
||||||
*
|
* transaction, ensuring that a failure to delete from one table does not
|
||||||
* @param ids An object containing arrays of IDs for each resource type to clean up.
|
* prevent cleanup attempts on others.
|
||||||
*/
|
*/
|
||||||
export const cleanupDb = async (ids: TestResourceIds) => {
|
export const cleanupDb = async (options: CleanupOptions) => {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
logger.info('[Test Cleanup] Starting database resource cleanup...');
|
const client = await pool.connect();
|
||||||
|
|
||||||
const {
|
|
||||||
userIds = [],
|
|
||||||
flyerIds = [],
|
|
||||||
storeIds = [],
|
|
||||||
recipeIds = [],
|
|
||||||
masterItemIds = [],
|
|
||||||
budgetIds = [],
|
|
||||||
} = ids;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Stage 1: Delete most dependent records ---
|
// Order of deletion matters to avoid foreign key violations.
|
||||||
// These records depend on users, recipes, flyers, etc.
|
// Children entities first, then parents.
|
||||||
if (userIds.length > 0) {
|
|
||||||
await pool.query('DELETE FROM public.recipe_comments WHERE user_id = ANY($1::uuid[])', [userIds]);
|
if (options.budgetIds?.filter(Boolean).length) {
|
||||||
await pool.query('DELETE FROM public.suggested_corrections WHERE user_id = ANY($1::uuid[])', [userIds]);
|
await client.query('DELETE FROM public.budgets WHERE budget_id = ANY($1::int[])', [options.budgetIds]);
|
||||||
await pool.query('DELETE FROM public.shopping_lists WHERE user_id = ANY($1::uuid[])', [userIds]); // Assumes shopping_list_items cascades
|
logger.debug(`Cleaned up ${options.budgetIds.length} budget(s).`);
|
||||||
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = ANY($1::uuid[])', [userIds]);
|
|
||||||
await pool.query('DELETE FROM public.user_achievements WHERE user_id = ANY($1::uuid[])', [userIds]);
|
|
||||||
await pool.query('DELETE FROM public.activity_log WHERE user_id = ANY($1::uuid[])', [userIds]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stage 2: Delete parent records that other things depend on ---
|
if (options.recipeIds?.filter(Boolean).length) {
|
||||||
if (recipeIds.length > 0) {
|
await client.query('DELETE FROM public.recipes WHERE recipe_id = ANY($1::int[])', [options.recipeIds]);
|
||||||
await pool.query('DELETE FROM public.recipes WHERE recipe_id = ANY($1::int[])', [recipeIds]);
|
logger.debug(`Cleaned up ${options.recipeIds.length} recipe(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flyers might be created by users, but we clean them up separately.
|
if (options.flyerIds?.filter(Boolean).length) {
|
||||||
// flyer_items should cascade from this.
|
await client.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::int[])', [options.flyerIds]);
|
||||||
if (flyerIds.length > 0) {
|
logger.debug(`Cleaned up ${options.flyerIds.length} flyer(s).`);
|
||||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::bigint[])', [flyerIds]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stores are parents of flyers, so they come after.
|
if (options.storeIds?.filter(Boolean).length) {
|
||||||
if (storeIds.length > 0) {
|
await client.query('DELETE FROM public.stores WHERE store_id = ANY($1::int[])', [options.storeIds]);
|
||||||
await pool.query('DELETE FROM public.stores WHERE store_id = ANY($1::int[])', [storeIds]);
|
logger.debug(`Cleaned up ${options.storeIds.length} store(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Master items are parents of flyer_items and watched_items.
|
if (options.userIds?.filter(Boolean).length) {
|
||||||
if (masterItemIds.length > 0) {
|
await client.query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [options.userIds]);
|
||||||
await pool.query('DELETE FROM public.master_grocery_items WHERE master_grocery_item_id = ANY($1::int[])', [masterItemIds]);
|
logger.debug(`Cleaned up ${options.userIds.length} user(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budgets are parents of nothing, but depend on users.
|
|
||||||
if (budgetIds.length > 0) {
|
|
||||||
await pool.query('DELETE FROM public.budgets WHERE budget_id = ANY($1::int[])', [budgetIds]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stage 3: Delete the root user records ---
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
const { rowCount } = await pool.query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [userIds]);
|
|
||||||
logger.info(`[Test Cleanup] Cleaned up ${rowCount} user(s).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[Test Cleanup] Finished database resource cleanup successfully.');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Test Cleanup] CRITICAL: An error occurred during database cleanup.');
|
logger.error({ error, options }, 'A database cleanup operation failed.');
|
||||||
throw error; // Re-throw to fail the test suite
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,48 +1,30 @@
|
|||||||
// src/tests/utils/cleanupFiles.ts
|
// src/tests/utils/cleanupFiles.ts
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'path';
|
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely cleans up files from the filesystem.
|
* A centralized utility to clean up files created during tests.
|
||||||
* Designed to be used in `afterAll` or `afterEach` hooks in integration tests.
|
* It iterates through a list of file paths and attempts to delete each one.
|
||||||
*
|
* It gracefully handles errors for files that don't exist (e.g., already deleted
|
||||||
* @param filePaths An array of file paths to clean up.
|
* or never created due to a test failure).
|
||||||
*/
|
*/
|
||||||
export const cleanupFiles = async (filePaths: string[]) => {
|
export const cleanupFiles = async (filePaths: (string | undefined | null)[]) => {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
const validPaths = filePaths.filter((p): p is string => !!p);
|
||||||
logger.info('[Test Cleanup] No file paths provided for cleanup.');
|
if (validPaths.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[Test Cleanup] Starting filesystem cleanup for ${filePaths.length} file(s)...`);
|
logger.debug(`Cleaning up ${validPaths.length} test-created file(s)...`);
|
||||||
|
|
||||||
try {
|
const cleanupPromises = validPaths.map(async (filePath) => {
|
||||||
await Promise.all(
|
try {
|
||||||
filePaths.map(async (filePath) => {
|
await fs.unlink(filePath);
|
||||||
try {
|
} catch (error) {
|
||||||
await fs.unlink(filePath);
|
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
logger.debug(`[Test Cleanup] Successfully deleted file: ${filePath}`);
|
logger.error({ error, filePath }, 'Failed to delete test file during cleanup.');
|
||||||
} catch (err: any) {
|
}
|
||||||
// Ignore "file not found" errors, but log other errors.
|
}
|
||||||
if (err.code === 'ENOENT') {
|
});
|
||||||
logger.debug(`[Test Cleanup] File not found, skipping: ${filePath}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
{ err, filePath },
|
|
||||||
'[Test Cleanup] Failed to clean up file from filesystem.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info('[Test Cleanup] Finished filesystem cleanup successfully.');
|
await Promise.allSettled(cleanupPromises);
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
{ error },
|
|
||||||
'[Test Cleanup] CRITICAL: An error occurred during filesystem cleanup.',
|
|
||||||
);
|
|
||||||
throw error; // Re-throw to fail the test suite if cleanup fails
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -178,7 +178,7 @@ export const createMockFlyer = (
|
|||||||
store_id: overrides.store_id ?? overrides.store?.store_id,
|
store_id: overrides.store_id ?? overrides.store?.store_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseUrl = 'http://localhost:3001'; // A reasonable default for tests
|
const baseUrl = 'https://example.com'; // A reasonable default for tests
|
||||||
|
|
||||||
// Determine the final file_name to generate dependent properties from.
|
// Determine the final file_name to generate dependent properties from.
|
||||||
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;
|
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;
|
||||||
|
|||||||
36
src/tests/utils/poll.ts
Normal file
36
src/tests/utils/poll.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// src/tests/utils/poll.ts
|
||||||
|
|
||||||
|
interface PollOptions {
|
||||||
|
/** The maximum time to wait in milliseconds. Defaults to 10000 (10 seconds). */
|
||||||
|
timeout?: number;
|
||||||
|
/** The interval between attempts in milliseconds. Defaults to 500. */
|
||||||
|
interval?: number;
|
||||||
|
/** A description of the operation for better error messages. */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic polling utility for asynchronous operations in tests.
|
||||||
|
*
|
||||||
|
* @param fn The async function to execute on each attempt.
|
||||||
|
* @param validate A function that returns `true` if the result is satisfactory, ending the poll.
|
||||||
|
* @param options Polling options like timeout and interval.
|
||||||
|
* @returns A promise that resolves with the first valid result from `fn`.
|
||||||
|
* @throws An error if the timeout is reached before `validate` returns `true`.
|
||||||
|
*/
|
||||||
|
export async function poll<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
validate: (result: T) => boolean,
|
||||||
|
options: PollOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { timeout = 10000, interval = 500, description = 'operation' } = options;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const result = await fn();
|
||||||
|
if (validate(result)) return result;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Polling timed out for ${description} after ${timeout}ms.`);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user