Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ae23a1ae | ||
| c059b30201 | |||
|
|
93ad624658 | ||
|
|
7dd4f21071 | ||
| 174b637a0a |
@@ -127,6 +127,17 @@ jobs:
|
||||
rsync -avz dist/ "$APP_PATH"
|
||||
echo "Application deployment complete."
|
||||
|
||||
- name: Log Workflow Metadata
|
||||
run: |
|
||||
echo "=== WORKFLOW METADATA ==="
|
||||
echo "Workflow file: deploy-to-prod.yml"
|
||||
echo "Workflow file hash: $(sha256sum .gitea/workflows/deploy-to-prod.yml | cut -d' ' -f1)"
|
||||
echo "Git commit: $(git rev-parse HEAD)"
|
||||
echo "Git branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Actor: ${{ gitea.actor }}"
|
||||
echo "=== END METADATA ==="
|
||||
|
||||
- name: Install Backend Dependencies and Restart Production Server
|
||||
env:
|
||||
# --- Production Secrets Injection ---
|
||||
@@ -165,9 +176,74 @@ jobs:
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
# === PRE-CLEANUP PM2 STATE LOGGING ===
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
|
||||
# --- Cleanup Errored Processes with Defense-in-Depth Safeguards ---
|
||||
echo "Cleaning up errored or stopped PRODUCTION PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker']; list.forEach(p => { if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') && prodProcesses.includes(p.name)) { console.log('Deleting ' + p.pm2_env.status + ' production 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); } } }); console.log('✅ Production process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
node -e "
|
||||
const exec = require('child_process').execSync;
|
||||
try {
|
||||
const list = JSON.parse(exec('pm2 jlist').toString());
|
||||
const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker'];
|
||||
|
||||
// Filter for processes that match our criteria
|
||||
const targetProcesses = list.filter(p =>
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
prodProcesses.includes(p.name)
|
||||
);
|
||||
|
||||
// SAFEGUARD 1: Process count validation
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error('Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length);
|
||||
console.error('This indicates a potential filter bug. Aborting cleanup.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SAFEGUARD 2: Explicit name verification
|
||||
console.log('Found ' + targetProcesses.length + ' PRODUCTION processes to clean:');
|
||||
targetProcesses.forEach(p => {
|
||||
console.log(' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')');
|
||||
});
|
||||
|
||||
// Perform the cleanup
|
||||
targetProcesses.forEach(p => {
|
||||
console.log('Deleting ' + p.pm2_env.status + ' production 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);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Production process cleanup complete.');
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up processes:', e);
|
||||
}
|
||||
"
|
||||
|
||||
# === POST-CLEANUP VERIFICATION ===
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist | node -e "
|
||||
try {
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test') && !p.name.endsWith('-dev'));
|
||||
console.log('Production processes after cleanup:');
|
||||
prodProcesses.forEach(p => {
|
||||
console.log(' ' + p.name + ': ' + p.pm2_env.status);
|
||||
});
|
||||
if (prodProcesses.length === 0) {
|
||||
console.log(' (no production processes currently running)');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse PM2 output:', e.message);
|
||||
}
|
||||
"
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
@@ -87,6 +87,17 @@ jobs:
|
||||
- name: Lint Check
|
||||
run: npm run lint || true
|
||||
|
||||
- name: Log Workflow Metadata
|
||||
run: |
|
||||
echo "=== WORKFLOW METADATA ==="
|
||||
echo "Workflow file: deploy-to-test.yml"
|
||||
echo "Workflow file hash: $(sha256sum .gitea/workflows/deploy-to-test.yml | cut -d' ' -f1)"
|
||||
echo "Git commit: $(git rev-parse HEAD)"
|
||||
echo "Git branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Actor: ${{ gitea.actor }}"
|
||||
echo "=== END METADATA ==="
|
||||
|
||||
- name: Stop Test Server Before Tests
|
||||
# This is a critical step to ensure a clean test environment.
|
||||
# It stops the currently running pm2 process, freeing up port 3001 so that the
|
||||
@@ -94,10 +105,74 @@ jobs:
|
||||
# '|| true' ensures the workflow doesn't fail if the process isn't running.
|
||||
run: |
|
||||
echo "--- Stopping and deleting all test processes ---"
|
||||
|
||||
# === PRE-CLEANUP PM2 STATE LOGGING ===
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist || echo "No PM2 processes running"
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
|
||||
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
|
||||
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
|
||||
# It prevents the accumulation of duplicate processes from previous test runs.
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test 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, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
|
||||
node -e "
|
||||
const exec = require('child_process').execSync;
|
||||
try {
|
||||
const list = JSON.parse(exec('pm2 jlist').toString());
|
||||
|
||||
// Filter for test processes only
|
||||
const targetProcesses = list.filter(p => p.name && p.name.endsWith('-test'));
|
||||
|
||||
// SAFEGUARD 1: Process count validation
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error('Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length);
|
||||
console.error('This indicates a potential filter bug. Aborting cleanup.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SAFEGUARD 2: Explicit name verification
|
||||
console.log('Found ' + targetProcesses.length + ' TEST processes to clean:');
|
||||
targetProcesses.forEach(p => {
|
||||
console.log(' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')');
|
||||
});
|
||||
|
||||
// Perform the cleanup
|
||||
targetProcesses.forEach(p => {
|
||||
console.log('Deleting test 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, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Test process cleanup complete.');
|
||||
} catch (e) {
|
||||
if (e.stdout && e.stdout.toString().includes('No process found')) {
|
||||
console.log('No PM2 processes running, cleanup not needed.');
|
||||
} else {
|
||||
console.error('Error cleaning up test processes:', e.message);
|
||||
}
|
||||
}
|
||||
" || true
|
||||
|
||||
# === POST-CLEANUP VERIFICATION ===
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist 2>/dev/null | node -e "
|
||||
try {
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const testProcesses = list.filter(p => p.name && p.name.endsWith('-test'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test') && !p.name.endsWith('-dev'));
|
||||
console.log('Test processes after cleanup: ' + testProcesses.length);
|
||||
testProcesses.forEach(p => console.log(' ' + p.name + ': ' + p.pm2_env.status));
|
||||
console.log('Production processes (should be untouched): ' + prodProcesses.length);
|
||||
prodProcesses.forEach(p => console.log(' ' + p.name + ': ' + p.pm2_env.status));
|
||||
} catch (e) {
|
||||
console.log('No PM2 processes or failed to parse output');
|
||||
}
|
||||
" || true
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
|
||||
- name: Flush Redis Test Database Before Tests
|
||||
# CRITICAL: Clear Redis database 1 (test database) to remove stale BullMQ jobs.
|
||||
@@ -492,9 +567,74 @@ jobs:
|
||||
cd /var/www/flyer-crawler-test.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
# === PRE-CLEANUP PM2 STATE LOGGING ===
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
|
||||
# --- Cleanup Errored Processes with Defense-in-Depth Safeguards ---
|
||||
echo "Cleaning up errored or stopped TEST 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') && p.name && p.name.endsWith('-test')) { console.log('Deleting ' + p.pm2_env.status + ' test 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); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
node -e "
|
||||
const exec = require('child_process').execSync;
|
||||
try {
|
||||
const list = JSON.parse(exec('pm2 jlist').toString());
|
||||
|
||||
// Filter for errored/stopped test processes only
|
||||
const targetProcesses = list.filter(p =>
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
p.name && p.name.endsWith('-test')
|
||||
);
|
||||
|
||||
// SAFEGUARD 1: Process count validation
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error('Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length);
|
||||
console.error('This indicates a potential filter bug. Aborting cleanup.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SAFEGUARD 2: Explicit name verification
|
||||
console.log('Found ' + targetProcesses.length + ' errored/stopped TEST processes to clean:');
|
||||
targetProcesses.forEach(p => {
|
||||
console.log(' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')');
|
||||
});
|
||||
|
||||
// Perform the cleanup
|
||||
targetProcesses.forEach(p => {
|
||||
console.log('Deleting ' + p.pm2_env.status + ' test 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);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Test process cleanup complete.');
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up processes:', e);
|
||||
}
|
||||
"
|
||||
|
||||
# === POST-CLEANUP VERIFICATION ===
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist | node -e "
|
||||
try {
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const testProcesses = list.filter(p => p.name && p.name.endsWith('-test'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test') && !p.name.endsWith('-dev'));
|
||||
console.log('Test processes after cleanup:');
|
||||
testProcesses.forEach(p => console.log(' ' + p.name + ': ' + p.pm2_env.status));
|
||||
if (testProcesses.length === 0) {
|
||||
console.log(' (no test processes currently running)');
|
||||
}
|
||||
console.log('Production processes (should be untouched): ' + prodProcesses.length);
|
||||
prodProcesses.forEach(p => console.log(' ' + p.name + ': ' + p.pm2_env.status));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse PM2 output:', e.message);
|
||||
}
|
||||
"
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -109,6 +109,17 @@ jobs:
|
||||
rsync -avz dist/ "$APP_PATH"
|
||||
echo "Application deployment complete."
|
||||
|
||||
- name: Log Workflow Metadata
|
||||
run: |
|
||||
echo "=== WORKFLOW METADATA ==="
|
||||
echo "Workflow file: manual-deploy-major.yml"
|
||||
echo "Workflow file hash: $(sha256sum .gitea/workflows/manual-deploy-major.yml | cut -d' ' -f1)"
|
||||
echo "Git commit: $(git rev-parse HEAD)"
|
||||
echo "Git branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Actor: ${{ gitea.actor }}"
|
||||
echo "=== END METADATA ==="
|
||||
|
||||
- name: Install Backend Dependencies and Restart Production Server
|
||||
env:
|
||||
# --- Production Secrets Injection ---
|
||||
@@ -138,9 +149,74 @@ jobs:
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
# === PRE-CLEANUP PM2 STATE LOGGING ===
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
|
||||
# --- Cleanup Errored Processes with Defense-in-Depth Safeguards ---
|
||||
echo "Cleaning up errored or stopped PRODUCTION PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker']; list.forEach(p => { if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') && prodProcesses.includes(p.name)) { console.log('Deleting ' + p.pm2_env.status + ' production 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); } } }); console.log('✅ Production process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
node -e "
|
||||
const exec = require('child_process').execSync;
|
||||
try {
|
||||
const list = JSON.parse(exec('pm2 jlist').toString());
|
||||
const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker'];
|
||||
|
||||
// Filter for processes that match our criteria
|
||||
const targetProcesses = list.filter(p =>
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
prodProcesses.includes(p.name)
|
||||
);
|
||||
|
||||
// SAFEGUARD 1: Process count validation
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error('Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length);
|
||||
console.error('This indicates a potential filter bug. Aborting cleanup.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SAFEGUARD 2: Explicit name verification
|
||||
console.log('Found ' + targetProcesses.length + ' PRODUCTION processes to clean:');
|
||||
targetProcesses.forEach(p => {
|
||||
console.log(' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')');
|
||||
});
|
||||
|
||||
// Perform the cleanup
|
||||
targetProcesses.forEach(p => {
|
||||
console.log('Deleting ' + p.pm2_env.status + ' production 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);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Production process cleanup complete.');
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up processes:', e);
|
||||
}
|
||||
"
|
||||
|
||||
# === POST-CLEANUP VERIFICATION ===
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist | node -e "
|
||||
try {
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test') && !p.name.endsWith('-dev'));
|
||||
console.log('Production processes after cleanup:');
|
||||
prodProcesses.forEach(p => {
|
||||
console.log(' ' + p.name + ': ' + p.pm2_env.status);
|
||||
});
|
||||
if (prodProcesses.length === 0) {
|
||||
console.log(' (no production processes currently running)');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse PM2 output:', e.message);
|
||||
}
|
||||
"
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -49,6 +49,8 @@ Out-of-sync = test failures.
|
||||
|
||||
**CRITICAL**: Production and test environments share the same PM2 daemon on the server.
|
||||
|
||||
**See also**: [PM2 Process Isolation Incidents](#pm2-process-isolation-incidents) for past incidents and response procedures.
|
||||
|
||||
| Environment | Processes | Config File |
|
||||
| ----------- | -------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| Production | `flyer-crawler-api`, `flyer-crawler-worker`, `flyer-crawler-analytics-worker` | `ecosystem.config.cjs` |
|
||||
@@ -288,6 +290,39 @@ Common issues with solutions:
|
||||
|
||||
**Full Details**: See test issues section at end of this document or [docs/development/TESTING.md](docs/development/TESTING.md)
|
||||
|
||||
### PM2 Process Isolation Incidents
|
||||
|
||||
**CRITICAL**: PM2 process cleanup scripts can affect all PM2 processes if not properly filtered.
|
||||
|
||||
**Incident**: 2026-02-17 Production Deployment (v0.15.0)
|
||||
|
||||
- **Impact**: ALL PM2 processes on production server were killed
|
||||
- **Affected**: stock-alert.projectium.com and all other PM2-managed applications
|
||||
- **Root Cause**: Under investigation (see [incident report](docs/operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md))
|
||||
- **Status**: Safeguards added to prevent recurrence
|
||||
|
||||
**Prevention Measures** (implemented):
|
||||
|
||||
1. Name-based filtering (exact match or pattern-based)
|
||||
2. Pre-cleanup process list logging
|
||||
3. Process count validation (abort if filtering all processes)
|
||||
4. Explicit name verification in logs
|
||||
5. Post-cleanup verification
|
||||
6. Workflow version hash logging
|
||||
|
||||
**If PM2 Incident Occurs**:
|
||||
|
||||
- **DO NOT** attempt another deployment immediately
|
||||
- Follow the [PM2 Incident Response Runbook](docs/operations/PM2-INCIDENT-RESPONSE.md)
|
||||
- Manually restore affected processes
|
||||
- Investigate workflow execution logs before next deployment
|
||||
|
||||
**Related Documentation**:
|
||||
|
||||
- [PM2 Process Isolation Requirements](#pm2-process-isolation-productiontest-servers) (existing section)
|
||||
- [Incident Report 2026-02-17](docs/operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md)
|
||||
- [PM2 Incident Response Runbook](docs/operations/PM2-INCIDENT-RESPONSE.md)
|
||||
|
||||
### Git Bash Path Conversion (Windows)
|
||||
|
||||
Git Bash auto-converts Unix paths, breaking container commands.
|
||||
|
||||
@@ -47,6 +47,14 @@ Production operations and deployment:
|
||||
- [Logstash Troubleshooting](operations/LOGSTASH-TROUBLESHOOTING.md) - Debugging logs
|
||||
- [Monitoring](operations/MONITORING.md) - Bugsink, health checks, observability
|
||||
|
||||
**Incident Response**:
|
||||
|
||||
- [PM2 Incident Response Runbook](operations/PM2-INCIDENT-RESPONSE.md) - Step-by-step procedures for PM2 incidents
|
||||
|
||||
**Incident Reports**:
|
||||
|
||||
- [2026-02-17 PM2 Process Kill](operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md) - ALL PM2 processes killed during v0.15.0 deployment (Mitigated)
|
||||
|
||||
**NGINX Reference Configs** (in repository root):
|
||||
|
||||
- `etc-nginx-sites-available-flyer-crawler.projectium.com` - Production server config
|
||||
|
||||
471
docs/adr/0060-typescript-test-error-remediation.md
Normal file
471
docs/adr/0060-typescript-test-error-remediation.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# ADR-060: TypeScript Test Error Remediation Strategy
|
||||
|
||||
**Date**: 2026-02-17
|
||||
|
||||
**Status**: Implemented
|
||||
|
||||
**Completed**: 2026-02-17
|
||||
|
||||
**Context**: Systematic remediation of 185 TypeScript errors in test files following API response standardization (ADR-028) and tsoa migration (ADR-059)
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
This ADR has been fully implemented. The remediation project achieved:
|
||||
|
||||
| Metric | Value |
|
||||
| -------------------- | ------------------------------------------- |
|
||||
| Initial Errors | 185 |
|
||||
| Final Errors | 0 |
|
||||
| Files Modified | 19 controller test files + shared utilities |
|
||||
| Test Suite | 4,603 passed, 59 failed (all pre-existing) |
|
||||
| Net Test Improvement | +3 tests fixed |
|
||||
|
||||
### Implementation Phases Completed
|
||||
|
||||
| Phase | Duration | Errors Fixed |
|
||||
| --------------------------- | --------- | ---------------------------------- |
|
||||
| Phase 1: Foundation | Completed | Infrastructure (enables all fixes) |
|
||||
| Phase 2-4: Parallel Tasks | 3 rounds | 185 -> 114 -> 67 -> 23 -> 0 |
|
||||
| Phase 5: Final Verification | Completed | All type-check passes |
|
||||
|
||||
### Artifacts Created
|
||||
|
||||
1. **Shared Test Utilities** (`src/tests/utils/testHelpers.ts`):
|
||||
- `asSuccessResponse<T>()` - Type guard for success responses
|
||||
- `asErrorResponse()` - Type guard for error responses
|
||||
- `asMock<T>()` - Mock function type casting
|
||||
- Re-export of `createMockLogger`
|
||||
|
||||
2. **Mock Logger** (`src/tests/utils/mockLogger.ts`):
|
||||
- `createMockLogger()` - Complete Pino logger mock
|
||||
- `mockLogger` - Pre-instantiated mock for convenience
|
||||
|
||||
3. **Updated Mock Factories** (`src/tests/utils/mockFactories.ts`):
|
||||
- 60+ type-safe mock factory functions
|
||||
- Deterministic ID generation with `getNextId()`
|
||||
- Complete type coverage for all domain entities
|
||||
|
||||
## Context
|
||||
|
||||
Following the implementation of ADR-028 (API Response Standardization) and ADR-059 (tsoa Migration), 185 TypeScript errors accumulated in test files. The errors stem from stricter type checking on API response handling, mock type mismatches, and response body access patterns. This ADR documents the systematic analysis, categorization, and phased remediation approach.
|
||||
|
||||
### Error Distribution
|
||||
|
||||
| Category | Count | Percentage |
|
||||
| ------------------------------- | ----- | ---------- |
|
||||
| SuccessResponse type narrowing | 89 | 48.1% |
|
||||
| Mock object type casting | 42 | 22.7% |
|
||||
| Response body property access | 28 | 15.1% |
|
||||
| Partial mock missing properties | 18 | 9.7% |
|
||||
| Generic type parameter issues | 5 | 2.7% |
|
||||
| Module import type issues | 3 | 1.6% |
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
1. **SuccessResponse Discriminated Union**: ADR-028 introduced `ApiSuccessResponse<T> | ApiErrorResponse` union types. Tests accessing `response.body.data` without type guards trigger TS2339 errors.
|
||||
|
||||
2. **Mock Type Strictness**: Vitest mocks return `MockedFunction<T>` types. Passing to functions expecting exact signatures requires explicit casting.
|
||||
|
||||
3. **Partial<T> vs Full Object**: Factory functions creating partial mocks lack required properties. Tests using spread operators without type assertions fail property access.
|
||||
|
||||
4. **Response Body Type Unknown**: Supertest `response.body` is typed as `unknown` or `any`. Direct property access without narrowing violates strict mode.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a 5-phase remediation strategy with parallelizable tasks, prioritized by error count per file and criticality.
|
||||
|
||||
### Phase 1: High-Impact Infrastructure (Est. 2 hours)
|
||||
|
||||
**Goal**: Fix foundational patterns that propagate to multiple files.
|
||||
|
||||
| Task | Files | Errors Fixed |
|
||||
| ----------------------------------------------------- | ---------------------------------- | ---------------- |
|
||||
| Add `asSuccessResponse<T>()` type guard to test utils | `src/tests/utils/testHelpers.ts` | Enables 89 fixes |
|
||||
| Add `asMock<T>()` utility for mock casting | `src/tests/utils/testHelpers.ts` | Enables 42 fixes |
|
||||
| Update mock factories with strict return types | `src/tests/utils/mockFactories.ts` | 18 |
|
||||
|
||||
**Type Guard Implementation**:
|
||||
|
||||
```typescript
|
||||
// src/tests/utils/testHelpers.ts
|
||||
|
||||
import { ApiSuccessResponse, ApiErrorResponse } from '@/types/api';
|
||||
|
||||
/**
|
||||
* Type guard to narrow supertest response body to ApiSuccessResponse.
|
||||
* Use when accessing .data property on API responses in tests.
|
||||
*
|
||||
* @example
|
||||
* const response = await request.get('/api/v1/users/1');
|
||||
* const body = asSuccessResponse<User>(response.body);
|
||||
* expect(body.data.id).toBe(1); // TypeScript knows body.data exists
|
||||
*/
|
||||
export function asSuccessResponse<T>(body: unknown): ApiSuccessResponse<T> {
|
||||
const parsed = body as ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
if (parsed.success !== true) {
|
||||
throw new Error(`Expected success response, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for error responses.
|
||||
*/
|
||||
export function asErrorResponse(body: unknown): ApiErrorResponse {
|
||||
const parsed = body as ApiSuccessResponse<unknown> | ApiErrorResponse;
|
||||
if (parsed.success !== false) {
|
||||
throw new Error(`Expected error response, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast Vitest mock to specific function type.
|
||||
* Use when passing mocked functions to code expecting exact signatures.
|
||||
*
|
||||
* @example
|
||||
* const mockFn = vi.fn();
|
||||
* someService.register(asMock<UserService['create']>(mockFn));
|
||||
*/
|
||||
export function asMock<T extends (...args: unknown[]) => unknown>(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
): T {
|
||||
return mock as unknown as T;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Route Test Files (Est. 3 hours)
|
||||
|
||||
**Priority**: Files with 10+ errors first.
|
||||
|
||||
| File | Errors | Pattern |
|
||||
| ----------------------------------------- | ------ | -------------------------------------- |
|
||||
| `src/routes/flyer.routes.test.ts` | 24 | Response body narrowing |
|
||||
| `src/routes/user.routes.test.ts` | 18 | Response body narrowing |
|
||||
| `src/routes/auth.routes.test.ts` | 15 | Response body narrowing |
|
||||
| `src/routes/recipe.routes.test.ts` | 12 | Response body narrowing |
|
||||
| `src/routes/shopping-list.routes.test.ts` | 11 | Response body narrowing |
|
||||
| `src/routes/notification.routes.test.ts` | 9 | Response body narrowing |
|
||||
| `src/routes/inventory.routes.test.ts` | 8 | Response body narrowing |
|
||||
| `src/routes/budget.routes.test.ts` | 7 | Response body narrowing |
|
||||
| `src/routes/admin.routes.test.ts` | 6 | Response body narrowing + mock casting |
|
||||
|
||||
**Fix Pattern**:
|
||||
|
||||
```typescript
|
||||
// BEFORE (TS2339: Property 'data' does not exist on type 'unknown')
|
||||
const response = await request.get('/api/v1/flyers/1');
|
||||
expect(response.body.data.flyer_id).toBe(1);
|
||||
|
||||
// AFTER
|
||||
import { asSuccessResponse } from '@/tests/utils/testHelpers';
|
||||
import { Flyer } from '@/types/flyer';
|
||||
|
||||
const response = await request.get('/api/v1/flyers/1');
|
||||
const body = asSuccessResponse<Flyer>(response.body);
|
||||
expect(body.data.flyer_id).toBe(1);
|
||||
```
|
||||
|
||||
### Phase 3: Service Test Files (Est. 2 hours)
|
||||
|
||||
**Priority**: Mock casting issues.
|
||||
|
||||
| File | Errors | Pattern |
|
||||
| ------------------------------------------------- | ------ | ------------------ |
|
||||
| `src/services/db/flyer.db.test.ts` | 8 | Pool mock typing |
|
||||
| `src/services/db/user.db.test.ts` | 7 | Pool mock typing |
|
||||
| `src/services/aiService.server.test.ts` | 6 | Gemini mock typing |
|
||||
| `src/services/cacheService.server.test.ts` | 5 | Redis mock typing |
|
||||
| `src/services/notificationService.server.test.ts` | 4 | Queue mock typing |
|
||||
|
||||
**Mock Casting Pattern**:
|
||||
|
||||
```typescript
|
||||
// BEFORE (TS2345: Argument of type 'Mock' is not assignable)
|
||||
const mockPool = { query: vi.fn() };
|
||||
const service = new FlyerService(mockPool);
|
||||
|
||||
// AFTER
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const mockPool = {
|
||||
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
|
||||
} as unknown as Pool;
|
||||
const service = new FlyerService(mockPool);
|
||||
```
|
||||
|
||||
### Phase 4: Integration Test Files (Est. 1.5 hours)
|
||||
|
||||
| File | Errors | Pattern |
|
||||
| ------------------------------------------------- | ------ | ----------------------- |
|
||||
| `src/tests/integration/flyer.integration.test.ts` | 6 | Response body + cleanup |
|
||||
| `src/tests/integration/auth.integration.test.ts` | 5 | Response body |
|
||||
| `src/tests/integration/user.integration.test.ts` | 4 | Response body |
|
||||
| `src/tests/integration/admin.integration.test.ts` | 4 | Response body |
|
||||
|
||||
**Integration Test Pattern**:
|
||||
|
||||
```typescript
|
||||
// Establish typed response helper at top of file
|
||||
const expectSuccess = <T>(response: Response) => {
|
||||
expect(response.status).toBeLessThan(400);
|
||||
return asSuccessResponse<T>(response.body);
|
||||
};
|
||||
|
||||
// Usage
|
||||
const body = expectSuccess<{ token: string }>(response);
|
||||
expect(body.data.token).toBeDefined();
|
||||
```
|
||||
|
||||
### Phase 5: Component and Hook Tests (Est. 1.5 hours)
|
||||
|
||||
| File | Errors | Pattern |
|
||||
| ----------------------------- | ------ | ------------------- |
|
||||
| `src/hooks/useFlyers.test.ts` | 3 | MSW response typing |
|
||||
| `src/hooks/useAuth.test.ts` | 3 | MSW response typing |
|
||||
| Various component tests | 8 | Mock prop typing |
|
||||
|
||||
**MSW Handler Pattern**:
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
http.get('/api/v1/flyers', () => {
|
||||
return HttpResponse.json({ data: [mockFlyer] });
|
||||
});
|
||||
|
||||
// AFTER
|
||||
import { ApiSuccessResponse } from '@/types/api';
|
||||
import { Flyer } from '@/types/flyer';
|
||||
|
||||
http.get('/api/v1/flyers', () => {
|
||||
const response: ApiSuccessResponse<Flyer[]> = {
|
||||
success: true,
|
||||
data: [mockFlyer],
|
||||
};
|
||||
return HttpResponse.json(response);
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### 1. Mock Object Casting Hierarchy
|
||||
|
||||
Use the least permissive cast that satisfies TypeScript:
|
||||
|
||||
```typescript
|
||||
// Level 1: Type assertion for compatible shapes
|
||||
const mock = createMockUser() as User;
|
||||
|
||||
// Level 2: Unknown bridge for incompatible shapes
|
||||
const mock = partialMock as unknown as User;
|
||||
|
||||
// Level 3: Partial with required overrides
|
||||
const mock: User = { ...createPartialUser(), id: 1, email: 'test@test.com' };
|
||||
```
|
||||
|
||||
### 2. Response Type Narrowing
|
||||
|
||||
**Always narrow before property access**:
|
||||
|
||||
```typescript
|
||||
// Standard pattern
|
||||
const body = asSuccessResponse<ExpectedType>(response.body);
|
||||
expect(body.data.property).toBe(value);
|
||||
|
||||
// With error expectation
|
||||
expect(response.status).toBe(400);
|
||||
const body = asErrorResponse(response.body);
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR');
|
||||
```
|
||||
|
||||
### 3. Mock Function Type Safety
|
||||
|
||||
```typescript
|
||||
// vi.fn() with implementation type
|
||||
const mockFn = vi.fn<[string], Promise<User>>().mockResolvedValue(mockUser);
|
||||
|
||||
// Mocked module function
|
||||
vi.mock('@/services/userService');
|
||||
const mockedService = vi.mocked(userService);
|
||||
mockedService.create.mockResolvedValue(mockUser);
|
||||
```
|
||||
|
||||
### 4. Generic Type Parameters
|
||||
|
||||
When TypeScript cannot infer generics, provide explicit parameters:
|
||||
|
||||
```typescript
|
||||
// Explicit generic on factory
|
||||
const mock = createMockPaginatedResponse<Flyer>({ data: [mockFlyer] });
|
||||
|
||||
// Explicit generic on assertion
|
||||
expect(result).toEqual<ApiSuccessResponse<User>>({
|
||||
success: true,
|
||||
data: mockUser,
|
||||
});
|
||||
```
|
||||
|
||||
## Parallelization Strategy
|
||||
|
||||
### Parallel Execution Groups
|
||||
|
||||
Tests can be fixed in parallel within these independent groups:
|
||||
|
||||
| Group | Files | Dependencies |
|
||||
| ----- | ------------------------------------------------- | ----------------- |
|
||||
| A | Route tests (auth, user, flyer) | Phase 1 utilities |
|
||||
| B | Route tests (recipe, shopping-list, notification) | Phase 1 utilities |
|
||||
| C | Service tests (db layer) | None |
|
||||
| D | Service tests (external services) | None |
|
||||
| E | Integration tests | Phase 1 utilities |
|
||||
| F | Component/hook tests | None |
|
||||
|
||||
**Dependency Graph**:
|
||||
|
||||
```
|
||||
Phase 1 (Infrastructure)
|
||||
│
|
||||
├── Group A ─┐
|
||||
├── Group B ─┼── Can run in parallel
|
||||
└── Group E ─┘
|
||||
|
||||
Groups C, D, F have no dependencies (can start immediately)
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
|
||||
Minimum time to completion: **Phase 1 (2h) + longest parallel group (1.5h) = 3.5 hours**
|
||||
|
||||
Sequential worst case: **10 hours** (if no parallelization)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Execution Environment
|
||||
|
||||
**All tests MUST run in the dev container** per ADR-014:
|
||||
|
||||
```bash
|
||||
# Type check (fast verification)
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Unit tests (after type check passes)
|
||||
podman exec -it flyer-crawler-dev npm run test:unit
|
||||
|
||||
# Full suite (final verification)
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
```
|
||||
|
||||
### Background Job Execution (MCP)
|
||||
|
||||
For long-running test suites, use the MCP background-job tools:
|
||||
|
||||
```bash
|
||||
# Estimate duration first
|
||||
mcp__background-job__estimate_command_duration("npm run type-check")
|
||||
|
||||
# Execute in background
|
||||
mcp__background-job__execute_command("npm run type-check")
|
||||
|
||||
# Poll status per guidelines (15-30s intervals)
|
||||
mcp__background-job__get_job_status(job_id)
|
||||
```
|
||||
|
||||
### Incremental Verification
|
||||
|
||||
After each phase, verify:
|
||||
|
||||
1. **Type check passes**: `npm run type-check` exits 0
|
||||
2. **Affected tests pass**: Run specific test file
|
||||
3. **No regressions**: Run full unit suite
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Type Safety**: All test files will have proper TypeScript coverage
|
||||
2. **IDE Support**: IntelliSense works correctly for response bodies
|
||||
3. **Refactoring Safety**: Type errors will catch API contract changes
|
||||
4. **Pattern Consistency**: Established patterns for future test writing
|
||||
5. **Reusable Utilities**: `asSuccessResponse`, `asMock` utilities for all tests
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Verbosity**: Tests require explicit type narrowing (2-3 extra lines)
|
||||
2. **Maintenance**: Type parameters must match actual API responses
|
||||
3. **Learning Curve**: Contributors must learn type guard patterns
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Test Execution**: No runtime performance impact (compile-time only)
|
||||
2. **Coverage**: No change to test coverage metrics
|
||||
|
||||
## File Priority Matrix
|
||||
|
||||
### By Error Count (Descending)
|
||||
|
||||
| Priority | File | Errors |
|
||||
| -------- | ----------------------------------------- | -------------- |
|
||||
| P0 | `src/tests/utils/testHelpers.ts` | Infrastructure |
|
||||
| P0 | `src/tests/utils/mockFactories.ts` | 18 |
|
||||
| P1 | `src/routes/flyer.routes.test.ts` | 24 |
|
||||
| P1 | `src/routes/user.routes.test.ts` | 18 |
|
||||
| P1 | `src/routes/auth.routes.test.ts` | 15 |
|
||||
| P2 | `src/routes/recipe.routes.test.ts` | 12 |
|
||||
| P2 | `src/routes/shopping-list.routes.test.ts` | 11 |
|
||||
| P2 | `src/routes/notification.routes.test.ts` | 9 |
|
||||
| P3 | `src/routes/inventory.routes.test.ts` | 8 |
|
||||
| P3 | `src/services/db/flyer.db.test.ts` | 8 |
|
||||
| P3 | `src/routes/budget.routes.test.ts` | 7 |
|
||||
| P3 | `src/services/db/user.db.test.ts` | 7 |
|
||||
|
||||
### By Criticality (Business Impact)
|
||||
|
||||
| Tier | Files | Rationale |
|
||||
| -------- | --------------------------- | ---------------------- |
|
||||
| Critical | auth, user routes | Authentication flows |
|
||||
| High | flyer, shopping-list routes | Core business features |
|
||||
| Medium | recipe, budget, inventory | Secondary features |
|
||||
| Low | admin, notification | Support features |
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
### Pre-Remediation
|
||||
|
||||
- [x] Read this ADR and understand patterns
|
||||
- [x] Verify dev container is running
|
||||
- [x] Run `npm run type-check` to confirm error count
|
||||
- [x] Create working branch
|
||||
|
||||
### During Remediation
|
||||
|
||||
- [x] Implement Phase 1 infrastructure utilities
|
||||
- [x] Fix highest-error files first within each phase
|
||||
- [x] Run type-check after each file fix
|
||||
- [x] Run specific test file to verify no runtime breaks
|
||||
|
||||
### Post-Remediation
|
||||
|
||||
- [x] Run full type-check: `npm run type-check` (0 errors)
|
||||
- [x] Run unit tests: `npm run test:unit` (4,603 passed)
|
||||
- [x] Run integration tests: `npm run test:integration`
|
||||
- [x] Update ADR status to Implemented
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy and Standards
|
||||
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Platform: Linux Only
|
||||
- [ADR-028](./0028-api-response-standardization.md) - API Response Standardization
|
||||
- [ADR-045](./0045-test-data-factories-and-fixtures.md) - Test Data Factories and Fixtures
|
||||
- [ADR-057](./0057-test-remediation-post-api-versioning.md) - Test Remediation Post-API Versioning
|
||||
- [ADR-059](./0059-dependency-modernization.md) - Dependency Modernization (tsoa Migration)
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------- | ------------------------------------------------------- |
|
||||
| `src/tests/utils/testHelpers.ts` | Type guard utilities (`asSuccessResponse`, `asMock`) |
|
||||
| `src/tests/utils/mockFactories.ts` | Typed mock object factories |
|
||||
| `src/types/api.ts` | `ApiSuccessResponse<T>`, `ApiErrorResponse` definitions |
|
||||
| `src/utils/apiResponse.ts` | `sendSuccess()`, `sendError()` implementations |
|
||||
| `vite.config.ts` | Unit test TypeScript configuration |
|
||||
| `vitest.config.integration.ts` | Integration test TypeScript configuration |
|
||||
199
docs/adr/0061-pm2-process-isolation-safeguards.md
Normal file
199
docs/adr/0061-pm2-process-isolation-safeguards.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# ADR-061: PM2 Process Isolation Safeguards
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
On 2026-02-17, a critical incident occurred during v0.15.0 production deployment where ALL PM2 processes on the production server were terminated, not just flyer-crawler processes. This caused unplanned downtime for multiple applications including `stock-alert.projectium.com`.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Production and test environments share the same PM2 daemon on the server. This creates a risk where deployment scripts that operate on PM2 processes can accidentally affect processes belonging to other applications or environments.
|
||||
|
||||
### Pre-existing Controls
|
||||
|
||||
Prior to the incident, PM2 process isolation controls were already in place (commit `b6a62a0`):
|
||||
|
||||
- Production workflows used whitelist-based filtering with explicit process names
|
||||
- Test workflows filtered by `-test` suffix pattern
|
||||
- CLAUDE.md documented the prohibition of `pm2 stop all`, `pm2 delete all`, and `pm2 restart all`
|
||||
|
||||
Despite these controls being present in the codebase and included in v0.15.0, the incident still occurred. The leading hypothesis is that the Gitea runner executed a cached/older version of the workflow file.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Prevent accidental deletion of processes from other applications or environments
|
||||
2. Provide audit trail for forensic analysis when incidents occur
|
||||
3. Enable automatic abort when dangerous conditions are detected
|
||||
4. Maintain visibility into PM2 operations during deployment
|
||||
5. Work correctly even if the filtering logic itself is bypassed
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a defense-in-depth strategy with 5 layers of safeguards in all deployment workflows that interact with PM2 processes.
|
||||
|
||||
### Safeguard Layers
|
||||
|
||||
#### Layer 1: Workflow Metadata Logging
|
||||
|
||||
Log workflow execution metadata at the start of each deployment:
|
||||
|
||||
```bash
|
||||
echo "=== WORKFLOW METADATA ==="
|
||||
echo "Workflow file: deploy-to-prod.yml"
|
||||
echo "Workflow file hash: $(sha256sum .gitea/workflows/deploy-to-prod.yml | cut -d' ' -f1)"
|
||||
echo "Git commit: $(git rev-parse HEAD)"
|
||||
echo "Git branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Actor: ${{ gitea.actor }}"
|
||||
echo "=== END METADATA ==="
|
||||
```
|
||||
|
||||
**Purpose**: Enables verification of which workflow version was actually executed.
|
||||
|
||||
#### Layer 2: Pre-Cleanup PM2 State Logging
|
||||
|
||||
Capture full PM2 process list before any modifications:
|
||||
|
||||
```bash
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
```
|
||||
|
||||
**Purpose**: Provides forensic evidence of system state before cleanup.
|
||||
|
||||
#### Layer 3: Process Count Validation (SAFETY ABORT)
|
||||
|
||||
Abort deployment if the filter would delete ALL processes and there are more than 3 processes total:
|
||||
|
||||
```javascript
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error(
|
||||
'Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose**: Catches filter bugs or unexpected conditions automatically.
|
||||
|
||||
**Threshold Rationale**: A threshold of 3 allows normal operation when only the expected processes exist (API, Worker, Analytics Worker) while catching anomalies when the server hosts additional applications.
|
||||
|
||||
#### Layer 4: Explicit Name Verification
|
||||
|
||||
Log the exact name, status, and PM2 ID of each process that will be affected:
|
||||
|
||||
```javascript
|
||||
console.log('Found ' + targetProcesses.length + ' PRODUCTION processes to clean:');
|
||||
targetProcesses.forEach((p) => {
|
||||
console.log(
|
||||
' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')',
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Purpose**: Provides clear visibility into cleanup operations.
|
||||
|
||||
#### Layer 5: Post-Cleanup Verification
|
||||
|
||||
After cleanup, verify environment isolation was maintained:
|
||||
|
||||
```bash
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist | node -e "
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test'));
|
||||
console.log('Production processes after cleanup: ' + prodProcesses.length);
|
||||
"
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
```
|
||||
|
||||
**Purpose**: Immediately identifies cross-environment contamination.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Automatic Prevention**: Layer 3 (process count validation) can prevent catastrophic process deletion automatically, without human intervention.
|
||||
|
||||
2. **Forensic Capability**: Layers 1 and 2 provide the data needed to determine root cause after an incident.
|
||||
|
||||
3. **Visibility**: Layers 4 and 5 make PM2 operations transparent in workflow logs.
|
||||
|
||||
4. **Fail-Safe Design**: Even if individual layers fail, other layers provide backup protection.
|
||||
|
||||
5. **Non-Breaking**: Safeguards are additive and do not change the existing filtering logic.
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Increased Log Volume**: Additional logging increases workflow output size.
|
||||
|
||||
2. **Minor Performance Impact**: Extra PM2 commands add a few seconds to deployment time.
|
||||
|
||||
3. **Threshold Tuning**: The threshold of 3 may need adjustment if the expected process count changes.
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Root Cause Still Unknown**: These safeguards mitigate the risk but do not definitively explain why the original incident occurred.
|
||||
|
||||
2. **No Structural Changes**: The underlying architecture (shared PM2 daemon) remains unchanged.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### PM2 Namespaces
|
||||
|
||||
PM2 supports namespaces to isolate groups of processes. This would provide complete isolation but requires:
|
||||
|
||||
- Changes to ecosystem config files
|
||||
- Changes to all PM2 commands in workflows
|
||||
- Potential breaking changes to monitoring and log aggregation
|
||||
|
||||
**Decision**: Deferred for future consideration. Current safeguards provide adequate protection.
|
||||
|
||||
### Separate PM2 Daemons
|
||||
|
||||
Running a separate PM2 daemon per application would eliminate cross-application risk entirely.
|
||||
|
||||
**Decision**: Not implemented due to increased operational complexity and the current safeguards being sufficient.
|
||||
|
||||
### Deployment Locks
|
||||
|
||||
Implementing mutex-style locks to prevent concurrent deployments could prevent race conditions.
|
||||
|
||||
**Decision**: Not implemented as the current safeguards address the identified risk. May be reconsidered if concurrent deployment issues are observed.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------ | ---------------------- |
|
||||
| `.gitea/workflows/deploy-to-prod.yml` | All 5 safeguard layers |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | All 5 safeguard layers |
|
||||
| `.gitea/workflows/manual-deploy-major.yml` | All 5 safeguard layers |
|
||||
|
||||
### Validation
|
||||
|
||||
A standalone test file validates the safeguard logic:
|
||||
|
||||
- **File**: `tests/qa/test-pm2-safeguard-logic.js`
|
||||
- **Coverage**: 11 scenarios covering normal operations and dangerous edge cases
|
||||
- **Result**: All tests pass
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Incident Report: 2026-02-17](../operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md)
|
||||
- [PM2 Incident Response Runbook](../operations/PM2-INCIDENT-RESPONSE.md)
|
||||
- [Session Summary](../archive/sessions/PM2_SAFEGUARDS_SESSION_2026-02-17.md)
|
||||
- [CLAUDE.md - PM2 Process Isolation](../../CLAUDE.md#pm2-process-isolation-productiontest-servers)
|
||||
- [ADR-014: Containerization and Deployment Strategy](0014-containerization-and-deployment-strategy.md)
|
||||
|
||||
## References
|
||||
|
||||
- PM2 Documentation: https://pm2.keymetrics.io/docs/usage/application-declaration/
|
||||
- Defense in Depth: https://en.wikipedia.org/wiki/Defense_in_depth_(computing)
|
||||
@@ -56,6 +56,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-038](./0038-graceful-shutdown-pattern.md)**: Graceful Shutdown Pattern (Accepted)
|
||||
**[ADR-053](./0053-worker-health-checks.md)**: Worker Health Checks and Stalled Job Monitoring (Accepted)
|
||||
**[ADR-054](./0054-bugsink-gitea-issue-sync.md)**: Bugsink to Gitea Issue Synchronization (Proposed)
|
||||
**[ADR-061](./0061-pm2-process-isolation-safeguards.md)**: PM2 Process Isolation Safeguards (Accepted)
|
||||
|
||||
## 7. Frontend / User Interface
|
||||
|
||||
@@ -75,6 +76,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed)
|
||||
**[ADR-057](./0057-test-remediation-post-api-versioning.md)**: Test Remediation Post-API Versioning (Accepted)
|
||||
**[ADR-059](./0059-dependency-modernization.md)**: Dependency Modernization - tsoa Migration (Accepted)
|
||||
**[ADR-060](./0060-typescript-test-error-remediation.md)**: TypeScript Test Error Remediation Strategy (Implemented)
|
||||
|
||||
## 9. Architecture Patterns
|
||||
|
||||
|
||||
377
docs/archive/sessions/PM2_SAFEGUARDS_SESSION_2026-02-17.md
Normal file
377
docs/archive/sessions/PM2_SAFEGUARDS_SESSION_2026-02-17.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# PM2 Process Isolation Safeguards Project
|
||||
|
||||
**Session Date**: 2026-02-17
|
||||
**Status**: Completed
|
||||
**Triggered By**: Critical production incident during v0.15.0 deployment
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
On 2026-02-17, a critical incident occurred during v0.15.0 production deployment where ALL PM2 processes on the production server were killed, not just the flyer-crawler processes. This caused unplanned downtime for multiple applications including `stock-alert.projectium.com`.
|
||||
|
||||
Despite PM2 process isolation fixes already being in place (commit `b6a62a0`), the incident still occurred. Investigation suggests the Gitea runner may have executed a cached/older version of the workflow files. In response, we implemented a comprehensive defense-in-depth strategy with 5 layers of safeguards across all deployment workflows.
|
||||
|
||||
---
|
||||
|
||||
## Incident Background
|
||||
|
||||
### What Happened
|
||||
|
||||
| Aspect | Detail |
|
||||
| --------------------- | ------------------------------------------------------- |
|
||||
| **Date/Time** | 2026-02-17 ~07:40 UTC |
|
||||
| **Trigger** | v0.15.0 production deployment via `deploy-to-prod.yml` |
|
||||
| **Impact** | ALL PM2 processes killed (all environments) |
|
||||
| **Collateral Damage** | `stock-alert.projectium.com` and other PM2-managed apps |
|
||||
| **Severity** | P1 - Critical |
|
||||
|
||||
### Key Mystery
|
||||
|
||||
The PM2 process isolation fix was already implemented in commit `b6a62a0` (2026-02-13) and was included in v0.15.0. The fix correctly used whitelist-based filtering:
|
||||
|
||||
```javascript
|
||||
const prodProcesses = [
|
||||
'flyer-crawler-api',
|
||||
'flyer-crawler-worker',
|
||||
'flyer-crawler-analytics-worker',
|
||||
];
|
||||
list.forEach((p) => {
|
||||
if (
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
prodProcesses.includes(p.name)
|
||||
) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Hypothesis**: Gitea runner executed a cached older version of the workflow file that did not contain the fix.
|
||||
|
||||
---
|
||||
|
||||
## Solution: Defense-in-Depth Safeguards
|
||||
|
||||
Rather than relying solely on the filter logic (which may be correct but not executed), we implemented 5 layers of safeguards that provide visibility, validation, and automatic abort capabilities.
|
||||
|
||||
### Safeguard Layers
|
||||
|
||||
| Layer | Name | Purpose |
|
||||
| ----- | --------------------------------- | ------------------------------------------------------- |
|
||||
| 1 | **Workflow Metadata Logging** | Audit trail of which workflow version actually executed |
|
||||
| 2 | **Pre-Cleanup PM2 State Logging** | Capture full process list before any modifications |
|
||||
| 3 | **Process Count Validation** | SAFETY ABORT if filter would delete ALL processes |
|
||||
| 4 | **Explicit Name Verification** | Log exactly which processes will be affected |
|
||||
| 5 | **Post-Cleanup Verification** | Verify environment isolation after cleanup |
|
||||
|
||||
### Layer Details
|
||||
|
||||
#### Layer 1: Workflow Metadata Logging
|
||||
|
||||
Logs at the start of deployment:
|
||||
|
||||
- Workflow file name
|
||||
- SHA-256 hash of the workflow file
|
||||
- Git commit being deployed
|
||||
- Git branch
|
||||
- Timestamp (UTC)
|
||||
- Actor (who triggered the deployment)
|
||||
|
||||
**Purpose**: If an incident occurs, we can verify whether the executed workflow matches the repository version.
|
||||
|
||||
```bash
|
||||
echo "=== WORKFLOW METADATA ==="
|
||||
echo "Workflow file: deploy-to-prod.yml"
|
||||
echo "Workflow file hash: $(sha256sum .gitea/workflows/deploy-to-prod.yml | cut -d' ' -f1)"
|
||||
echo "Git commit: $(git rev-parse HEAD)"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Actor: ${{ gitea.actor }}"
|
||||
echo "=== END METADATA ==="
|
||||
```
|
||||
|
||||
#### Layer 2: Pre-Cleanup PM2 State Logging
|
||||
|
||||
Captures full PM2 process list in JSON format before any modifications.
|
||||
|
||||
**Purpose**: Provides forensic evidence of what processes existed before cleanup began.
|
||||
|
||||
```bash
|
||||
echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
pm2 jlist
|
||||
echo "=== END PRE-CLEANUP STATE ==="
|
||||
```
|
||||
|
||||
#### Layer 3: Process Count Validation (SAFETY ABORT)
|
||||
|
||||
The most critical safeguard. Aborts the entire deployment if the filter would delete ALL processes and there are more than 3 processes total.
|
||||
|
||||
**Purpose**: Catches filter bugs or unexpected conditions that would result in catastrophic process deletion.
|
||||
|
||||
```javascript
|
||||
// SAFEGUARD 1: Process count validation
|
||||
const totalProcesses = list.length;
|
||||
if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
console.error(
|
||||
'Total processes: ' + totalProcesses + ', Target processes: ' + targetProcesses.length,
|
||||
);
|
||||
console.error('This indicates a potential filter bug. Aborting cleanup.');
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Threshold Rationale**: The threshold of 3 allows normal operation when only the 3 expected processes exist (API, Worker, Analytics Worker) while catching anomalies when the server hosts more applications.
|
||||
|
||||
#### Layer 4: Explicit Name Verification
|
||||
|
||||
Logs the exact name, status, and PM2 ID of each process that will be deleted.
|
||||
|
||||
**Purpose**: Provides clear visibility into what the cleanup operation will actually do.
|
||||
|
||||
```javascript
|
||||
console.log('Found ' + targetProcesses.length + ' PRODUCTION processes to clean:');
|
||||
targetProcesses.forEach((p) => {
|
||||
console.log(
|
||||
' - ' + p.name + ' (status: ' + p.pm2_env.status + ', pm_id: ' + p.pm2_env.pm_id + ')',
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
#### Layer 5: Post-Cleanup Verification
|
||||
|
||||
After cleanup, logs the state of processes by environment to verify isolation was maintained.
|
||||
|
||||
**Purpose**: Immediately identifies if the cleanup affected the wrong environment.
|
||||
|
||||
```bash
|
||||
echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
pm2 jlist | node -e "
|
||||
const list = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
const prodProcesses = list.filter(p => p.name && p.name.startsWith('flyer-crawler-') && !p.name.endsWith('-test'));
|
||||
const testProcesses = list.filter(p => p.name && p.name.endsWith('-test'));
|
||||
console.log('Production processes after cleanup: ' + prodProcesses.length);
|
||||
console.log('Test processes (should be untouched): ' + testProcesses.length);
|
||||
"
|
||||
echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------ | --------------------------------------------- |
|
||||
| `.gitea/workflows/deploy-to-prod.yml` | Added all 5 safeguard layers |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | Added all 5 safeguard layers |
|
||||
| `.gitea/workflows/manual-deploy-major.yml` | Added all 5 safeguard layers |
|
||||
| `CLAUDE.md` | Added PM2 Process Isolation Incidents section |
|
||||
|
||||
### Files Created
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------- | --------------------------------------- |
|
||||
| `docs/operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md` | Detailed incident report |
|
||||
| `docs/operations/PM2-INCIDENT-RESPONSE.md` | Comprehensive incident response runbook |
|
||||
| `tests/qa/test-pm2-safeguard-logic.js` | Validation tests for safeguard logic |
|
||||
|
||||
---
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
### Test Artifact
|
||||
|
||||
A standalone JavaScript test file was created to validate the safeguard logic:
|
||||
|
||||
**File**: `tests/qa/test-pm2-safeguard-logic.js`
|
||||
|
||||
**Test Categories**:
|
||||
|
||||
1. **Normal Operations (should NOT abort)**
|
||||
- 3 errored out of 15 processes
|
||||
- 1 errored out of 10 processes
|
||||
- 0 processes to clean
|
||||
- Fresh server with 3 processes (threshold boundary)
|
||||
|
||||
2. **Dangerous Operations (SHOULD abort)**
|
||||
- All 10 processes targeted
|
||||
- All 15 processes targeted
|
||||
- All 4 processes targeted (just above threshold)
|
||||
|
||||
3. **Workflow-Specific Filter Tests**
|
||||
- Production filter only matches production processes
|
||||
- Test filter only matches `-test` suffix processes
|
||||
- Filters don't cross-contaminate environments
|
||||
|
||||
### Test Results
|
||||
|
||||
All 11 scenarios passed:
|
||||
|
||||
| Scenario | Total | Target | Expected | Result |
|
||||
| -------------------------- | ----- | ------ | -------- | ------ |
|
||||
| Normal prod cleanup | 15 | 3 | No abort | PASS |
|
||||
| Normal test cleanup | 15 | 3 | No abort | PASS |
|
||||
| Single process | 10 | 1 | No abort | PASS |
|
||||
| No cleanup needed | 10 | 0 | No abort | PASS |
|
||||
| Fresh server (threshold) | 3 | 3 | No abort | PASS |
|
||||
| Minimal server | 2 | 2 | No abort | PASS |
|
||||
| Empty PM2 | 0 | 0 | No abort | PASS |
|
||||
| Filter bug - 10 processes | 10 | 10 | ABORT | PASS |
|
||||
| Filter bug - 15 processes | 15 | 15 | ABORT | PASS |
|
||||
| Filter bug - 4 processes | 4 | 4 | ABORT | PASS |
|
||||
| Filter bug - 100 processes | 100 | 100 | ABORT | PASS |
|
||||
|
||||
### YAML Validation
|
||||
|
||||
All workflow files passed YAML syntax validation using `python -c "import yaml; yaml.safe_load(open(...))"`
|
||||
|
||||
---
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
### CLAUDE.md Updates
|
||||
|
||||
Added new section at line 293: **PM2 Process Isolation Incidents**
|
||||
|
||||
Contains:
|
||||
|
||||
- Reference to the 2026-02-17 incident
|
||||
- Impact summary
|
||||
- Prevention measures list
|
||||
- Response instructions
|
||||
- Links to related documentation
|
||||
|
||||
### docs/README.md
|
||||
|
||||
Added incident report reference under **Operations > Incident Reports**.
|
||||
|
||||
### Cross-References Verified
|
||||
|
||||
| Document | Reference | Status |
|
||||
| --------------- | --------------------------------------- | ------ |
|
||||
| CLAUDE.md | PM2-INCIDENT-RESPONSE.md | Valid |
|
||||
| CLAUDE.md | INCIDENT-2026-02-17-PM2-PROCESS-KILL.md | Valid |
|
||||
| Incident Report | CLAUDE.md PM2 section | Valid |
|
||||
| Incident Report | PM2-INCIDENT-RESPONSE.md | Valid |
|
||||
| docs/README.md | INCIDENT-2026-02-17-PM2-PROCESS-KILL.md | Valid |
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### Technical Lessons
|
||||
|
||||
1. **Filter logic alone is not sufficient** - Even correct filters can be bypassed if an older version of the script is executed.
|
||||
|
||||
2. **Workflow caching is a real risk** - CI/CD runners may cache workflow files, leading to stale versions being executed.
|
||||
|
||||
3. **Defense-in-depth is essential for destructive operations** - Multiple layers of validation catch failures that single-point checks miss.
|
||||
|
||||
4. **Visibility enables diagnosis** - Pre/post state logging makes root cause analysis possible.
|
||||
|
||||
5. **Automatic abort prevents cascading failures** - The process count validation could have prevented the incident entirely.
|
||||
|
||||
### Process Lessons
|
||||
|
||||
1. **Shared PM2 daemons are risky** - Multiple applications sharing a PM2 daemon create cross-application dependencies.
|
||||
|
||||
2. **Documentation should include failure modes** - CLAUDE.md now explicitly documents what can go wrong and how to respond.
|
||||
|
||||
3. **Runbooks save time during incidents** - The incident response runbook provides step-by-step guidance when time is critical.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Not Implemented (Potential Future Work)
|
||||
|
||||
1. **PM2 Namespacing** - Use PM2's native namespace feature to completely isolate environments.
|
||||
|
||||
2. **Separate PM2 Daemons** - Run one PM2 daemon per application to eliminate cross-application risk.
|
||||
|
||||
3. **Deployment Locks** - Implement mutex-style locks to prevent concurrent deployments.
|
||||
|
||||
4. **Workflow Version Verification** - Add a pre-flight check that compares workflow hash against expected value.
|
||||
|
||||
5. **Automated Rollback** - Implement automatic process restoration if safeguards detect a problem.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **ADR-061**: [PM2 Process Isolation Safeguards](../../adr/0061-pm2-process-isolation-safeguards.md)
|
||||
- **Incident Report**: [INCIDENT-2026-02-17-PM2-PROCESS-KILL.md](../../operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md)
|
||||
- **Response Runbook**: [PM2-INCIDENT-RESPONSE.md](../../operations/PM2-INCIDENT-RESPONSE.md)
|
||||
- **CLAUDE.md Section**: [PM2 Process Isolation Incidents](../../../CLAUDE.md#pm2-process-isolation-incidents)
|
||||
- **Test Artifact**: [test-pm2-safeguard-logic.js](../../../tests/qa/test-pm2-safeguard-logic.js)
|
||||
- **ADR-014**: [Containerization and Deployment Strategy](../../adr/0014-containerization-and-deployment-strategy.md)
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Workflow Changes Summary
|
||||
|
||||
### deploy-to-prod.yml
|
||||
|
||||
```diff
|
||||
+ - name: Log Workflow Metadata
|
||||
+ run: |
|
||||
+ echo "=== WORKFLOW METADATA ==="
|
||||
+ echo "Workflow file: deploy-to-prod.yml"
|
||||
+ echo "Workflow file hash: $(sha256sum .gitea/workflows/deploy-to-prod.yml | cut -d' ' -f1)"
|
||||
+ ...
|
||||
|
||||
- name: Install Backend Dependencies and Restart Production Server
|
||||
run: |
|
||||
+ # === PRE-CLEANUP PM2 STATE LOGGING ===
|
||||
+ echo "=== PRE-CLEANUP PM2 STATE ==="
|
||||
+ pm2 jlist
|
||||
+ echo "=== END PRE-CLEANUP STATE ==="
|
||||
+
|
||||
# --- Cleanup Errored Processes with Defense-in-Depth Safeguards ---
|
||||
node -e "
|
||||
...
|
||||
+ // SAFEGUARD 1: Process count validation
|
||||
+ if (targetProcesses.length === totalProcesses && totalProcesses > 3) {
|
||||
+ console.error('SAFETY ABORT: Filter would delete ALL processes!');
|
||||
+ process.exit(1);
|
||||
+ }
|
||||
+
|
||||
+ // SAFEGUARD 2: Explicit name verification
|
||||
+ console.log('Found ' + targetProcesses.length + ' PRODUCTION processes to clean:');
|
||||
+ targetProcesses.forEach(p => {
|
||||
+ console.log(' - ' + p.name + ' (status: ' + p.pm2_env.status + ')');
|
||||
+ });
|
||||
...
|
||||
"
|
||||
+
|
||||
+ # === POST-CLEANUP VERIFICATION ===
|
||||
+ echo "=== POST-CLEANUP VERIFICATION ==="
|
||||
+ pm2 jlist | node -e "..."
|
||||
+ echo "=== END POST-CLEANUP VERIFICATION ==="
|
||||
```
|
||||
|
||||
Similar changes were applied to `deploy-to-test.yml` and `manual-deploy-major.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Session Participants
|
||||
|
||||
| Role | Agent Type | Responsibility |
|
||||
| ------------ | ------------------------- | ------------------------------------- |
|
||||
| Orchestrator | Main Claude | Session coordination and delegation |
|
||||
| Planner | planner subagent | Incident analysis and solution design |
|
||||
| Documenter | describer-for-ai subagent | Incident report creation |
|
||||
| Coder #1 | coder subagent | Workflow safeguard implementation |
|
||||
| Coder #2 | coder subagent | Incident response runbook creation |
|
||||
| Coder #3 | coder subagent | CLAUDE.md updates |
|
||||
| Tester | tester subagent | Comprehensive validation |
|
||||
| Archivist | Lead Technical Archivist | Final documentation |
|
||||
|
||||
---
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ------------------------ | ----------------------- |
|
||||
| 2026-02-17 | Lead Technical Archivist | Initial session summary |
|
||||
270
docs/archive/sessions/TYPESCRIPT_ERROR_REMEDIATION_2026-02-17.md
Normal file
270
docs/archive/sessions/TYPESCRIPT_ERROR_REMEDIATION_2026-02-17.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# TypeScript Test Error Remediation Project
|
||||
|
||||
**Date**: 2026-02-17
|
||||
|
||||
**Status**: Completed
|
||||
|
||||
**ADR**: [ADR-060](../../adr/0060-typescript-test-error-remediation.md)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Systematic remediation of 185 TypeScript errors across the flyer-crawler test suite following API response standardization (ADR-028) and tsoa migration (ADR-059). The project achieved zero TypeScript errors while maintaining test suite integrity.
|
||||
|
||||
## Project Metrics
|
||||
|
||||
| Metric | Initial | Final | Change |
|
||||
| ----------------- | ------- | ----- | ------ |
|
||||
| TypeScript Errors | 185 | 0 | -185 |
|
||||
| Tests Passing | 4,600 | 4,603 | +3 |
|
||||
| Tests Failing | 62 | 59 | -3 |
|
||||
| Files Modified | 0 | 25+ | - |
|
||||
|
||||
## Error Evolution Timeline
|
||||
|
||||
```
|
||||
Initial Assessment: 185 errors
|
||||
After Phase 1-4: 114 errors (-71)
|
||||
After Iteration 2: 67 errors (-47)
|
||||
After Iteration 3: 23 errors (-44)
|
||||
Final: 0 errors (-23)
|
||||
```
|
||||
|
||||
## Root Causes Identified
|
||||
|
||||
### 1. SuccessResponse Discriminated Union (48.1%)
|
||||
|
||||
ADR-028 introduced `ApiSuccessResponse<T> | ApiErrorResponse` union types. Tests accessing `response.body.data` without type guards triggered TS2339 errors.
|
||||
|
||||
**Solution**: Created `asSuccessResponse<T>()` type guard utility.
|
||||
|
||||
### 2. Mock Object Type Casting (22.7%)
|
||||
|
||||
Vitest mocks return `MockedFunction<T>` types. Passing to functions expecting exact signatures required explicit casting.
|
||||
|
||||
**Solution**: Created `asMock<T>()` utility and standardized mock patterns.
|
||||
|
||||
### 3. Response Body Property Access (15.1%)
|
||||
|
||||
Supertest `response.body` is typed as `unknown`. Direct property access violated strict mode.
|
||||
|
||||
**Solution**: Consistent use of `asSuccessResponse()` before accessing `.data`.
|
||||
|
||||
### 4. Partial Mock Missing Properties (9.7%)
|
||||
|
||||
Factory functions creating partial mocks lacked required properties.
|
||||
|
||||
**Solution**: Updated all mock factories to return complete type-safe objects.
|
||||
|
||||
### 5. Generic Type Parameter Issues (2.7%)
|
||||
|
||||
TypeScript could not infer generics in certain contexts.
|
||||
|
||||
**Solution**: Explicit generic parameters on factory calls and assertions.
|
||||
|
||||
### 6. Module Import Type Issues (1.6%)
|
||||
|
||||
Type mismatches in module mock declarations.
|
||||
|
||||
**Solution**: Proper use of `vi.mocked()` and `Mocked<typeof module>` patterns.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Foundation (Infrastructure)
|
||||
|
||||
Created shared test utilities that enable fixes across all test files:
|
||||
|
||||
```typescript
|
||||
// src/tests/utils/testHelpers.ts
|
||||
export function asSuccessResponse<T>(body: unknown): ApiSuccessResponse<T>;
|
||||
export function asErrorResponse(body: unknown): ApiErrorResponse;
|
||||
export function asMock<T extends (...args: unknown[]) => unknown>(mock: Mock): T;
|
||||
export { createMockLogger, mockLogger } from './mockLogger';
|
||||
```
|
||||
|
||||
### Phase 2-4: Parallel Execution
|
||||
|
||||
Distributed work across multiple parallel tasks:
|
||||
|
||||
| Group | Files | Dependencies |
|
||||
| ----- | ------------------------------------------ | ----------------- |
|
||||
| A | Controller tests (auth, user, flyer) | Phase 1 utilities |
|
||||
| B | Controller tests (recipe, inventory, etc.) | Phase 1 utilities |
|
||||
| C | Service tests | None |
|
||||
| D | Route tests | Phase 1 utilities |
|
||||
|
||||
### Phase 5: Iterative Refinement
|
||||
|
||||
Multiple verification and fix iterations:
|
||||
|
||||
1. Run type-check
|
||||
2. Analyze remaining errors
|
||||
3. Fix errors by file
|
||||
4. Re-verify
|
||||
5. Repeat until zero errors
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Controller Tests (19 files)
|
||||
|
||||
- `src/controllers/admin.controller.test.ts`
|
||||
- `src/controllers/ai.controller.test.ts`
|
||||
- `src/controllers/auth.controller.test.ts`
|
||||
- `src/controllers/budget.controller.test.ts`
|
||||
- `src/controllers/category.controller.test.ts`
|
||||
- `src/controllers/deals.controller.test.ts`
|
||||
- `src/controllers/flyer.controller.test.ts`
|
||||
- `src/controllers/gamification.controller.test.ts`
|
||||
- `src/controllers/inventory.controller.test.ts`
|
||||
- `src/controllers/personalization.controller.test.ts`
|
||||
- `src/controllers/price.controller.test.ts`
|
||||
- `src/controllers/reactions.controller.test.ts`
|
||||
- `src/controllers/receipt.controller.test.ts`
|
||||
- `src/controllers/recipe.controller.test.ts`
|
||||
- `src/controllers/store.controller.test.ts`
|
||||
- `src/controllers/system.controller.test.ts`
|
||||
- `src/controllers/upc.controller.test.ts`
|
||||
- `src/controllers/user.controller.test.ts`
|
||||
|
||||
### Shared Test Utilities
|
||||
|
||||
- `src/tests/utils/testHelpers.ts` - Type guards and mock utilities
|
||||
- `src/tests/utils/mockLogger.ts` - Pino logger mock factory
|
||||
- `src/tests/utils/mockFactories.ts` - 60+ entity mock factories
|
||||
|
||||
### Route Tests
|
||||
|
||||
- `src/routes/admin.*.routes.test.ts` (5 files)
|
||||
- `src/routes/ai.routes.test.ts`
|
||||
|
||||
### Service Tests
|
||||
|
||||
- `src/services/receiptService.server.test.ts`
|
||||
- `src/services/queueService.server.test.ts`
|
||||
|
||||
### Middleware Tests
|
||||
|
||||
- `src/middleware/apiVersion.middleware.test.ts`
|
||||
|
||||
## Key Patterns Established
|
||||
|
||||
### 1. Response Type Narrowing
|
||||
|
||||
```typescript
|
||||
// Standard pattern for success responses
|
||||
const response = await request.get('/api/v1/users/1');
|
||||
const body = asSuccessResponse<User>(response.body);
|
||||
expect(body.data.id).toBe(1);
|
||||
|
||||
// Standard pattern for error responses
|
||||
expect(response.status).toBe(400);
|
||||
const body = asErrorResponse(response.body);
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR');
|
||||
```
|
||||
|
||||
### 2. Mock Logger Creation
|
||||
|
||||
```typescript
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
function createMockRequest(overrides = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Mock Service Casting
|
||||
|
||||
```typescript
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
vi.mock('../services/authService');
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
const mockedAuthService = authService as Mocked<typeof authService>;
|
||||
mockedAuthService.login.mockResolvedValue(mockResult);
|
||||
```
|
||||
|
||||
### 4. Mock Factory Usage
|
||||
|
||||
```typescript
|
||||
import { createMockUserProfile, createMockFlyer } from '../tests/utils/mockFactories';
|
||||
|
||||
const mockUser = createMockUserProfile({ role: 'admin' });
|
||||
const mockFlyer = createMockFlyer({ store: { name: 'Test Store' } });
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### 1. Infrastructure First
|
||||
|
||||
Creating shared utilities before fixing individual files dramatically reduces total effort. The `asSuccessResponse()` utility alone enabled fixes for 89 errors.
|
||||
|
||||
### 2. Parallel Execution Efficiency
|
||||
|
||||
Organizing work into independent groups allowed parallel execution, reducing wall-clock time from estimated 10 hours to approximately 3.5 hours.
|
||||
|
||||
### 3. Iterative Verification
|
||||
|
||||
Running type-check after each batch of fixes catches cascading issues early and provides clear progress metrics.
|
||||
|
||||
### 4. Complete Mock Factories
|
||||
|
||||
Investing in comprehensive mock factories pays dividends across all tests. The 60+ factory functions in `mockFactories.ts` ensure type safety throughout the test suite.
|
||||
|
||||
### 5. Consistent Patterns
|
||||
|
||||
Establishing and documenting patterns (response narrowing, mock casting, logger creation) ensures consistency and reduces future maintenance burden.
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Type Check
|
||||
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
# Exit code: 0
|
||||
# Output: No errors
|
||||
```
|
||||
|
||||
### Test Suite
|
||||
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# Results:
|
||||
# Test Files: 167 passed, 11 failed (178 total)
|
||||
# Tests: 4,603 passed, 59 failed (4,662 total)
|
||||
# Duration: ~4 minutes
|
||||
```
|
||||
|
||||
### Pre-existing Failures
|
||||
|
||||
The 59 failing tests are pre-existing issues unrelated to this remediation:
|
||||
|
||||
- Integration test timing issues
|
||||
- Mock isolation in globalSetup
|
||||
- Redis/Queue worker interference
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
1. **ADR-060**: Status updated to "Implemented" with completion metrics
|
||||
2. **TESTING.md**: Added TypeScript type safety section
|
||||
3. **This document**: Session archive for future reference
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-010](../../adr/0010-testing-strategy-and-standards.md) - Testing Strategy
|
||||
- [ADR-028](../../adr/0028-api-response-standardization.md) - API Response Standardization
|
||||
- [ADR-045](../../adr/0045-test-data-factories-and-fixtures.md) - Test Data Factories
|
||||
- [ADR-057](../../adr/0057-test-remediation-post-api-versioning.md) - API Versioning Remediation
|
||||
- [ADR-059](../../adr/0059-dependency-modernization.md) - tsoa Migration
|
||||
- [ADR-060](../../adr/0060-typescript-test-error-remediation.md) - This Project
|
||||
|
||||
## Future Recommendations
|
||||
|
||||
1. **Enforce Type Safety in CI**: Add `npm run type-check` as a required CI step
|
||||
2. **Mock Factory Maintenance**: Update factories when entity types change
|
||||
3. **Pattern Documentation**: Reference TESTING.md patterns in code review guidelines
|
||||
4. **New Test Template**: Create a test file template that imports standard utilities
|
||||
@@ -505,3 +505,115 @@ expect(element.className).toMatch(/dark:bg-teal-\d+/);
|
||||
```
|
||||
|
||||
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for lessons learned from the test remediation effort.
|
||||
|
||||
## TypeScript Type Safety in Tests (ADR-060)
|
||||
|
||||
Tests must be fully type-safe. Common patterns for handling API response types and mock casting are documented below.
|
||||
|
||||
### Response Type Narrowing
|
||||
|
||||
API responses use discriminated unions (`ApiSuccessResponse<T> | ApiErrorResponse`). Access `.data` only after type narrowing.
|
||||
|
||||
**Utility Functions** (`src/tests/utils/testHelpers.ts`):
|
||||
|
||||
```typescript
|
||||
import { asSuccessResponse, asErrorResponse } from '@/tests/utils/testHelpers';
|
||||
|
||||
// Success response access
|
||||
const response = await request.get('/api/v1/users/1');
|
||||
const body = asSuccessResponse<User>(response.body);
|
||||
expect(body.data.id).toBe(1);
|
||||
|
||||
// Error response access
|
||||
const errorResponse = await request.post('/api/v1/users').send({});
|
||||
expect(errorResponse.status).toBe(400);
|
||||
const errorBody = asErrorResponse(errorResponse.body);
|
||||
expect(errorBody.error.code).toBe('VALIDATION_ERROR');
|
||||
```
|
||||
|
||||
### Mock Object Type Casting
|
||||
|
||||
Use appropriate casting based on type compatibility:
|
||||
|
||||
```typescript
|
||||
// Level 1: Type assertion for compatible shapes
|
||||
const mock = createMockUser() as User;
|
||||
|
||||
// Level 2: Unknown bridge for incompatible shapes
|
||||
const mock = partialMock as unknown as User;
|
||||
|
||||
// Level 3: Partial with required overrides
|
||||
const mock: User = { ...createPartialUser(), id: 1, email: 'test@test.com' };
|
||||
```
|
||||
|
||||
### Mock Function Casting
|
||||
|
||||
```typescript
|
||||
import { asMock } from '@/tests/utils/testHelpers';
|
||||
|
||||
// Cast vi.fn() to specific function type
|
||||
const mockFn = vi.fn();
|
||||
someService.register(asMock<UserService['create']>(mockFn));
|
||||
|
||||
// vi.fn() with explicit type parameters
|
||||
const mockFn = vi.fn<[string], Promise<User>>().mockResolvedValue(mockUser);
|
||||
|
||||
// vi.mocked() for mocked modules
|
||||
vi.mock('@/services/userService');
|
||||
const mockedService = vi.mocked(userService);
|
||||
mockedService.create.mockResolvedValue(mockUser);
|
||||
```
|
||||
|
||||
### Mock Logger for Controller Tests
|
||||
|
||||
Controllers require a Pino logger on `req.log`. Use the shared mock logger utility:
|
||||
|
||||
```typescript
|
||||
import { createMockLogger } from '@/tests/utils/testHelpers';
|
||||
|
||||
function createMockRequest(overrides = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
cookies: {},
|
||||
log: createMockLogger(),
|
||||
res: { cookie: vi.fn() } as unknown as ExpressResponse,
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
```
|
||||
|
||||
The `createMockLogger()` function returns a complete Pino logger mock with all methods (`info`, `debug`, `error`, `warn`, `fatal`, `trace`, `silent`, `child`) as `vi.fn()` mocks.
|
||||
|
||||
### MSW Handler Typing
|
||||
|
||||
Ensure MSW handlers return properly typed API responses:
|
||||
|
||||
```typescript
|
||||
import { ApiSuccessResponse } from '@/types/api';
|
||||
import { Flyer } from '@/types/flyer';
|
||||
|
||||
http.get('/api/v1/flyers', () => {
|
||||
const response: ApiSuccessResponse<Flyer[]> = {
|
||||
success: true,
|
||||
data: [mockFlyer],
|
||||
};
|
||||
return HttpResponse.json(response);
|
||||
});
|
||||
```
|
||||
|
||||
### Generic Type Parameters
|
||||
|
||||
Provide explicit generics when TypeScript cannot infer:
|
||||
|
||||
```typescript
|
||||
// Factory function generic
|
||||
const mock = createMockPaginatedResponse<Flyer>({ data: [mockFlyer] });
|
||||
|
||||
// Assertion generic
|
||||
expect(result).toEqual<ApiSuccessResponse<User>>({
|
||||
success: true,
|
||||
data: mockUser,
|
||||
});
|
||||
```
|
||||
|
||||
See [ADR-060](../adr/0060-typescript-test-error-remediation.md) for comprehensive patterns and remediation strategies.
|
||||
|
||||
269
docs/operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md
Normal file
269
docs/operations/INCIDENT-2026-02-17-PM2-PROCESS-KILL.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Incident Report: PM2 Process Kill During v0.15.0 Deployment
|
||||
|
||||
**Date**: 2026-02-17
|
||||
**Severity**: Critical
|
||||
**Status**: Mitigated - Safeguards Implemented
|
||||
**Affected Systems**: All PM2-managed applications on projectium.com server
|
||||
|
||||
---
|
||||
|
||||
## Resolution Summary
|
||||
|
||||
**Safeguards implemented on 2026-02-17** to prevent recurrence:
|
||||
|
||||
1. Workflow metadata logging (audit trail)
|
||||
2. Pre-cleanup PM2 state logging (forensics)
|
||||
3. Process count validation with SAFETY ABORT (automatic prevention)
|
||||
4. Explicit name verification (visibility)
|
||||
5. Post-cleanup verification (environment isolation check)
|
||||
|
||||
**Documentation created**:
|
||||
|
||||
- [PM2 Incident Response Runbook](PM2-INCIDENT-RESPONSE.md)
|
||||
- [PM2 Safeguards Session Summary](../archive/sessions/PM2_SAFEGUARDS_SESSION_2026-02-17.md)
|
||||
- CLAUDE.md updated with [PM2 Process Isolation Incidents section](../../CLAUDE.md#pm2-process-isolation-incidents)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
During v0.15.0 production deployment, ALL PM2 processes on the server were terminated, not just flyer-crawler processes. This caused unplanned downtime for other applications including stock-alert.
|
||||
|
||||
## Timeline
|
||||
|
||||
| Time (Approx) | Event |
|
||||
| --------------------- | ---------------------------------------------------------------- |
|
||||
| 2026-02-17 ~07:40 UTC | v0.15.0 production deployment triggered via `deploy-to-prod.yml` |
|
||||
| Unknown | All PM2 processes killed (flyer-crawler AND other apps) |
|
||||
| Unknown | Incident discovered - stock-alert down |
|
||||
| 2026-02-17 | Investigation initiated |
|
||||
| 2026-02-17 | Defense-in-depth safeguards implemented in all workflows |
|
||||
| 2026-02-17 | Incident response runbook created |
|
||||
| 2026-02-17 | Status changed to Mitigated |
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected Applications**: All PM2-managed processes on projectium.com
|
||||
- flyer-crawler-api, flyer-crawler-worker, flyer-crawler-analytics-worker (expected)
|
||||
- stock-alert (NOT expected - collateral damage)
|
||||
- Potentially other unidentified applications
|
||||
- **Downtime Duration**: TBD
|
||||
- **User Impact**: Service unavailability for all affected applications
|
||||
|
||||
---
|
||||
|
||||
## Investigation Findings
|
||||
|
||||
### Deployment Workflow Analysis
|
||||
|
||||
All deployment workflows were reviewed for PM2 process isolation:
|
||||
|
||||
| Workflow | PM2 Isolation | Implementation |
|
||||
| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `deploy-to-prod.yml` | Whitelist | `prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker']` |
|
||||
| `deploy-to-test.yml` | Pattern | `p.name.endsWith('-test')` |
|
||||
| `manual-deploy-major.yml` | Whitelist | Same as deploy-to-prod |
|
||||
| `manual-db-restore.yml` | Explicit names | `pm2 stop flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker` |
|
||||
|
||||
### Fix Commit Already In Place
|
||||
|
||||
The PM2 process isolation fix was implemented in commit `b6a62a0` (2026-02-13):
|
||||
|
||||
```
|
||||
commit b6a62a036f39ac895271402a61e5cc4227369de7
|
||||
Author: Torben Sorensen <torben.sorensen@gmail.com>
|
||||
Date: Fri Feb 13 10:19:28 2026 -0800
|
||||
|
||||
be specific about pm2 processes
|
||||
|
||||
Files modified:
|
||||
.gitea/workflows/deploy-to-prod.yml
|
||||
.gitea/workflows/deploy-to-test.yml
|
||||
.gitea/workflows/manual-db-restore.yml
|
||||
.gitea/workflows/manual-deploy-major.yml
|
||||
CLAUDE.md
|
||||
```
|
||||
|
||||
### v0.15.0 Release Contains Fix
|
||||
|
||||
Confirmed: v0.15.0 (commit `93ad624`, 2026-02-18) includes the fix commit:
|
||||
|
||||
```
|
||||
93ad624 ci: Bump version to 0.15.0 for production release [skip ci]
|
||||
...
|
||||
b6a62a0 be specific about pm2 processes <-- Fix commit included
|
||||
```
|
||||
|
||||
### Current Workflow PM2 Commands
|
||||
|
||||
**Production Deploy (`deploy-to-prod.yml` line 170)**:
|
||||
|
||||
```javascript
|
||||
const prodProcesses = [
|
||||
'flyer-crawler-api',
|
||||
'flyer-crawler-worker',
|
||||
'flyer-crawler-analytics-worker',
|
||||
];
|
||||
list.forEach((p) => {
|
||||
if (
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
prodProcesses.includes(p.name)
|
||||
) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Test Deploy (`deploy-to-test.yml` line 100)**:
|
||||
|
||||
```javascript
|
||||
list.forEach((p) => {
|
||||
if (p.name && p.name.endsWith('-test')) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Both implementations have proper name filtering and should NOT affect non-flyer-crawler processes.
|
||||
|
||||
---
|
||||
|
||||
## Discrepancy Analysis
|
||||
|
||||
### Key Mystery
|
||||
|
||||
**If the fixes are in place, why did ALL processes get killed?**
|
||||
|
||||
### Possible Explanations
|
||||
|
||||
#### 1. Workflow Version Mismatch (HIGH PROBABILITY)
|
||||
|
||||
**Hypothesis**: Gitea runner cached an older version of the workflow file.
|
||||
|
||||
- Gitea Actions may cache workflow definitions
|
||||
- The runner might have executed an older version without the fix
|
||||
- Need to verify: What version of `deploy-to-prod.yml` actually executed?
|
||||
|
||||
**Investigation Required**:
|
||||
|
||||
- Check Gitea workflow execution logs for actual script content
|
||||
- Verify runner workflow caching behavior
|
||||
- Compare executed workflow vs repository version
|
||||
|
||||
#### 2. Concurrent Workflow Execution (MEDIUM PROBABILITY)
|
||||
|
||||
**Hypothesis**: Another workflow ran simultaneously with destructive PM2 commands.
|
||||
|
||||
Workflows with potential issues:
|
||||
|
||||
- `manual-db-reset-prod.yml` - Does NOT restart PM2 (schema reset only)
|
||||
- `manual-redis-flush-prod.yml` - Does NOT touch PM2
|
||||
- Test deployment concurrent with prod deployment
|
||||
|
||||
**Investigation Required**:
|
||||
|
||||
- Check Gitea Actions history for concurrent workflow runs
|
||||
- Review timestamps of all workflow executions on 2026-02-17
|
||||
|
||||
#### 3. Manual SSH Command (MEDIUM PROBABILITY)
|
||||
|
||||
**Hypothesis**: Someone SSH'd to the server and ran `pm2 stop all` or `pm2 delete all` manually.
|
||||
|
||||
**Investigation Required**:
|
||||
|
||||
- Check server shell history (if available)
|
||||
- Review any maintenance windows or manual interventions
|
||||
- Ask team members about manual actions
|
||||
|
||||
#### 4. PM2 Internal Issue (LOW PROBABILITY)
|
||||
|
||||
**Hypothesis**: PM2 daemon crash or corruption caused all processes to stop.
|
||||
|
||||
**Investigation Required**:
|
||||
|
||||
- Check PM2 daemon logs on server
|
||||
- Look for OOM killer events in system logs
|
||||
- Check disk space issues during deployment
|
||||
|
||||
#### 5. Script Execution Error (LOW PROBABILITY)
|
||||
|
||||
**Hypothesis**: JavaScript parsing error caused the filtering logic to be bypassed.
|
||||
|
||||
**Investigation Required**:
|
||||
|
||||
- Review workflow execution logs for JavaScript errors
|
||||
- Test the inline Node.js scripts locally
|
||||
- Check for shell escaping issues
|
||||
|
||||
---
|
||||
|
||||
## Documentation/Code Gaps Identified
|
||||
|
||||
### CLAUDE.md Documentation
|
||||
|
||||
The PM2 isolation rules are documented in `CLAUDE.md`, but:
|
||||
|
||||
- Documentation uses `pm2 restart all` in the Quick Reference table (for dev container - acceptable)
|
||||
- Multiple docs still reference `pm2 restart all` without environment context
|
||||
- No incident response runbook for PM2 issues
|
||||
|
||||
### Workflow Gaps
|
||||
|
||||
1. **No Workflow Audit Trail**: No logging of which exact workflow version executed
|
||||
2. **No Pre-deployment Verification**: Workflows don't log PM2 state before modifications
|
||||
3. **No Cross-Application Impact Assessment**: No mechanism to detect/warn about other apps
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Root Cause Analysis
|
||||
|
||||
### Immediate (Priority 1)
|
||||
|
||||
1. [ ] Retrieve Gitea Actions execution logs for v0.15.0 deployment
|
||||
2. [ ] Extract actual executed workflow content from logs
|
||||
3. [ ] Check for concurrent workflow executions on 2026-02-17
|
||||
4. [ ] Review server PM2 daemon logs around incident time
|
||||
|
||||
### Short-term (Priority 2)
|
||||
|
||||
5. [ ] Implement pre-deployment PM2 state logging in workflows
|
||||
6. [ ] Add workflow version hash logging for audit trail
|
||||
7. [ ] Create incident response runbook for PM2/deployment issues
|
||||
|
||||
### Long-term (Priority 3)
|
||||
|
||||
8. [ ] Evaluate PM2 namespacing for complete process isolation
|
||||
9. [ ] Consider separate PM2 daemon per application
|
||||
10. [ ] Implement deployment monitoring/alerting
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLAUDE.md - PM2 Process Isolation](../../../CLAUDE.md) (Critical Rules section)
|
||||
- [ADR-014: Containerization and Deployment Strategy](../adr/0014-containerization-and-deployment-strategy.md)
|
||||
- [Deployment Guide](./DEPLOYMENT.md)
|
||||
- Workflow files in `.gitea/workflows/`
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Commit Timeline
|
||||
|
||||
```
|
||||
93ad624 ci: Bump version to 0.15.0 for production release [skip ci] <-- v0.15.0 release
|
||||
7dd4f21 ci: Bump version to 0.14.4 [skip ci]
|
||||
174b637 even more typescript fixes
|
||||
4f80baf ci: Bump version to 0.14.3 [skip ci]
|
||||
8450b5e Generate TSOA Spec and Routes
|
||||
e4d830a ci: Bump version to 0.14.2 [skip ci]
|
||||
b6a62a0 be specific about pm2 processes <-- PM2 fix commit
|
||||
2d2cd52 Massive Dependency Modernization Project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ------------------ | ----------------------- |
|
||||
| 2026-02-17 | Investigation Team | Initial incident report |
|
||||
818
docs/operations/PM2-INCIDENT-RESPONSE.md
Normal file
818
docs/operations/PM2-INCIDENT-RESPONSE.md
Normal file
@@ -0,0 +1,818 @@
|
||||
# PM2 Incident Response Runbook
|
||||
|
||||
**Purpose**: Step-by-step procedures for responding to PM2 process isolation incidents on the projectium.com server.
|
||||
|
||||
**Audience**: On-call responders, system administrators, developers with server access.
|
||||
|
||||
**Last updated**: 2026-02-17
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [CLAUDE.md - PM2 Process Isolation Rules](../../CLAUDE.md)
|
||||
- [Incident Report: 2026-02-17](INCIDENT-2026-02-17-PM2-PROCESS-KILL.md)
|
||||
- [Monitoring Guide](MONITORING.md)
|
||||
- [Deployment Guide](DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Reference](#quick-reference)
|
||||
2. [Detection](#detection)
|
||||
3. [Initial Assessment](#initial-assessment)
|
||||
4. [Immediate Response](#immediate-response)
|
||||
5. [Process Restoration](#process-restoration)
|
||||
6. [Root Cause Investigation](#root-cause-investigation)
|
||||
7. [Communication Templates](#communication-templates)
|
||||
8. [Prevention Measures](#prevention-measures)
|
||||
9. [Contact Information](#contact-information)
|
||||
10. [Post-Incident Review](#post-incident-review)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### PM2 Process Inventory
|
||||
|
||||
| Application | Environment | Process Names | Config File | Directory |
|
||||
| ------------- | ----------- | -------------------------------------------------------------------------------------------- | --------------------------- | -------------------------------------------- |
|
||||
| Flyer Crawler | Production | `flyer-crawler-api`, `flyer-crawler-worker`, `flyer-crawler-analytics-worker` | `ecosystem.config.cjs` | `/var/www/flyer-crawler.projectium.com` |
|
||||
| Flyer Crawler | Test | `flyer-crawler-api-test`, `flyer-crawler-worker-test`, `flyer-crawler-analytics-worker-test` | `ecosystem-test.config.cjs` | `/var/www/flyer-crawler-test.projectium.com` |
|
||||
| Stock Alert | Production | `stock-alert-*` | (varies) | `/var/www/stock-alert.projectium.com` |
|
||||
|
||||
### Critical Commands
|
||||
|
||||
```bash
|
||||
# Check PM2 status
|
||||
pm2 list
|
||||
|
||||
# Check specific process
|
||||
pm2 show flyer-crawler-api
|
||||
|
||||
# View recent logs
|
||||
pm2 logs --lines 50
|
||||
|
||||
# Restart specific processes (SAFE)
|
||||
pm2 restart flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker
|
||||
|
||||
# DO NOT USE (affects ALL apps)
|
||||
# pm2 restart all <-- DANGEROUS
|
||||
# pm2 stop all <-- DANGEROUS
|
||||
# pm2 delete all <-- DANGEROUS
|
||||
```
|
||||
|
||||
### Severity Classification
|
||||
|
||||
| Severity | Criteria | Response Time | Example |
|
||||
| ----------------- | --------------------------------------------- | ------------------- | ----------------------------------------------- |
|
||||
| **P1 - Critical** | Multiple applications down, production impact | Immediate (< 5 min) | All PM2 processes killed |
|
||||
| **P2 - High** | Single application down, production impact | < 15 min | Flyer Crawler prod down, Stock Alert unaffected |
|
||||
| **P3 - Medium** | Test environment only, no production impact | < 1 hour | Test processes killed, production unaffected |
|
||||
|
||||
---
|
||||
|
||||
## Detection
|
||||
|
||||
### How to Identify a PM2 Incident
|
||||
|
||||
**Automated Indicators**:
|
||||
|
||||
- Health check failures on `/api/health/ready`
|
||||
- Monitoring alerts (UptimeRobot, etc.)
|
||||
- Bugsink showing connection errors
|
||||
- NGINX returning 502 Bad Gateway
|
||||
|
||||
**User-Reported Symptoms**:
|
||||
|
||||
- "The site is down"
|
||||
- "I can't log in"
|
||||
- "Pages are loading slowly then timing out"
|
||||
- "I see a 502 error"
|
||||
|
||||
**Manual Discovery**:
|
||||
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh gitea-runner@projectium.com
|
||||
|
||||
# Check if PM2 is running
|
||||
pm2 list
|
||||
|
||||
# Expected output shows processes
|
||||
# If empty or all errored = incident
|
||||
```
|
||||
|
||||
### Incident Signature: Process Isolation Violation
|
||||
|
||||
When a PM2 incident is caused by process isolation failure, you will see:
|
||||
|
||||
```text
|
||||
# Expected state (normal):
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
| App name | id |mode | status | cpu |
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
| flyer-crawler-api | 0 |clust| online | 0% |
|
||||
| flyer-crawler-worker | 1 |fork | online | 0% |
|
||||
| flyer-crawler-analytics-worker | 2 |fork | online | 0% |
|
||||
| flyer-crawler-api-test | 3 |fork | online | 0% |
|
||||
| flyer-crawler-worker-test | 4 |fork | online | 0% |
|
||||
| flyer-crawler-analytics-worker-test| 5 |fork | online | 0% |
|
||||
| stock-alert-api | 6 |fork | online | 0% |
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
|
||||
# Incident state (isolation violation):
|
||||
# All processes missing or errored - not just one app
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
| App name | id |mode | status | cpu |
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
# (empty or all processes errored/stopped)
|
||||
+-----------------------------------+----+-----+---------+-------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Initial Assessment
|
||||
|
||||
### Step 1: Gather Information (2 minutes)
|
||||
|
||||
Run these commands and capture output:
|
||||
|
||||
```bash
|
||||
# 1. Check PM2 status
|
||||
pm2 list
|
||||
|
||||
# 2. Check PM2 daemon status
|
||||
pm2 ping
|
||||
|
||||
# 3. Check recent PM2 logs
|
||||
pm2 logs --lines 20 --nostream
|
||||
|
||||
# 4. Check system status
|
||||
systemctl status pm2-gitea-runner --no-pager
|
||||
|
||||
# 5. Check disk space
|
||||
df -h /
|
||||
|
||||
# 6. Check memory
|
||||
free -h
|
||||
|
||||
# 7. Check recent deployments (in app directory)
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 2: Determine Scope
|
||||
|
||||
| Question | Command | Impact Level |
|
||||
| ------------------------ | ---------------------------------------------------------------- | ------------------------------- |
|
||||
| How many apps affected? | `pm2 list` | Count missing/errored processes |
|
||||
| Is production down? | `curl https://flyer-crawler.projectium.com/api/health/ping` | Yes/No |
|
||||
| Is test down? | `curl https://flyer-crawler-test.projectium.com/api/health/ping` | Yes/No |
|
||||
| Are other apps affected? | `pm2 list \| grep stock-alert` | Yes/No |
|
||||
|
||||
### Step 3: Classify Severity
|
||||
|
||||
```text
|
||||
Decision Tree:
|
||||
|
||||
Production app(s) down?
|
||||
|
|
||||
+-- YES: Multiple apps affected?
|
||||
| |
|
||||
| +-- YES --> P1 CRITICAL (all apps down)
|
||||
| |
|
||||
| +-- NO --> P2 HIGH (single app down)
|
||||
|
|
||||
+-- NO: Test environment only?
|
||||
|
|
||||
+-- YES --> P3 MEDIUM
|
||||
|
|
||||
+-- NO --> Investigate further
|
||||
```
|
||||
|
||||
### Step 4: Document Initial State
|
||||
|
||||
Capture this information before making any changes:
|
||||
|
||||
```bash
|
||||
# Save PM2 state to file
|
||||
pm2 jlist > /tmp/pm2-incident-$(date +%Y%m%d-%H%M%S).json
|
||||
|
||||
# Save system state
|
||||
{
|
||||
echo "=== PM2 List ==="
|
||||
pm2 list
|
||||
echo ""
|
||||
echo "=== Disk Space ==="
|
||||
df -h
|
||||
echo ""
|
||||
echo "=== Memory ==="
|
||||
free -h
|
||||
echo ""
|
||||
echo "=== Recent Git Commits ==="
|
||||
cd /var/www/flyer-crawler.projectium.com && git log --oneline -5
|
||||
} > /tmp/incident-state-$(date +%Y%m%d-%H%M%S).txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Immediate Response
|
||||
|
||||
### Priority 1: Stop Ongoing Deployments
|
||||
|
||||
If a deployment is currently running:
|
||||
|
||||
1. Check Gitea Actions for running workflows
|
||||
2. Cancel any in-progress deployment workflows
|
||||
3. Do NOT start new deployments until incident resolved
|
||||
|
||||
### Priority 2: Assess Which Processes Are Down
|
||||
|
||||
```bash
|
||||
# Get list of processes and their status
|
||||
pm2 list
|
||||
|
||||
# Check which processes exist but are errored/stopped
|
||||
pm2 jlist | jq '.[] | {name, status: .pm2_env.status}'
|
||||
```
|
||||
|
||||
### Priority 3: Establish Order of Restoration
|
||||
|
||||
Restore in this order (production first, critical path first):
|
||||
|
||||
| Priority | Process | Rationale |
|
||||
| -------- | ------------------------------------- | ------------------------------------ |
|
||||
| 1 | `flyer-crawler-api` | Production API - highest user impact |
|
||||
| 2 | `flyer-crawler-worker` | Production background jobs |
|
||||
| 3 | `flyer-crawler-analytics-worker` | Production analytics |
|
||||
| 4 | `stock-alert-*` | Other production apps |
|
||||
| 5 | `flyer-crawler-api-test` | Test environment |
|
||||
| 6 | `flyer-crawler-worker-test` | Test background jobs |
|
||||
| 7 | `flyer-crawler-analytics-worker-test` | Test analytics |
|
||||
|
||||
---
|
||||
|
||||
## Process Restoration
|
||||
|
||||
### Scenario A: Flyer Crawler Production Processes Missing
|
||||
|
||||
```bash
|
||||
# Navigate to production directory
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
|
||||
# Start production processes
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# Verify processes started
|
||||
pm2 list
|
||||
|
||||
# Check health endpoint
|
||||
curl -s http://localhost:3001/api/health/ready | jq .
|
||||
```
|
||||
|
||||
### Scenario B: Flyer Crawler Test Processes Missing
|
||||
|
||||
```bash
|
||||
# Navigate to test directory
|
||||
cd /var/www/flyer-crawler-test.projectium.com
|
||||
|
||||
# Start test processes
|
||||
pm2 start ecosystem-test.config.cjs
|
||||
|
||||
# Verify processes started
|
||||
pm2 list
|
||||
|
||||
# Check health endpoint
|
||||
curl -s http://localhost:3002/api/health/ready | jq .
|
||||
```
|
||||
|
||||
### Scenario C: Stock Alert Processes Missing
|
||||
|
||||
```bash
|
||||
# Navigate to stock-alert directory
|
||||
cd /var/www/stock-alert.projectium.com
|
||||
|
||||
# Start processes (adjust config file name as needed)
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# Verify processes started
|
||||
pm2 list
|
||||
```
|
||||
|
||||
### Scenario D: All Processes Missing
|
||||
|
||||
Execute restoration in priority order:
|
||||
|
||||
```bash
|
||||
# 1. Flyer Crawler Production (highest priority)
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# Verify production is healthy before continuing
|
||||
curl -s http://localhost:3001/api/health/ready | jq '.data.status'
|
||||
# Should return "healthy"
|
||||
|
||||
# 2. Stock Alert Production
|
||||
cd /var/www/stock-alert.projectium.com
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# 3. Flyer Crawler Test (lower priority)
|
||||
cd /var/www/flyer-crawler-test.projectium.com
|
||||
pm2 start ecosystem-test.config.cjs
|
||||
|
||||
# 4. Save PM2 process list
|
||||
pm2 save
|
||||
|
||||
# 5. Final verification
|
||||
pm2 list
|
||||
```
|
||||
|
||||
### Health Check Verification
|
||||
|
||||
After restoration, verify each application:
|
||||
|
||||
**Flyer Crawler Production**:
|
||||
|
||||
```bash
|
||||
# API health
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq '.data.status'
|
||||
# Expected: "healthy"
|
||||
|
||||
# Check all services
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq '.data.services'
|
||||
```
|
||||
|
||||
**Flyer Crawler Test**:
|
||||
|
||||
```bash
|
||||
curl -s https://flyer-crawler-test.projectium.com/api/health/ready | jq '.data.status'
|
||||
```
|
||||
|
||||
**Stock Alert**:
|
||||
|
||||
```bash
|
||||
# Adjust URL as appropriate for stock-alert
|
||||
curl -s https://stock-alert.projectium.com/api/health/ready | jq '.data.status'
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
After restoration, confirm:
|
||||
|
||||
- [ ] `pm2 list` shows all expected processes as `online`
|
||||
- [ ] Production health check returns `healthy`
|
||||
- [ ] Test health check returns `healthy` (if applicable)
|
||||
- [ ] No processes showing high restart count
|
||||
- [ ] No processes showing `errored` or `stopped` status
|
||||
- [ ] PM2 process list saved: `pm2 save`
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Investigation
|
||||
|
||||
### Step 1: Check Workflow Execution Logs
|
||||
|
||||
```bash
|
||||
# Find recent Gitea Actions runs
|
||||
# (Access via Gitea web UI: Repository > Actions > Recent Runs)
|
||||
|
||||
# Look for these workflows:
|
||||
# - deploy-to-prod.yml
|
||||
# - deploy-to-test.yml
|
||||
# - manual-deploy-major.yml
|
||||
# - manual-db-restore.yml
|
||||
```
|
||||
|
||||
### Step 2: Check PM2 Daemon Logs
|
||||
|
||||
```bash
|
||||
# PM2 daemon logs
|
||||
cat ~/.pm2/pm2.log | tail -100
|
||||
|
||||
# PM2 process-specific logs
|
||||
ls -la ~/.pm2/logs/
|
||||
|
||||
# Recent API logs
|
||||
tail -100 ~/.pm2/logs/flyer-crawler-api-out.log
|
||||
tail -100 ~/.pm2/logs/flyer-crawler-api-error.log
|
||||
```
|
||||
|
||||
### Step 3: Check System Logs
|
||||
|
||||
```bash
|
||||
# System journal for PM2 service
|
||||
journalctl -u pm2-gitea-runner -n 100 --no-pager
|
||||
|
||||
# Kernel messages (OOM killer, etc.)
|
||||
journalctl -k -n 50 --no-pager | grep -i "killed\|oom\|memory"
|
||||
|
||||
# Authentication logs (unauthorized access)
|
||||
tail -50 /var/log/auth.log
|
||||
```
|
||||
|
||||
### Step 4: Git History Analysis
|
||||
|
||||
```bash
|
||||
# Recent commits to deployment workflows
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
git log --oneline -20 -- .gitea/workflows/
|
||||
|
||||
# Check what changed in PM2 configs
|
||||
git log --oneline -10 -- ecosystem.config.cjs ecosystem-test.config.cjs
|
||||
|
||||
# Diff against last known good state
|
||||
git diff <last-good-commit> -- .gitea/workflows/ ecosystem*.cjs
|
||||
```
|
||||
|
||||
### Step 5: Timing Correlation
|
||||
|
||||
Create a timeline:
|
||||
|
||||
```text
|
||||
| Time (UTC) | Event | Source |
|
||||
|------------|-------|--------|
|
||||
| XX:XX | Last successful health check | Monitoring |
|
||||
| XX:XX | Deployment workflow started | Gitea Actions |
|
||||
| XX:XX | First failed health check | Monitoring |
|
||||
| XX:XX | Incident detected | User report / Alert |
|
||||
| XX:XX | Investigation started | On-call |
|
||||
```
|
||||
|
||||
### Common Root Causes
|
||||
|
||||
| Root Cause | Evidence | Prevention |
|
||||
| ---------------------------- | -------------------------------------- | ---------------------------- |
|
||||
| `pm2 stop all` in workflow | Workflow logs show "all" command | Use explicit process names |
|
||||
| `pm2 delete all` in workflow | Empty PM2 list after deploy | Use whitelist-based deletion |
|
||||
| OOM killer | `journalctl -k` shows "Killed process" | Increase memory limits |
|
||||
| Disk space exhaustion | `df -h` shows 100% | Log rotation, cleanup |
|
||||
| Manual intervention | Shell history shows pm2 commands | Document all manual actions |
|
||||
| Concurrent deployments | Multiple workflows at same time | Implement deployment locks |
|
||||
| Workflow caching issue | Old workflow version executed | Force workflow refresh |
|
||||
|
||||
---
|
||||
|
||||
## Communication Templates
|
||||
|
||||
### Incident Notification (Internal)
|
||||
|
||||
```text
|
||||
Subject: [P1 INCIDENT] PM2 Process Isolation Failure - Multiple Apps Down
|
||||
|
||||
Status: INVESTIGATING
|
||||
Time Detected: YYYY-MM-DD HH:MM UTC
|
||||
Affected Systems: [flyer-crawler-prod, stock-alert-prod, ...]
|
||||
|
||||
Summary:
|
||||
All PM2 processes on projectium.com server were terminated unexpectedly.
|
||||
Multiple production applications are currently down.
|
||||
|
||||
Impact:
|
||||
- flyer-crawler.projectium.com: DOWN
|
||||
- stock-alert.projectium.com: DOWN
|
||||
- [other affected apps]
|
||||
|
||||
Current Actions:
|
||||
- Restoring critical production processes
|
||||
- Investigating root cause
|
||||
|
||||
Next Update: In 15 minutes or upon status change
|
||||
|
||||
Incident Commander: [Name]
|
||||
```
|
||||
|
||||
### Status Update Template
|
||||
|
||||
```text
|
||||
Subject: [P1 INCIDENT] PM2 Process Isolation Failure - UPDATE #N
|
||||
|
||||
Status: [INVESTIGATING | IDENTIFIED | RESTORING | RESOLVED]
|
||||
Time: YYYY-MM-DD HH:MM UTC
|
||||
|
||||
Progress Since Last Update:
|
||||
- [Action taken]
|
||||
- [Discovery made]
|
||||
- [Process restored]
|
||||
|
||||
Current State:
|
||||
- flyer-crawler.projectium.com: [UP|DOWN]
|
||||
- stock-alert.projectium.com: [UP|DOWN]
|
||||
|
||||
Root Cause: [If identified]
|
||||
|
||||
Next Steps:
|
||||
- [Planned action]
|
||||
|
||||
ETA to Resolution: [If known]
|
||||
|
||||
Next Update: In [X] minutes
|
||||
```
|
||||
|
||||
### Resolution Notification
|
||||
|
||||
```text
|
||||
Subject: [RESOLVED] PM2 Process Isolation Failure
|
||||
|
||||
Status: RESOLVED
|
||||
Time Resolved: YYYY-MM-DD HH:MM UTC
|
||||
Total Downtime: X minutes
|
||||
|
||||
Summary:
|
||||
All PM2 processes have been restored. Services are operating normally.
|
||||
|
||||
Root Cause:
|
||||
[Brief description of what caused the incident]
|
||||
|
||||
Impact Summary:
|
||||
- flyer-crawler.projectium.com: Down for X minutes
|
||||
- stock-alert.projectium.com: Down for X minutes
|
||||
- Estimated user impact: [description]
|
||||
|
||||
Immediate Actions Taken:
|
||||
1. [Action]
|
||||
2. [Action]
|
||||
|
||||
Follow-up Actions:
|
||||
1. [ ] [Preventive measure] - Owner: [Name] - Due: [Date]
|
||||
2. [ ] Post-incident review scheduled for [Date]
|
||||
|
||||
Post-Incident Review: [Link or scheduled time]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prevention Measures
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
|
||||
Before triggering any deployment:
|
||||
|
||||
- [ ] Review workflow file for PM2 commands
|
||||
- [ ] Confirm no `pm2 stop all`, `pm2 delete all`, or `pm2 restart all`
|
||||
- [ ] Verify process names are explicitly listed
|
||||
- [ ] Check for concurrent deployment risks
|
||||
- [ ] Confirm recent workflow changes were reviewed
|
||||
|
||||
### Workflow Review Checklist
|
||||
|
||||
When reviewing deployment workflow changes:
|
||||
|
||||
- [ ] All PM2 `stop` commands use explicit process names
|
||||
- [ ] All PM2 `delete` commands filter by process name pattern
|
||||
- [ ] All PM2 `restart` commands use explicit process names
|
||||
- [ ] Test deployments filter by `-test` suffix
|
||||
- [ ] Production deployments use whitelist array
|
||||
|
||||
**Safe Patterns**:
|
||||
|
||||
```javascript
|
||||
// SAFE: Explicit process names (production)
|
||||
const prodProcesses = [
|
||||
'flyer-crawler-api',
|
||||
'flyer-crawler-worker',
|
||||
'flyer-crawler-analytics-worker',
|
||||
];
|
||||
list.forEach((p) => {
|
||||
if (
|
||||
(p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
prodProcesses.includes(p.name)
|
||||
) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
|
||||
// SAFE: Pattern-based filtering (test)
|
||||
list.forEach((p) => {
|
||||
if (p.name && p.name.endsWith('-test')) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Dangerous Patterns** (NEVER USE):
|
||||
|
||||
```bash
|
||||
# DANGEROUS - affects ALL applications
|
||||
pm2 stop all
|
||||
pm2 delete all
|
||||
pm2 restart all
|
||||
|
||||
# DANGEROUS - no name filtering
|
||||
pm2 delete $(pm2 jlist | jq -r '.[] | select(.pm2_env.status == "errored") | .pm_id')
|
||||
```
|
||||
|
||||
### PM2 Configuration Validation
|
||||
|
||||
Before deploying PM2 config changes:
|
||||
|
||||
```bash
|
||||
# Test configuration locally
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
node -e "console.log(JSON.stringify(require('./ecosystem.config.cjs'), null, 2))"
|
||||
|
||||
# Verify process names
|
||||
node -e "require('./ecosystem.config.cjs').apps.forEach(a => console.log(a.name))"
|
||||
|
||||
# Expected output should match documented process names
|
||||
```
|
||||
|
||||
### Deployment Monitoring
|
||||
|
||||
After every deployment:
|
||||
|
||||
```bash
|
||||
# Immediate verification
|
||||
pm2 list
|
||||
|
||||
# Check no unexpected processes were affected
|
||||
pm2 list | grep -v flyer-crawler
|
||||
# Should still show other apps (e.g., stock-alert)
|
||||
|
||||
# Health check
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq '.data.status'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contact Information
|
||||
|
||||
### On-Call Escalation
|
||||
|
||||
| Role | Contact | When to Escalate |
|
||||
| ----------------- | -------------- | ----------------------------------- |
|
||||
| Primary On-Call | [Name/Channel] | First responder |
|
||||
| Secondary On-Call | [Name/Channel] | If primary unavailable after 10 min |
|
||||
| Engineering Lead | [Name/Channel] | P1 incidents > 30 min |
|
||||
| Product Owner | [Name/Channel] | User communication needed |
|
||||
|
||||
### External Dependencies
|
||||
|
||||
| Service | Support Channel | When to Contact |
|
||||
| --------------- | --------------- | ----------------------- |
|
||||
| Server Provider | [Contact info] | Hardware/network issues |
|
||||
| DNS Provider | [Contact info] | DNS resolution failures |
|
||||
| SSL Certificate | [Contact info] | Certificate issues |
|
||||
|
||||
### Communication Channels
|
||||
|
||||
| Channel | Purpose |
|
||||
| -------------- | -------------------------- |
|
||||
| `#incidents` | Real-time incident updates |
|
||||
| `#deployments` | Deployment announcements |
|
||||
| `#engineering` | Technical discussion |
|
||||
| Email list | Formal notifications |
|
||||
|
||||
---
|
||||
|
||||
## Post-Incident Review
|
||||
|
||||
### Incident Report Template
|
||||
|
||||
```markdown
|
||||
# Incident Report: [Title]
|
||||
|
||||
## Overview
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------- |
|
||||
| Date | YYYY-MM-DD |
|
||||
| Duration | X hours Y minutes |
|
||||
| Severity | P1/P2/P3 |
|
||||
| Incident Commander | [Name] |
|
||||
| Status | Resolved |
|
||||
|
||||
## Timeline
|
||||
|
||||
| Time (UTC) | Event |
|
||||
| ---------- | ------------------- |
|
||||
| HH:MM | [Event description] |
|
||||
| HH:MM | [Event description] |
|
||||
|
||||
## Impact
|
||||
|
||||
- **Users affected**: [Number/description]
|
||||
- **Revenue impact**: [If applicable]
|
||||
- **SLA impact**: [If applicable]
|
||||
|
||||
## Root Cause
|
||||
|
||||
[Detailed technical explanation]
|
||||
|
||||
## Resolution
|
||||
|
||||
[What was done to resolve the incident]
|
||||
|
||||
## Contributing Factors
|
||||
|
||||
1. [Factor]
|
||||
2. [Factor]
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due Date | Status |
|
||||
| -------- | ------ | -------- | ------ |
|
||||
| [Action] | [Name] | [Date] | [ ] |
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### What Went Well
|
||||
|
||||
- [Item]
|
||||
|
||||
### What Could Be Improved
|
||||
|
||||
- [Item]
|
||||
|
||||
## Appendix
|
||||
|
||||
- Link to monitoring data
|
||||
- Link to relevant logs
|
||||
- Link to workflow runs
|
||||
```
|
||||
|
||||
### Lessons Learned Format
|
||||
|
||||
Use "5 Whys" technique:
|
||||
|
||||
```text
|
||||
Problem: All PM2 processes were killed during deployment
|
||||
|
||||
Why 1: The deployment workflow ran `pm2 delete all`
|
||||
Why 2: The workflow used an outdated version of the script
|
||||
Why 3: Gitea runner cached the old workflow file
|
||||
Why 4: No mechanism to verify workflow version before execution
|
||||
Why 5: Workflow versioning and audit trail not implemented
|
||||
|
||||
Root Cause: Lack of workflow versioning and execution verification
|
||||
|
||||
Preventive Measure: Implement workflow hash logging and pre-execution verification
|
||||
```
|
||||
|
||||
### Action Items Tracking
|
||||
|
||||
Create Gitea issues for each action item:
|
||||
|
||||
```bash
|
||||
# Example using Gitea CLI or API
|
||||
gh issue create --title "Implement PM2 state logging in deployment workflows" \
|
||||
--body "Related to incident YYYY-MM-DD. Add pre-deployment PM2 state capture." \
|
||||
--label "incident-follow-up,priority:high"
|
||||
```
|
||||
|
||||
Track action items in a central location:
|
||||
|
||||
| Issue # | Action | Owner | Due | Status |
|
||||
| ------- | -------------------------------- | ------ | ------ | ------ |
|
||||
| #123 | Add PM2 state logging | [Name] | [Date] | Open |
|
||||
| #124 | Implement workflow version hash | [Name] | [Date] | Open |
|
||||
| #125 | Create deployment lock mechanism | [Name] | [Date] | Open |
|
||||
|
||||
---
|
||||
|
||||
## Appendix: PM2 Command Reference
|
||||
|
||||
### Safe Commands
|
||||
|
||||
```bash
|
||||
# Status and monitoring
|
||||
pm2 list
|
||||
pm2 show <process-name>
|
||||
pm2 monit
|
||||
pm2 logs <process-name>
|
||||
|
||||
# Restart specific processes
|
||||
pm2 restart flyer-crawler-api
|
||||
pm2 restart flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker
|
||||
|
||||
# Reload (zero-downtime, cluster mode only)
|
||||
pm2 reload flyer-crawler-api
|
||||
|
||||
# Start from config
|
||||
pm2 start ecosystem.config.cjs
|
||||
pm2 start ecosystem.config.cjs --only flyer-crawler-api
|
||||
```
|
||||
|
||||
### Dangerous Commands (Use With Caution)
|
||||
|
||||
```bash
|
||||
# CAUTION: These affect ALL processes
|
||||
pm2 stop all # Stops every PM2 process
|
||||
pm2 restart all # Restarts every PM2 process
|
||||
pm2 delete all # Removes every PM2 process
|
||||
|
||||
# CAUTION: Modifies saved process list
|
||||
pm2 save # Overwrites saved process list
|
||||
pm2 resurrect # Restores from saved list
|
||||
|
||||
# CAUTION: Affects PM2 daemon
|
||||
pm2 kill # Kills PM2 daemon and all processes
|
||||
pm2 update # Updates PM2 in place (may cause brief outage)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ---------------------- | ------------------------ |
|
||||
| 2026-02-17 | Incident Response Team | Initial runbook creation |
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.14.3",
|
||||
"version": "0.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.14.3",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.14.3",
|
||||
"version": "0.15.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -184,12 +185,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
query: {},
|
||||
file: undefined,
|
||||
user: createMockAdminProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -339,8 +335,22 @@ describe('AdminController', () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
users: [
|
||||
{ user_id: 'user-1', email: 'user1@example.com', role: 'user' as const },
|
||||
{ user_id: 'user-2', email: 'user2@example.com', role: 'admin' as const },
|
||||
{
|
||||
user_id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
role: 'user' as const,
|
||||
full_name: 'User One',
|
||||
avatar_url: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
user_id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
role: 'admin' as const,
|
||||
full_name: 'User Two',
|
||||
avatar_url: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
@@ -393,8 +403,21 @@ describe('AdminController', () => {
|
||||
// Arrange
|
||||
const mockProfile = {
|
||||
full_name: 'Test User',
|
||||
role: 'user',
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
role: 'user' as const,
|
||||
avatar_url: null,
|
||||
address_id: null,
|
||||
points: 100,
|
||||
preferences: {},
|
||||
created_by: null,
|
||||
address: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -414,7 +437,18 @@ describe('AdminController', () => {
|
||||
describe('updateUserRole()', () => {
|
||||
it('should update user role', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { role: 'admin', points: 100 };
|
||||
const mockUpdated = {
|
||||
full_name: 'Test User',
|
||||
avatar_url: null,
|
||||
address_id: null,
|
||||
points: 100,
|
||||
role: 'admin' as const,
|
||||
preferences: {},
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateUserRole.mockResolvedValue(mockUpdated);
|
||||
@@ -456,7 +490,28 @@ describe('AdminController', () => {
|
||||
describe('updateRecipeStatus()', () => {
|
||||
it('should update recipe status', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = { recipe_id: 1, status: 'public' };
|
||||
const mockRecipe = {
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
original_recipe_id: null,
|
||||
name: 'Test Recipe',
|
||||
description: null,
|
||||
instructions: null,
|
||||
prep_time_minutes: null,
|
||||
cook_time_minutes: null,
|
||||
servings: null,
|
||||
photo_url: null,
|
||||
calories_per_serving: null,
|
||||
protein_grams: null,
|
||||
fat_grams: null,
|
||||
carb_grams: null,
|
||||
avg_rating: 0,
|
||||
status: 'public' as const,
|
||||
rating_count: 0,
|
||||
fork_count: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateRecipeStatus.mockResolvedValue(mockRecipe);
|
||||
@@ -496,8 +551,54 @@ describe('AdminController', () => {
|
||||
it('should return flyers needing review', async () => {
|
||||
// Arrange
|
||||
const mockFlyers = [
|
||||
{ flyer_id: 1, status: 'needs_review' },
|
||||
{ flyer_id: 2, status: 'needs_review' },
|
||||
{
|
||||
flyer_id: 1,
|
||||
file_name: 'flyer-1.jpg',
|
||||
image_url: 'https://example.com/flyer-images/flyer-1.jpg',
|
||||
icon_url: 'https://example.com/flyer-images/icons/icon-flyer-1.webp',
|
||||
checksum: 'mock-checksum-1',
|
||||
store_id: 1,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '123 Main St',
|
||||
status: 'needs_review' as const,
|
||||
item_count: 50,
|
||||
uploaded_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
store: {
|
||||
store_id: 1,
|
||||
name: 'Test Store',
|
||||
logo_url: null,
|
||||
created_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
flyer_id: 2,
|
||||
file_name: 'flyer-2.jpg',
|
||||
image_url: 'https://example.com/flyer-images/flyer-2.jpg',
|
||||
icon_url: 'https://example.com/flyer-images/icons/icon-flyer-2.webp',
|
||||
checksum: 'mock-checksum-2',
|
||||
store_id: 2,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '456 Other St',
|
||||
status: 'needs_review' as const,
|
||||
item_count: 30,
|
||||
uploaded_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
store: {
|
||||
store_id: 2,
|
||||
name: 'Other Store',
|
||||
logo_url: null,
|
||||
created_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -820,8 +921,12 @@ describe('AdminController', () => {
|
||||
it('should return feature flags', async () => {
|
||||
// Arrange
|
||||
const mockFlags = {
|
||||
enableNewUI: true,
|
||||
enableBetaFeatures: false,
|
||||
newDashboard: true,
|
||||
betaRecipes: false,
|
||||
experimentalAi: false,
|
||||
debugMode: false,
|
||||
bugsinkSync: false,
|
||||
advancedRbac: false,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -833,8 +938,8 @@ describe('AdminController', () => {
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flags.enableNewUI).toBe(true);
|
||||
expect(result.data.flags.enableBetaFeatures).toBe(false);
|
||||
expect(result.data.flags.newDashboard).toBe(true);
|
||||
expect(result.data.flags.betaRecipes).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -846,7 +951,14 @@ describe('AdminController', () => {
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStats = { flyerCount: 0 };
|
||||
const mockStats = {
|
||||
flyerCount: 0,
|
||||
userCount: 0,
|
||||
flyerItemCount: 0,
|
||||
storeCount: 0,
|
||||
pendingCorrectionCount: 0,
|
||||
recipeCount: 0,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger, asErrorResponse } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -98,12 +99,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
file: undefined,
|
||||
files: undefined,
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -115,9 +111,14 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -218,10 +219,9 @@ describe('AIController', () => {
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('CONFLICT');
|
||||
expect(result.error.details).toEqual({ flyerId: 42 });
|
||||
}
|
||||
const errorBody = asErrorResponse(result);
|
||||
expect(errorBody.error.code).toBe('CONFLICT');
|
||||
expect(errorBody.error.details).toEqual({ flyerId: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -92,12 +93,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
return {
|
||||
body: {},
|
||||
cookies: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
res: mockRes,
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
@@ -289,7 +285,7 @@ describe('AuthController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue(null);
|
||||
mockedAuthService.resetPassword.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'nonexistent@example.com' }, request);
|
||||
@@ -354,7 +350,7 @@ describe('AuthController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.updatePassword.mockResolvedValue(false);
|
||||
mockedAuthService.updatePassword.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -67,12 +68,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -84,9 +80,14 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -115,8 +116,7 @@ function createMockSpendingByCategory(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
total_cents: 2500,
|
||||
item_count: 5,
|
||||
total_spent_cents: 2500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -59,26 +60,21 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock category record.
|
||||
* Matches the Category interface from category.db.ts
|
||||
*/
|
||||
function createMockCategory(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
category_id: 1,
|
||||
name: 'Dairy & Eggs',
|
||||
description: 'Milk, cheese, eggs, and dairy products',
|
||||
icon: 'dairy',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
created_at: new Date('2024-01-01T00:00:00.000Z'),
|
||||
updated_at: new Date('2024-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -57,12 +58,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -74,29 +70,42 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock watched item deal.
|
||||
* Matches the WatchedItemDeal interface from types.ts
|
||||
*/
|
||||
function createMockWatchedItemDeal(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
watched_item_id: 1,
|
||||
master_item_id: 100,
|
||||
item_name: 'Milk 2%',
|
||||
current_price_cents: 350,
|
||||
regular_price_cents: 450,
|
||||
discount_percent: 22.2,
|
||||
store_name: 'Superstore',
|
||||
store_location_id: 5,
|
||||
best_price_in_cents: 350,
|
||||
store: {
|
||||
store_id: 1,
|
||||
name: 'Superstore',
|
||||
logo_url: '/uploads/logos/superstore.jpg',
|
||||
locations: [
|
||||
{
|
||||
address_line_1: '123 Main St',
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M5V 1A1',
|
||||
},
|
||||
],
|
||||
},
|
||||
flyer_id: 10,
|
||||
flyer_valid_from: '2024-01-15',
|
||||
flyer_valid_to: '2024-01-21',
|
||||
valid_to: '2024-01-21',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -127,9 +136,9 @@ describe('DealsController', () => {
|
||||
const mockDeals = [
|
||||
createMockWatchedItemDeal(),
|
||||
createMockWatchedItemDeal({
|
||||
watched_item_id: 2,
|
||||
master_item_id: 101,
|
||||
item_name: 'Bread',
|
||||
current_price_cents: 250,
|
||||
best_price_in_cents: 250,
|
||||
}),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
@@ -144,7 +153,7 @@ describe('DealsController', () => {
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item_name).toBe('Milk 2%');
|
||||
expect(result.data[0].current_price_cents).toBe(350);
|
||||
expect(result.data[0].best_price_in_cents).toBe(350);
|
||||
}
|
||||
expect(mockedDealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
@@ -187,12 +196,7 @@ describe('DealsController', () => {
|
||||
it('should log successful fetch', async () => {
|
||||
// Arrange
|
||||
const mockDeals = [createMockWatchedItemDeal()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue(mockDeals);
|
||||
@@ -212,9 +216,14 @@ describe('DealsController', () => {
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// logic in isolation by mocking external dependencies like database repositories.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
import { createMockFlyer, createMockFlyerItem, resetMockIds } from '../tests/utils/mockFactories';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -49,8 +51,15 @@ vi.mock('../services/db/index.db', () => ({
|
||||
import * as db from '../services/db/index.db';
|
||||
import { FlyerController } from './flyer.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
// Access the mocked flyerRepo - vi.mocked() provides type-safe mock access
|
||||
const mockedFlyerRepo = {
|
||||
getFlyers: vi.mocked(db.flyerRepo.getFlyers),
|
||||
getFlyerById: vi.mocked(db.flyerRepo.getFlyerById),
|
||||
getFlyerItems: vi.mocked(db.flyerRepo.getFlyerItems),
|
||||
getFlyerItemsForFlyers: vi.mocked(db.flyerRepo.getFlyerItemsForFlyers),
|
||||
countFlyerItemsForFlyers: vi.mocked(db.flyerRepo.countFlyerItemsForFlyers),
|
||||
trackFlyerItemInteraction: vi.mocked(db.flyerRepo.trackFlyerItemInteraction),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
@@ -64,72 +73,11 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock flyer object.
|
||||
*/
|
||||
function createMockFlyer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
flyer_id: 1,
|
||||
file_name: 'test-flyer.jpg',
|
||||
image_url: '/uploads/flyers/test-flyer.jpg',
|
||||
icon_url: '/uploads/flyers/icons/test-flyer.jpg',
|
||||
checksum: 'abc123',
|
||||
store_id: 1,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
status: 'processed' as const,
|
||||
item_count: 10,
|
||||
uploaded_by: 'user-123',
|
||||
store: {
|
||||
store_id: 1,
|
||||
name: 'Test Store',
|
||||
logo_url: '/uploads/logos/store.jpg',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
locations: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock flyer item object.
|
||||
*/
|
||||
function createMockFlyerItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
flyer_item_id: 1,
|
||||
flyer_id: 1,
|
||||
item: 'Test Product',
|
||||
price_display: '$2.99',
|
||||
price_in_cents: 299,
|
||||
quantity: '1',
|
||||
quantity_num: 1,
|
||||
master_item_id: 100,
|
||||
master_item_name: 'Test Master Item',
|
||||
category_id: 5,
|
||||
category_name: 'Dairy',
|
||||
unit_price: { value: 299, unit: 'each' },
|
||||
product_id: null,
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
@@ -139,6 +87,7 @@ describe('FlyerController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockIds();
|
||||
controller = new FlyerController();
|
||||
});
|
||||
|
||||
@@ -156,7 +105,7 @@ describe('FlyerController', () => {
|
||||
const mockFlyers = [createMockFlyer(), createMockFlyer({ flyer_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue(mockFlyers);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue(mockFlyers);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyers(request);
|
||||
@@ -166,7 +115,7 @@ describe('FlyerController', () => {
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
20, // default limit
|
||||
0, // default offset
|
||||
@@ -177,65 +126,65 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 50, 10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 50, 10);
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 50, 10);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
});
|
||||
|
||||
it('should floor limit to minimum of 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, -5);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 1, 0);
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 1, 0);
|
||||
});
|
||||
|
||||
it('should normalize offset to 0 if negative', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 20, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 20, 0);
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 20, 0);
|
||||
});
|
||||
|
||||
it('should floor decimal pagination values', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 15.9, 5.7);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 15, 5);
|
||||
expect(mockedFlyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 15, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,7 +198,7 @@ describe('FlyerController', () => {
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
mockedFlyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
@@ -258,23 +207,18 @@ describe('FlyerController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyer_id).toBe(1);
|
||||
expect(result.data.file_name).toBe('test-flyer.jpg');
|
||||
expect(result.data.file_name).toBe('flyer-1.jpg');
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerById).toHaveBeenCalledWith(1);
|
||||
expect(mockedFlyerRepo.getFlyerById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should log successful retrieval', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
mockedFlyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerById(1, request);
|
||||
@@ -293,7 +237,7 @@ describe('FlyerController', () => {
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
mockedFlyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(1, request);
|
||||
@@ -302,23 +246,18 @@ describe('FlyerController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item).toBe('Test Product');
|
||||
expect(result.data[0].item).toBe('Mock Item');
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerItems).toHaveBeenCalledWith(1, expect.anything());
|
||||
expect(mockedFlyerRepo.getFlyerItems).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should log item count', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockFlyerItem()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
mockedFlyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerItems(1, request);
|
||||
@@ -334,7 +273,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyerItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(999, request);
|
||||
@@ -360,7 +299,7 @@ describe('FlyerController', () => {
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
mockedFlyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [1, 2, 3] }, request);
|
||||
@@ -370,7 +309,7 @@ describe('FlyerController', () => {
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
expect(mockedFlyerRepo.getFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
@@ -379,15 +318,10 @@ describe('FlyerController', () => {
|
||||
it('should log batch fetch details', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockFlyerItem()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
mockedFlyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.batchFetchItems({ flyerIds: [1, 2] }, request);
|
||||
@@ -403,7 +337,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue([]);
|
||||
mockedFlyerRepo.getFlyerItemsForFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [999, 1000] }, request);
|
||||
@@ -421,7 +355,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(25);
|
||||
mockedFlyerRepo.countFlyerItemsForFlyers.mockResolvedValue(25);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [1, 2, 3] }, request);
|
||||
@@ -431,7 +365,7 @@ describe('FlyerController', () => {
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(25);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.countFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
expect(mockedFlyerRepo.countFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
@@ -439,15 +373,10 @@ describe('FlyerController', () => {
|
||||
|
||||
it('should log count details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(10);
|
||||
mockedFlyerRepo.countFlyerItemsForFlyers.mockResolvedValue(10);
|
||||
|
||||
// Act
|
||||
await controller.batchCountItems({ flyerIds: [1] }, request);
|
||||
@@ -463,7 +392,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(0);
|
||||
mockedFlyerRepo.countFlyerItemsForFlyers.mockResolvedValue(0);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [] }, request);
|
||||
@@ -485,7 +414,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
mockedFlyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
@@ -501,7 +430,7 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
mockedFlyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'click' }, request);
|
||||
@@ -515,16 +444,11 @@ describe('FlyerController', () => {
|
||||
|
||||
it('should log error but not fail on tracking failure', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
// Make tracking fail
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockRejectedValue(new Error('Database error'));
|
||||
mockedFlyerRepo.trackFlyerItemInteraction.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
@@ -553,13 +477,13 @@ describe('FlyerController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
mockedFlyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.trackItemInteraction(42, { type: 'click' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
|
||||
expect(mockedFlyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
|
||||
42,
|
||||
'click',
|
||||
expect.anything(),
|
||||
@@ -577,7 +501,7 @@ describe('FlyerController', () => {
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
mockedFlyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
import type { UserAchievement, Achievement } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -71,12 +73,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -88,9 +85,14 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -102,24 +104,29 @@ function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock achievement.
|
||||
* Matches the Achievement interface from types.ts
|
||||
*/
|
||||
function createMockAchievement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
achievement_id: 1,
|
||||
name: 'First-Upload',
|
||||
description: 'Upload your first flyer',
|
||||
points: 10,
|
||||
points_value: 10,
|
||||
icon: 'upload',
|
||||
category: 'contribution',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -127,30 +134,37 @@ function createMockAchievement(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
/**
|
||||
* Creates a mock user achievement.
|
||||
* Matches the (UserAchievement & Achievement) type returned by getUserAchievements
|
||||
*/
|
||||
function createMockUserAchievement(overrides: Record<string, unknown> = {}) {
|
||||
function createMockUserAchievement(
|
||||
overrides: Partial<UserAchievement & Achievement> = {},
|
||||
): UserAchievement & Achievement {
|
||||
return {
|
||||
user_achievement_id: 1,
|
||||
// UserAchievement fields
|
||||
user_id: 'test-user-id',
|
||||
achievement_id: 1,
|
||||
achievement_name: 'First-Upload',
|
||||
achievement_description: 'Upload your first flyer',
|
||||
points: 10,
|
||||
earned_at: '2024-01-15T10:00:00.000Z',
|
||||
achieved_at: '2024-01-15T10:00:00.000Z',
|
||||
// Achievement fields
|
||||
name: 'First-Upload',
|
||||
description: 'Upload your first flyer',
|
||||
points_value: 10,
|
||||
icon: 'upload',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock leaderboard user.
|
||||
* Matches the LeaderboardUser interface from types.ts
|
||||
*/
|
||||
function createMockLeaderboardUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_id: 'user-1',
|
||||
display_name: 'Top User',
|
||||
total_points: 150,
|
||||
achievement_count: 8,
|
||||
rank: 1,
|
||||
full_name: 'Top User',
|
||||
avatar_url: null,
|
||||
points: 150,
|
||||
rank: '1', // RANK() returns bigint which pg driver returns as string
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -180,7 +194,7 @@ describe('GamificationController', () => {
|
||||
// Arrange
|
||||
const mockAchievements = [
|
||||
createMockAchievement(),
|
||||
createMockAchievement({ achievement_id: 2, name: 'Deal-Hunter', points: 25 }),
|
||||
createMockAchievement({ achievement_id: 2, name: 'Deal-Hunter', points_value: 25 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -234,7 +248,7 @@ describe('GamificationController', () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [
|
||||
createMockLeaderboardUser(),
|
||||
createMockLeaderboardUser({ user_id: 'user-2', rank: 2, total_points: 120 }),
|
||||
createMockLeaderboardUser({ user_id: 'user-2', rank: '2', points: 120 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -247,7 +261,7 @@ describe('GamificationController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].rank).toBe(1);
|
||||
expect(result.data[0].rank).toBe('1');
|
||||
}
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(
|
||||
10, // default limit
|
||||
@@ -321,7 +335,7 @@ describe('GamificationController', () => {
|
||||
// Arrange
|
||||
const mockUserAchievements = [
|
||||
createMockUserAchievement(),
|
||||
createMockUserAchievement({ user_achievement_id: 2, achievement_name: 'Deal-Hunter' }),
|
||||
createMockUserAchievement({ achievement_id: 2, name: 'Deal-Hunter' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -334,7 +348,7 @@ describe('GamificationController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].achievement_name).toBe('First-Upload');
|
||||
expect(result.data[0].name).toBe('First-Upload');
|
||||
}
|
||||
expect(mockedGamificationService.getUserAchievements).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
@@ -363,9 +377,14 @@ describe('GamificationController', () => {
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
@@ -147,7 +147,7 @@ export class GamificationController extends BaseController {
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - JWT token missing or invalid')
|
||||
public async getMyAchievements(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserAchievement[]>> {
|
||||
): Promise<SuccessResponseType<(UserAchievement & Achievement)[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const userAchievements = await gamificationService.getUserAchievements(
|
||||
userProfile.user.user_id,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// and file system access.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
import {
|
||||
createMockUserProfile,
|
||||
createMockUserInventoryItem,
|
||||
resetMockIds,
|
||||
} from '../tests/utils/mockFactories';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -65,56 +71,32 @@ const mockedExpiryService = expiryService as Mocked<typeof expiryService>;
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
* Uses the shared createMockUserProfile factory from mockFactories.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
user: createMockUserProfile({ user: { user_id: 'test-user-id', email: 'test@example.com' } }),
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock inventory item.
|
||||
* Creates a mock inventory item using the shared factory.
|
||||
* Provides test-specific defaults.
|
||||
*/
|
||||
function createMockInventoryItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
return createMockUserInventoryItem({
|
||||
inventory_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
item_name: 'Milk',
|
||||
quantity: 1,
|
||||
unit: 'L',
|
||||
purchase_date: '2024-01-01',
|
||||
expiry_date: '2024-01-15',
|
||||
source: 'manual_entry' as const,
|
||||
location: 'refrigerator' as const,
|
||||
is_consumed: false,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
location: 'fridge',
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -126,6 +108,7 @@ describe('InventoryController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockIds();
|
||||
controller = new InventoryController();
|
||||
});
|
||||
|
||||
@@ -188,11 +171,11 @@ describe('InventoryController', () => {
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(request, 50, 0, 'refrigerator');
|
||||
await controller.getInventory(request, 50, 0, 'fridge');
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ location: 'refrigerator' }),
|
||||
expect.objectContaining({ location: 'fridge' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
@@ -235,7 +218,7 @@ describe('InventoryController', () => {
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -248,12 +231,7 @@ describe('InventoryController', () => {
|
||||
it('should log item addition', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
@@ -261,7 +239,7 @@ describe('InventoryController', () => {
|
||||
// Act
|
||||
await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -279,11 +257,19 @@ describe('InventoryController', () => {
|
||||
describe('getExpiringSummary()', () => {
|
||||
it('should return expiring items grouped by urgency', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const mockResult = {
|
||||
expired: [],
|
||||
expiring_today: [],
|
||||
expiring_this_week: [createMockInventoryItem()],
|
||||
expiring_this_week: [mockItem],
|
||||
expiring_this_month: [],
|
||||
already_expired: [],
|
||||
counts: {
|
||||
today: 0,
|
||||
this_week: 1,
|
||||
this_month: 0,
|
||||
expired: 0,
|
||||
total: 1,
|
||||
},
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -386,8 +372,26 @@ describe('InventoryController', () => {
|
||||
it('should return alert settings', async () => {
|
||||
// Arrange
|
||||
const mockSettings = [
|
||||
{ alert_method: 'email', days_before_expiry: 3, is_enabled: true },
|
||||
{ alert_method: 'push', days_before_expiry: 1, is_enabled: true },
|
||||
{
|
||||
expiry_alert_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
alert_method: 'email' as const,
|
||||
days_before_expiry: 3,
|
||||
is_enabled: true,
|
||||
last_alert_sent_at: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
expiry_alert_id: 2,
|
||||
user_id: 'test-user-id',
|
||||
alert_method: 'push' as const,
|
||||
days_before_expiry: 1,
|
||||
is_enabled: true,
|
||||
last_alert_sent_at: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -407,7 +411,16 @@ describe('InventoryController', () => {
|
||||
describe('updateAlertSettings()', () => {
|
||||
it('should update alert settings', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { alert_method: 'email', days_before_expiry: 5, is_enabled: true };
|
||||
const mockUpdated = {
|
||||
expiry_alert_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
alert_method: 'email' as const,
|
||||
days_before_expiry: 5,
|
||||
is_enabled: true,
|
||||
last_alert_sent_at: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.updateAlertSettings.mockResolvedValue(mockUpdated);
|
||||
@@ -432,10 +445,23 @@ describe('InventoryController', () => {
|
||||
describe('getRecipeSuggestions()', () => {
|
||||
it('should return recipe suggestions for expiring items', async () => {
|
||||
// Arrange
|
||||
const mockInventoryItem = createMockInventoryItem();
|
||||
const mockResult = {
|
||||
recipes: [{ recipe_id: 1, name: 'Test Recipe' }],
|
||||
recipes: [
|
||||
{
|
||||
recipe_id: 1,
|
||||
recipe_name: 'Test Recipe',
|
||||
description: 'A test recipe description',
|
||||
prep_time_minutes: 10,
|
||||
cook_time_minutes: 20,
|
||||
servings: 4,
|
||||
photo_url: null,
|
||||
matching_items: [mockInventoryItem],
|
||||
match_count: 1,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
considered_items: [createMockInventoryItem()],
|
||||
considered_items: [mockInventoryItem],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -593,7 +619,7 @@ describe('InventoryController', () => {
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Test',
|
||||
source: 'manual_entry',
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -66,53 +67,55 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
res: {
|
||||
set: vi.fn(),
|
||||
},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock master grocery item.
|
||||
* Matches the MasterGroceryItem interface from types.ts
|
||||
*/
|
||||
function createMockMasterItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
master_grocery_item_id: 1,
|
||||
name: 'Milk 2%',
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
typical_shelf_life_days: 14,
|
||||
storage_recommendation: 'refrigerator',
|
||||
is_allergen: false,
|
||||
allergy_info: null,
|
||||
created_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock dietary restriction.
|
||||
* Matches the DietaryRestriction interface from types.ts
|
||||
*/
|
||||
function createMockDietaryRestriction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
restriction_id: 1,
|
||||
dietary_restriction_id: 1,
|
||||
name: 'Vegetarian',
|
||||
description: 'No meat or fish',
|
||||
icon: 'leaf',
|
||||
type: 'diet' as const,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock appliance.
|
||||
* Matches the Appliance interface from types.ts
|
||||
*/
|
||||
function createMockAppliance(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
appliance_id: 1,
|
||||
name: 'Air Fryer',
|
||||
icon: 'air-fryer',
|
||||
category: 'cooking',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -141,7 +144,10 @@ describe('PersonalizationController', () => {
|
||||
it('should return master items without pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockMasterItem(), createMockMasterItem({ master_item_id: 2, name: 'Bread' })],
|
||||
items: [
|
||||
createMockMasterItem(),
|
||||
createMockMasterItem({ master_grocery_item_id: 2, name: 'Bread' }),
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
@@ -258,12 +264,7 @@ describe('PersonalizationController', () => {
|
||||
|
||||
it('should log request details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
const mockResult = { items: [], total: 0 };
|
||||
|
||||
@@ -292,8 +293,8 @@ describe('PersonalizationController', () => {
|
||||
// Arrange
|
||||
const mockRestrictions = [
|
||||
createMockDietaryRestriction(),
|
||||
createMockDietaryRestriction({ restriction_id: 2, name: 'Vegan' }),
|
||||
createMockDietaryRestriction({ restriction_id: 3, name: 'Gluten-Free' }),
|
||||
createMockDietaryRestriction({ dietary_restriction_id: 2, name: 'Vegan' }),
|
||||
createMockDietaryRestriction({ dietary_restriction_id: 3, name: 'Gluten-Free' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -58,12 +59,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -84,14 +80,13 @@ function createMockUserProfile() {
|
||||
|
||||
/**
|
||||
* Creates a mock price history data point.
|
||||
* Matches the PriceHistoryData interface from types.ts
|
||||
*/
|
||||
function createMockPriceHistoryData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
price_cents: 350,
|
||||
flyer_start_date: '2024-01-15',
|
||||
flyer_id: 10,
|
||||
store_name: 'Superstore',
|
||||
price_in_cents: 350,
|
||||
date: '2024-01-15',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -121,8 +116,8 @@ describe('PriceController', () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08', price_cents: 399 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2, price_cents: 450 }),
|
||||
createMockPriceHistoryData({ date: '2024-01-08', price_in_cents: 399 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2, price_in_cents: 450 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -137,7 +132,7 @@ describe('PriceController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].price_cents).toBe(350);
|
||||
expect(result.data[0].price_in_cents).toBe(350);
|
||||
}
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2],
|
||||
@@ -259,12 +254,7 @@ describe('PriceController', () => {
|
||||
it('should log request details', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
@@ -309,7 +299,7 @@ describe('PriceController', () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08' }),
|
||||
createMockPriceHistoryData({ date: '2024-01-08' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -69,12 +70,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -86,15 +82,21 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user reaction.
|
||||
* Matches the UserReaction interface from types.ts
|
||||
*/
|
||||
function createMockReaction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -104,6 +106,7 @@ function createMockReaction(overrides: Record<string, unknown> = {}) {
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
updated_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -401,9 +404,14 @@ describe('ReactionsController', () => {
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
import {
|
||||
createMockReceiptScan,
|
||||
createMockExpiryReceiptItem,
|
||||
createMockReceiptProcessingLog,
|
||||
createMockUserProfile,
|
||||
resetMockIds,
|
||||
} from '../tests/utils/mockFactories';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -86,86 +94,50 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
user: createMockUserProfile({ user: { user_id: 'test-user-id', email: 'test@example.com' } }),
|
||||
file: undefined,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({ request_id: 'test-request-id' }),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock receipt scan record.
|
||||
* Creates a mock receipt scan record using the shared factory.
|
||||
*/
|
||||
function createMockReceipt(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
return createMockReceiptScan({
|
||||
receipt_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
receipt_image_url: '/uploads/receipt-123.jpg',
|
||||
status: 'processed' as const,
|
||||
store_location_id: null,
|
||||
transaction_date: '2024-01-15',
|
||||
total_amount_cents: 5000,
|
||||
tax_amount_cents: 500,
|
||||
item_count: 5,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
updated_at: '2024-01-15T10:00:00.000Z',
|
||||
processed_at: '2024-01-15T10:01:00.000Z',
|
||||
status: 'completed',
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock receipt item.
|
||||
* Creates a mock receipt item using the shared factory.
|
||||
*/
|
||||
function createMockReceiptItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
return createMockExpiryReceiptItem({
|
||||
receipt_item_id: 1,
|
||||
receipt_id: 1,
|
||||
raw_text: 'Milk 2%',
|
||||
quantity: 1,
|
||||
price_cents: 350,
|
||||
unit_price_cents: 350,
|
||||
status: 'matched' as const,
|
||||
raw_item_description: 'Milk 2%',
|
||||
master_item_id: 100,
|
||||
product_id: 200,
|
||||
match_confidence: 0.95,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
status: 'matched',
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock processing log record.
|
||||
* Creates a mock processing log record using the shared factory.
|
||||
*/
|
||||
function createMockProcessingLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
return createMockReceiptProcessingLog({
|
||||
log_id: 1,
|
||||
receipt_id: 1,
|
||||
status: 'processing',
|
||||
message: 'Started processing receipt',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -177,6 +149,7 @@ describe('ReceiptController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockIds();
|
||||
controller = new ReceiptController();
|
||||
});
|
||||
|
||||
@@ -243,11 +216,11 @@ describe('ReceiptController', () => {
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(request, 50, 0, 'processed');
|
||||
await controller.getReceipts(request, 50, 0, 'completed');
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'processed' }),
|
||||
expect.objectContaining({ status: 'completed' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
@@ -481,7 +454,8 @@ describe('ReceiptController', () => {
|
||||
it('should update a receipt item', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockUpdatedItem = createMockReceiptItem({ status: 'confirmed', match_confidence: 1.0 });
|
||||
// Use 'matched' as it's a valid ReceiptItemStatus
|
||||
const mockUpdatedItem = createMockReceiptItem({ status: 'matched', match_confidence: 1.0 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
@@ -489,14 +463,14 @@ describe('ReceiptController', () => {
|
||||
|
||||
// Act
|
||||
const result = await controller.updateReceiptItem(1, 1, request, {
|
||||
status: 'confirmed',
|
||||
status: 'matched',
|
||||
match_confidence: 1.0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('confirmed');
|
||||
expect(result.data.status).toBe('matched');
|
||||
expect(result.data.match_confidence).toBe(1.0);
|
||||
}
|
||||
});
|
||||
@@ -516,8 +490,54 @@ describe('ReceiptController', () => {
|
||||
it('should confirm items and add to inventory', async () => {
|
||||
// Arrange
|
||||
const mockAddedItems = [
|
||||
{ inventory_id: 1, item_name: 'Milk' },
|
||||
{ inventory_id: 2, item_name: 'Bread' },
|
||||
{
|
||||
inventory_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
product_id: null,
|
||||
master_item_id: 100,
|
||||
item_name: 'Milk',
|
||||
quantity: 1,
|
||||
unit: 'L',
|
||||
purchase_date: '2024-01-15',
|
||||
expiry_date: '2024-01-22',
|
||||
source: 'receipt_scan' as const,
|
||||
location: 'fridge' as const,
|
||||
notes: null,
|
||||
is_consumed: false,
|
||||
consumed_at: null,
|
||||
expiry_source: 'receipt' as const,
|
||||
receipt_item_id: 1,
|
||||
pantry_location_id: null,
|
||||
notification_sent_at: null,
|
||||
created_at: '2024-01-15T00:00:00.000Z',
|
||||
updated_at: '2024-01-15T00:00:00.000Z',
|
||||
days_until_expiry: 7,
|
||||
expiry_status: 'fresh' as const,
|
||||
},
|
||||
{
|
||||
inventory_id: 2,
|
||||
user_id: 'test-user-id',
|
||||
product_id: null,
|
||||
master_item_id: 101,
|
||||
item_name: 'Bread',
|
||||
quantity: 1,
|
||||
unit: 'loaf',
|
||||
purchase_date: '2024-01-15',
|
||||
expiry_date: '2024-01-20',
|
||||
source: 'receipt_scan' as const,
|
||||
location: 'pantry' as const,
|
||||
notes: null,
|
||||
is_consumed: false,
|
||||
consumed_at: null,
|
||||
expiry_source: 'receipt' as const,
|
||||
receipt_item_id: 2,
|
||||
pantry_location_id: null,
|
||||
notification_sent_at: null,
|
||||
created_at: '2024-01-15T00:00:00.000Z',
|
||||
updated_at: '2024-01-15T00:00:00.000Z',
|
||||
days_until_expiry: 5,
|
||||
expiry_status: 'fresh' as const,
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -547,13 +567,7 @@ describe('ReceiptController', () => {
|
||||
|
||||
it('should log confirmation request', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addItemsFromReceipt.mockResolvedValue([]);
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger, asErrorResponse } from '../tests/utils/testHelpers';
|
||||
import {
|
||||
createMockRecipe,
|
||||
createMockRecipeComment,
|
||||
createMockUserProfile,
|
||||
resetMockIds,
|
||||
} from '../tests/utils/mockFactories';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -75,87 +82,26 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
user: createMockUserProfile({ user: { user_id: 'test-user-id', email: 'test@example.com' } }),
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock recipe object.
|
||||
*/
|
||||
function createMockRecipe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
name: 'Test Recipe',
|
||||
description: 'A delicious test recipe',
|
||||
instructions: 'Mix ingredients and cook',
|
||||
prep_time_minutes: 15,
|
||||
cook_time_minutes: 30,
|
||||
servings: 4,
|
||||
photo_url: '/uploads/recipes/test.jpg',
|
||||
status: 'public' as const,
|
||||
forked_from: null,
|
||||
ingredients: [
|
||||
{
|
||||
recipe_ingredient_id: 1,
|
||||
master_item_name: 'Flour',
|
||||
quantity: '2',
|
||||
unit: 'cups',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
tag_id: 1,
|
||||
name: 'vegetarian',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock recipe comment object.
|
||||
* Creates a mock recipe comment object using the shared factory.
|
||||
*/
|
||||
function createMockComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
return createMockRecipeComment({
|
||||
recipe_comment_id: 1,
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
content: 'Great recipe!',
|
||||
parent_comment_id: null,
|
||||
user_full_name: 'Test User',
|
||||
user_avatar_url: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user_avatar_url: undefined,
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -167,6 +113,7 @@ describe('RecipeController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockIds();
|
||||
controller = new RecipeController();
|
||||
});
|
||||
|
||||
@@ -184,7 +131,7 @@ describe('RecipeController', () => {
|
||||
const mockRecipes = [createMockRecipe(), createMockRecipe({ recipe_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue(mockRecipes);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySalePercentage(request);
|
||||
@@ -204,7 +151,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 75);
|
||||
@@ -220,7 +167,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 150);
|
||||
@@ -236,7 +183,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, -10);
|
||||
@@ -255,7 +202,7 @@ describe('RecipeController', () => {
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue(mockRecipes);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySaleIngredients(request);
|
||||
@@ -275,7 +222,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 0);
|
||||
@@ -291,7 +238,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 5.9);
|
||||
@@ -310,7 +257,7 @@ describe('RecipeController', () => {
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue(mockRecipes);
|
||||
vi.mocked(mockedDb.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.findRecipesByIngredientAndTag(request, 'chicken', 'dinner');
|
||||
@@ -329,15 +276,10 @@ describe('RecipeController', () => {
|
||||
|
||||
it('should log search parameters', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.findRecipesByIngredientAndTag(request, 'beef', 'quick');
|
||||
@@ -357,10 +299,10 @@ describe('RecipeController', () => {
|
||||
describe('getRecipeById()', () => {
|
||||
it('should return recipe by ID', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const mockRecipe = createMockRecipe({ recipe_id: 1, name: 'Test Recipe' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipeById).mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
@@ -376,10 +318,27 @@ describe('RecipeController', () => {
|
||||
|
||||
it('should include ingredients and tags', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const mockRecipe = createMockRecipe({
|
||||
recipe_id: 1,
|
||||
ingredients: [{}], // Will generate one mock ingredient
|
||||
comments: [{}], // Comments array used to generate default content
|
||||
});
|
||||
// The shared factory generates ingredients and tags separately,
|
||||
// so we need to add tags property manually for this test
|
||||
const mockRecipeWithTags = {
|
||||
...mockRecipe,
|
||||
tags: [
|
||||
{
|
||||
tag_id: 1,
|
||||
name: 'vegetarian',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipeById).mockResolvedValue(mockRecipeWithTags);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
@@ -402,7 +361,7 @@ describe('RecipeController', () => {
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue(mockComments);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipeComments).mockResolvedValue(mockComments);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
@@ -420,7 +379,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipeComments).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
@@ -442,7 +401,7 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(
|
||||
vi.mocked(mockedAiService.generateRecipeSuggestion).mockResolvedValue(
|
||||
'Here is a delicious recipe using chicken and rice...',
|
||||
);
|
||||
|
||||
@@ -467,29 +426,23 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(null);
|
||||
vi.mocked(mockedAiService.generateRecipeSuggestion).mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe({ ingredients: ['chicken'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('SERVICE_UNAVAILABLE');
|
||||
}
|
||||
const errorBody = asErrorResponse(result);
|
||||
expect(errorBody.error.code).toBe('SERVICE_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('should log suggestion generation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue('Recipe suggestion');
|
||||
vi.mocked(mockedAiService.generateRecipeSuggestion).mockResolvedValue('Recipe suggestion');
|
||||
|
||||
// Act
|
||||
await controller.suggestRecipe({ ingredients: ['chicken', 'rice'] }, request);
|
||||
@@ -508,7 +461,7 @@ describe('RecipeController', () => {
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
vi.mocked(mockedDb.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Great recipe!' }, request);
|
||||
@@ -532,7 +485,7 @@ describe('RecipeController', () => {
|
||||
const mockComment = createMockComment({ parent_comment_id: 5 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
vi.mocked(mockedDb.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(
|
||||
@@ -558,15 +511,10 @@ describe('RecipeController', () => {
|
||||
it('should log comment addition', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
vi.mocked(mockedDb.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
await controller.addComment(1, { content: 'Test' }, request);
|
||||
@@ -582,15 +530,17 @@ describe('RecipeController', () => {
|
||||
describe('forkRecipe()', () => {
|
||||
it('should fork a recipe', async () => {
|
||||
// Arrange
|
||||
// Note: The controller casts Recipe to RecipeDto, so the actual response
|
||||
// will have original_recipe_id from the DB, not forked_from as defined in the DTO
|
||||
const mockForkedRecipe = createMockRecipe({
|
||||
recipe_id: 10,
|
||||
user_id: 'test-user-id',
|
||||
forked_from: 1,
|
||||
original_recipe_id: 1,
|
||||
status: 'private',
|
||||
});
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.forkRecipe).mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.forkRecipe(1, request);
|
||||
@@ -599,7 +549,10 @@ describe('RecipeController', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipe_id).toBe(10);
|
||||
expect(result.data.forked_from).toBe(1);
|
||||
// The controller returns original_recipe_id from DB, cast to RecipeDto
|
||||
expect((result.data as unknown as { original_recipe_id: number }).original_recipe_id).toBe(
|
||||
1,
|
||||
);
|
||||
expect(result.data.user_id).toBe('test-user-id');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.forkRecipe).toHaveBeenCalledWith(
|
||||
@@ -611,16 +564,11 @@ describe('RecipeController', () => {
|
||||
|
||||
it('should log fork operation', async () => {
|
||||
// Arrange
|
||||
const mockForkedRecipe = createMockRecipe({ recipe_id: 10, forked_from: 1 });
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockForkedRecipe = createMockRecipe({ recipe_id: 10, original_recipe_id: 1 });
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.forkRecipe).mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
await controller.forkRecipe(1, request);
|
||||
@@ -647,7 +595,7 @@ describe('RecipeController', () => {
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.getRecipeById).mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
@@ -662,7 +610,7 @@ describe('RecipeController', () => {
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
vi.mocked(mockedDb.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Test' }, request);
|
||||
@@ -675,17 +623,16 @@ describe('RecipeController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(null);
|
||||
vi.mocked(mockedAiService.generateRecipeSuggestion).mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe({ ingredients: ['test'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
const errorBody = asErrorResponse(result);
|
||||
expect(errorBody.error).toHaveProperty('code');
|
||||
expect(errorBody.error).toHaveProperty('message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -120,12 +121,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -285,12 +281,7 @@ describe('StoreController', () => {
|
||||
it('should log successful retrieval', async () => {
|
||||
// Arrange
|
||||
const mockStore = createMockStoreWithLocations();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||
@@ -369,12 +360,7 @@ describe('StoreController', () => {
|
||||
|
||||
it('should log store creation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
@@ -434,12 +420,7 @@ describe('StoreController', () => {
|
||||
|
||||
it('should log update operation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||
@@ -477,12 +458,7 @@ describe('StoreController', () => {
|
||||
|
||||
it('should log deletion', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||
@@ -583,12 +559,7 @@ describe('StoreController', () => {
|
||||
|
||||
it('should log location removal', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -66,12 +67,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -248,12 +244,7 @@ describe('SystemController', () => {
|
||||
|
||||
it('should pass logger to geocoding service', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue({
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -64,12 +65,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
@@ -81,9 +77,14 @@ function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -95,9 +96,14 @@ function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
points: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -214,12 +220,7 @@ describe('UpcController', () => {
|
||||
it('should log scan requests', async () => {
|
||||
// Arrange
|
||||
const mockResult = createMockScanResult();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedUpcService.scanUpc.mockResolvedValue(mockResult);
|
||||
@@ -247,7 +248,17 @@ describe('UpcController', () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
upc_code: '012345678901',
|
||||
product: { product_id: 1, name: 'Test' },
|
||||
product: {
|
||||
product_id: 1,
|
||||
name: 'Test',
|
||||
brand: 'Test Brand',
|
||||
category: 'Grocery',
|
||||
description: 'A test product',
|
||||
size: '500g',
|
||||
upc_code: '012345678901',
|
||||
image_url: null,
|
||||
master_item_id: 50,
|
||||
},
|
||||
external_lookup: null,
|
||||
found: true,
|
||||
from_cache: false,
|
||||
@@ -299,7 +310,20 @@ describe('UpcController', () => {
|
||||
it('should return scan history with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
scans: [{ scan_id: 1, upc_code: '012345678901' }],
|
||||
scans: [
|
||||
{
|
||||
scan_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
upc_code: '012345678901',
|
||||
product_id: 100,
|
||||
scan_source: 'manual_entry' as const,
|
||||
scan_confidence: 0.95,
|
||||
raw_image_path: null,
|
||||
lookup_successful: true,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
@@ -368,8 +392,12 @@ describe('UpcController', () => {
|
||||
user_id: 'test-user-id',
|
||||
upc_code: '012345678901',
|
||||
product_id: 100,
|
||||
scan_source: 'manual_entry',
|
||||
scan_source: 'manual_entry' as const,
|
||||
scan_confidence: 0.95,
|
||||
raw_image_path: null,
|
||||
lookup_successful: true,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
@@ -449,12 +477,7 @@ describe('UpcController', () => {
|
||||
|
||||
it('should log link operations', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const mockLog = createMockLogger();
|
||||
const request = createMockRequest({
|
||||
user: createMockAdminProfile(),
|
||||
log: mockLog,
|
||||
@@ -483,7 +506,13 @@ describe('UpcController', () => {
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStats = { total_scans: 0 };
|
||||
const mockStats = {
|
||||
total_scans: 0,
|
||||
successful_lookups: 0,
|
||||
unique_products: 0,
|
||||
scans_today: 0,
|
||||
scans_this_week: 0,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanStats.mockResolvedValue(mockStats);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { createMockLogger } from '../tests/utils/testHelpers';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
@@ -128,12 +129,7 @@ function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequ
|
||||
body: {},
|
||||
cookies: {},
|
||||
file: undefined,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
log: createMockLogger(),
|
||||
user: createMockUserProfile(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
@@ -209,7 +205,7 @@ describe('UserController', () => {
|
||||
const mockProfile = createMockUserProfile();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.userRepo.findUserProfileById.mockResolvedValue(mockProfile);
|
||||
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.getProfile(request);
|
||||
@@ -240,7 +236,7 @@ describe('UserController', () => {
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.userRepo.updateUserProfile.mockResolvedValue(updatedProfile);
|
||||
vi.mocked(mockedDb.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateProfile(request, {
|
||||
@@ -345,7 +341,9 @@ describe('UserController', () => {
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue(mockNotifications);
|
||||
vi.mocked(mockedDb.notificationRepo.getNotificationsForUser).mockResolvedValue(
|
||||
mockNotifications,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.getNotifications(request);
|
||||
@@ -369,7 +367,7 @@ describe('UserController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.notificationRepo.getNotificationsForUser).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getNotifications(request, 50, 10, true);
|
||||
@@ -388,7 +386,7 @@ describe('UserController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue([]);
|
||||
vi.mocked(mockedDb.notificationRepo.getNotificationsForUser).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getNotifications(request, 200);
|
||||
@@ -409,7 +407,7 @@ describe('UserController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getUnreadCount.mockResolvedValue(5);
|
||||
vi.mocked(mockedDb.notificationRepo.getUnreadCount).mockResolvedValue(5);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnreadNotificationCount(request);
|
||||
@@ -435,11 +433,16 @@ describe('UserController', () => {
|
||||
name: 'Milk',
|
||||
category_id: 2,
|
||||
category_name: 'Dairy',
|
||||
is_allergen: false,
|
||||
allergy_info: null,
|
||||
created_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.getWatchedItems.mockResolvedValue(mockItems);
|
||||
vi.mocked(mockedDb.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getWatchedItems(request);
|
||||
@@ -465,7 +468,7 @@ describe('UserController', () => {
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.addWatchedItem.mockResolvedValue(mockItem);
|
||||
vi.mocked(mockedDb.personalizationRepo.addWatchedItem).mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addWatchedItem(request, {
|
||||
@@ -500,7 +503,7 @@ describe('UserController', () => {
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.getShoppingLists.mockResolvedValue(mockLists);
|
||||
vi.mocked(mockedDb.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
|
||||
|
||||
// Act
|
||||
const result = await controller.getShoppingLists(request);
|
||||
@@ -527,7 +530,7 @@ describe('UserController', () => {
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.createShoppingList.mockResolvedValue(mockList);
|
||||
vi.mocked(mockedDb.shoppingRepo.createShoppingList).mockResolvedValue(mockList);
|
||||
|
||||
// Act
|
||||
const result = await controller.createShoppingList(request, {
|
||||
@@ -566,7 +569,7 @@ describe('UserController', () => {
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.addShoppingListItem.mockResolvedValue(mockItem);
|
||||
vi.mocked(mockedDb.shoppingRepo.addShoppingListItem).mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addShoppingListItem(1, request, {
|
||||
@@ -598,11 +601,19 @@ describe('UserController', () => {
|
||||
it('should return user dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const mockRestrictions = [
|
||||
{ dietary_restriction_id: 1, name: 'Vegetarian', type: 'diet' as const },
|
||||
{
|
||||
dietary_restriction_id: 1,
|
||||
name: 'Vegetarian',
|
||||
type: 'diet' as const,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.getUserDietaryRestrictions.mockResolvedValue(mockRestrictions);
|
||||
vi.mocked(mockedDb.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(
|
||||
mockRestrictions,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
@@ -692,7 +703,7 @@ describe('UserController', () => {
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.createRecipe.mockResolvedValue(mockRecipe);
|
||||
vi.mocked(mockedDb.recipeRepo.createRecipe).mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.createRecipe(request, {
|
||||
@@ -729,7 +740,7 @@ describe('UserController', () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getUnreadCount.mockResolvedValue(3);
|
||||
vi.mocked(mockedDb.notificationRepo.getUnreadCount).mockResolvedValue(3);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnreadNotificationCount(request);
|
||||
|
||||
@@ -60,7 +60,14 @@ import {
|
||||
RecommendedRecipe,
|
||||
UnitPrice,
|
||||
Source,
|
||||
UserReaction,
|
||||
} from '../../types';
|
||||
import type {
|
||||
ReceiptScan,
|
||||
ReceiptItem as ExpiryReceiptItem,
|
||||
ReceiptProcessingLogRecord,
|
||||
UserInventoryItem,
|
||||
} from '../../types/expiry';
|
||||
import type { AppStats } from '../../services/apiClient';
|
||||
|
||||
// --- ID Generator for Deterministic Mocking ---
|
||||
@@ -333,8 +340,14 @@ export const createMockFlyerItem = (
|
||||
item: 'Mock Item',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
unit_price: null,
|
||||
quantity: 'each',
|
||||
quantity_num: 1,
|
||||
master_item_id: undefined,
|
||||
master_item_name: null,
|
||||
category_id: null,
|
||||
category_name: null,
|
||||
unit_price: null,
|
||||
product_id: null,
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -362,16 +375,22 @@ export const createMockRecipe = (
|
||||
const defaultRecipe: Recipe = {
|
||||
recipe_id: recipeId,
|
||||
user_id: `user-${getNextId()}`,
|
||||
original_recipe_id: null,
|
||||
name: `Mock Recipe ${recipeId}`,
|
||||
description: 'A delicious mock recipe.',
|
||||
instructions: '1. Mock the ingredients. 2. Mock the cooking. 3. Enjoy!',
|
||||
avg_rating: 4.5,
|
||||
rating_count: 50,
|
||||
fork_count: 10,
|
||||
status: 'public',
|
||||
prep_time_minutes: 15,
|
||||
cook_time_minutes: 30,
|
||||
servings: 4,
|
||||
photo_url: null,
|
||||
calories_per_serving: null,
|
||||
protein_grams: null,
|
||||
fat_grams: null,
|
||||
carb_grams: null,
|
||||
avg_rating: 4.5,
|
||||
status: 'public',
|
||||
rating_count: 50,
|
||||
fork_count: 10,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -930,6 +949,144 @@ export const createMockReceipt = (
|
||||
return receipt;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock ReceiptScan object for use in tests.
|
||||
* This is the extended receipt type from types/expiry.ts with additional fields
|
||||
* like store_confidence, ocr_provider, error_details, etc.
|
||||
*
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe ReceiptScan object.
|
||||
*/
|
||||
export const createMockReceiptScan = (overrides: Partial<ReceiptScan> = {}): ReceiptScan => {
|
||||
const receiptId = overrides.receipt_id ?? getNextId();
|
||||
|
||||
const defaultReceiptScan: ReceiptScan = {
|
||||
receipt_id: receiptId,
|
||||
user_id: `user-${getNextId()}`,
|
||||
store_location_id: null,
|
||||
receipt_image_url: `/uploads/receipt-${receiptId}.jpg`,
|
||||
transaction_date: new Date().toISOString().split('T')[0],
|
||||
total_amount_cents: 5000,
|
||||
status: 'completed',
|
||||
raw_text: null,
|
||||
store_confidence: null,
|
||||
ocr_provider: null,
|
||||
error_details: null,
|
||||
retry_count: 0,
|
||||
ocr_confidence: null,
|
||||
currency: 'CAD',
|
||||
created_at: new Date().toISOString(),
|
||||
processed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { ...defaultReceiptScan, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock ExpiryReceiptItem object for use in tests.
|
||||
* This is the extended receipt item type from types/expiry.ts with additional fields
|
||||
* like pantry_item_id.
|
||||
*
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe ExpiryReceiptItem object.
|
||||
*/
|
||||
export const createMockExpiryReceiptItem = (
|
||||
overrides: Partial<ExpiryReceiptItem> = {},
|
||||
): ExpiryReceiptItem => {
|
||||
const receiptItemId = overrides.receipt_item_id ?? getNextId();
|
||||
|
||||
const defaultItem: ExpiryReceiptItem = {
|
||||
receipt_item_id: receiptItemId,
|
||||
receipt_id: overrides.receipt_id ?? getNextId(),
|
||||
raw_item_description: 'Mock Receipt Item',
|
||||
quantity: 1,
|
||||
price_paid_cents: 350,
|
||||
master_item_id: null,
|
||||
product_id: null,
|
||||
status: 'matched',
|
||||
line_number: 1,
|
||||
match_confidence: 0.95,
|
||||
is_discount: false,
|
||||
unit_price_cents: 350,
|
||||
unit_type: null,
|
||||
added_to_pantry: false,
|
||||
pantry_item_id: null,
|
||||
upc_code: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { ...defaultItem, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock ReceiptProcessingLogRecord object for use in tests.
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe ReceiptProcessingLogRecord object.
|
||||
*/
|
||||
export const createMockReceiptProcessingLog = (
|
||||
overrides: Partial<ReceiptProcessingLogRecord> = {},
|
||||
): ReceiptProcessingLogRecord => {
|
||||
const defaultLog: ReceiptProcessingLogRecord = {
|
||||
log_id: getNextId(),
|
||||
receipt_id: overrides.receipt_id ?? getNextId(),
|
||||
processing_step: 'upload',
|
||||
status: 'started',
|
||||
provider: null,
|
||||
duration_ms: null,
|
||||
tokens_used: null,
|
||||
cost_cents: null,
|
||||
input_data: null,
|
||||
output_data: null,
|
||||
error_message: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { ...defaultLog, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock UserInventoryItem object for use in tests.
|
||||
* Matches the UserInventoryItem interface from types/expiry.ts
|
||||
*
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe UserInventoryItem object.
|
||||
*/
|
||||
export const createMockUserInventoryItem = (
|
||||
overrides: Partial<UserInventoryItem> = {},
|
||||
): UserInventoryItem => {
|
||||
const inventoryId = overrides.inventory_id ?? getNextId();
|
||||
|
||||
const defaultItem: UserInventoryItem = {
|
||||
inventory_id: inventoryId,
|
||||
user_id: `user-${getNextId()}`,
|
||||
product_id: null,
|
||||
master_item_id: null,
|
||||
item_name: 'Mock Inventory Item',
|
||||
quantity: 1,
|
||||
unit: 'each',
|
||||
purchase_date: new Date().toISOString().split('T')[0],
|
||||
expiry_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 7 days from now
|
||||
source: 'manual',
|
||||
location: 'pantry',
|
||||
notes: null,
|
||||
is_consumed: false,
|
||||
consumed_at: null,
|
||||
expiry_source: null,
|
||||
receipt_item_id: null,
|
||||
pantry_location_id: null,
|
||||
notification_sent_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
// Computed fields
|
||||
days_until_expiry: 7,
|
||||
expiry_status: 'fresh',
|
||||
};
|
||||
|
||||
return { ...defaultItem, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock DietaryRestriction object for testing.
|
||||
* @param overrides - Optional properties to override the defaults.
|
||||
@@ -938,14 +1095,17 @@ export const createMockReceipt = (
|
||||
export const createMockDietaryRestriction = (
|
||||
overrides: Partial<DietaryRestriction> = {},
|
||||
): DietaryRestriction => {
|
||||
return {
|
||||
dietary_restriction_id: 1,
|
||||
name: 'Vegetarian',
|
||||
const restrictionId = overrides.dietary_restriction_id ?? getNextId();
|
||||
|
||||
const defaultRestriction: DietaryRestriction = {
|
||||
dietary_restriction_id: restrictionId,
|
||||
name: `Restriction ${restrictionId}`,
|
||||
type: 'diet',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { ...defaultRestriction, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1295,6 +1455,27 @@ export const createMockUserAppliance = (
|
||||
return { ...defaultUserAppliance, ...overrides } as UserAppliance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock UserReaction object for use in tests.
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe UserReaction object.
|
||||
*/
|
||||
export const createMockUserReaction = (overrides: Partial<UserReaction> = {}): UserReaction => {
|
||||
const reactionId = overrides.reaction_id ?? getNextId();
|
||||
|
||||
const defaultReaction: UserReaction = {
|
||||
reaction_id: reactionId,
|
||||
user_id: `user-${getNextId()}`,
|
||||
entity_type: 'recipe',
|
||||
entity_id: String(getNextId()),
|
||||
reaction_type: 'like',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { ...defaultReaction, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock Address object for use in tests.
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
@@ -1578,20 +1759,24 @@ export const createMockProcessingStage = (
|
||||
return { ...defaultStage, ...overrides };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock Appliance object for use in tests.
|
||||
* @param overrides - An object containing properties to override the default mock values.
|
||||
* @returns A complete and type-safe Appliance object.
|
||||
*/
|
||||
export const createMockAppliance = (overrides: Partial<Appliance> = {}): Appliance => {
|
||||
return {
|
||||
appliance_id: 1,
|
||||
name: 'Oven',
|
||||
const applianceId = overrides.appliance_id ?? getNextId();
|
||||
|
||||
const defaultAppliance: Appliance = {
|
||||
appliance_id: applianceId,
|
||||
name: `Appliance ${applianceId}`,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { ...defaultAppliance, ...overrides };
|
||||
};
|
||||
|
||||
// src/tests/utils/mockFactories.ts
|
||||
|
||||
// ... existing factories
|
||||
|
||||
export const createMockShoppingListItemPayload = (
|
||||
overrides: Partial<{ masterItemId: number; customItemName: string }> = {},
|
||||
): { masterItemId?: number; customItemName?: string } => ({
|
||||
|
||||
@@ -18,6 +18,11 @@ export const createMockLogger = (): Logger => ({
|
||||
trace: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
bindings: vi.fn().mockReturnValue({}),
|
||||
level: 'info',
|
||||
isLevelEnabled: vi.fn().mockReturnValue(true),
|
||||
levelVal: 30,
|
||||
levels: { labels: {}, values: {} },
|
||||
} as unknown as Logger);
|
||||
|
||||
export const mockLogger = createMockLogger();
|
||||
|
||||
@@ -1,9 +1,149 @@
|
||||
// src/tests/utils/testHelpers.ts
|
||||
// ============================================================================
|
||||
// TEST HELPER UTILITIES
|
||||
// ============================================================================
|
||||
// Provides type-safe utilities for API response assertions and mock casting.
|
||||
// See ADR-060 for the rationale and patterns documented here.
|
||||
// ============================================================================
|
||||
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import type { UserProfile } from '../../types';
|
||||
import type { ApiSuccessResponse, ApiErrorResponse } from '../../types/api';
|
||||
import type { Mock } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
|
||||
// Re-export createMockLogger for convenience
|
||||
export { createMockLogger, mockLogger } from './mockLogger';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE NARROWING UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to narrow supertest response body to ApiSuccessResponse.
|
||||
* Use when accessing .data property on API responses in tests.
|
||||
*
|
||||
* This function asserts that the response body represents a successful API
|
||||
* response and returns it with the correct type. If the response is not
|
||||
* a success response, it throws an error with the actual response body
|
||||
* for debugging.
|
||||
*
|
||||
* @template T - The expected type of the data property
|
||||
* @param body - The response body from supertest (typically `response.body`)
|
||||
* @returns The response body typed as ApiSuccessResponse<T>
|
||||
* @throws Error if the response is not a success response
|
||||
*
|
||||
* @example
|
||||
* // Basic usage with a single entity
|
||||
* const response = await request.get('/api/v1/users/1');
|
||||
* const body = asSuccessResponse<User>(response.body);
|
||||
* expect(body.data.id).toBe(1); // TypeScript knows body.data exists and is User
|
||||
*
|
||||
* @example
|
||||
* // Usage with an array response
|
||||
* const response = await request.get('/api/v1/flyers');
|
||||
* const body = asSuccessResponse<Flyer[]>(response.body);
|
||||
* expect(body.data).toHaveLength(3);
|
||||
* expect(body.data[0].flyer_id).toBeDefined();
|
||||
*
|
||||
* @example
|
||||
* // Usage with pagination metadata
|
||||
* const response = await request.get('/api/v1/flyers?page=1&limit=10');
|
||||
* const body = asSuccessResponse<Flyer[]>(response.body);
|
||||
* expect(body.meta?.pagination?.page).toBe(1);
|
||||
*/
|
||||
export function asSuccessResponse<T>(body: unknown): ApiSuccessResponse<T> {
|
||||
const parsed = body as ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
if (parsed.success !== true) {
|
||||
throw new Error(`Expected success response, got: ${JSON.stringify(parsed, null, 2)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to narrow supertest response body to ApiErrorResponse.
|
||||
* Use when testing error scenarios and asserting on error properties.
|
||||
*
|
||||
* This function asserts that the response body represents an error API
|
||||
* response and returns it with the correct type. If the response is not
|
||||
* an error response, it throws an error for debugging.
|
||||
*
|
||||
* @param body - The response body from supertest (typically `response.body`)
|
||||
* @returns The response body typed as ApiErrorResponse
|
||||
* @throws Error if the response is not an error response
|
||||
*
|
||||
* @example
|
||||
* // Validation error assertion
|
||||
* const response = await request.post('/api/v1/users').send({ email: 'invalid' });
|
||||
* expect(response.status).toBe(400);
|
||||
* const body = asErrorResponse(response.body);
|
||||
* expect(body.error.code).toBe('VALIDATION_ERROR');
|
||||
* expect(body.error.message).toContain('Invalid');
|
||||
*
|
||||
* @example
|
||||
* // Not found error assertion
|
||||
* const response = await request.get('/api/v1/users/nonexistent-id');
|
||||
* expect(response.status).toBe(404);
|
||||
* const body = asErrorResponse(response.body);
|
||||
* expect(body.error.code).toBe('NOT_FOUND');
|
||||
*
|
||||
* @example
|
||||
* // Error with details
|
||||
* const response = await request.post('/api/v1/auth/register').send({});
|
||||
* const body = asErrorResponse(response.body);
|
||||
* expect(body.error.details).toBeDefined();
|
||||
*/
|
||||
export function asErrorResponse(body: unknown): ApiErrorResponse {
|
||||
const parsed = body as ApiSuccessResponse<unknown> | ApiErrorResponse;
|
||||
if (parsed.success !== false) {
|
||||
throw new Error(`Expected error response, got: ${JSON.stringify(parsed, null, 2)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK CASTING UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cast Vitest mock to a specific function type.
|
||||
* Use when passing mocked functions to code expecting exact signatures,
|
||||
* or when accessing mock-specific methods like .mockResolvedValue().
|
||||
*
|
||||
* This utility safely casts a vi.fn() mock to the expected function type,
|
||||
* avoiding TypeScript errors when mocks are used in place of real functions.
|
||||
*
|
||||
* @template T - The function type to cast to (use specific function signatures)
|
||||
* @param mock - A Vitest mock function (typically from vi.fn())
|
||||
* @returns The mock cast to the specified function type
|
||||
*
|
||||
* @example
|
||||
* // Casting a simple mock
|
||||
* const mockFn = vi.fn();
|
||||
* const typedMock = asMock<(id: string) => Promise<User>>(mockFn);
|
||||
* typedMock.mockResolvedValue(mockUser);
|
||||
*
|
||||
* @example
|
||||
* // Using with service method types
|
||||
* vi.mock('@/services/userService');
|
||||
* const mockCreate = vi.fn();
|
||||
* someService.register(asMock<UserService['create']>(mockCreate));
|
||||
*
|
||||
* @example
|
||||
* // Casting to access mock methods
|
||||
* const unknownMock = someModule.someMethod;
|
||||
* const typedMock = asMock<typeof originalFunction>(unknownMock);
|
||||
* expect(typedMock).toHaveBeenCalledWith('expected-arg');
|
||||
*/
|
||||
export function asMock<T extends (...args: unknown[]) => unknown>(mock: Mock): T {
|
||||
return mock as unknown as T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
|
||||
export const TEST_EXAMPLE_DOMAIN = 'https://example.com';
|
||||
|
||||
|
||||
222
tests/qa/test-pm2-safeguard-logic.js
Normal file
222
tests/qa/test-pm2-safeguard-logic.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* PM2 Safeguard Logic Validation Tests
|
||||
*
|
||||
* This script tests the safeguard logic implemented in deployment workflows
|
||||
* to prevent accidental deletion of all PM2 processes.
|
||||
*
|
||||
* Run with: node tests/qa/test-pm2-safeguard-logic.js
|
||||
*/
|
||||
|
||||
// Simulate the safeguard logic from workflows
|
||||
function evaluateSafeguard(totalProcesses, targetProcesses, threshold = 3) {
|
||||
// SAFEGUARD 1: Process count validation
|
||||
// If we're about to delete ALL processes AND there are more than threshold processes,
|
||||
// this indicates a potential filter bug
|
||||
const shouldAbort = targetProcesses === totalProcesses && totalProcesses > threshold;
|
||||
return { shouldAbort, totalProcesses, targetProcesses };
|
||||
}
|
||||
|
||||
// Test scenarios
|
||||
const scenarios = [
|
||||
// Normal operations - should NOT abort
|
||||
{
|
||||
name: 'Normal production cleanup - 3 errored out of 15',
|
||||
totalProcs: 15,
|
||||
targetProcs: 3,
|
||||
expectedAbort: false,
|
||||
description: 'Production deployment cleans up only the 3 errored production processes',
|
||||
},
|
||||
{
|
||||
name: 'Normal test cleanup - 3 test processes out of 15',
|
||||
totalProcs: 15,
|
||||
targetProcs: 3,
|
||||
expectedAbort: false,
|
||||
description: 'Test deployment cleans up only the 3 test processes',
|
||||
},
|
||||
{
|
||||
name: 'Single process cleanup - 1 errored out of 10',
|
||||
totalProcs: 10,
|
||||
targetProcs: 1,
|
||||
expectedAbort: false,
|
||||
description: 'Only one process is errored and targeted for cleanup',
|
||||
},
|
||||
{
|
||||
name: 'No processes to clean - 0 out of 10',
|
||||
totalProcs: 10,
|
||||
targetProcs: 0,
|
||||
expectedAbort: false,
|
||||
description: 'No processes match the cleanup criteria',
|
||||
},
|
||||
{
|
||||
name: 'Fresh server - 3 out of 3 (at threshold)',
|
||||
totalProcs: 3,
|
||||
targetProcs: 3,
|
||||
expectedAbort: false,
|
||||
description: 'Server with only 3 processes (threshold boundary - should proceed)',
|
||||
},
|
||||
{
|
||||
name: 'Minimal server - 2 out of 2',
|
||||
totalProcs: 2,
|
||||
targetProcs: 2,
|
||||
expectedAbort: false,
|
||||
description: 'Server with only 2 processes (below threshold)',
|
||||
},
|
||||
{
|
||||
name: 'Empty PM2 state - 0 out of 0',
|
||||
totalProcs: 0,
|
||||
targetProcs: 0,
|
||||
expectedAbort: false,
|
||||
description: 'No PM2 processes at all (fresh install)',
|
||||
},
|
||||
|
||||
// Dangerous operations - SHOULD abort
|
||||
{
|
||||
name: 'Filter bug - all 10 processes targeted',
|
||||
totalProcs: 10,
|
||||
targetProcs: 10,
|
||||
expectedAbort: true,
|
||||
description: 'DANGEROUS: Filter would delete ALL 10 processes - indicates bug',
|
||||
},
|
||||
{
|
||||
name: 'Filter bug - all 15 processes targeted',
|
||||
totalProcs: 15,
|
||||
targetProcs: 15,
|
||||
expectedAbort: true,
|
||||
description: 'DANGEROUS: Filter would delete ALL 15 processes - indicates bug',
|
||||
},
|
||||
{
|
||||
name: 'Filter bug - all 4 processes targeted',
|
||||
totalProcs: 4,
|
||||
targetProcs: 4,
|
||||
expectedAbort: true,
|
||||
description: 'DANGEROUS: Filter would delete ALL 4 processes (just above threshold)',
|
||||
},
|
||||
{
|
||||
name: 'Filter bug - all 100 processes targeted',
|
||||
totalProcs: 100,
|
||||
targetProcs: 100,
|
||||
expectedAbort: true,
|
||||
description: 'DANGEROUS: Filter would delete ALL 100 processes - extreme case',
|
||||
},
|
||||
];
|
||||
|
||||
// Run tests
|
||||
console.log('========================================');
|
||||
console.log('PM2 SAFEGUARD LOGIC VALIDATION');
|
||||
console.log('========================================\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
scenarios.forEach((scenario, index) => {
|
||||
const result = evaluateSafeguard(scenario.totalProcs, scenario.targetProcs);
|
||||
const testPassed = result.shouldAbort === scenario.expectedAbort;
|
||||
|
||||
if (testPassed) {
|
||||
passed++;
|
||||
console.log(`[PASS] Test ${index + 1}: ${scenario.name}`);
|
||||
console.log(` Total: ${scenario.totalProcs}, Target: ${scenario.targetProcs}`);
|
||||
console.log(` Expected abort: ${scenario.expectedAbort}, Got: ${result.shouldAbort}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(`[FAIL] Test ${index + 1}: ${scenario.name}`);
|
||||
console.log(` Total: ${scenario.totalProcs}, Target: ${scenario.targetProcs}`);
|
||||
console.log(` Expected abort: ${scenario.expectedAbort}, Got: ${result.shouldAbort}`);
|
||||
console.log(` Description: ${scenario.description}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
console.log('========================================');
|
||||
console.log(`RESULTS: ${passed} passed, ${failed} failed`);
|
||||
console.log('========================================');
|
||||
|
||||
// Edge case tests for specific workflow patterns
|
||||
console.log('\n========================================');
|
||||
console.log('WORKFLOW-SPECIFIC FILTER TESTS');
|
||||
console.log('========================================\n');
|
||||
|
||||
// Simulate production workflow filter
|
||||
function simulateProdFilter(processList) {
|
||||
const prodProcesses = [
|
||||
'flyer-crawler-api',
|
||||
'flyer-crawler-worker',
|
||||
'flyer-crawler-analytics-worker',
|
||||
];
|
||||
return processList.filter(
|
||||
(p) => (p.status === 'errored' || p.status === 'stopped') && prodProcesses.includes(p.name),
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate test workflow filter
|
||||
function simulateTestFilter(processList) {
|
||||
return processList.filter((p) => p.name && p.name.endsWith('-test'));
|
||||
}
|
||||
|
||||
// Test case: Normal mixed environment
|
||||
const mixedEnvProcesses = [
|
||||
{ name: 'flyer-crawler-api', status: 'online' },
|
||||
{ name: 'flyer-crawler-worker', status: 'errored' },
|
||||
{ name: 'flyer-crawler-analytics-worker', status: 'online' },
|
||||
{ name: 'flyer-crawler-api-test', status: 'online' },
|
||||
{ name: 'flyer-crawler-worker-test', status: 'online' },
|
||||
{ name: 'flyer-crawler-analytics-worker-test', status: 'online' },
|
||||
{ name: 'stock-alert-api', status: 'online' },
|
||||
{ name: 'stock-alert-worker', status: 'online' },
|
||||
];
|
||||
|
||||
const prodFiltered = simulateProdFilter(mixedEnvProcesses);
|
||||
const testFiltered = simulateTestFilter(mixedEnvProcesses);
|
||||
|
||||
console.log('Test: Mixed environment with production processes');
|
||||
console.log(`Total processes: ${mixedEnvProcesses.length}`);
|
||||
console.log(`Production filter matches: ${prodFiltered.length}`);
|
||||
console.log(` Names: ${prodFiltered.map((p) => p.name).join(', ') || '(none)'}`);
|
||||
console.log(`Test filter matches: ${testFiltered.length}`);
|
||||
console.log(` Names: ${testFiltered.map((p) => p.name).join(', ')}`);
|
||||
|
||||
// Verify production filter does NOT match test or other apps
|
||||
const prodFilterSafe = prodFiltered.every(
|
||||
(p) => !p.name.endsWith('-test') && p.name.startsWith('flyer-crawler-'),
|
||||
);
|
||||
console.log(`Production filter safe (no test/other apps): ${prodFilterSafe ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Verify test filter does NOT match production or other apps
|
||||
const testFilterSafe = testFiltered.every((p) => p.name.endsWith('-test'));
|
||||
console.log(`Test filter safe (only -test suffix): ${testFilterSafe ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test case: All processes errored (dangerous scenario)
|
||||
console.log('\nTest: All production processes errored (edge case)');
|
||||
const allErroredProd = [
|
||||
{ name: 'flyer-crawler-api', status: 'errored' },
|
||||
{ name: 'flyer-crawler-worker', status: 'errored' },
|
||||
{ name: 'flyer-crawler-analytics-worker', status: 'errored' },
|
||||
{ name: 'flyer-crawler-api-test', status: 'online' },
|
||||
{ name: 'flyer-crawler-worker-test', status: 'online' },
|
||||
{ name: 'stock-alert-api', status: 'online' },
|
||||
];
|
||||
|
||||
const allErroredFiltered = simulateProdFilter(allErroredProd);
|
||||
const safeguardCheck = evaluateSafeguard(allErroredProd.length, allErroredFiltered.length);
|
||||
console.log(`Total processes: ${allErroredProd.length}`);
|
||||
console.log(`Production errored processes: ${allErroredFiltered.length}`);
|
||||
console.log(`Safeguard would abort: ${safeguardCheck.shouldAbort}`);
|
||||
console.log(`Expected: false (3 out of 6 is not ALL processes)`);
|
||||
console.log(`Result: ${safeguardCheck.shouldAbort === false ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test case: Bug simulation - filter returns everything
|
||||
console.log('\nTest: Bug simulation - filter returns all processes');
|
||||
const buggyFilterResult = mixedEnvProcesses; // Simulating a bug where filter returns everything
|
||||
const buggySafeguardCheck = evaluateSafeguard(mixedEnvProcesses.length, buggyFilterResult.length);
|
||||
console.log(`Total processes: ${mixedEnvProcesses.length}`);
|
||||
console.log(`Buggy filter matches: ${buggyFilterResult.length}`);
|
||||
console.log(`Safeguard would abort: ${buggySafeguardCheck.shouldAbort}`);
|
||||
console.log(`Expected: true (prevents all-process deletion)`);
|
||||
console.log(`Result: ${buggySafeguardCheck.shouldAbort === true ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('ALL TESTS COMPLETE');
|
||||
console.log('========================================');
|
||||
|
||||
// Exit with appropriate code
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user