Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a8a8bf4b | ||
| 96038cfcf4 | |||
|
|
981214fdd0 | ||
| 92b0138108 | |||
|
|
27f0255240 | ||
| 4e06dde9e1 | |||
|
|
b9a0e5b82c | ||
| bb7fe8dc2c |
@@ -171,7 +171,7 @@ jobs:
|
||||
else
|
||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||
fi
|
||||
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save
|
||||
echo "Production backend server reloaded successfully."
|
||||
else
|
||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||
|
||||
@@ -476,10 +476,11 @@ jobs:
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
||||
# It will START the process if it's not running, or RELOAD it if it is.
|
||||
# Use `startOrReload` with the TEST ecosystem file. This starts test-specific processes
|
||||
# (flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test)
|
||||
# that run separately from production processes.
|
||||
# We also add `&& pm2 save` to persist the process list across server reboots.
|
||||
pm2 startOrReload ecosystem.config.cjs --env test --update-env && pm2 save
|
||||
pm2 startOrReload ecosystem-test.config.cjs --update-env && pm2 save
|
||||
echo "Test backend server reloaded successfully."
|
||||
|
||||
# After a successful deployment, update the schema hash in the database.
|
||||
|
||||
@@ -244,19 +244,87 @@ For detailed information on secrets management, see [CLAUDE.md](../CLAUDE.md).
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
### Start Application with PM2
|
||||
### PM2 Configuration Files
|
||||
|
||||
The application uses **separate ecosystem config files** for production and test environments:
|
||||
|
||||
| File | Purpose | Processes Started |
|
||||
| --------------------------- | --------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `ecosystem.config.cjs` | Production deployment | `flyer-crawler-api`, `flyer-crawler-worker`, `flyer-crawler-analytics-worker` |
|
||||
| `ecosystem-test.config.cjs` | Test deployment | `flyer-crawler-api-test`, `flyer-crawler-worker-test`, `flyer-crawler-analytics-worker-test` |
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Production and test processes run **simultaneously** with distinct names
|
||||
- Test processes use `NODE_ENV=test` which enables file logging
|
||||
- Test processes use Redis database 1 (isolated from production which uses database 0)
|
||||
- Both configs validate required environment variables but only warn (don't exit) if missing
|
||||
|
||||
### Start Production Application
|
||||
|
||||
```bash
|
||||
cd /opt/flyer-crawler
|
||||
npm run start:prod
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
|
||||
# Set required environment variables (usually done via CI/CD)
|
||||
export DB_HOST=localhost
|
||||
export JWT_SECRET=your-secret
|
||||
export GEMINI_API_KEY=your-api-key
|
||||
# ... other required variables
|
||||
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save
|
||||
```
|
||||
|
||||
This starts three processes:
|
||||
This starts three production processes:
|
||||
|
||||
- `flyer-crawler-api` - Main API server (port 3001)
|
||||
- `flyer-crawler-worker` - Background job worker
|
||||
- `flyer-crawler-analytics-worker` - Analytics processing worker
|
||||
|
||||
### Start Test Application
|
||||
|
||||
```bash
|
||||
cd /var/www/flyer-crawler-test.projectium.com
|
||||
|
||||
# Set required environment variables (usually done via CI/CD)
|
||||
export DB_HOST=localhost
|
||||
export DB_NAME=flyer-crawler-test
|
||||
export JWT_SECRET=your-secret
|
||||
export GEMINI_API_KEY=your-test-api-key
|
||||
export REDIS_URL=redis://localhost:6379/1 # Use database 1 for isolation
|
||||
# ... other required variables
|
||||
|
||||
pm2 startOrReload ecosystem-test.config.cjs --update-env && pm2 save
|
||||
```
|
||||
|
||||
This starts three test processes (running alongside production):
|
||||
|
||||
- `flyer-crawler-api-test` - Test API server (port 3001 via different NGINX vhost)
|
||||
- `flyer-crawler-worker-test` - Test background job worker
|
||||
- `flyer-crawler-analytics-worker-test` - Test analytics worker
|
||||
|
||||
### Verify Running Processes
|
||||
|
||||
After starting both environments, you should see 6 application processes:
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
┌────┬───────────────────────────────────┬──────────┬────────┬───────────┐
|
||||
│ id │ name │ mode │ status │ cpu │
|
||||
├────┼───────────────────────────────────┼──────────┼────────┼───────────┤
|
||||
│ 0 │ flyer-crawler-api │ cluster │ online │ 0% │
|
||||
│ 1 │ flyer-crawler-worker │ fork │ online │ 0% │
|
||||
│ 2 │ flyer-crawler-analytics-worker │ fork │ online │ 0% │
|
||||
│ 3 │ flyer-crawler-api-test │ fork │ online │ 0% │
|
||||
│ 4 │ flyer-crawler-worker-test │ fork │ online │ 0% │
|
||||
│ 5 │ flyer-crawler-analytics-worker-test│ fork │ online │ 0% │
|
||||
└────┴───────────────────────────────────┴──────────┴────────┴───────────┘
|
||||
```
|
||||
|
||||
### Configure PM2 Startup
|
||||
|
||||
```bash
|
||||
@@ -275,6 +343,22 @@ pm2 set pm2-logrotate:retain 14
|
||||
pm2 set pm2-logrotate:compress true
|
||||
```
|
||||
|
||||
### Useful PM2 Commands
|
||||
|
||||
```bash
|
||||
# View logs for a specific process
|
||||
pm2 logs flyer-crawler-api-test --lines 50
|
||||
|
||||
# View environment variables for a process
|
||||
pm2 env <process-id>
|
||||
|
||||
# Restart only test processes
|
||||
pm2 restart flyer-crawler-api-test flyer-crawler-worker-test flyer-crawler-analytics-worker-test
|
||||
|
||||
# Delete all test processes (without affecting production)
|
||||
pm2 delete flyer-crawler-api-test flyer-crawler-worker-test flyer-crawler-analytics-worker-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NGINX Reverse Proxy
|
||||
|
||||
114
ecosystem-test.config.cjs
Normal file
114
ecosystem-test.config.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
// ecosystem-test.config.cjs
|
||||
// PM2 configuration for the TEST environment only.
|
||||
// NOTE: The filename must end with `.config.cjs` for PM2 to recognize it as a config file.
|
||||
// This file defines test-specific apps that run alongside production apps.
|
||||
//
|
||||
// Test apps: flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test
|
||||
//
|
||||
// These apps:
|
||||
// - Run from /var/www/flyer-crawler-test.projectium.com
|
||||
// - Use NODE_ENV='test' (enables file logging in logger.server.ts)
|
||||
// - Use Redis database 1 (isolated from production which uses database 0)
|
||||
// - Have distinct PM2 process names to avoid conflicts with production
|
||||
|
||||
// --- Environment Variable Validation ---
|
||||
// NOTE: We only WARN about missing secrets, not exit.
|
||||
// Calling process.exit(1) prevents PM2 from reading the apps array.
|
||||
// The actual application will fail to start if secrets are missing,
|
||||
// which PM2 will handle with its restart logic.
|
||||
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
|
||||
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
|
||||
|
||||
if (missingSecrets.length > 0) {
|
||||
console.warn('\n[ecosystem.config.test.cjs] WARNING: The following environment variables are MISSING:');
|
||||
missingSecrets.forEach(key => console.warn(` - ${key}`));
|
||||
console.warn('[ecosystem.config.test.cjs] The application may fail to start if these are required.\n');
|
||||
} else {
|
||||
console.log('[ecosystem.config.test.cjs] Critical environment variables are present.');
|
||||
}
|
||||
|
||||
// --- Shared Environment Variables ---
|
||||
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,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
SENTRY_ENABLED: process.env.SENTRY_ENABLED,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
// =========================================================================
|
||||
// TEST APPS
|
||||
// =========================================================================
|
||||
{
|
||||
// --- Test API Server ---
|
||||
name: 'flyer-crawler-api-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'server.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '500M',
|
||||
// Test environment: single instance (no cluster) to conserve resources
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
kill_timeout: 5000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
WORKER_LOCK_DURATION: '120000',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- Test General Worker ---
|
||||
name: 'flyer-crawler-worker-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- Test Analytics Worker ---
|
||||
name: 'flyer-crawler-analytics-worker-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2,18 +2,28 @@
|
||||
// This file is the standard way to configure applications for PM2.
|
||||
// It allows us to define all the settings for our application in one place.
|
||||
// The .cjs extension is required because the project's package.json has "type": "module".
|
||||
//
|
||||
// IMPORTANT: This file defines SEPARATE apps for production and test environments.
|
||||
// Production apps: flyer-crawler-api, flyer-crawler-worker, flyer-crawler-analytics-worker
|
||||
// Test apps: flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test
|
||||
//
|
||||
// Use ecosystem-test.config.cjs for test deployments (contains only test apps).
|
||||
// Use this file (ecosystem.config.cjs) for production deployments.
|
||||
|
||||
// --- Environment Variable Validation ---
|
||||
// NOTE: We only WARN about missing secrets, not exit.
|
||||
// Calling process.exit(1) prevents PM2 from reading the apps array.
|
||||
// The actual application will fail to start if secrets are missing,
|
||||
// which PM2 will handle with its restart logic.
|
||||
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
|
||||
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
|
||||
|
||||
if (missingSecrets.length > 0) {
|
||||
console.warn('\n[ecosystem.config.cjs] ⚠️ WARNING: The following environment variables are MISSING in the shell:');
|
||||
console.warn('\n[ecosystem.config.cjs] WARNING: The following environment variables are MISSING:');
|
||||
missingSecrets.forEach(key => console.warn(` - ${key}`));
|
||||
console.warn('[ecosystem.config.cjs] The application may crash if these are required for startup.\n');
|
||||
process.exit(1); // Fail fast so PM2 doesn't attempt to start a broken app
|
||||
console.warn('[ecosystem.config.cjs] The application may fail to start if these are required.\n');
|
||||
} else {
|
||||
console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.');
|
||||
console.log('[ecosystem.config.cjs] Critical environment variables are present.');
|
||||
}
|
||||
|
||||
// --- Shared Environment Variables ---
|
||||
@@ -35,125 +45,67 @@ const sharedEnv = {
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASS: process.env.SMTP_PASS,
|
||||
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
SENTRY_ENABLED: process.env.SENTRY_ENABLED,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
// =========================================================================
|
||||
// PRODUCTION APPS
|
||||
// =========================================================================
|
||||
{
|
||||
// --- API Server ---
|
||||
// --- Production API Server ---
|
||||
name: 'flyer-crawler-api',
|
||||
// Note: The process names below are referenced in .gitea/workflows/ for status checks.
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'server.ts',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
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
|
||||
kill_timeout: 5000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
|
||||
// Restart Logic
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
|
||||
// Production Environment Settings
|
||||
env_production: {
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
name: 'flyer-crawler-api',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
WORKER_LOCK_DURATION: '120000',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Test Environment Settings
|
||||
env_test: {
|
||||
NODE_ENV: 'test',
|
||||
name: 'flyer-crawler-api-test',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
WORKER_LOCK_DURATION: '120000',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Development Environment Settings
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
name: 'flyer-crawler-api-dev',
|
||||
watch: true,
|
||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||
WORKER_LOCK_DURATION: '120000',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- General Worker ---
|
||||
// --- Production General Worker ---
|
||||
name: 'flyer-crawler-worker',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000, // Workers may need more time to complete a job
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
|
||||
// Restart Logic
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
|
||||
// Production Environment Settings
|
||||
env_production: {
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
name: 'flyer-crawler-worker',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Test Environment Settings
|
||||
env_test: {
|
||||
NODE_ENV: 'test',
|
||||
name: 'flyer-crawler-worker-test',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Development Environment Settings
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
name: 'flyer-crawler-worker-dev',
|
||||
watch: true,
|
||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- Analytics Worker ---
|
||||
// --- Production Analytics Worker ---
|
||||
name: 'flyer-crawler-analytics-worker',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
|
||||
// Restart Logic
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
|
||||
// Production Environment Settings
|
||||
env_production: {
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
name: 'flyer-crawler-analytics-worker',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Test Environment Settings
|
||||
env_test: {
|
||||
NODE_ENV: 'test',
|
||||
name: 'flyer-crawler-analytics-worker-test',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
...sharedEnv,
|
||||
},
|
||||
// Development Environment Settings
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
name: 'flyer-crawler-analytics-worker-dev',
|
||||
watch: true,
|
||||
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.99",
|
||||
"version": "0.9.103",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.99",
|
||||
"version": "0.9.103",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.99",
|
||||
"version": "0.9.103",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -943,13 +943,21 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
||||
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||
raw_text TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
processed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
processed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
-- Columns from migration 003_receipt_scanning_enhancements.sql
|
||||
store_confidence NUMERIC(5,4) CHECK (store_confidence IS NULL OR (store_confidence >= 0 AND store_confidence <= 1)),
|
||||
ocr_provider TEXT,
|
||||
error_details JSONB,
|
||||
retry_count INTEGER DEFAULT 0 CHECK (retry_count >= 0),
|
||||
ocr_confidence NUMERIC(5,4) CHECK (ocr_confidence IS NULL OR (ocr_confidence >= 0 AND ocr_confidence <= 1)),
|
||||
currency TEXT DEFAULT 'CAD'
|
||||
);
|
||||
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
||||
|
||||
-- 53. Store individual line items extracted from a user receipt.
|
||||
CREATE TABLE IF NOT EXISTS public.receipt_items (
|
||||
|
||||
@@ -962,13 +962,21 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
||||
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||
raw_text TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
processed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
processed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
-- Columns from migration 003_receipt_scanning_enhancements.sql
|
||||
store_confidence NUMERIC(5,4) CHECK (store_confidence IS NULL OR (store_confidence >= 0 AND store_confidence <= 1)),
|
||||
ocr_provider TEXT,
|
||||
error_details JSONB,
|
||||
retry_count INTEGER DEFAULT 0 CHECK (retry_count >= 0),
|
||||
ocr_confidence NUMERIC(5,4) CHECK (ocr_confidence IS NULL OR (ocr_confidence >= 0 AND ocr_confidence <= 1)),
|
||||
currency TEXT DEFAULT 'CAD'
|
||||
);
|
||||
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
||||
|
||||
-- 53. Store individual line items extracted from a user receipt.
|
||||
CREATE TABLE IF NOT EXISTS public.receipt_items (
|
||||
|
||||
382
src/components/ErrorBoundary.test.tsx
Normal file
382
src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
// src/components/ErrorBoundary.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
// Mock the sentry.client module
|
||||
vi.mock('../services/sentry.client', () => ({
|
||||
Sentry: {
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
showReportDialog: vi.fn(),
|
||||
},
|
||||
captureException: vi.fn(() => 'mock-event-id-123'),
|
||||
isSentryConfigured: false,
|
||||
}));
|
||||
|
||||
/**
|
||||
* A component that throws an error when rendered.
|
||||
* Used to test ErrorBoundary behavior.
|
||||
*/
|
||||
const ThrowingComponent = ({ shouldThrow = true }: { shouldThrow?: boolean }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error from ThrowingComponent');
|
||||
}
|
||||
return <div>Normal render</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that throws an error with a custom message.
|
||||
*/
|
||||
const ThrowingComponentWithMessage = ({ message }: { message: string }) => {
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
// Suppress console.error during error boundary tests
|
||||
// React logs errors to console when error boundaries catch them
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering children', () => {
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Child content</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child-1">First</div>
|
||||
<div data-testid="child-2">Second</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render nested components', () => {
|
||||
const NestedComponent = () => (
|
||||
<div data-testid="nested">
|
||||
<span>Nested content</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NestedComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('nested')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nested content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catching errors', () => {
|
||||
it('should catch errors thrown by child components', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Should show fallback UI, not the throwing component
|
||||
expect(screen.queryByText('Normal render')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the default error message', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/We're sorry, but an unexpected error occurred/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log error to console', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call captureException with the error', async () => {
|
||||
const { captureException } = await import('../services/sentry.client');
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
componentStack: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom fallback UI', () => {
|
||||
it('should render custom fallback when provided', () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="custom-fallback">Custom error UI</div>}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom error UI')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render React element as fallback', () => {
|
||||
const CustomFallback = () => (
|
||||
<div>
|
||||
<h1>Oops!</h1>
|
||||
<p>Something broke</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<CustomFallback />}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Oops!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Something broke')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError callback', () => {
|
||||
it('should call onError callback when error is caught', () => {
|
||||
const onErrorMock = vi.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onErrorMock}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(onErrorMock).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
componentStack: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass the error message to onError callback', () => {
|
||||
const onErrorMock = vi.fn();
|
||||
const errorMessage = 'Specific test error message';
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onErrorMock}>
|
||||
<ThrowingComponentWithMessage message={errorMessage} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const [error] = onErrorMock.mock.calls[0];
|
||||
expect(error.message).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('should not call onError when no error occurs', () => {
|
||||
const onErrorMock = vi.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onErrorMock}>
|
||||
<ThrowingComponent shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload button', () => {
|
||||
it('should render reload button in default fallback', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call window.location.reload when reload button is clicked', () => {
|
||||
// Mock window.location.reload
|
||||
const reloadMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...originalLocation, reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /reload page/i }));
|
||||
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Restore original location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default fallback UI structure', () => {
|
||||
it('should render error icon', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const svg = document.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('should have proper accessibility attributes', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Check that heading is present
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveTextContent('Something went wrong');
|
||||
});
|
||||
|
||||
it('should have proper styling classes', () => {
|
||||
const { container } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Check for layout classes
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument();
|
||||
expect(container.querySelector('.min-h-screen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state management', () => {
|
||||
it('should set hasError to true when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// If hasError is true, fallback UI is shown
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should store the error in state', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Error is stored and can be displayed in development mode
|
||||
// We verify this by checking the fallback UI is rendered
|
||||
expect(screen.queryByText('Normal render')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDerivedStateFromError', () => {
|
||||
it('should update state correctly via getDerivedStateFromError', () => {
|
||||
const error = new Error('Test error');
|
||||
const result = ErrorBoundary.getDerivedStateFromError(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
hasError: true,
|
||||
error: error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SentryErrorBoundary export', () => {
|
||||
it('should export SentryErrorBoundary', async () => {
|
||||
const { SentryErrorBoundary } = await import('./ErrorBoundary');
|
||||
expect(SentryErrorBoundary).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorBoundary with Sentry configured', () => {
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show report feedback button when Sentry is configured and eventId exists', async () => {
|
||||
// Re-mock with Sentry configured
|
||||
vi.doMock('../services/sentry.client', () => ({
|
||||
Sentry: {
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
showReportDialog: vi.fn(),
|
||||
},
|
||||
captureException: vi.fn(() => 'mock-event-id-456'),
|
||||
isSentryConfigured: true,
|
||||
}));
|
||||
|
||||
// Re-import after mock
|
||||
const { ErrorBoundary: ErrorBoundaryWithSentry } = await import('./ErrorBoundary');
|
||||
|
||||
render(
|
||||
<ErrorBoundaryWithSentry>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundaryWithSentry>,
|
||||
);
|
||||
|
||||
// The report feedback button should be visible when Sentry is configured
|
||||
// Note: Due to module caching, this may not work as expected in all cases
|
||||
// The button visibility depends on isSentryConfigured being true at render time
|
||||
expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
191
src/config.test.ts
Normal file
191
src/config.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// src/config.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import config from './config';
|
||||
|
||||
/**
|
||||
* Tests for src/config.ts - client-side configuration module.
|
||||
*
|
||||
* Note: import.meta.env values are replaced at build time by Vite.
|
||||
* These tests verify the config object structure and the logic for boolean
|
||||
* parsing. Testing dynamic env variable loading requires build-time
|
||||
* configuration changes, so we focus on structure and logic validation.
|
||||
*/
|
||||
describe('config (client-side)', () => {
|
||||
describe('config structure', () => {
|
||||
it('should export a default config object', () => {
|
||||
expect(config).toBeDefined();
|
||||
expect(typeof config).toBe('object');
|
||||
});
|
||||
|
||||
it('should have app section with version, commitMessage, and commitUrl', () => {
|
||||
expect(config).toHaveProperty('app');
|
||||
expect(config.app).toHaveProperty('version');
|
||||
expect(config.app).toHaveProperty('commitMessage');
|
||||
expect(config.app).toHaveProperty('commitUrl');
|
||||
});
|
||||
|
||||
it('should have google section with mapsEmbedApiKey', () => {
|
||||
expect(config).toHaveProperty('google');
|
||||
expect(config.google).toHaveProperty('mapsEmbedApiKey');
|
||||
});
|
||||
|
||||
it('should have sentry section with dsn, environment, debug, and enabled', () => {
|
||||
expect(config).toHaveProperty('sentry');
|
||||
expect(config.sentry).toHaveProperty('dsn');
|
||||
expect(config.sentry).toHaveProperty('environment');
|
||||
expect(config.sentry).toHaveProperty('debug');
|
||||
expect(config.sentry).toHaveProperty('enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app configuration values', () => {
|
||||
it('should have app.version as a string or undefined', () => {
|
||||
expect(
|
||||
typeof config.app.version === 'string' || config.app.version === undefined,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have app.commitMessage as a string or undefined', () => {
|
||||
expect(
|
||||
typeof config.app.commitMessage === 'string' || config.app.commitMessage === undefined,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have app.commitUrl as a string or undefined', () => {
|
||||
expect(
|
||||
typeof config.app.commitUrl === 'string' || config.app.commitUrl === undefined,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('google configuration values', () => {
|
||||
it('should have google.mapsEmbedApiKey as a string or undefined', () => {
|
||||
expect(
|
||||
typeof config.google.mapsEmbedApiKey === 'string' ||
|
||||
config.google.mapsEmbedApiKey === undefined,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sentry configuration values', () => {
|
||||
it('should have sentry.dsn as a string or undefined', () => {
|
||||
expect(typeof config.sentry.dsn === 'string' || config.sentry.dsn === undefined).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have sentry.environment as a string', () => {
|
||||
// environment falls back to MODE, so should always be a string
|
||||
expect(typeof config.sentry.environment).toBe('string');
|
||||
});
|
||||
|
||||
it('should have sentry.debug as a boolean', () => {
|
||||
expect(typeof config.sentry.debug).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have sentry.enabled as a boolean', () => {
|
||||
expect(typeof config.sentry.enabled).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sentry boolean parsing logic', () => {
|
||||
// These tests verify the parsing logic used in config.ts
|
||||
// by testing the same expressions used there
|
||||
// Helper to simulate env var parsing (values come as strings at runtime)
|
||||
const parseDebug = (value: string | undefined): boolean => value === 'true';
|
||||
const parseEnabled = (value: string | undefined): boolean => value !== 'false';
|
||||
|
||||
describe('debug parsing (=== "true")', () => {
|
||||
it('should return true only when value is exactly "true"', () => {
|
||||
expect(parseDebug('true')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when value is "false"', () => {
|
||||
expect(parseDebug('false')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is "1"', () => {
|
||||
expect(parseDebug('1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is empty string', () => {
|
||||
expect(parseDebug('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is undefined', () => {
|
||||
expect(parseDebug(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is "TRUE" (case sensitive)', () => {
|
||||
expect(parseDebug('TRUE')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled parsing (!== "false")', () => {
|
||||
it('should return true when value is undefined (default enabled)', () => {
|
||||
expect(parseEnabled(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when value is empty string', () => {
|
||||
expect(parseEnabled('')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when value is "true"', () => {
|
||||
expect(parseEnabled('true')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false only when value is exactly "false"', () => {
|
||||
expect(parseEnabled('false')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when value is "FALSE" (case sensitive)', () => {
|
||||
expect(parseEnabled('FALSE')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when value is "0"', () => {
|
||||
expect(parseEnabled('0')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment fallback logic', () => {
|
||||
// Tests the || fallback pattern used in config.ts
|
||||
it('should use first value when VITE_SENTRY_ENVIRONMENT is set', () => {
|
||||
const sentryEnv = 'production';
|
||||
const mode = 'development';
|
||||
const result = sentryEnv || mode;
|
||||
expect(result).toBe('production');
|
||||
});
|
||||
|
||||
it('should fall back to MODE when VITE_SENTRY_ENVIRONMENT is undefined', () => {
|
||||
const sentryEnv = undefined;
|
||||
const mode = 'development';
|
||||
const result = sentryEnv || mode;
|
||||
expect(result).toBe('development');
|
||||
});
|
||||
|
||||
it('should fall back to MODE when VITE_SENTRY_ENVIRONMENT is empty string', () => {
|
||||
const sentryEnv = '';
|
||||
const mode = 'development';
|
||||
const result = sentryEnv || mode;
|
||||
expect(result).toBe('development');
|
||||
});
|
||||
});
|
||||
|
||||
describe('current test environment values', () => {
|
||||
// These tests document what the config looks like in the test environment
|
||||
// They help ensure the test setup is working correctly
|
||||
|
||||
it('should have test environment mode', () => {
|
||||
// In test environment, MODE should be 'test'
|
||||
expect(config.sentry.environment).toBe('test');
|
||||
});
|
||||
|
||||
it('should have sentry disabled in test environment by default', () => {
|
||||
// Test environment typically has sentry disabled
|
||||
expect(config.sentry.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have sentry debug disabled in test environment', () => {
|
||||
expect(config.sentry.debug).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
265
src/config/swagger.test.ts
Normal file
265
src/config/swagger.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// src/config/swagger.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swaggerSpec } from './swagger';
|
||||
|
||||
// Type definition for OpenAPI 3.0 spec structure used in tests
|
||||
interface OpenAPISpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
contact?: { name: string };
|
||||
license?: { name: string };
|
||||
};
|
||||
servers: Array<{ url: string; description?: string }>;
|
||||
components: {
|
||||
securitySchemes?: {
|
||||
bearerAuth?: {
|
||||
type: string;
|
||||
scheme: string;
|
||||
bearerFormat?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
tags: Array<{ name: string; description?: string }>;
|
||||
paths?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Cast to typed spec for property access
|
||||
const spec = swaggerSpec as OpenAPISpec;
|
||||
|
||||
/**
|
||||
* Tests for src/config/swagger.ts - OpenAPI/Swagger configuration.
|
||||
*
|
||||
* These tests verify the swagger specification structure and content
|
||||
* without testing the swagger-jsdoc library itself.
|
||||
*/
|
||||
describe('swagger configuration', () => {
|
||||
describe('swaggerSpec export', () => {
|
||||
it('should export a swagger specification object', () => {
|
||||
expect(swaggerSpec).toBeDefined();
|
||||
expect(typeof swaggerSpec).toBe('object');
|
||||
});
|
||||
|
||||
it('should have openapi version 3.0.0', () => {
|
||||
expect(spec.openapi).toBe('3.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('info section', () => {
|
||||
it('should have info object with required fields', () => {
|
||||
expect(spec.info).toBeDefined();
|
||||
expect(spec.info.title).toBe('Flyer Crawler API');
|
||||
expect(spec.info.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should have description', () => {
|
||||
expect(spec.info.description).toBeDefined();
|
||||
expect(spec.info.description).toContain('Flyer Crawler');
|
||||
});
|
||||
|
||||
it('should have contact information', () => {
|
||||
expect(spec.info.contact).toBeDefined();
|
||||
expect(spec.info.contact?.name).toBe('API Support');
|
||||
});
|
||||
|
||||
it('should have license information', () => {
|
||||
expect(spec.info.license).toBeDefined();
|
||||
expect(spec.info.license?.name).toBe('Private');
|
||||
});
|
||||
});
|
||||
|
||||
describe('servers section', () => {
|
||||
it('should have servers array', () => {
|
||||
expect(spec.servers).toBeDefined();
|
||||
expect(Array.isArray(spec.servers)).toBe(true);
|
||||
expect(spec.servers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have /api as the server URL', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api');
|
||||
expect(apiServer).toBeDefined();
|
||||
expect(apiServer?.description).toBe('API server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('components section', () => {
|
||||
it('should have components object', () => {
|
||||
expect(spec.components).toBeDefined();
|
||||
});
|
||||
|
||||
describe('securitySchemes', () => {
|
||||
it('should have bearerAuth security scheme', () => {
|
||||
expect(spec.components.securitySchemes).toBeDefined();
|
||||
expect(spec.components.securitySchemes?.bearerAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure bearerAuth as HTTP bearer with JWT format', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.type).toBe('http');
|
||||
expect(bearerAuth?.scheme).toBe('bearer');
|
||||
expect(bearerAuth?.bearerFormat).toBe('JWT');
|
||||
});
|
||||
|
||||
it('should have description for bearerAuth', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.description).toContain('JWT token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas', () => {
|
||||
const schemas = () => spec.components.schemas as Record<string, any>;
|
||||
|
||||
it('should have schemas object', () => {
|
||||
expect(spec.components.schemas).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have SuccessResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().SuccessResponse;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.data).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('data');
|
||||
});
|
||||
|
||||
it('should have ErrorResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().ErrorResponse;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.error).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('error');
|
||||
});
|
||||
|
||||
it('should have ErrorResponse error object with code and message', () => {
|
||||
const errorSchema = schemas().ErrorResponse.properties.error;
|
||||
expect(errorSchema.properties.code).toBeDefined();
|
||||
expect(errorSchema.properties.message).toBeDefined();
|
||||
expect(errorSchema.required).toContain('code');
|
||||
expect(errorSchema.required).toContain('message');
|
||||
});
|
||||
|
||||
it('should have ServiceHealth schema', () => {
|
||||
const schema = schemas().ServiceHealth;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.status).toBeDefined();
|
||||
expect(schema.properties.status.enum).toContain('healthy');
|
||||
expect(schema.properties.status.enum).toContain('degraded');
|
||||
expect(schema.properties.status.enum).toContain('unhealthy');
|
||||
});
|
||||
|
||||
it('should have Achievement schema', () => {
|
||||
const schema = schemas().Achievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.achievement_id).toBeDefined();
|
||||
expect(schema.properties.name).toBeDefined();
|
||||
expect(schema.properties.description).toBeDefined();
|
||||
expect(schema.properties.icon).toBeDefined();
|
||||
expect(schema.properties.points_value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have UserAchievement schema extending Achievement', () => {
|
||||
const schema = schemas().UserAchievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.allOf).toBeDefined();
|
||||
expect(schema.allOf[0].$ref).toBe('#/components/schemas/Achievement');
|
||||
});
|
||||
|
||||
it('should have LeaderboardUser schema', () => {
|
||||
const schema = schemas().LeaderboardUser;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.user_id).toBeDefined();
|
||||
expect(schema.properties.full_name).toBeDefined();
|
||||
expect(schema.properties.points).toBeDefined();
|
||||
expect(schema.properties.rank).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tags section', () => {
|
||||
it('should have tags array', () => {
|
||||
expect(spec.tags).toBeDefined();
|
||||
expect(Array.isArray(spec.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have Health tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Health');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('health');
|
||||
});
|
||||
|
||||
it('should have Auth tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Auth');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('Authentication');
|
||||
});
|
||||
|
||||
it('should have Users tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Users');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('User');
|
||||
});
|
||||
|
||||
it('should have Achievements tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Achievements');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('Gamification');
|
||||
});
|
||||
|
||||
it('should have Flyers tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Flyers');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Recipes tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Recipes');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Budgets tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Budgets');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Admin tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Admin');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('admin');
|
||||
});
|
||||
|
||||
it('should have System tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'System');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have 9 tags total', () => {
|
||||
expect(spec.tags.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specification validity', () => {
|
||||
it('should have paths object (may be empty if no JSDoc annotations parsed)', () => {
|
||||
// swagger-jsdoc creates paths from JSDoc annotations in route files
|
||||
// In test environment, this may be empty if routes aren't scanned
|
||||
expect(swaggerSpec).toHaveProperty('paths');
|
||||
});
|
||||
|
||||
it('should be a valid JSON-serializable object', () => {
|
||||
expect(() => JSON.stringify(swaggerSpec)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce valid JSON output', () => {
|
||||
const json = JSON.stringify(swaggerSpec);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
349
src/services/cacheService.server.test.ts
Normal file
349
src/services/cacheService.server.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// src/services/cacheService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Use vi.hoisted to ensure mockRedis is available before vi.mock runs
|
||||
const { mockRedis } = vi.hoisted(() => ({
|
||||
mockRedis: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
scan: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./redis.server', () => ({
|
||||
connection: mockRedis,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.server', async () => ({
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
import { cacheService, CACHE_TTL, CACHE_PREFIX } from './cacheService.server';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
describe('cacheService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('CACHE_TTL constants', () => {
|
||||
it('should have BRANDS TTL of 1 hour', () => {
|
||||
expect(CACHE_TTL.BRANDS).toBe(60 * 60);
|
||||
});
|
||||
|
||||
it('should have FLYERS TTL of 5 minutes', () => {
|
||||
expect(CACHE_TTL.FLYERS).toBe(5 * 60);
|
||||
});
|
||||
|
||||
it('should have FLYER TTL of 10 minutes', () => {
|
||||
expect(CACHE_TTL.FLYER).toBe(10 * 60);
|
||||
});
|
||||
|
||||
it('should have FLYER_ITEMS TTL of 10 minutes', () => {
|
||||
expect(CACHE_TTL.FLYER_ITEMS).toBe(10 * 60);
|
||||
});
|
||||
|
||||
it('should have STATS TTL of 5 minutes', () => {
|
||||
expect(CACHE_TTL.STATS).toBe(5 * 60);
|
||||
});
|
||||
|
||||
it('should have FREQUENT_SALES TTL of 15 minutes', () => {
|
||||
expect(CACHE_TTL.FREQUENT_SALES).toBe(15 * 60);
|
||||
});
|
||||
|
||||
it('should have CATEGORIES TTL of 1 hour', () => {
|
||||
expect(CACHE_TTL.CATEGORIES).toBe(60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CACHE_PREFIX constants', () => {
|
||||
it('should have correct prefix values', () => {
|
||||
expect(CACHE_PREFIX.BRANDS).toBe('cache:brands');
|
||||
expect(CACHE_PREFIX.FLYERS).toBe('cache:flyers');
|
||||
expect(CACHE_PREFIX.FLYER).toBe('cache:flyer');
|
||||
expect(CACHE_PREFIX.FLYER_ITEMS).toBe('cache:flyer-items');
|
||||
expect(CACHE_PREFIX.STATS).toBe('cache:stats');
|
||||
expect(CACHE_PREFIX.FREQUENT_SALES).toBe('cache:frequent-sales');
|
||||
expect(CACHE_PREFIX.CATEGORIES).toBe('cache:categories');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return parsed JSON on cache hit', async () => {
|
||||
const testData = { foo: 'bar', count: 42 };
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(testData));
|
||||
|
||||
const result = await cacheService.get<typeof testData>('test-key');
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockRedis.get).toHaveBeenCalledWith('test-key');
|
||||
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache hit');
|
||||
});
|
||||
|
||||
it('should return null on cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
const result = await cacheService.get('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
||||
});
|
||||
|
||||
it('should return null and log warning on Redis error', async () => {
|
||||
const error = new Error('Redis connection failed');
|
||||
mockRedis.get.mockRejectedValue(error);
|
||||
|
||||
const result = await cacheService.get('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error, cacheKey: 'test-key' },
|
||||
'Redis GET failed, proceeding without cache',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided logger', async () => {
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
await cacheService.get('test-key', customLogger);
|
||||
|
||||
expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should store JSON stringified value with TTL', async () => {
|
||||
const testData = { foo: 'bar' };
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
await cacheService.set('test-key', testData, 300);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith('test-key', JSON.stringify(testData), 'EX', 300);
|
||||
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key', ttl: 300 }, 'Value cached');
|
||||
});
|
||||
|
||||
it('should log warning on Redis error', async () => {
|
||||
const error = new Error('Redis write failed');
|
||||
mockRedis.set.mockRejectedValue(error);
|
||||
|
||||
await cacheService.set('test-key', { data: 'value' }, 300);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error, cacheKey: 'test-key' },
|
||||
'Redis SET failed, value not cached',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided logger', async () => {
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
await cacheService.set('test-key', 'value', 300, customLogger);
|
||||
|
||||
expect(customLogger.debug).toHaveBeenCalledWith(
|
||||
{ cacheKey: 'test-key', ttl: 300 },
|
||||
'Value cached',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('del', () => {
|
||||
it('should delete key from cache', async () => {
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
await cacheService.del('test-key');
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('test-key');
|
||||
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache key deleted');
|
||||
});
|
||||
|
||||
it('should log warning on Redis error', async () => {
|
||||
const error = new Error('Redis delete failed');
|
||||
mockRedis.del.mockRejectedValue(error);
|
||||
|
||||
await cacheService.del('test-key');
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error, cacheKey: 'test-key' },
|
||||
'Redis DEL failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided logger', async () => {
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
await cacheService.del('test-key', customLogger);
|
||||
|
||||
expect(customLogger.debug).toHaveBeenCalledWith(
|
||||
{ cacheKey: 'test-key' },
|
||||
'Cache key deleted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidatePattern', () => {
|
||||
it('should scan and delete keys matching pattern', async () => {
|
||||
// First scan returns some keys, second scan returns cursor '0' to stop
|
||||
mockRedis.scan
|
||||
.mockResolvedValueOnce(['1', ['cache:test:1', 'cache:test:2']])
|
||||
.mockResolvedValueOnce(['0', ['cache:test:3']]);
|
||||
mockRedis.del.mockResolvedValue(2).mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
||||
|
||||
const result = await cacheService.invalidatePattern('cache:test:*');
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:test:*', 'COUNT', 100);
|
||||
expect(mockRedis.del).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ pattern: 'cache:test:*', totalDeleted: 3 },
|
||||
'Cache invalidation completed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty scan results', async () => {
|
||||
mockRedis.scan.mockResolvedValue(['0', []]);
|
||||
|
||||
const result = await cacheService.invalidatePattern('cache:empty:*');
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw and log error on Redis failure', async () => {
|
||||
const error = new Error('Redis scan failed');
|
||||
mockRedis.scan.mockRejectedValue(error);
|
||||
|
||||
await expect(cacheService.invalidatePattern('cache:test:*')).rejects.toThrow(error);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: error, pattern: 'cache:test:*' },
|
||||
'Cache invalidation failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrSet', () => {
|
||||
it('should return cached value on cache hit', async () => {
|
||||
const cachedData = { id: 1, name: 'Test' };
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||
const fetcher = vi.fn();
|
||||
|
||||
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
||||
|
||||
expect(result).toEqual(cachedData);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call fetcher and cache result on cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const freshData = { id: 2, name: 'Fresh' };
|
||||
const fetcher = vi.fn().mockResolvedValue(freshData);
|
||||
|
||||
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
||||
|
||||
expect(result).toEqual(freshData);
|
||||
expect(fetcher).toHaveBeenCalled();
|
||||
// set is fire-and-forget, but we can verify it was called
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'test-key',
|
||||
JSON.stringify(freshData),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use provided logger from options', async () => {
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const fetcher = vi.fn().mockResolvedValue({ data: 'value' });
|
||||
|
||||
await cacheService.getOrSet('test-key', fetcher, { ttl: 300, logger: customLogger });
|
||||
|
||||
expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
||||
});
|
||||
|
||||
it('should not throw if set fails after fetching', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockRejectedValue(new Error('Redis write failed'));
|
||||
const freshData = { id: 3, name: 'Data' };
|
||||
const fetcher = vi.fn().mockResolvedValue(freshData);
|
||||
|
||||
// Should not throw - set failures are caught internally
|
||||
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
||||
|
||||
expect(result).toEqual(freshData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateBrands', () => {
|
||||
it('should invalidate all brand cache entries', async () => {
|
||||
mockRedis.scan.mockResolvedValue(['0', ['cache:brands:1', 'cache:brands:2']]);
|
||||
mockRedis.del.mockResolvedValue(2);
|
||||
|
||||
const result = await cacheService.invalidateBrands();
|
||||
|
||||
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:brands*', 'COUNT', 100);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateFlyers', () => {
|
||||
it('should invalidate all flyer-related cache entries', async () => {
|
||||
// Mock scan for each pattern
|
||||
mockRedis.scan
|
||||
.mockResolvedValueOnce(['0', ['cache:flyers:list']])
|
||||
.mockResolvedValueOnce(['0', ['cache:flyer:1', 'cache:flyer:2']])
|
||||
.mockResolvedValueOnce(['0', ['cache:flyer-items:1']]);
|
||||
mockRedis.del.mockResolvedValueOnce(1).mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
||||
|
||||
const result = await cacheService.invalidateFlyers();
|
||||
|
||||
expect(result).toBe(4);
|
||||
expect(mockRedis.scan).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateFlyer', () => {
|
||||
it('should invalidate specific flyer and its items', async () => {
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
mockRedis.scan.mockResolvedValue(['0', []]);
|
||||
|
||||
await cacheService.invalidateFlyer(123);
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer:123');
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer-items:123');
|
||||
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:flyers*', 'COUNT', 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateStats', () => {
|
||||
it('should invalidate all stats cache entries', async () => {
|
||||
mockRedis.scan.mockResolvedValue(['0', ['cache:stats:daily', 'cache:stats:weekly']]);
|
||||
mockRedis.del.mockResolvedValue(2);
|
||||
|
||||
const result = await cacheService.invalidateStats();
|
||||
|
||||
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:stats*', 'COUNT', 100);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ interface ReceiptRow {
|
||||
raw_text: string | null;
|
||||
store_confidence: number | null;
|
||||
ocr_provider: OcrProvider | null;
|
||||
error_details: string | null;
|
||||
// JSONB columns are automatically parsed by pg driver
|
||||
error_details: Record<string, unknown> | null;
|
||||
retry_count: number;
|
||||
ocr_confidence: number | null;
|
||||
currency: string;
|
||||
@@ -1036,7 +1037,7 @@ export class ReceiptRepository {
|
||||
raw_text: row.raw_text,
|
||||
store_confidence: row.store_confidence !== null ? Number(row.store_confidence) : null,
|
||||
ocr_provider: row.ocr_provider,
|
||||
error_details: row.error_details ? JSON.parse(row.error_details) : null,
|
||||
error_details: row.error_details ?? null,
|
||||
retry_count: row.retry_count,
|
||||
ocr_confidence: row.ocr_confidence !== null ? Number(row.ocr_confidence) : null,
|
||||
currency: row.currency,
|
||||
|
||||
@@ -124,4 +124,171 @@ describe('Server Logger', () => {
|
||||
mockMultistream,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use LOG_DIR environment variable when set', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('LOG_DIR', '/custom/log/dir');
|
||||
await import('./logger.server');
|
||||
|
||||
// Should use the custom LOG_DIR in the file path
|
||||
expect(mockDestination).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dest: '/custom/log/dir/app.log',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to stdout only when log directory creation fails', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
// Mock fs.existsSync to return false (dir doesn't exist)
|
||||
// and mkdirSync to throw an error
|
||||
const fs = await import('fs');
|
||||
vi.mocked(fs.default.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.default.mkdirSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
// Suppress console.error during this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await import('./logger.server');
|
||||
|
||||
// Should have tried to create directory
|
||||
expect(fs.default.mkdirSync).toHaveBeenCalled();
|
||||
|
||||
// Should log error to console
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to create log directory'),
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Should fall back to stdout-only logger (no multistream)
|
||||
// When logDir is null, pino is called without multistream
|
||||
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('createScopedLogger', () => {
|
||||
it('should create a child logger with module name', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const { createScopedLogger } = await import('./logger.server');
|
||||
|
||||
const scopedLogger = createScopedLogger('test-module');
|
||||
|
||||
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ module: 'test-module' }),
|
||||
);
|
||||
expect(scopedLogger).toBeDefined();
|
||||
});
|
||||
|
||||
it('should enable debug level when DEBUG_MODULES includes module name', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('DEBUG_MODULES', 'test-module,other-module');
|
||||
const { createScopedLogger } = await import('./logger.server');
|
||||
|
||||
createScopedLogger('test-module');
|
||||
|
||||
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'test-module',
|
||||
level: 'debug',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable debug level when DEBUG_MODULES includes wildcard', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('DEBUG_MODULES', '*');
|
||||
const { createScopedLogger } = await import('./logger.server');
|
||||
|
||||
createScopedLogger('any-module');
|
||||
|
||||
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'any-module',
|
||||
level: 'debug',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default level when module not in DEBUG_MODULES', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('DEBUG_MODULES', 'other-module');
|
||||
const { createScopedLogger } = await import('./logger.server');
|
||||
|
||||
createScopedLogger('test-module');
|
||||
|
||||
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'test-module',
|
||||
level: 'info', // Uses logger.level which is 'info'
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty DEBUG_MODULES', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('DEBUG_MODULES', '');
|
||||
const { createScopedLogger } = await import('./logger.server');
|
||||
|
||||
createScopedLogger('test-module');
|
||||
|
||||
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'test-module',
|
||||
level: 'info',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redaction configuration', () => {
|
||||
it('should configure redaction for sensitive fields', async () => {
|
||||
// Reset fs mock to ensure directory creation succeeds
|
||||
const fs = await import('fs');
|
||||
vi.mocked(fs.default.existsSync).mockReturnValue(true);
|
||||
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
await import('./logger.server');
|
||||
|
||||
// Verify redact configuration is passed to pino
|
||||
// When log directory exists, pino is called with config and multistream
|
||||
expect(pinoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
redact: expect.objectContaining({
|
||||
paths: expect.arrayContaining([
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'*.body.password',
|
||||
'*.body.newPassword',
|
||||
'*.body.currentPassword',
|
||||
'*.body.confirmPassword',
|
||||
'*.body.refreshToken',
|
||||
'*.body.token',
|
||||
]),
|
||||
censor: '[REDACTED]',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment detection', () => {
|
||||
it('should treat undefined NODE_ENV as development', async () => {
|
||||
vi.stubEnv('NODE_ENV', '');
|
||||
await import('./logger.server');
|
||||
|
||||
// Development uses pino-pretty transport
|
||||
expect(pinoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transport: expect.objectContaining({
|
||||
target: 'pino-pretty',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -787,5 +787,252 @@ describe('receiptService.server', () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when updating receipt status fails after processing error', async () => {
|
||||
const mockReceipt = {
|
||||
receipt_id: 1,
|
||||
user_id: 'user-1',
|
||||
store_id: null,
|
||||
receipt_image_url: '/uploads/receipt.jpg',
|
||||
transaction_date: null,
|
||||
total_amount_cents: null,
|
||||
status: 'pending' as ReceiptStatus,
|
||||
raw_text: null,
|
||||
store_confidence: null,
|
||||
ocr_provider: null,
|
||||
error_details: null,
|
||||
retry_count: 0,
|
||||
ocr_confidence: null,
|
||||
currency: 'USD',
|
||||
created_at: new Date().toISOString(),
|
||||
processed_at: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// First call returns receipt, then processReceipt calls it internally
|
||||
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
|
||||
|
||||
// All updateReceipt calls fail
|
||||
vi.mocked(receiptRepo.updateReceipt).mockRejectedValue(new Error('Database unavailable'));
|
||||
|
||||
vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1);
|
||||
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
|
||||
|
||||
const mockJob = {
|
||||
id: 'job-4',
|
||||
data: {
|
||||
receiptId: 1,
|
||||
userId: 'user-1',
|
||||
},
|
||||
attemptsMade: 1,
|
||||
} as Job<ReceiptJobData>;
|
||||
|
||||
// When all updateReceipt calls fail, the error is propagated
|
||||
await expect(processReceiptJob(mockJob, mockLogger)).rejects.toThrow('Database unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
// Test internal logic patterns used in the service
|
||||
describe('receipt text parsing patterns', () => {
|
||||
// These test the regex patterns and logic used in parseReceiptText
|
||||
|
||||
it('should match price pattern at end of line', () => {
|
||||
const pricePattern = /\$?(\d+)\.(\d{2})\s*$/;
|
||||
|
||||
expect('MILK 2% $4.99'.match(pricePattern)).toBeTruthy();
|
||||
expect('BREAD 2.49'.match(pricePattern)).toBeTruthy();
|
||||
expect('Item Name $12.00'.match(pricePattern)).toBeTruthy();
|
||||
expect('No price here'.match(pricePattern)).toBeNull();
|
||||
});
|
||||
|
||||
it('should match quantity pattern', () => {
|
||||
const quantityPattern = /^(\d+)\s*[@xX]/;
|
||||
|
||||
expect('2 @ $3.99 APPLES'.match(quantityPattern)?.[1]).toBe('2');
|
||||
expect('3x Bananas'.match(quantityPattern)?.[1]).toBe('3');
|
||||
expect('5X ITEM'.match(quantityPattern)?.[1]).toBe('5');
|
||||
expect('Regular Item'.match(quantityPattern)).toBeNull();
|
||||
});
|
||||
|
||||
it('should identify discount lines', () => {
|
||||
const isDiscount = (line: string) =>
|
||||
line.includes('-') || line.toLowerCase().includes('discount');
|
||||
|
||||
expect(isDiscount('COUPON DISCOUNT -$2.00')).toBe(true);
|
||||
expect(isDiscount('MEMBER DISCOUNT')).toBe(true);
|
||||
expect(isDiscount('-$1.50')).toBe(true);
|
||||
expect(isDiscount('Regular Item $4.99')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receipt header/footer detection patterns', () => {
|
||||
// Test the isHeaderOrFooter logic
|
||||
const skipPatterns = [
|
||||
'thank you',
|
||||
'thanks for',
|
||||
'visit us',
|
||||
'total',
|
||||
'subtotal',
|
||||
'tax',
|
||||
'change',
|
||||
'cash',
|
||||
'credit',
|
||||
'debit',
|
||||
'visa',
|
||||
'mastercard',
|
||||
'approved',
|
||||
'transaction',
|
||||
'terminal',
|
||||
'receipt',
|
||||
'store #',
|
||||
'date:',
|
||||
'time:',
|
||||
'cashier',
|
||||
];
|
||||
|
||||
const isHeaderOrFooter = (line: string): boolean => {
|
||||
const lowercaseLine = line.toLowerCase();
|
||||
return skipPatterns.some((pattern) => lowercaseLine.includes(pattern));
|
||||
};
|
||||
|
||||
it('should skip thank you lines', () => {
|
||||
expect(isHeaderOrFooter('THANK YOU FOR SHOPPING')).toBe(true);
|
||||
expect(isHeaderOrFooter('Thanks for visiting!')).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip total/subtotal lines', () => {
|
||||
expect(isHeaderOrFooter('SUBTOTAL $45.99')).toBe(true);
|
||||
expect(isHeaderOrFooter('TOTAL $49.99')).toBe(true);
|
||||
expect(isHeaderOrFooter('TAX $3.00')).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip payment method lines', () => {
|
||||
expect(isHeaderOrFooter('VISA **** 1234')).toBe(true);
|
||||
expect(isHeaderOrFooter('MASTERCARD APPROVED')).toBe(true);
|
||||
expect(isHeaderOrFooter('CASH TENDERED')).toBe(true);
|
||||
expect(isHeaderOrFooter('CREDIT CARD')).toBe(true);
|
||||
expect(isHeaderOrFooter('DEBIT $50.00')).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip store info lines', () => {
|
||||
expect(isHeaderOrFooter('Store #1234')).toBe(true);
|
||||
expect(isHeaderOrFooter('DATE: 01/15/2024')).toBe(true);
|
||||
expect(isHeaderOrFooter('TIME: 14:30')).toBe(true);
|
||||
expect(isHeaderOrFooter('Cashier: John')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow regular item lines', () => {
|
||||
expect(isHeaderOrFooter('MILK 2% $4.99')).toBe(false);
|
||||
expect(isHeaderOrFooter('BREAD WHOLE WHEAT')).toBe(false);
|
||||
expect(isHeaderOrFooter('BANANAS 2.5LB')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receipt metadata extraction patterns', () => {
|
||||
// Test the extractReceiptMetadata logic
|
||||
|
||||
it('should extract total amount from different formats', () => {
|
||||
const totalPatterns = [
|
||||
/total[:\s]+\$?(\d+)\.(\d{2})/i,
|
||||
/grand total[:\s]+\$?(\d+)\.(\d{2})/i,
|
||||
/amount due[:\s]+\$?(\d+)\.(\d{2})/i,
|
||||
];
|
||||
|
||||
const extractTotal = (text: string): number | undefined => {
|
||||
for (const pattern of totalPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10) * 100 + parseInt(match[2], 10);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
expect(extractTotal('TOTAL: $45.99')).toBe(4599);
|
||||
expect(extractTotal('Grand Total $123.00')).toBe(12300);
|
||||
expect(extractTotal('AMOUNT DUE: 78.50')).toBe(7850);
|
||||
expect(extractTotal('No total here')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract date from MM/DD/YYYY format', () => {
|
||||
const datePattern = /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/;
|
||||
|
||||
const match1 = '01/15/2024'.match(datePattern);
|
||||
expect(match1?.[1]).toBe('01');
|
||||
expect(match1?.[2]).toBe('15');
|
||||
expect(match1?.[3]).toBe('2024');
|
||||
|
||||
const match2 = '1/5/24'.match(datePattern);
|
||||
expect(match2?.[1]).toBe('1');
|
||||
expect(match2?.[2]).toBe('5');
|
||||
expect(match2?.[3]).toBe('24');
|
||||
});
|
||||
|
||||
it('should extract date from YYYY-MM-DD format', () => {
|
||||
const datePattern = /(\d{4})-(\d{2})-(\d{2})/;
|
||||
|
||||
const match = '2024-01-15'.match(datePattern);
|
||||
expect(match?.[1]).toBe('2024');
|
||||
expect(match?.[2]).toBe('01');
|
||||
expect(match?.[3]).toBe('15');
|
||||
});
|
||||
|
||||
it('should convert 2-digit years to 4-digit years', () => {
|
||||
const convertYear = (year: number): number => {
|
||||
if (year < 100) {
|
||||
return year + 2000;
|
||||
}
|
||||
return year;
|
||||
};
|
||||
|
||||
expect(convertYear(24)).toBe(2024);
|
||||
expect(convertYear(99)).toBe(2099);
|
||||
expect(convertYear(2024)).toBe(2024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCR extraction edge cases', () => {
|
||||
// These test the logic in performOcrExtraction
|
||||
|
||||
it('should determine if URL is local path', () => {
|
||||
const isLocalPath = (url: string) => !url.startsWith('http');
|
||||
|
||||
expect(isLocalPath('/uploads/receipt.jpg')).toBe(true);
|
||||
expect(isLocalPath('./images/receipt.png')).toBe(true);
|
||||
expect(isLocalPath('https://example.com/receipt.jpg')).toBe(false);
|
||||
expect(isLocalPath('http://localhost/receipt.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('should determine MIME type from extension', () => {
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const getMimeType = (ext: string) => mimeTypeMap[ext] || 'image/jpeg';
|
||||
|
||||
expect(getMimeType('.jpg')).toBe('image/jpeg');
|
||||
expect(getMimeType('.jpeg')).toBe('image/jpeg');
|
||||
expect(getMimeType('.png')).toBe('image/png');
|
||||
expect(getMimeType('.gif')).toBe('image/gif');
|
||||
expect(getMimeType('.webp')).toBe('image/webp');
|
||||
expect(getMimeType('.unknown')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should format extracted items as text', () => {
|
||||
const extractedItems = [
|
||||
{ raw_item_description: 'MILK 2%', price_paid_cents: 499 },
|
||||
{ raw_item_description: 'BREAD', price_paid_cents: 299 },
|
||||
];
|
||||
|
||||
const textLines = extractedItems.map(
|
||||
(item) => `${item.raw_item_description} - $${(item.price_paid_cents / 100).toFixed(2)}`,
|
||||
);
|
||||
|
||||
expect(textLines).toEqual(['MILK 2% - $4.99', 'BREAD - $2.99']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
300
src/services/sentry.client.test.ts
Normal file
300
src/services/sentry.client.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
// src/services/sentry.client.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Use vi.hoisted to define mocks that need to be available before vi.mock runs
|
||||
const { mockSentry, mockLogger } = vi.hoisted(() => ({
|
||||
mockSentry: {
|
||||
init: vi.fn(),
|
||||
captureException: vi.fn(() => 'mock-event-id'),
|
||||
captureMessage: vi.fn(() => 'mock-message-id'),
|
||||
setContext: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
addBreadcrumb: vi.fn(),
|
||||
breadcrumbsIntegration: vi.fn(() => ({})),
|
||||
ErrorBoundary: vi.fn(),
|
||||
},
|
||||
mockLogger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@sentry/react', () => mockSentry);
|
||||
|
||||
vi.mock('./logger.client', () => ({
|
||||
logger: mockLogger,
|
||||
default: mockLogger,
|
||||
}));
|
||||
|
||||
describe('sentry.client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('with Sentry disabled (default test environment)', () => {
|
||||
// The test environment has Sentry disabled by default (VITE_SENTRY_DSN not set)
|
||||
// Import the module fresh for each test
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should have isSentryConfigured as false in test environment', async () => {
|
||||
const { isSentryConfigured } = await import('./sentry.client');
|
||||
expect(isSentryConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('should not initialize Sentry when not configured', async () => {
|
||||
const { initSentry, isSentryConfigured } = await import('./sentry.client');
|
||||
|
||||
initSentry();
|
||||
|
||||
// When Sentry is not configured, Sentry.init should NOT be called
|
||||
if (!isSentryConfigured) {
|
||||
expect(mockSentry.init).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined from captureException when not configured', async () => {
|
||||
const { captureException } = await import('./sentry.client');
|
||||
|
||||
const result = captureException(new Error('test error'));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined from captureMessage when not configured', async () => {
|
||||
const { captureMessage } = await import('./sentry.client');
|
||||
|
||||
const result = captureMessage('test message');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set user when not configured', async () => {
|
||||
const { setUser } = await import('./sentry.client');
|
||||
|
||||
setUser({ id: '123', email: 'test@example.com' });
|
||||
|
||||
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add breadcrumb when not configured', async () => {
|
||||
const { addBreadcrumb } = await import('./sentry.client');
|
||||
|
||||
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
|
||||
|
||||
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry re-export', () => {
|
||||
it('should re-export Sentry object', async () => {
|
||||
const { Sentry } = await import('./sentry.client');
|
||||
|
||||
expect(Sentry).toBeDefined();
|
||||
expect(Sentry.init).toBeDefined();
|
||||
expect(Sentry.captureException).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initSentry beforeSend filter logic', () => {
|
||||
// Test the beforeSend filter function logic in isolation
|
||||
// This tests the filter that's passed to Sentry.init
|
||||
|
||||
it('should filter out browser extension errors', () => {
|
||||
// Simulate the beforeSend logic from the implementation
|
||||
const filterExtensionErrors = (event: {
|
||||
exception?: {
|
||||
values?: Array<{
|
||||
stacktrace?: {
|
||||
frames?: Array<{ filename?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
||||
frame.filename?.includes('extension://'),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const extensionError = {
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
stacktrace: {
|
||||
frames: [{ filename: 'chrome-extension://abc123/script.js' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterExtensionErrors(extensionError)).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow normal errors through', () => {
|
||||
const filterExtensionErrors = (event: {
|
||||
exception?: {
|
||||
values?: Array<{
|
||||
stacktrace?: {
|
||||
frames?: Array<{ filename?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
||||
frame.filename?.includes('extension://'),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const normalError = {
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
stacktrace: {
|
||||
frames: [{ filename: '/app/src/index.js' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterExtensionErrors(normalError)).toBe(normalError);
|
||||
});
|
||||
|
||||
it('should handle events without exception property', () => {
|
||||
const filterExtensionErrors = (event: {
|
||||
exception?: {
|
||||
values?: Array<{
|
||||
stacktrace?: {
|
||||
frames?: Array<{ filename?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
||||
frame.filename?.includes('extension://'),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const eventWithoutException = { message: 'test' };
|
||||
|
||||
expect(filterExtensionErrors(eventWithoutException as any)).toBe(eventWithoutException);
|
||||
});
|
||||
|
||||
it('should handle firefox extension URLs', () => {
|
||||
const filterExtensionErrors = (event: {
|
||||
exception?: {
|
||||
values?: Array<{
|
||||
stacktrace?: {
|
||||
frames?: Array<{ filename?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
||||
frame.filename?.includes('extension://'),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const firefoxExtensionError = {
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
stacktrace: {
|
||||
frames: [{ filename: 'moz-extension://abc123/script.js' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterExtensionErrors(firefoxExtensionError)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSentryConfigured logic', () => {
|
||||
// Test the logic that determines if Sentry is configured
|
||||
// This mirrors the implementation: !!config.sentry.dsn && config.sentry.enabled
|
||||
|
||||
it('should return false when DSN is empty', () => {
|
||||
const dsn = '';
|
||||
const enabled = true;
|
||||
const result = !!dsn && enabled;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when enabled is false', () => {
|
||||
const dsn = 'https://test@sentry.io/123';
|
||||
const enabled = false;
|
||||
const result = !!dsn && enabled;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when DSN is set and enabled is true', () => {
|
||||
const dsn = 'https://test@sentry.io/123';
|
||||
const enabled = true;
|
||||
const result = !!dsn && enabled;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when DSN is undefined', () => {
|
||||
const dsn = undefined;
|
||||
const enabled = true;
|
||||
const result = !!dsn && enabled;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureException logic', () => {
|
||||
it('should set context before capturing when context is provided', () => {
|
||||
// This tests the conditional context setting logic
|
||||
const context = { userId: '123' };
|
||||
const shouldSetContext = !!context;
|
||||
expect(shouldSetContext).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set context when not provided', () => {
|
||||
const context = undefined;
|
||||
const shouldSetContext = !!context;
|
||||
expect(shouldSetContext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureMessage default level', () => {
|
||||
it('should default to info level', () => {
|
||||
// Test the default parameter behavior
|
||||
const defaultLevel = 'info';
|
||||
expect(defaultLevel).toBe('info');
|
||||
});
|
||||
});
|
||||
});
|
||||
338
src/services/sentry.server.test.ts
Normal file
338
src/services/sentry.server.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// src/services/sentry.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Use vi.hoisted to define mocks that need to be available before vi.mock runs
|
||||
const { mockSentry, mockLogger } = vi.hoisted(() => ({
|
||||
mockSentry: {
|
||||
init: vi.fn(),
|
||||
captureException: vi.fn(() => 'mock-event-id'),
|
||||
captureMessage: vi.fn(() => 'mock-message-id'),
|
||||
setContext: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
addBreadcrumb: vi.fn(),
|
||||
},
|
||||
mockLogger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@sentry/node', () => mockSentry);
|
||||
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
// Mock config/env module - by default isSentryConfigured is false and isTest is true
|
||||
vi.mock('../config/env', () => ({
|
||||
config: {
|
||||
sentry: {
|
||||
dsn: '',
|
||||
environment: 'test',
|
||||
debug: false,
|
||||
},
|
||||
server: {
|
||||
nodeEnv: 'test',
|
||||
},
|
||||
},
|
||||
isSentryConfigured: false,
|
||||
isProduction: false,
|
||||
isTest: true,
|
||||
}));
|
||||
|
||||
describe('sentry.server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('with Sentry disabled (default test environment)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should not initialize Sentry when not configured', async () => {
|
||||
const { initSentry } = await import('./sentry.server');
|
||||
|
||||
initSentry();
|
||||
|
||||
// Sentry.init should NOT be called when DSN is not configured
|
||||
expect(mockSentry.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null from captureException when not configured', async () => {
|
||||
const { captureException } = await import('./sentry.server');
|
||||
|
||||
const result = captureException(new Error('test error'));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null from captureMessage when not configured', async () => {
|
||||
const { captureMessage } = await import('./sentry.server');
|
||||
|
||||
const result = captureMessage('test message');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set user when not configured', async () => {
|
||||
const { setUser } = await import('./sentry.server');
|
||||
|
||||
setUser({ id: '123', email: 'test@example.com' });
|
||||
|
||||
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add breadcrumb when not configured', async () => {
|
||||
const { addBreadcrumb } = await import('./sentry.server');
|
||||
|
||||
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
|
||||
|
||||
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry re-export', () => {
|
||||
it('should re-export Sentry object', async () => {
|
||||
const { Sentry } = await import('./sentry.server');
|
||||
|
||||
expect(Sentry).toBeDefined();
|
||||
expect(Sentry.init).toBeDefined();
|
||||
expect(Sentry.captureException).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSentryMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should return no-op middleware when Sentry is not configured', async () => {
|
||||
const { getSentryMiddleware } = await import('./sentry.server');
|
||||
|
||||
const middleware = getSentryMiddleware();
|
||||
|
||||
expect(middleware.requestHandler).toBeDefined();
|
||||
expect(middleware.errorHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have requestHandler that calls next()', async () => {
|
||||
const { getSentryMiddleware } = await import('./sentry.server');
|
||||
const middleware = getSentryMiddleware();
|
||||
|
||||
const req = {} as Request;
|
||||
const res = {} as Response;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
middleware.requestHandler(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should have errorHandler that passes error to next()', async () => {
|
||||
const { getSentryMiddleware } = await import('./sentry.server');
|
||||
const middleware = getSentryMiddleware();
|
||||
|
||||
const error = new Error('test error');
|
||||
const req = {} as Request;
|
||||
const res = {} as Response;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
middleware.errorHandler(error, req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initSentry beforeSend logic', () => {
|
||||
// Test the beforeSend logic in isolation
|
||||
it('should return event from beforeSend', () => {
|
||||
// Simulate the beforeSend logic when isProduction is true
|
||||
const isProduction = true;
|
||||
const mockEvent = { event_id: '123' };
|
||||
|
||||
const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => {
|
||||
// In development, log errors - but don't do extra processing
|
||||
if (!isProduction && hint.originalException) {
|
||||
// Would log here in real implementation
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const result = beforeSend(mockEvent, {});
|
||||
|
||||
expect(result).toBe(mockEvent);
|
||||
});
|
||||
|
||||
it('should return event in development with original exception', () => {
|
||||
// Simulate the beforeSend logic when isProduction is false
|
||||
const isProduction = false;
|
||||
const mockEvent = { event_id: '123' };
|
||||
const mockException = new Error('test');
|
||||
|
||||
const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => {
|
||||
if (!isProduction && hint.originalException) {
|
||||
// Would log here in real implementation
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const result = beforeSend(mockEvent, { originalException: mockException });
|
||||
|
||||
expect(result).toBe(mockEvent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handler status code logic', () => {
|
||||
// Test the error handler's status code filtering logic in isolation
|
||||
|
||||
it('should identify 5xx errors for Sentry capture', () => {
|
||||
// Test the logic that determines if an error should be captured
|
||||
const shouldCapture = (statusCode: number) => statusCode >= 500;
|
||||
|
||||
expect(shouldCapture(500)).toBe(true);
|
||||
expect(shouldCapture(502)).toBe(true);
|
||||
expect(shouldCapture(503)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not capture 4xx errors', () => {
|
||||
const shouldCapture = (statusCode: number) => statusCode >= 500;
|
||||
|
||||
expect(shouldCapture(400)).toBe(false);
|
||||
expect(shouldCapture(401)).toBe(false);
|
||||
expect(shouldCapture(403)).toBe(false);
|
||||
expect(shouldCapture(404)).toBe(false);
|
||||
expect(shouldCapture(422)).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract statusCode from error object', () => {
|
||||
// Test the status code extraction logic
|
||||
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
|
||||
err.statusCode || err.status || 500;
|
||||
|
||||
const errorWithStatusCode = Object.assign(new Error('test'), { statusCode: 503 });
|
||||
const errorWithStatus = Object.assign(new Error('test'), { status: 502 });
|
||||
const plainError = new Error('test');
|
||||
|
||||
expect(getStatusCode(errorWithStatusCode)).toBe(503);
|
||||
expect(getStatusCode(errorWithStatus)).toBe(502);
|
||||
expect(getStatusCode(plainError)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSentryConfigured and isTest guard logic', () => {
|
||||
// Test the guard condition logic used throughout the module
|
||||
|
||||
it('should block execution when Sentry is not configured', () => {
|
||||
const isSentryConfigured = false;
|
||||
const isTest = false;
|
||||
|
||||
const shouldExecute = isSentryConfigured && !isTest;
|
||||
expect(shouldExecute).toBe(false);
|
||||
});
|
||||
|
||||
it('should block execution in test environment', () => {
|
||||
const isSentryConfigured = true;
|
||||
const isTest = true;
|
||||
|
||||
const shouldExecute = isSentryConfigured && !isTest;
|
||||
expect(shouldExecute).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution when configured and not in test', () => {
|
||||
const isSentryConfigured = true;
|
||||
const isTest = false;
|
||||
|
||||
const shouldExecute = isSentryConfigured && !isTest;
|
||||
expect(shouldExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureException with context', () => {
|
||||
// Test the context-setting logic
|
||||
|
||||
it('should set context when provided', () => {
|
||||
const context = { userId: '123', action: 'test' };
|
||||
const shouldSetContext = !!context;
|
||||
expect(shouldSetContext).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set context when not provided', () => {
|
||||
const context = undefined;
|
||||
const shouldSetContext = !!context;
|
||||
expect(shouldSetContext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureMessage default level', () => {
|
||||
it('should default to info level', () => {
|
||||
// Test the default parameter behavior
|
||||
const defaultLevel = 'info';
|
||||
expect(defaultLevel).toBe('info');
|
||||
});
|
||||
|
||||
it('should accept other severity levels', () => {
|
||||
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
||||
validLevels.forEach((level) => {
|
||||
expect(['fatal', 'error', 'warning', 'log', 'info', 'debug']).toContain(level);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUser', () => {
|
||||
it('should accept user object with id only', () => {
|
||||
const user = { id: '123' };
|
||||
expect(user.id).toBe('123');
|
||||
expect(user).not.toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('should accept user object with all fields', () => {
|
||||
const user = { id: '123', email: 'test@example.com', username: 'testuser' };
|
||||
expect(user.id).toBe('123');
|
||||
expect(user.email).toBe('test@example.com');
|
||||
expect(user.username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should accept null to clear user', () => {
|
||||
const user = null;
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBreadcrumb', () => {
|
||||
it('should accept breadcrumb with message', () => {
|
||||
const breadcrumb = { message: 'User clicked button' };
|
||||
expect(breadcrumb.message).toBe('User clicked button');
|
||||
});
|
||||
|
||||
it('should accept breadcrumb with category', () => {
|
||||
const breadcrumb = { message: 'Navigation', category: 'navigation' };
|
||||
expect(breadcrumb.category).toBe('navigation');
|
||||
});
|
||||
|
||||
it('should accept breadcrumb with level', () => {
|
||||
const breadcrumb = { message: 'Error occurred', level: 'error' as const };
|
||||
expect(breadcrumb.level).toBe('error');
|
||||
});
|
||||
|
||||
it('should accept breadcrumb with data', () => {
|
||||
const breadcrumb = {
|
||||
message: 'API call',
|
||||
category: 'http',
|
||||
data: { url: '/api/test', method: 'GET' },
|
||||
};
|
||||
expect(breadcrumb.data).toEqual({ url: '/api/test', method: 'GET' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -416,7 +416,14 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
.send({ expiry_date: futureDate });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.expiry_date).toContain(futureDate);
|
||||
// Compare date portions only - the response is in UTC, which may differ by timezone offset
|
||||
// e.g., '2026-02-27' sent becomes '2026-02-26T19:00:00.000Z' in UTC (for UTC-5 timezone)
|
||||
const responseDate = new Date(response.body.data.expiry_date);
|
||||
const sentDate = new Date(futureDate + 'T00:00:00');
|
||||
// Dates should be within 24 hours of each other (same logical day)
|
||||
expect(Math.abs(responseDate.getTime() - sentDate.getTime())).toBeLessThan(
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject empty update body', async () => {
|
||||
|
||||
@@ -14,6 +14,14 @@ import { getPool } from '../../services/db/connection.db';
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
// Mock Bull Board to prevent BullMQAdapter from validating queue instances
|
||||
vi.mock('@bull-board/api', () => ({
|
||||
createBullBoard: vi.fn(),
|
||||
}));
|
||||
vi.mock('@bull-board/api/bullMQAdapter', () => ({
|
||||
BullMQAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the queues to prevent actual background processing
|
||||
// IMPORTANT: Must include all queue exports that are imported by workers.server.ts
|
||||
vi.mock('../../services/queues.server', () => ({
|
||||
@@ -88,7 +96,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
createdReceiptIds,
|
||||
]);
|
||||
await pool.query(
|
||||
'DELETE FROM public.receipt_processing_logs WHERE receipt_id = ANY($1::int[])',
|
||||
'DELETE FROM public.receipt_processing_log WHERE receipt_id = ANY($1::int[])',
|
||||
[createdReceiptIds],
|
||||
);
|
||||
await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [
|
||||
@@ -337,8 +345,8 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
beforeAll(async () => {
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status, error_message)
|
||||
VALUES ($1, '/uploads/receipts/failed-test.jpg', 'failed', 'OCR failed')
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status, error_details)
|
||||
VALUES ($1, '/uploads/receipts/failed-test.jpg', 'failed', '{"message": "OCR failed"}'::jsonb)
|
||||
RETURNING receipt_id`,
|
||||
[testUser.user.user_id],
|
||||
);
|
||||
@@ -551,12 +559,14 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
receiptWithLogsId = receiptResult.rows[0].receipt_id;
|
||||
createdReceiptIds.push(receiptWithLogsId);
|
||||
|
||||
// Add processing logs
|
||||
// Add processing logs - using correct table name and column names
|
||||
// processing_step must be one of: upload, ocr_extraction, text_parsing, store_detection,
|
||||
// item_extraction, item_matching, price_parsing, finalization
|
||||
await pool.query(
|
||||
`INSERT INTO public.receipt_processing_logs (receipt_id, step, status, message)
|
||||
VALUES ($1, 'ocr', 'completed', 'OCR completed successfully'),
|
||||
`INSERT INTO public.receipt_processing_log (receipt_id, processing_step, status, error_message)
|
||||
VALUES ($1, 'ocr_extraction', 'completed', 'OCR completed successfully'),
|
||||
($1, 'item_extraction', 'completed', 'Extracted 5 items'),
|
||||
($1, 'matching', 'completed', 'Matched 3 items')`,
|
||||
($1, 'item_matching', 'completed', 'Matched 3 items')`,
|
||||
[receiptWithLogsId],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user