Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0bf96824c | ||
| e86e09703e | |||
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a | |||
|
|
e4acab77c8 | ||
| 4e20b1b430 |
@@ -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 }}" \
|
||||||
|
|||||||
@@ -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.24",
|
"version": "0.9.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.24",
|
"version": "0.9.28",
|
||||||
"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.24",
|
"version": "0.9.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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,82 @@ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ const forgotPasswordLimiter = rateLimit({
|
|||||||
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
// Do not skip in test environment so we can write integration tests for it.
|
// Skip in test env unless a specific header is present.
|
||||||
// The limiter uses an in-memory store by default, so counts are reset when the test server restarts.
|
// This allows E2E tests to run unblocked, while specific integration
|
||||||
// skip: () => isTestEnv,
|
// tests for the limiter can opt-in by sending the header.
|
||||||
|
skip: (req) => {
|
||||||
|
if (!isTestEnv) return false; // Never skip in non-test environments.
|
||||||
|
// In test env, skip UNLESS the opt-in header is present.
|
||||||
|
return req.headers['x-test-rate-limit-enable'] !== 'true';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetPasswordLimiter = rateLimit({
|
const resetPasswordLimiter = rateLimit({
|
||||||
@@ -37,20 +42,24 @@ const resetPasswordLimiter = rateLimit({
|
|||||||
skip: () => isTestEnv, // Skip this middleware if in test environment
|
skip: () => isTestEnv, // Skip this middleware if in test environment
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Reusable Schemas ---
|
||||||
|
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.trim() // Prevent leading/trailing whitespace in passwords.
|
||||||
|
.min(8, 'Password must be at least 8 characters long.')
|
||||||
|
.superRefine((password, ctx) => {
|
||||||
|
const strength = validatePasswordStrength(password);
|
||||||
|
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
||||||
|
});
|
||||||
|
|
||||||
const registerSchema = z.object({
|
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 +68,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 +86,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 });
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,52 +132,56 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Login Route
|
// Login Route
|
||||||
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
router.post(
|
||||||
passport.authenticate(
|
'/login',
|
||||||
'local',
|
validateRequest(loginSchema),
|
||||||
{ session: false },
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
async (err: Error, user: Express.User | false, info: { message: string }) => {
|
passport.authenticate(
|
||||||
// --- LOGIN ROUTE DEBUG LOGGING ---
|
'local',
|
||||||
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
|
{ session: false },
|
||||||
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
|
async (err: Error, user: Express.User | false, info: { message: string }) => {
|
||||||
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
|
// --- LOGIN ROUTE DEBUG LOGGING ---
|
||||||
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
|
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
|
||||||
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
|
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(
|
||||||
|
|||||||
@@ -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.'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'.
|
||||||
@@ -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.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
@@ -373,62 +373,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
|
||||||
@@ -436,37 +417,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,6 +787,18 @@ async enqueueFlyerProcessing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(logger);
|
const baseUrl = 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', {
|
||||||
@@ -907,14 +907,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);
|
||||||
logger.debug({ baseUrl, file }, 'Building legacy URLs');
|
|
||||||
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 legacy URLs');
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
|
|
||||||
@@ -48,36 +47,21 @@ 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 | undefined,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): { imageUrl: string; iconUrl: string } {
|
): { imageUrl: string; iconUrl: string } {
|
||||||
logger.debug({ firstImage, iconFileName, baseUrl }, 'Building URLs');
|
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
|
||||||
let finalBaseUrl = baseUrl;
|
let finalBaseUrl = baseUrl;
|
||||||
if (!finalBaseUrl) {
|
if (!finalBaseUrl) {
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
@@ -85,10 +69,9 @@ export class FlyerDataTransformer {
|
|||||||
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
|
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
|
||||||
}
|
}
|
||||||
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
||||||
const imageBasename = path.basename(firstImage);
|
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
|
||||||
const imageUrl = `${finalBaseUrl}/flyer-images/${imageBasename}`;
|
|
||||||
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||||
logger.debug({ imageUrl, iconUrl, imageBasename }, 'Constructed URLs');
|
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');
|
||||||
return { imageUrl, iconUrl };
|
return { imageUrl, iconUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +87,9 @@ export class FlyerDataTransformer {
|
|||||||
*/
|
*/
|
||||||
async transform(
|
async transform(
|
||||||
aiResult: AiProcessorResult,
|
aiResult: AiProcessorResult,
|
||||||
imagePaths: { path: string; mimetype: string }[],
|
|
||||||
originalFileName: string,
|
originalFileName: string,
|
||||||
|
imageFileName: string,
|
||||||
|
iconFileName: string,
|
||||||
checksum: string,
|
checksum: string,
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
@@ -116,9 +100,7 @@ export class FlyerDataTransformer {
|
|||||||
try {
|
try {
|
||||||
const { data: extractedData, needsReview } = aiResult;
|
const { data: extractedData, needsReview } = aiResult;
|
||||||
|
|
||||||
const firstImage = imagePaths[0].path;
|
const { imageUrl, iconUrl } = this._buildUrls(imageFileName, iconFileName, baseUrl, logger);
|
||||||
const iconFileName = await 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: {
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
|
'http://localhost:3000', // 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: 'http://localhost:3000/flyer-images/flyer-abc.jpg',
|
||||||
|
icon_url: 'http://localhost:3000/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,
|
||||||
|
|||||||
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { describe, it, expect, afterAll } from 'vitest';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
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 { poll } from '../utils/poll';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -41,10 +42,22 @@ 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 apiClient.loginUser(adminEmail, adminPassword, false);
|
// We poll because the direct DB write above runs in a separate transaction
|
||||||
|
// from the login API call. Due to PostgreSQL's `Read Committed` transaction
|
||||||
|
// 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);
|
||||||
const loginData = await loginResponse.json();
|
|
||||||
authToken = loginData.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'
|
||||||
|
|||||||
@@ -12,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) {
|
||||||
@@ -119,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
|
||||||
@@ -140,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',
|
||||||
@@ -229,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { describe, it, expect, afterAll } from 'vitest';
|
import { describe, it, expect, afterAll } 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -33,11 +34,21 @@ describe('E2E User Journey', () => {
|
|||||||
const registerData = await registerResponse.json();
|
const registerData = await registerResponse.json();
|
||||||
expect(registerData.message).toBe('User registered successfully!');
|
expect(registerData.message).toBe('User registered successfully!');
|
||||||
|
|
||||||
// 2. Login to get the access token
|
// 2. Login to get the access token.
|
||||||
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
// We poll here because even between two API calls (register and login),
|
||||||
|
// there can be a small delay before the newly created user record is visible
|
||||||
|
// 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);
|
||||||
const loginData = await loginResponse.json();
|
|
||||||
authToken = loginData.token;
|
authToken = loginData.token;
|
||||||
userId = loginData.userprofile.user.user_id;
|
userId = loginData.userprofile.user.user_id;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,13 +39,13 @@ 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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,9 +83,10 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
|
|
||||||
// 2. Restore DB Service Mock to real implementation
|
// 2. Restore DB Service Mock to real implementation
|
||||||
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
|
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
|
||||||
const actual = await vi.importActual<typeof import('../../services/db/flyer.db')>('../../services/db/flyer.db');
|
const { withTransaction } = await import('../../services/db/index.db');
|
||||||
vi.mocked(createFlyerAndItems).mockReset();
|
const actualDb = await vi.importActual<typeof import('../../services/db/index.db')>('../../services/db/index.db');
|
||||||
vi.mocked(createFlyerAndItems).mockImplementation(actual.createFlyerAndItems);
|
vi.mocked(withTransaction).mockReset();
|
||||||
|
vi.mocked(withTransaction).mockImplementation(actualDb.withTransaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -128,6 +128,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', 'http://localhost:3000')
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
if (token) {
|
if (token) {
|
||||||
uploadReq.set('Authorization', `Bearer ${token}`);
|
uploadReq.set('Authorization', `Bearer ${token}`);
|
||||||
@@ -245,6 +248,7 @@ 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', 'http://localhost:3000')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
|
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
|
||||||
|
|
||||||
@@ -329,6 +333,7 @@ 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', 'http://localhost:3000')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
|
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
|
||||||
|
|
||||||
@@ -377,7 +382,7 @@ 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');
|
||||||
@@ -394,6 +399,7 @@ 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', 'http://localhost:3000')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
@@ -424,9 +430,11 @@ 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');
|
||||||
@@ -443,6 +451,7 @@ 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', 'http://localhost:3000')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
@@ -475,7 +484,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');
|
||||||
@@ -496,6 +505,7 @@ 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', 'http://localhost:3000')
|
||||||
.field('checksum', checksum)
|
.field('checksum', checksum)
|
||||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
const updatedProfile = response.body;
|
const updatedProfile = response.body;
|
||||||
expect(updatedProfile.avatar_url).toBeDefined();
|
expect(updatedProfile.avatar_url).toBeDefined();
|
||||||
expect(updatedProfile.avatar_url).not.toBeNull();
|
expect(updatedProfile.avatar_url).not.toBeNull();
|
||||||
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/avatar-');
|
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar');
|
||||||
|
|
||||||
// Assert (Verification): Fetch the profile again to ensure the change was persisted
|
// Assert (Verification): Fetch the profile again to ensure the change was persisted
|
||||||
const verifyResponse = await request
|
const verifyResponse = await request
|
||||||
@@ -375,6 +375,6 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Assert: Check for a 400 Bad Request response from the multer error handler.
|
// Assert: Check for a 400 Bad Request response from the multer error handler.
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect(response.body.message).toBe('File too large');
|
expect(response.body.message).toBe('File upload error: File too large');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// src/utils/dateUtils.test.ts
|
// src/utils/dateUtils.test.ts
|
||||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { getSimpleWeekAndYear } from './dateUtils';
|
import {
|
||||||
|
calculateSimpleWeekAndYear,
|
||||||
|
formatShortDate,
|
||||||
|
calculateDaysBetween,
|
||||||
|
formatDateRange,
|
||||||
|
getCurrentDateISOString,
|
||||||
|
} from './dateUtils';
|
||||||
|
|
||||||
describe('dateUtils', () => {
|
describe('dateUtils', () => {
|
||||||
describe('getSimpleWeekAndYear', () => {
|
describe('calculateSimpleWeekAndYear', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Use fake timers to control the current date in tests
|
// Use fake timers to control the current date in tests
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -16,35 +22,35 @@ describe('dateUtils', () => {
|
|||||||
|
|
||||||
it('should return week 1 for the first day of the year', () => {
|
it('should return week 1 for the first day of the year', () => {
|
||||||
const date = new Date('2024-01-01T12:00:00Z');
|
const date = new Date('2024-01-01T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return week 1 for the 7th day of the year', () => {
|
it('should return week 1 for the 7th day of the year', () => {
|
||||||
const date = new Date('2024-01-07T12:00:00Z');
|
const date = new Date('2024-01-07T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return week 2 for the 8th day of the year', () => {
|
it('should return week 2 for the 8th day of the year', () => {
|
||||||
const date = new Date('2024-01-08T12:00:00Z');
|
const date = new Date('2024-01-08T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 2 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly calculate the week for a date in the middle of the year', () => {
|
it('should correctly calculate the week for a date in the middle of the year', () => {
|
||||||
// July 1st is the 183rd day of a leap year. 182 / 7 = 26. So it's week 27.
|
// July 1st is the 183rd day of a leap year. 182 / 7 = 26. So it's week 27.
|
||||||
const date = new Date('2024-07-01T12:00:00Z');
|
const date = new Date('2024-07-01T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 27 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 27 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly calculate the week for the last day of a non-leap year', () => {
|
it('should correctly calculate the week for the last day of a non-leap year', () => {
|
||||||
// Dec 31, 2023 is the 365th day. 364 / 7 = 52. So it's week 53.
|
// Dec 31, 2023 is the 365th day. 364 / 7 = 52. So it's week 53.
|
||||||
const date = new Date('2023-12-31T12:00:00Z');
|
const date = new Date('2023-12-31T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2023, week: 53 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2023, week: 53 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly calculate the week for the last day of a leap year', () => {
|
it('should correctly calculate the week for the last day of a leap year', () => {
|
||||||
// Dec 31, 2024 is the 366th day. 365 / 7 = 52.14. floor(52.14) + 1 = 53.
|
// Dec 31, 2024 is the 366th day. 365 / 7 = 52.14. floor(52.14) + 1 = 53.
|
||||||
const date = new Date('2024-12-31T12:00:00Z');
|
const date = new Date('2024-12-31T12:00:00Z');
|
||||||
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 53 });
|
expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 53 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the current date if no date is provided', () => {
|
it('should use the current date if no date is provided', () => {
|
||||||
@@ -52,7 +58,172 @@ describe('dateUtils', () => {
|
|||||||
vi.setSystemTime(fakeCurrentDate);
|
vi.setSystemTime(fakeCurrentDate);
|
||||||
|
|
||||||
// 40 / 7 = 5.71. floor(5.71) + 1 = 6.
|
// 40 / 7 = 5.71. floor(5.71) + 1 = 6.
|
||||||
expect(getSimpleWeekAndYear()).toEqual({ year: 2025, week: 6 });
|
expect(calculateSimpleWeekAndYear()).toEqual({ year: 2025, week: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty strings as invalid dates', () => {
|
||||||
|
expect(formatDateRange('', '2023-01-05')).toBe('Jan 5');
|
||||||
|
expect(formatDateRange('2023-01-05', '')).toBe('Jan 5');
|
||||||
|
expect(formatDateRange('', '')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle garbage strings as invalid dates', () => {
|
||||||
|
expect(formatDateRange('garbage', '2023-01-05')).toBe('Jan 5');
|
||||||
|
expect(formatDateRange('2023-01-05', 'garbage')).toBe('Jan 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle start date being after end date (raw format)', () => {
|
||||||
|
// The function currently doesn't validate order, it just formats what it's given.
|
||||||
|
// This test ensures it doesn't crash or behave unexpectedly.
|
||||||
|
expect(formatDateRange('2023-02-01', '2023-01-01')).toBe('Feb 1 - Jan 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dates with time components correctly', () => {
|
||||||
|
// parseISO should handle the time component and formatShortDate should strip it
|
||||||
|
expect(formatDateRange('2023-01-01T10:00:00', '2023-01-05T15:30:00')).toBe(
|
||||||
|
'Jan 1 - 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle start date being after end date verbosely', () => {
|
||||||
|
expect(formatDateRange('2023-02-01', '2023-01-01', { verbose: true })).toBe(
|
||||||
|
'Deals valid from February 1, 2023 to January 1, 2023',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentDateISOString', () => {
|
||||||
|
it('should return the current date in YYYY-MM-DD format', () => {
|
||||||
|
const fakeDate = new Date('2025-12-25T10:00:00Z');
|
||||||
|
vi.setSystemTime(fakeDate);
|
||||||
|
expect(getCurrentDateISOString()).toBe('2025-12-25');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// src/utils/dateUtils.ts
|
// src/utils/dateUtils.ts
|
||||||
|
import { parseISO, format, isValid, differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the current year and a simplified week number.
|
* Calculates the current year and a simplified week number.
|
||||||
@@ -7,16 +8,93 @@
|
|||||||
* For true ISO 8601 week numbers (where week 1 is the first week with a Thursday),
|
* For true ISO 8601 week numbers (where week 1 is the first week with a Thursday),
|
||||||
* a dedicated library like `date-fns` (`getISOWeek` and `getISOWeekYear`) is recommended.
|
* a dedicated library like `date-fns` (`getISOWeek` and `getISOWeekYear`) is recommended.
|
||||||
*
|
*
|
||||||
* @param date The date to calculate the week for. Defaults to the current date.
|
* @param date The date to calculate the simple week for. Defaults to the current date.
|
||||||
* @returns An object containing the year and week number.
|
* @returns An object containing the year and week number.
|
||||||
*/
|
*/
|
||||||
export function getSimpleWeekAndYear(date: Date = new Date()): { year: number; week: number } {
|
export function calculateSimpleWeekAndYear(date: Date = new Date()): { year: number; week: number } {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const startOfYear = new Date(year, 0, 1);
|
// Use UTC dates to calculate the difference in days.
|
||||||
// Calculate the difference in days from the start of the year (day 0 to 364/365)
|
// This avoids issues with Daylight Saving Time (DST) where a day might have 23 or 25 hours,
|
||||||
const dayOfYear = (date.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24);
|
// which can cause the millisecond-based calculation to be slightly off (e.g., 149.96 days).
|
||||||
|
const startOfYear = Date.UTC(year, 0, 1);
|
||||||
|
const current = Date.UTC(year, date.getMonth(), date.getDate());
|
||||||
|
const msPerDay = 1000 * 60 * 60 * 24;
|
||||||
|
const dayOfYear = (current - startOfYear) / msPerDay;
|
||||||
// Divide by 7, take the floor to get the zero-based week, and add 1 for a one-based week number.
|
// Divide by 7, take the floor to get the zero-based week, and add 1 for a one-based week number.
|
||||||
const week = Math.floor(dayOfYear / 7) + 1;
|
const week = Math.floor(dayOfYear / 7) + 1;
|
||||||
|
|
||||||
return { year, week };
|
return { year, week };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current date as an ISO 8601 string (YYYY-MM-DD).
|
||||||
|
* Useful for getting "today" without the time component.
|
||||||
|
*/
|
||||||
|
export const getCurrentDateISOString = (): string => {
|
||||||
|
return format(new Date(), 'yyyy-MM-dd');
|
||||||
|
};
|
||||||
|
|
||||||
|
export { calculateSimpleWeekAndYear as getSimpleWeekAndYear };
|
||||||
|
|||||||
33
src/utils/formatUtils.test.ts
Normal file
33
src/utils/formatUtils.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/utils/formatUtils.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatCurrency } from './formatUtils';
|
||||||
|
|
||||||
|
describe('formatCurrency', () => {
|
||||||
|
it('should format a positive integer of cents correctly', () => {
|
||||||
|
expect(formatCurrency(199)).toBe('$1.99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format a larger number of cents correctly', () => {
|
||||||
|
expect(formatCurrency(12345)).toBe('$123.45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-digit cents correctly', () => {
|
||||||
|
expect(formatCurrency(5)).toBe('$0.05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero cents correctly', () => {
|
||||||
|
expect(formatCurrency(0)).toBe('$0.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "N/A" for a null input', () => {
|
||||||
|
expect(formatCurrency(null)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "N/A" for an undefined input', () => {
|
||||||
|
expect(formatCurrency(undefined)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative cents correctly', () => {
|
||||||
|
expect(formatCurrency(-500)).toBe('-$5.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/utils/formatUtils.ts
Normal file
14
src/utils/formatUtils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// src/utils/formatUtils.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a numeric value in cents into a currency string (e.g., $4.99).
|
||||||
|
* Handles different locales and currency symbols gracefully.
|
||||||
|
*
|
||||||
|
* @param amountInCents The amount in cents to format. Can be null or undefined.
|
||||||
|
* @returns A formatted currency string (e.g., "$4.99"), or 'N/A' if the input is null/undefined.
|
||||||
|
*/
|
||||||
|
export const formatCurrency = (amountInCents: number | null | undefined): string => {
|
||||||
|
if (amountInCents === null || amountInCents === undefined) return 'N/A';
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amountInCents / 100);
|
||||||
|
};
|
||||||
@@ -71,11 +71,11 @@ describe('generateFlyerIcon', () => {
|
|||||||
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
|
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
|
||||||
|
|
||||||
// Check that sharp was called with the correct source
|
// Check that sharp was called with the correct source
|
||||||
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath);
|
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath, { failOn: 'none' });
|
||||||
|
|
||||||
// Check the processing chain
|
// Check the processing chain
|
||||||
expect(mocks.resize).toHaveBeenCalledWith(64, 64, { fit: 'cover' });
|
expect(mocks.resize).toHaveBeenCalledWith({ width: 128, height: 128, fit: 'inside' });
|
||||||
expect(mocks.webp).toHaveBeenCalledWith({ quality: 80 });
|
expect(mocks.webp).toHaveBeenCalledWith({ quality: 75 });
|
||||||
expect(mocks.toFile).toHaveBeenCalledWith('/path/to/icons/icon-flyer-image-1.webp');
|
expect(mocks.toFile).toHaveBeenCalledWith('/path/to/icons/icon-flyer-image-1.webp');
|
||||||
|
|
||||||
// Check the returned filename
|
// Check the returned filename
|
||||||
@@ -87,8 +87,11 @@ describe('generateFlyerIcon', () => {
|
|||||||
mocks.toFile.mockRejectedValue(sharpError);
|
mocks.toFile.mockRejectedValue(sharpError);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger),
|
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger), // This was a duplicate, fixed.
|
||||||
).rejects.toThrow('Icon generation failed.');
|
).rejects.toThrow('Failed to generate icon for /path/to/bad-image.jpg.');
|
||||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), 'Failed to generate flyer icon:');
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ err: sharpError, sourcePath: '/path/to/bad-image.jpg', outputPath: '/path/to/icons/icon-bad-image.webp' },
|
||||||
|
'An error occurred during icon generation.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,46 +6,91 @@ import type { Logger } from 'pino';
|
|||||||
import { sanitizeFilename } from './stringUtils';
|
import { sanitizeFilename } from './stringUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a 64x64 square icon from a source image.
|
* Processes an uploaded image file by stripping all metadata (like EXIF)
|
||||||
* @param sourceImagePath The full path to the original image file.
|
* and optimizing it for web use.
|
||||||
* @param iconsDirectory The directory where the icon should be saved.
|
*
|
||||||
* @returns A promise that resolves to the filename of the newly created icon.
|
* @param sourcePath The path to the temporary uploaded file.
|
||||||
* @param logger The request-scoped logger instance, as per ADR-004.
|
* @param destinationDir The directory where the final image should be saved.
|
||||||
* @throws An error if the icon generation fails.
|
* @param originalFileName The original name of the file, used to determine the output name.
|
||||||
|
* @param logger A pino logger instance for logging.
|
||||||
|
* @returns The file name of the newly processed image.
|
||||||
*/
|
*/
|
||||||
export async function generateFlyerIcon(
|
export async function processAndSaveImage(
|
||||||
sourceImagePath: string,
|
sourcePath: string,
|
||||||
iconsDirectory: string,
|
destinationDir: string,
|
||||||
|
originalFileName: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// Create a unique-ish filename to avoid collisions, but keep the original extension.
|
||||||
|
const fileExt = path.extname(originalFileName);
|
||||||
|
const fileBase = path.basename(originalFileName, fileExt);
|
||||||
|
const outputFileName = `${fileBase}-${Date.now()}${fileExt}`;
|
||||||
|
const outputPath = path.join(destinationDir, outputFileName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create a new filename, standardizing the extension to .webp for consistency and performance.
|
// Ensure the destination directory exists.
|
||||||
// We sanitize the original filename to remove spaces and special characters, ensuring URL safety.
|
await fs.mkdir(destinationDir, { recursive: true });
|
||||||
// The sourceImagePath is already sanitized by multer, but we apply it here again for robustness
|
|
||||||
// in case this function is ever called from a different context.
|
|
||||||
const sanitizedBaseName = sanitizeFilename(path.basename(sourceImagePath));
|
|
||||||
const originalFileName = path.parse(sanitizedBaseName).name;
|
|
||||||
const iconFileName = `icon-${originalFileName}.webp`;
|
|
||||||
const iconOutputPath = path.join(iconsDirectory, iconFileName);
|
|
||||||
|
|
||||||
// Ensure the icons subdirectory exists.
|
logger.debug({ sourcePath, outputPath }, 'Starting image processing: stripping metadata and optimizing.');
|
||||||
await fs.mkdir(iconsDirectory, { recursive: true });
|
|
||||||
|
|
||||||
// 2. Use sharp to process the image.
|
// Use sharp to process the image.
|
||||||
await sharp(sourceImagePath)
|
// .withMetadata({}) strips all EXIF and other metadata.
|
||||||
// Use `resize` with a `fit` strategy to prevent distortion.
|
// .jpeg() and .png() apply format-specific optimizations.
|
||||||
// `sharp.fit.cover` will resize to fill 64x64 and crop any excess,
|
await sharp(sourcePath, { failOn: 'none' })
|
||||||
// ensuring the icon is always a non-distorted square.
|
.withMetadata({}) // This is the key to stripping metadata
|
||||||
.resize(64, 64, { fit: sharp.fit.cover })
|
.jpeg({ quality: 85, mozjpeg: true }) // Optimize JPEGs
|
||||||
// 3. Convert the output to WebP format.
|
.png({ compressionLevel: 8, quality: 85 }) // Optimize PNGs
|
||||||
// The `quality` option is a good balance between size and clarity.
|
.toFile(outputPath);
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toFile(iconOutputPath);
|
|
||||||
|
|
||||||
logger.info(`Generated 64x64 icon: ${iconFileName}`);
|
logger.info(`Successfully processed image and saved to ${outputPath}`);
|
||||||
return iconFileName;
|
return outputFileName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, sourceImagePath }, 'Failed to generate flyer icon:');
|
logger.error(
|
||||||
throw new Error('Icon generation failed.');
|
{ err: error, sourcePath, outputPath },
|
||||||
|
'An error occurred during image processing and saving.',
|
||||||
|
);
|
||||||
|
// Re-throw the error to be handled by the calling service (e.g., the worker).
|
||||||
|
throw new Error(`Failed to process image ${originalFileName}.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a small WebP icon from a source image.
|
||||||
|
*
|
||||||
|
* @param sourcePath The path to the source image (can be the original upload or the processed image).
|
||||||
|
* @param outputDir The directory to save the icon in (e.g., 'flyer-images/icons').
|
||||||
|
* @param logger A pino logger instance for logging.
|
||||||
|
* @returns The file name of the generated icon.
|
||||||
|
*/
|
||||||
|
export async function generateFlyerIcon(
|
||||||
|
sourcePath: string,
|
||||||
|
outputDir: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<string> {
|
||||||
|
// Sanitize the base name of the source file to create a clean icon name.
|
||||||
|
const sourceBaseName = path.parse(sourcePath).name;
|
||||||
|
const iconFileName = `icon-${sanitizeFilename(sourceBaseName)}.webp`;
|
||||||
|
const outputPath = path.join(outputDir, iconFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure the output directory exists.
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
logger.debug({ sourcePath, outputPath }, 'Starting icon generation.');
|
||||||
|
|
||||||
|
await sharp(sourcePath, { failOn: 'none' })
|
||||||
|
.resize({ width: 128, height: 128, fit: 'inside' })
|
||||||
|
.webp({ quality: 75 }) // Slightly lower quality for icons is acceptable.
|
||||||
|
.toFile(outputPath);
|
||||||
|
|
||||||
|
logger.info(`Successfully generated icon: ${outputPath}`);
|
||||||
|
return iconFileName;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, sourcePath, outputPath },
|
||||||
|
'An error occurred during icon generation.',
|
||||||
|
);
|
||||||
|
// Re-throw the error to be handled by the calling service.
|
||||||
|
throw new Error(`Failed to generate icon for ${sourcePath}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user