Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f80baf466 | ||
| 8450b5e22f | |||
|
|
e4d830ab90 | ||
| b6a62a036f | |||
| 2d2cd52011 | |||
| 379b8bf532 | |||
|
|
d06a1952a0 | ||
| 4d323a51ca | |||
|
|
ee15c67429 | ||
|
|
9956d07480 | ||
|
|
5bc8f6a42b | ||
| 4fd5e900af | |||
|
|
39ab773b82 | ||
| 75406cd924 | |||
|
|
8fb0a57f02 | ||
| c78323275b | |||
|
|
5fe537b93d | ||
| 61f24305fb | |||
|
|
de3f0cf26e | ||
| 45ac4fccf5 |
32
.env.example
32
.env.example
@@ -128,3 +128,35 @@ GENERATE_SOURCE_MAPS=true
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# URL of your Bugsink instance (for source map uploads)
|
||||
SENTRY_URL=https://bugsink.projectium.com
|
||||
|
||||
# ===================
|
||||
# Feature Flags (ADR-024)
|
||||
# ===================
|
||||
# Feature flags control the availability of features at runtime.
|
||||
# All flags default to disabled (false) when not set or set to any value other than 'true'.
|
||||
# Set to 'true' to enable a feature.
|
||||
#
|
||||
# Backend flags use: FEATURE_SNAKE_CASE
|
||||
# Frontend flags use: VITE_FEATURE_SNAKE_CASE (VITE_ prefix required for client-side access)
|
||||
#
|
||||
# Lifecycle:
|
||||
# 1. Add flag with default false
|
||||
# 2. Enable via env var when ready for testing/rollout
|
||||
# 3. Remove conditional code when feature is fully rolled out
|
||||
# 4. Remove flag from config within 3 months of full rollout
|
||||
#
|
||||
# See: docs/adr/0024-feature-flagging-strategy.md
|
||||
|
||||
# Backend Feature Flags
|
||||
# FEATURE_BUGSINK_SYNC=false # Enable Bugsink error sync integration
|
||||
# FEATURE_ADVANCED_RBAC=false # Enable advanced RBAC features
|
||||
# FEATURE_NEW_DASHBOARD=false # Enable new dashboard experience
|
||||
# FEATURE_BETA_RECIPES=false # Enable beta recipe features
|
||||
# FEATURE_EXPERIMENTAL_AI=false # Enable experimental AI features
|
||||
# FEATURE_DEBUG_MODE=false # Enable debug mode for development
|
||||
|
||||
# Frontend Feature Flags (VITE_ prefix required)
|
||||
# VITE_FEATURE_NEW_DASHBOARD=false # Enable new dashboard experience
|
||||
# VITE_FEATURE_BETA_RECIPES=false # Enable beta recipe features
|
||||
# VITE_FEATURE_EXPERIMENTAL_AI=false # Enable experimental AI features
|
||||
# VITE_FEATURE_DEBUG_MODE=false # Enable debug mode for development
|
||||
|
||||
@@ -166,8 +166,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
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); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
@@ -75,6 +75,9 @@ jobs:
|
||||
echo "--- Listing SRC Directory ---"
|
||||
ls -alF src
|
||||
|
||||
- name: Generate TSOA Spec and Routes
|
||||
run: npm run tsoa:build
|
||||
|
||||
- name: TypeScript Type-Check
|
||||
run: npm run type-check
|
||||
|
||||
@@ -490,8 +493,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
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); }"
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
|
||||
- name: Step 1 - Stop Application Server
|
||||
run: |
|
||||
echo "Stopping all PM2 processes to release database connections..."
|
||||
pm2 stop all || echo "PM2 processes were not running."
|
||||
echo "✅ Application server stopped."
|
||||
echo "Stopping PRODUCTION PM2 processes to release database connections..."
|
||||
pm2 stop flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker || echo "Production PM2 processes were not running."
|
||||
echo "✅ Production application server stopped."
|
||||
|
||||
- name: Step 2 - Drop and Recreate Database
|
||||
run: |
|
||||
|
||||
@@ -139,8 +139,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
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); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,6 +14,10 @@ dist-ssr
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
|
||||
# tsoa generated files (regenerated on build)
|
||||
src/routes/tsoa-generated.ts
|
||||
src/config/tsoa-spec.json
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
202
CLAUDE.md
202
CLAUDE.md
@@ -31,47 +31,62 @@ Out-of-sync = test failures.
|
||||
|
||||
**CRITICAL**: The `claude-win10` user has **READ-ONLY** access to production and test servers.
|
||||
|
||||
**Claude Code does NOT have:**
|
||||
| Capability | Status |
|
||||
| ---------------------- | ---------------------- |
|
||||
| Root/sudo access | NO |
|
||||
| Write permissions | NO |
|
||||
| PM2 restart, systemctl | NO - User must execute |
|
||||
|
||||
- Root or sudo access
|
||||
- Write permissions on servers
|
||||
- Ability to execute PM2 restart, systemctl, or other write operations directly
|
||||
**Server Operations Workflow**: Diagnose → User executes → Analyze → Fix (1-3 commands) → User executes → Verify
|
||||
|
||||
**Correct Workflow for Server Operations:**
|
||||
**Rules**:
|
||||
|
||||
| Step | Actor | Action |
|
||||
| ---- | ------ | --------------------------------------------------------------------------- |
|
||||
| 1 | Claude | Provide **diagnostic commands** (read-only checks) for user to run |
|
||||
| 2 | User | Execute commands on server, report results |
|
||||
| 3 | Claude | Analyze results, provide **fix commands** (1-3 at a time) |
|
||||
| 4 | User | Execute fix commands, report results |
|
||||
| 5 | Claude | Provide **verification commands** to confirm success and check side effects |
|
||||
| 6 | Claude | Document progress stage by stage |
|
||||
| 7 | Claude | Update ALL relevant documentation when complete |
|
||||
- Provide diagnostic commands first, wait for user to report results
|
||||
- Maximum 3 fix commands at a time (errors may cascade)
|
||||
- Always verify after fixes complete
|
||||
|
||||
**Example - Diagnosing PM2 Issues:**
|
||||
### PM2 Process Isolation (Production/Test Servers)
|
||||
|
||||
**CRITICAL**: Production and test environments share the same PM2 daemon on the server.
|
||||
|
||||
| Environment | Processes | Config File |
|
||||
| ----------- | -------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| Production | `flyer-crawler-api`, `flyer-crawler-worker`, `flyer-crawler-analytics-worker` | `ecosystem.config.cjs` |
|
||||
| Test | `flyer-crawler-api-test`, `flyer-crawler-worker-test`, `flyer-crawler-analytics-worker-test` | `ecosystem-test.config.cjs` |
|
||||
| Development | `flyer-crawler-api-dev`, `flyer-crawler-worker-dev`, `flyer-crawler-vite-dev` | `ecosystem.dev.config.cjs` |
|
||||
|
||||
**Deployment Scripts MUST:**
|
||||
|
||||
- ✅ Filter PM2 commands by exact process names or name patterns (e.g., `endsWith('-test')`)
|
||||
- ❌ NEVER use `pm2 stop all`, `pm2 delete all`, or `pm2 restart all`
|
||||
- ❌ NEVER delete/stop processes based solely on status without name filtering
|
||||
- ✅ Always verify process names match the target environment before any operation
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Step 1: Claude provides diagnostic commands (user runs these)
|
||||
pm2 list
|
||||
pm2 logs flyer-crawler-api --lines 50
|
||||
systemctl status redis
|
||||
# ✅ CORRECT - Production cleanup (filter by name)
|
||||
pm2 stop flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker
|
||||
|
||||
# Step 3: After user reports results, Claude provides fix commands
|
||||
pm2 restart flyer-crawler-api
|
||||
# Wait for user confirmation before next command
|
||||
# ✅ CORRECT - Test cleanup (filter by name pattern)
|
||||
# Only delete test processes that are errored/stopped
|
||||
list.forEach(p => {
|
||||
if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
p.name && p.name.endsWith('-test')) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
|
||||
# Step 5: Claude provides verification
|
||||
pm2 list
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
|
||||
# ❌ WRONG - Affects all environments
|
||||
pm2 stop all
|
||||
pm2 delete all
|
||||
|
||||
# ❌ WRONG - No name filtering (could delete test processes during prod deploy)
|
||||
if (p.pm2_env.status === 'errored') {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
```
|
||||
|
||||
**Never Do:**
|
||||
|
||||
- `ssh root@projectium.com "pm2 restart all"` (Claude cannot execute this)
|
||||
- Assume commands succeeded without user confirmation
|
||||
- Provide more than 3 fix commands at once (errors may cascade)
|
||||
|
||||
### Communication Style
|
||||
|
||||
Ask before assuming. Never assume:
|
||||
@@ -105,25 +120,27 @@ Ask before assuming. Never assume:
|
||||
|
||||
### Key Patterns (with file locations)
|
||||
|
||||
| Pattern | ADR | Implementation | File |
|
||||
| ------------------ | ------- | ------------------------------------------------- | ----------------------------------- |
|
||||
| Error Handling | ADR-001 | `handleDbError()`, throw `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | ADR-034 | `get*` (throws), `find*` (null), `list*` (array) | `src/services/db/*.db.ts` |
|
||||
| API Responses | ADR-028 | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | ADR-002 | `withTransaction(async (client) => {...})` | `src/services/db/transaction.db.ts` |
|
||||
| Pattern | ADR | Implementation | File |
|
||||
| ------------------ | ------- | ------------------------------------------------- | ------------------------------------- |
|
||||
| Error Handling | ADR-001 | `handleDbError()`, throw `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | ADR-034 | `get*` (throws), `find*` (null), `list*` (array) | `src/services/db/*.db.ts` |
|
||||
| API Responses | ADR-028 | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | ADR-002 | `withTransaction(async (client) => {...})` | `src/services/db/connection.db.ts` |
|
||||
| Feature Flags | ADR-024 | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
|
||||
### Key Files Quick Access
|
||||
|
||||
| Purpose | File |
|
||||
| ----------------- | -------------------------------- |
|
||||
| Express app | `server.ts` |
|
||||
| Environment | `src/config/env.ts` |
|
||||
| Routes | `src/routes/*.routes.ts` |
|
||||
| Repositories | `src/services/db/*.db.ts` |
|
||||
| Workers | `src/services/workers.server.ts` |
|
||||
| Queues | `src/services/queues.server.ts` |
|
||||
| PM2 Config (Dev) | `ecosystem.dev.config.cjs` |
|
||||
| PM2 Config (Prod) | `ecosystem.config.cjs` |
|
||||
| Purpose | File |
|
||||
| ----------------- | ------------------------------------- |
|
||||
| Express app | `server.ts` |
|
||||
| Environment | `src/config/env.ts` |
|
||||
| Routes | `src/routes/*.routes.ts` |
|
||||
| Repositories | `src/services/db/*.db.ts` |
|
||||
| Workers | `src/services/workers.server.ts` |
|
||||
| Queues | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `src/services/featureFlags.server.ts` |
|
||||
| PM2 Config (Dev) | `ecosystem.dev.config.cjs` |
|
||||
| PM2 Config (Prod) | `ecosystem.config.cjs` |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,7 +183,7 @@ The dev container now matches production by using PM2 for process management.
|
||||
- `flyer-crawler-worker-dev` - Background job worker
|
||||
- `flyer-crawler-vite-dev` - Vite frontend dev server (port 5173)
|
||||
|
||||
### Log Aggregation (ADR-050)
|
||||
### Log Aggregation (ADR-015)
|
||||
|
||||
All logs flow to Bugsink via Logstash with 3-project routing:
|
||||
|
||||
@@ -249,7 +266,7 @@ All logs flow to Bugsink via Logstash with 3-project routing:
|
||||
|
||||
**Launch Pattern**:
|
||||
|
||||
```
|
||||
```text
|
||||
Use Task tool with subagent_type: "coder", "db-dev", "tester", etc.
|
||||
```
|
||||
|
||||
@@ -330,8 +347,8 @@ podman cp "d:/path/file" container:/tmp/file
|
||||
|
||||
**Quick Access**:
|
||||
|
||||
- **Dev**: https://localhost:8443 (`admin@localhost`/`admin`)
|
||||
- **Prod**: https://bugsink.projectium.com
|
||||
- **Dev**: <https://localhost:8443> (`admin@localhost`/`admin`)
|
||||
- **Prod**: <https://bugsink.projectium.com>
|
||||
|
||||
**Token Creation** (required for MCP):
|
||||
|
||||
@@ -347,7 +364,7 @@ cd /opt/bugsink && bugsink-manage create_auth_token
|
||||
|
||||
**See**: [docs/operations/LOGSTASH-QUICK-REF.md](docs/operations/LOGSTASH-QUICK-REF.md)
|
||||
|
||||
Log aggregation: PostgreSQL + PM2 + Redis + NGINX → Bugsink (ADR-050)
|
||||
Log aggregation: PostgreSQL + PM2 + Redis + NGINX → Bugsink (ADR-015)
|
||||
|
||||
---
|
||||
|
||||
@@ -367,84 +384,3 @@ Log aggregation: PostgreSQL + PM2 + Redis + NGINX → Bugsink (ADR-050)
|
||||
| **Logstash** | [LOGSTASH-QUICK-REF.md](docs/operations/LOGSTASH-QUICK-REF.md) |
|
||||
| **ADRs** | [docs/adr/index.md](docs/adr/index.md) |
|
||||
| **All Docs** | [docs/README.md](docs/README.md) |
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Integration Test Issues (Full Details)
|
||||
|
||||
### 1. Vitest globalSetup Context Isolation
|
||||
|
||||
Vitest's `globalSetup` runs in separate Node.js context. Singletons, spies, mocks do NOT share instances with test files.
|
||||
|
||||
**Affected**: BullMQ worker service mocks (AI/DB failure tests)
|
||||
|
||||
**Solutions**: Mark `.todo()`, create test-only API endpoints, use Redis-based mock flags
|
||||
|
||||
```typescript
|
||||
// DOES NOT WORK - different instances
|
||||
const { flyerProcessingService } = await import('../../services/workers.server');
|
||||
flyerProcessingService._getAiProcessor()._setExtractAndValidateData(mockFn);
|
||||
```
|
||||
|
||||
### 2. Cleanup Queue Deletes Before Verification
|
||||
|
||||
Cleanup worker processes jobs in globalSetup context, ignoring test spies.
|
||||
|
||||
**Solution**: Drain and pause queue:
|
||||
|
||||
```typescript
|
||||
const { cleanupQueue } = await import('../../services/queues.server');
|
||||
await cleanupQueue.drain();
|
||||
await cleanupQueue.pause();
|
||||
// ... test ...
|
||||
await cleanupQueue.resume();
|
||||
```
|
||||
|
||||
### 3. Cache Stale After Direct SQL
|
||||
|
||||
Direct `pool.query()` inserts bypass cache invalidation.
|
||||
|
||||
**Solution**: `await cacheService.invalidateFlyers();` after inserts
|
||||
|
||||
### 4. Test Filename Collisions
|
||||
|
||||
Multer predictable filenames cause race conditions.
|
||||
|
||||
**Solution**: Use unique suffix: `${Date.now()}-${Math.round(Math.random() * 1e9)}`
|
||||
|
||||
### 5. Response Format Mismatches
|
||||
|
||||
API formats change: `data.jobId` vs `data.job.id`, nested vs flat, string vs number IDs.
|
||||
|
||||
**Solution**: Log response bodies, update assertions
|
||||
|
||||
### 6. External Service Availability
|
||||
|
||||
PM2/Redis health checks fail when unavailable.
|
||||
|
||||
**Solution**: try/catch with graceful degradation or mock
|
||||
|
||||
### 7. TZ Environment Variable Breaking Async Hooks
|
||||
|
||||
**Problem**: When `TZ=America/Los_Angeles` (or other timezone values) is set in the environment, Node.js async_hooks module can produce `RangeError: Invalid triggerAsyncId value: NaN`. This breaks React Testing Library's `render()` function which uses async hooks internally.
|
||||
|
||||
**Root Cause**: Setting `TZ` to certain timezone values interferes with Node.js's internal async tracking mechanism, causing invalid async IDs to be generated.
|
||||
|
||||
**Symptoms**:
|
||||
|
||||
```text
|
||||
RangeError: Invalid triggerAsyncId value: NaN
|
||||
❯ process.env.NODE_ENV.queueSeveralMicrotasks node_modules/react/cjs/react.development.js:751:15
|
||||
❯ process.env.NODE_ENV.exports.act node_modules/react/cjs/react.development.js:886:11
|
||||
❯ node_modules/@testing-library/react/dist/act-compat.js:46:25
|
||||
❯ renderRoot node_modules/@testing-library/react/dist/pure.js:189:26
|
||||
```
|
||||
|
||||
**Solution**: Explicitly unset `TZ` in all test scripts by adding `TZ=` (empty value) to cross-env:
|
||||
|
||||
```json
|
||||
"test:unit": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
"test:integration": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
```
|
||||
|
||||
**Context**: This issue was introduced in commit `d03900c` which added `TZ: 'America/Los_Angeles'` to PM2 ecosystem configs for consistent log timestamps in production/dev environments. Tests must explicitly override this to prevent the async hooks error.
|
||||
|
||||
393
docs/AI-DOCUMENTATION-INDEX.md
Normal file
393
docs/AI-DOCUMENTATION-INDEX.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# AI Documentation Index
|
||||
|
||||
Machine-optimized navigation for AI agents. Structured for vector retrieval and semantic search.
|
||||
|
||||
---
|
||||
|
||||
## Quick Lookup Table
|
||||
|
||||
| Task/Question | Primary Doc | Section/ADR |
|
||||
| ----------------------- | --------------------------------------------------- | --------------------------------------- |
|
||||
| Add new API endpoint | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | API Response Patterns, Input Validation |
|
||||
| Add repository method | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Repository Patterns (get*/find*/list\*) |
|
||||
| Fix failing test | [TESTING.md](development/TESTING.md) | Known Integration Test Issues |
|
||||
| Run tests correctly | [TESTING.md](development/TESTING.md) | Test Execution Environment |
|
||||
| Add database column | [DATABASE-GUIDE.md](subagents/DATABASE-GUIDE.md) | Schema sync required |
|
||||
| Deploy to production | [DEPLOYMENT.md](operations/DEPLOYMENT.md) | Application Deployment |
|
||||
| Debug container issue | [DEBUGGING.md](development/DEBUGGING.md) | Container Issues |
|
||||
| Configure environment | [ENVIRONMENT.md](getting-started/ENVIRONMENT.md) | Configuration by Environment |
|
||||
| Add background job | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Background Jobs |
|
||||
| Handle errors correctly | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Error Handling |
|
||||
| Use transactions | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Transaction Management |
|
||||
| Add authentication | [AUTHENTICATION.md](architecture/AUTHENTICATION.md) | JWT Token Architecture |
|
||||
| Cache data | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Caching |
|
||||
| Check PM2 status | [DEV-CONTAINER.md](development/DEV-CONTAINER.md) | PM2 Process Management |
|
||||
| View logs | [DEBUGGING.md](development/DEBUGGING.md) | PM2 Log Access |
|
||||
| Understand architecture | [OVERVIEW.md](architecture/OVERVIEW.md) | System Architecture Diagram |
|
||||
| Check ADR for decision | [adr/index.md](adr/index.md) | ADR by category |
|
||||
| Use subagent | [subagents/OVERVIEW.md](subagents/OVERVIEW.md) | Available Subagents |
|
||||
| API versioning | [API-VERSIONING.md](development/API-VERSIONING.md) | Phase 2 infrastructure |
|
||||
|
||||
---
|
||||
|
||||
## Documentation Tree
|
||||
|
||||
```
|
||||
docs/
|
||||
+-- AI-DOCUMENTATION-INDEX.md # THIS FILE - AI navigation index
|
||||
+-- README.md # Human-readable doc hub
|
||||
|
|
||||
+-- adr/ # Architecture Decision Records (57 ADRs)
|
||||
| +-- index.md # ADR index by category
|
||||
| +-- 0001-*.md # Standardized error handling
|
||||
| +-- 0002-*.md # Transaction management (withTransaction)
|
||||
| +-- 0003-*.md # Input validation (Zod middleware)
|
||||
| +-- 0008-*.md # API versioning (/api/v1/)
|
||||
| +-- 0014-*.md # Platform: Linux only (CRITICAL)
|
||||
| +-- 0028-*.md # API response (sendSuccess/sendError)
|
||||
| +-- 0034-*.md # Repository pattern (get*/find*/list*)
|
||||
| +-- 0035-*.md # Service layer architecture
|
||||
| +-- 0050-*.md # PostgreSQL observability + Logstash
|
||||
| +-- 0057-*.md # Test remediation post-API versioning
|
||||
| +-- adr-implementation-tracker.md # Implementation status
|
||||
|
|
||||
+-- architecture/
|
||||
| +-- OVERVIEW.md # System architecture, data flows, entities
|
||||
| +-- DATABASE.md # Schema design, extensions, setup
|
||||
| +-- AUTHENTICATION.md # OAuth, JWT, security features
|
||||
| +-- WEBSOCKET_USAGE.md # Real-time communication patterns
|
||||
| +-- api-versioning-infrastructure.md # Phase 2 versioning details
|
||||
|
|
||||
+-- development/
|
||||
| +-- CODE-PATTERNS.md # Error handling, repos, API responses
|
||||
| +-- TESTING.md # Unit/integration/E2E, known issues
|
||||
| +-- DEBUGGING.md # Container, DB, API, PM2 debugging
|
||||
| +-- DEV-CONTAINER.md # PM2, Logstash, container services
|
||||
| +-- API-VERSIONING.md # API versioning workflows
|
||||
| +-- DESIGN_TOKENS.md # Neo-Brutalism design system
|
||||
| +-- ERROR-LOGGING-PATHS.md # req.originalUrl pattern
|
||||
| +-- test-path-migration.md # Test file reorganization
|
||||
|
|
||||
+-- getting-started/
|
||||
| +-- QUICKSTART.md # Quick setup instructions
|
||||
| +-- INSTALL.md # Full installation guide
|
||||
| +-- ENVIRONMENT.md # Environment variables reference
|
||||
|
|
||||
+-- operations/
|
||||
| +-- DEPLOYMENT.md # Production deployment guide
|
||||
| +-- BARE-METAL-SETUP.md # Server provisioning
|
||||
| +-- MONITORING.md # Bugsink, health checks
|
||||
| +-- LOGSTASH-QUICK-REF.md # Log aggregation reference
|
||||
| +-- LOGSTASH-TROUBLESHOOTING.md # Logstash debugging
|
||||
|
|
||||
+-- subagents/
|
||||
| +-- OVERVIEW.md # Subagent system introduction
|
||||
| +-- CODER-GUIDE.md # Code development patterns
|
||||
| +-- TESTER-GUIDE.md # Testing strategies
|
||||
| +-- DATABASE-GUIDE.md # Database workflows
|
||||
| +-- DEVOPS-GUIDE.md # Deployment/infrastructure
|
||||
| +-- FRONTEND-GUIDE.md # UI/UX development
|
||||
| +-- AI-USAGE-GUIDE.md # Gemini integration
|
||||
| +-- DOCUMENTATION-GUIDE.md # Writing docs
|
||||
| +-- SECURITY-DEBUG-GUIDE.md # Security and debugging
|
||||
|
|
||||
+-- tools/
|
||||
| +-- MCP-CONFIGURATION.md # MCP servers setup
|
||||
| +-- BUGSINK-SETUP.md # Error tracking setup
|
||||
| +-- VSCODE-SETUP.md # Editor configuration
|
||||
|
|
||||
+-- archive/ # Historical docs, session notes
|
||||
+-- sessions/ # Development session logs
|
||||
+-- plans/ # Feature implementation plans
|
||||
+-- research/ # Investigation notes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problem-to-Document Mapping
|
||||
|
||||
### Database Issues
|
||||
|
||||
| Problem | Documents |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Schema out of sync | [DATABASE-GUIDE.md](subagents/DATABASE-GUIDE.md), [CLAUDE.md](../CLAUDE.md) schema sync section |
|
||||
| Migration needed | [DATABASE.md](architecture/DATABASE.md), ADR-013, ADR-023 |
|
||||
| Query performance | [DEBUGGING.md](development/DEBUGGING.md) Query Performance Issues |
|
||||
| Connection errors | [DEBUGGING.md](development/DEBUGGING.md) Database Issues |
|
||||
| Transaction patterns | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) Transaction Management, ADR-002 |
|
||||
| Repository methods | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) Repository Patterns, ADR-034 |
|
||||
|
||||
### Test Failures
|
||||
|
||||
| Problem | Documents |
|
||||
| ---------------------------- | --------------------------------------------------------------------- |
|
||||
| Tests fail in container | [TESTING.md](development/TESTING.md), ADR-014 |
|
||||
| Vitest globalSetup isolation | [CLAUDE.md](../CLAUDE.md) Integration Test Issues #1 |
|
||||
| Cache stale after insert | [CLAUDE.md](../CLAUDE.md) Integration Test Issues #3 |
|
||||
| Queue interference | [CLAUDE.md](../CLAUDE.md) Integration Test Issues #2 |
|
||||
| API path mismatches | [TESTING.md](development/TESTING.md) API Versioning in Tests, ADR-057 |
|
||||
| Type check failures | [DEBUGGING.md](development/DEBUGGING.md) Type Check Failures |
|
||||
| TZ environment breaks async | [CLAUDE.md](../CLAUDE.md) Integration Test Issues #7 |
|
||||
|
||||
### Deployment Issues
|
||||
|
||||
| Problem | Documents |
|
||||
| --------------------- | ------------------------------------------------------------------------------------- |
|
||||
| PM2 not starting | [DEBUGGING.md](development/DEBUGGING.md) PM2 Process Issues |
|
||||
| NGINX configuration | [DEPLOYMENT.md](operations/DEPLOYMENT.md) NGINX Configuration |
|
||||
| SSL certificates | [DEBUGGING.md](development/DEBUGGING.md) SSL Certificate Issues |
|
||||
| CI/CD failures | [DEPLOYMENT.md](operations/DEPLOYMENT.md) CI/CD Pipeline, ADR-017 |
|
||||
| Container won't start | [DEBUGGING.md](development/DEBUGGING.md) Container Issues |
|
||||
| Bugsink not receiving | [BUGSINK-SETUP.md](tools/BUGSINK-SETUP.md), [MONITORING.md](operations/MONITORING.md) |
|
||||
|
||||
### Frontend/UI Changes
|
||||
|
||||
| Problem | Documents |
|
||||
| ------------------ | --------------------------------------------------------------- |
|
||||
| Component patterns | [FRONTEND-GUIDE.md](subagents/FRONTEND-GUIDE.md), ADR-044 |
|
||||
| Design tokens | [DESIGN_TOKENS.md](development/DESIGN_TOKENS.md), ADR-012 |
|
||||
| State management | ADR-005, [OVERVIEW.md](architecture/OVERVIEW.md) Frontend Stack |
|
||||
| Hot reload broken | [DEBUGGING.md](development/DEBUGGING.md) Frontend Issues |
|
||||
| CORS errors | [DEBUGGING.md](development/DEBUGGING.md) API Calls Failing |
|
||||
|
||||
### API Development
|
||||
|
||||
| Problem | Documents |
|
||||
| ---------------- | ------------------------------------------------------------------------------- |
|
||||
| Response format | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) API Response Patterns, ADR-028 |
|
||||
| Input validation | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) Input Validation, ADR-003 |
|
||||
| Error handling | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) Error Handling, ADR-001 |
|
||||
| Rate limiting | ADR-032, [OVERVIEW.md](architecture/OVERVIEW.md) |
|
||||
| API versioning | [API-VERSIONING.md](development/API-VERSIONING.md), ADR-008 |
|
||||
| Authentication | [AUTHENTICATION.md](architecture/AUTHENTICATION.md), ADR-048 |
|
||||
|
||||
### Background Jobs
|
||||
|
||||
| Problem | Documents |
|
||||
| ------------------- | ------------------------------------------------------------------------- |
|
||||
| Jobs not processing | [DEBUGGING.md](development/DEBUGGING.md) Background Job Issues |
|
||||
| Queue configuration | [CODE-PATTERNS.md](development/CODE-PATTERNS.md) Background Jobs, ADR-006 |
|
||||
| Worker crashes | [DEBUGGING.md](development/DEBUGGING.md), ADR-053 |
|
||||
| Scheduled jobs | ADR-037, [OVERVIEW.md](architecture/OVERVIEW.md) Scheduled Jobs |
|
||||
|
||||
---
|
||||
|
||||
## Document Priority Matrix
|
||||
|
||||
### CRITICAL (Read First)
|
||||
|
||||
| Document | Purpose | Key Content |
|
||||
| --------------------------------------------------------------- | ----------------------- | ----------------------------- |
|
||||
| [CLAUDE.md](../CLAUDE.md) | AI agent instructions | Rules, patterns, known issues |
|
||||
| [ADR-014](adr/0014-containerization-and-deployment-strategy.md) | Platform requirement | Tests MUST run in container |
|
||||
| [DEV-CONTAINER.md](development/DEV-CONTAINER.md) | Development environment | PM2, Logstash, services |
|
||||
|
||||
### HIGH (Core Development)
|
||||
|
||||
| Document | Purpose | Key Content |
|
||||
| --------------------------------------------------- | ----------------- | ---------------------------- |
|
||||
| [CODE-PATTERNS.md](development/CODE-PATTERNS.md) | Code templates | Error handling, repos, APIs |
|
||||
| [TESTING.md](development/TESTING.md) | Test execution | Commands, known issues |
|
||||
| [DATABASE.md](architecture/DATABASE.md) | Schema reference | Setup, extensions, users |
|
||||
| [ADR-034](adr/0034-repository-pattern-standards.md) | Repository naming | get*/find*/list\* |
|
||||
| [ADR-028](adr/0028-api-response-standardization.md) | API responses | sendSuccess/sendError |
|
||||
| [ADR-001](adr/0001-standardized-error-handling.md) | Error handling | handleDbError, NotFoundError |
|
||||
|
||||
### MEDIUM (Specialized Tasks)
|
||||
|
||||
| Document | Purpose | Key Content |
|
||||
| --------------------------------------------------- | --------------------- | ------------------------ |
|
||||
| [subagents/OVERVIEW.md](subagents/OVERVIEW.md) | Subagent selection | When to delegate |
|
||||
| [DEPLOYMENT.md](operations/DEPLOYMENT.md) | Production deployment | PM2, NGINX, CI/CD |
|
||||
| [DEBUGGING.md](development/DEBUGGING.md) | Troubleshooting | Common issues, solutions |
|
||||
| [ENVIRONMENT.md](getting-started/ENVIRONMENT.md) | Config reference | Variables by environment |
|
||||
| [AUTHENTICATION.md](architecture/AUTHENTICATION.md) | Auth patterns | OAuth, JWT, security |
|
||||
| [API-VERSIONING.md](development/API-VERSIONING.md) | Versioning | /api/v1/ prefix |
|
||||
|
||||
### LOW (Reference/Historical)
|
||||
|
||||
| Document | Purpose | Key Content |
|
||||
| -------------------- | ------------------ | ------------------------- |
|
||||
| [archive/](archive/) | Historical docs | Session notes, old plans |
|
||||
| ADR-013, ADR-023 | Migration strategy | Proposed, not implemented |
|
||||
| ADR-024 | Feature flags | Proposed |
|
||||
| ADR-025 | i18n/l10n | Proposed |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Reference Matrix
|
||||
|
||||
| Document | References | Referenced By |
|
||||
| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| **CLAUDE.md** | ADR-001, ADR-002, ADR-008, ADR-014, ADR-028, ADR-034, ADR-035, ADR-050, ADR-057 | All development docs |
|
||||
| **ADR-008** | ADR-028 | API-VERSIONING.md, TESTING.md, ADR-057 |
|
||||
| **ADR-014** | - | CLAUDE.md, TESTING.md, DEPLOYMENT.md, DEV-CONTAINER.md |
|
||||
| **ADR-028** | ADR-001 | CODE-PATTERNS.md, OVERVIEW.md |
|
||||
| **ADR-034** | ADR-001 | CODE-PATTERNS.md, DATABASE-GUIDE.md |
|
||||
| **ADR-057** | ADR-008, ADR-028 | TESTING.md |
|
||||
| **CODE-PATTERNS.md** | ADR-001, ADR-002, ADR-003, ADR-028, ADR-034, ADR-036, ADR-048 | CODER-GUIDE.md |
|
||||
| **TESTING.md** | ADR-014, ADR-057, CLAUDE.md | TESTER-GUIDE.md, DEBUGGING.md |
|
||||
| **DEBUGGING.md** | DEV-CONTAINER.md, TESTING.md, MONITORING.md | DEVOPS-GUIDE.md |
|
||||
| **DEV-CONTAINER.md** | ADR-014, ADR-050, ecosystem.dev.config.cjs | DEBUGGING.md, CLAUDE.md |
|
||||
| **OVERVIEW.md** | ADR-001 through ADR-050+ | All architecture docs |
|
||||
| **DATABASE.md** | ADR-002, ADR-013, ADR-055 | DATABASE-GUIDE.md |
|
||||
|
||||
---
|
||||
|
||||
## Navigation Patterns
|
||||
|
||||
### Adding a Feature
|
||||
|
||||
```
|
||||
1. CLAUDE.md -> Project rules, patterns
|
||||
2. CODE-PATTERNS.md -> Implementation templates
|
||||
3. Relevant subagent guide -> Domain-specific patterns
|
||||
4. Related ADRs -> Design decisions
|
||||
5. TESTING.md -> Test requirements
|
||||
```
|
||||
|
||||
### Fixing a Bug
|
||||
|
||||
```
|
||||
1. DEBUGGING.md -> Common issues checklist
|
||||
2. TESTING.md -> Run tests in container
|
||||
3. Error logs (pm2/bugsink) -> Identify root cause
|
||||
4. CODE-PATTERNS.md -> Correct pattern reference
|
||||
5. Related ADR -> Architectural context
|
||||
```
|
||||
|
||||
### Deploying
|
||||
|
||||
```
|
||||
1. DEPLOYMENT.md -> Deployment procedures
|
||||
2. ENVIRONMENT.md -> Required variables
|
||||
3. MONITORING.md -> Health check verification
|
||||
4. LOGSTASH-QUICK-REF.md -> Log aggregation check
|
||||
```
|
||||
|
||||
### Database Changes
|
||||
|
||||
```
|
||||
1. DATABASE-GUIDE.md -> Schema sync requirements (CRITICAL)
|
||||
2. DATABASE.md -> Schema design patterns
|
||||
3. ADR-002 -> Transaction patterns
|
||||
4. ADR-034 -> Repository methods
|
||||
5. ADR-055 -> Normalization rules
|
||||
```
|
||||
|
||||
### Subagent Selection
|
||||
|
||||
| Task Type | Subagent | Guide |
|
||||
| --------------------- | ------------------------- | ------------------------------------------------------------ |
|
||||
| Write production code | `coder` | [CODER-GUIDE.md](subagents/CODER-GUIDE.md) |
|
||||
| Database changes | `db-dev` | [DATABASE-GUIDE.md](subagents/DATABASE-GUIDE.md) |
|
||||
| Create tests | `testwriter` | [TESTER-GUIDE.md](subagents/TESTER-GUIDE.md) |
|
||||
| Fix failing tests | `tester` | [TESTER-GUIDE.md](subagents/TESTER-GUIDE.md) |
|
||||
| Container/deployment | `devops` | [DEVOPS-GUIDE.md](subagents/DEVOPS-GUIDE.md) |
|
||||
| UI components | `frontend-specialist` | [FRONTEND-GUIDE.md](subagents/FRONTEND-GUIDE.md) |
|
||||
| External APIs | `integrations-specialist` | - |
|
||||
| Security review | `security-engineer` | [SECURITY-DEBUG-GUIDE.md](subagents/SECURITY-DEBUG-GUIDE.md) |
|
||||
| Production errors | `log-debug` | [SECURITY-DEBUG-GUIDE.md](subagents/SECURITY-DEBUG-GUIDE.md) |
|
||||
| AI/Gemini issues | `ai-usage` | [AI-USAGE-GUIDE.md](subagents/AI-USAGE-GUIDE.md) |
|
||||
|
||||
---
|
||||
|
||||
## Key File Quick Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `server.ts` | Express app setup |
|
||||
| `src/config/env.ts` | Environment validation (Zod) |
|
||||
| `ecosystem.dev.config.cjs` | PM2 dev config |
|
||||
| `ecosystem.config.cjs` | PM2 prod config |
|
||||
| `vite.config.ts` | Vite build config |
|
||||
|
||||
### Core Implementation
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | ----------------------------------- |
|
||||
| `src/routes/*.routes.ts` | API route handlers |
|
||||
| `src/services/db/*.db.ts` | Repository layer |
|
||||
| `src/services/*.server.ts` | Server-only services |
|
||||
| `src/services/queues.server.ts` | BullMQ queue definitions |
|
||||
| `src/services/workers.server.ts` | BullMQ workers |
|
||||
| `src/utils/apiResponse.ts` | sendSuccess/sendError/sendPaginated |
|
||||
| `src/services/db/errors.db.ts` | handleDbError, NotFoundError |
|
||||
| `src/services/db/transaction.db.ts` | withTransaction |
|
||||
|
||||
### Database Schema
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------ | ----------------------------------- |
|
||||
| `sql/master_schema_rollup.sql` | Test DB, complete reference |
|
||||
| `sql/initial_schema.sql` | Fresh install (identical to rollup) |
|
||||
| `sql/migrations/*.sql` | Production ALTER statements |
|
||||
|
||||
### Testing
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------- | ----------------------- |
|
||||
| `vitest.config.ts` | Unit test config |
|
||||
| `vitest.config.integration.ts` | Integration test config |
|
||||
| `vitest.config.e2e.ts` | E2E test config |
|
||||
| `src/tests/utils/mockFactories.ts` | Mock data factories |
|
||||
| `src/tests/utils/storeHelpers.ts` | Store test helpers |
|
||||
|
||||
---
|
||||
|
||||
## ADR Quick Reference
|
||||
|
||||
### By Implementation Status
|
||||
|
||||
**Implemented**: 001, 002, 003, 004, 006, 008, 009, 010, 016, 017, 020, 021, 028, 032, 033, 034, 035, 036, 037, 038, 040, 041, 043, 044, 045, 046, 050, 051, 052, 055, 057
|
||||
|
||||
**Partially Implemented**: 012, 014, 015, 048
|
||||
|
||||
**Proposed**: 011, 013, 022, 023, 024, 025, 029, 030, 031, 039, 047, 053, 054, 056
|
||||
|
||||
### By Category
|
||||
|
||||
| Category | ADRs |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| Core Infrastructure | 002, 007, 020, 030 |
|
||||
| Data Management | 009, 013, 019, 023, 031, 055 |
|
||||
| API & Integration | 003, 008, 018, 022, 028 |
|
||||
| Security | 001, 011, 016, 029, 032, 033, 048 |
|
||||
| Observability | 004, 015, 050, 051, 052, 056 |
|
||||
| Deployment & Ops | 006, 014, 017, 024, 037, 038, 053, 054 |
|
||||
| Frontend/UI | 005, 012, 025, 026, 044 |
|
||||
| Dev Workflow | 010, 021, 027, 040, 045, 047, 057 |
|
||||
| Architecture Patterns | 034, 035, 036, 039, 041, 042, 043, 046, 049 |
|
||||
|
||||
---
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Run all tests (MUST use container)
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# Run unit tests
|
||||
podman exec -it flyer-crawler-dev npm run test:unit
|
||||
|
||||
# Run type check
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Run integration tests
|
||||
podman exec -it flyer-crawler-dev npm run test:integration
|
||||
|
||||
# PM2 status
|
||||
podman exec -it flyer-crawler-dev pm2 status
|
||||
|
||||
# PM2 logs
|
||||
podman exec -it flyer-crawler-dev pm2 logs
|
||||
|
||||
# Restart all processes
|
||||
podman exec -it flyer-crawler-dev pm2 restart all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_This index is optimized for AI agent consumption. Updated: 2026-01-28_
|
||||
@@ -316,6 +316,7 @@ app.use('/api/v1', (req, res, next) => {
|
||||
- [ADR-018](./0018-api-documentation-strategy.md) - API Documentation Strategy (versioned OpenAPI specs)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization (envelope pattern applies to all versions)
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening (applies to all versions)
|
||||
- [ADR-057](./0057-test-remediation-post-api-versioning.md) - Test Remediation Post-API Versioning (documents test migration)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
|
||||
@@ -363,6 +363,13 @@ The following files contain acknowledged code smell violations that are deferred
|
||||
- `src/tests/utils/mockFactories.ts` - Mock factories (1553 lines)
|
||||
- `src/tests/utils/testHelpers.ts` - Test utilities
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Containerization (tests must run in dev container)
|
||||
- [ADR-040](./0040-testing-economics-and-priorities.md) - Testing Economics and Priorities
|
||||
- [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
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Browser E2E Tests**: Consider adding Playwright for actual browser testing
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Superseded by [ADR-023](./0023-database-schema-migration-strategy.md)
|
||||
|
||||
**Note**: This ADR was an early draft. ADR-023 provides a more detailed specification for the same topic.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted
|
||||
**Status**: Superseded
|
||||
|
||||
**Implemented**: 2026-01-11
|
||||
**Superseded By**: This ADR was updated in February 2026 to reflect the migration from swagger-jsdoc to tsoa. The original approach using JSDoc annotations has been replaced with a decorator-based controller pattern.
|
||||
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,139 +18,296 @@ Key requirements:
|
||||
2. **Code-Documentation Sync**: Documentation should stay in sync with the actual code to prevent drift.
|
||||
3. **Low Maintenance Overhead**: The documentation approach should be "fast and lite" - minimal additional work for developers.
|
||||
4. **Security**: Documentation should not expose sensitive information in production environments.
|
||||
5. **Type Safety**: Documentation should be derived from TypeScript types to ensure accuracy.
|
||||
|
||||
### Why We Migrated from swagger-jsdoc to tsoa
|
||||
|
||||
The original implementation used `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments. This approach had several limitations:
|
||||
|
||||
| Issue | Impact |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `swagger-jsdoc` unmaintained since 2022 | Security and compatibility risks |
|
||||
| JSDoc duplication with TypeScript types | Maintenance burden, potential for drift |
|
||||
| No runtime validation from schema | Validation logic separate from documentation |
|
||||
| Manual type definitions in comments | Error-prone, no compiler verification |
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt **OpenAPI 3.0 (Swagger)** for API documentation using the following approach:
|
||||
We adopt **tsoa** for API documentation using a decorator-based controller pattern:
|
||||
|
||||
1. **JSDoc Annotations**: Use `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments in route files.
|
||||
2. **Swagger UI**: Use `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
3. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
4. **Incremental Adoption**: Start with key public routes and progressively add annotations to all endpoints.
|
||||
1. **Controller Classes**: Use tsoa decorators (`@Route`, `@Get`, `@Post`, `@Security`, etc.) on controller classes.
|
||||
2. **TypeScript-First**: OpenAPI specs are generated directly from TypeScript interfaces and types.
|
||||
3. **Swagger UI**: Continue using `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
4. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
5. **BaseController Pattern**: All controllers extend a base class providing response formatting utilities.
|
||||
|
||||
### Tooling Selection
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `swagger-jsdoc` | Generates OpenAPI 3.0 spec from JSDoc comments |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ----------------------------------------------------- |
|
||||
| `tsoa` (6.6.0) | Generates OpenAPI 3.0 spec from decorators and routes |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
|
||||
**Why JSDoc over separate schema files?**
|
||||
**Why tsoa over swagger-jsdoc?**
|
||||
|
||||
- Documentation lives with the code, reducing drift
|
||||
- No separate files to maintain
|
||||
- Developers see documentation when editing routes
|
||||
- Lower learning curve for the team
|
||||
- **Type-safe contracts**: Decorators derive types directly from TypeScript, eliminating duplicate definitions
|
||||
- **Active maintenance**: tsoa has an active community and regular releases
|
||||
- **Route generation**: tsoa generates Express routes automatically, reducing boilerplate
|
||||
- **Validation integration**: Request body types serve as validation contracts
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### OpenAPI Configuration
|
||||
### tsoa Configuration
|
||||
|
||||
Located in `src/config/swagger.ts`:
|
||||
Located in `tsoa.json`:
|
||||
|
||||
```typescript
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description: 'API for the Flyer Crawler application',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
```json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/config",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"basePath": "/api",
|
||||
"specFileBaseName": "tsoa-spec",
|
||||
"name": "Flyer Crawler API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"basePath": "/api",
|
||||
"middleware": "express",
|
||||
"routesFileName": "tsoa-generated.ts",
|
||||
"esm": true,
|
||||
"authenticationModule": "src/middleware/tsoaAuthentication.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JSDoc Annotation Pattern
|
||||
### Controller Pattern
|
||||
|
||||
Each route handler should include OpenAPI annotations using the `@openapi` tag:
|
||||
Each controller extends `BaseController` and uses tsoa decorators:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* description: Returns a pong response to verify server is responsive
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: pong
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
import { Route, Tags, Get, Post, Body, Security, SuccessResponse, Response } from 'tsoa';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
@Route('users')
|
||||
@Tags('Users')
|
||||
export class UserController extends BaseController {
|
||||
/**
|
||||
* Create a new user account.
|
||||
* @summary Create user
|
||||
* @param requestBody User creation data
|
||||
* @returns Created user profile
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'User created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(409, 'Email already exists')
|
||||
public async createUser(
|
||||
@Body() requestBody: CreateUserRequest,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
// Implementation
|
||||
const user = await userService.createUser(requestBody);
|
||||
return this.created(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's profile.
|
||||
* @summary Get my profile
|
||||
* @param request Express request with authenticated user
|
||||
* @returns User profile
|
||||
*/
|
||||
@Get('me')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(200, 'Profile retrieved')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async getMyProfile(
|
||||
@Request() request: Express.Request,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
const user = request.user as UserProfile;
|
||||
return this.success(toUserProfileDto(user));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route Documentation Priority
|
||||
### BaseController Helpers
|
||||
|
||||
Document routes in this order of priority:
|
||||
The `BaseController` class provides standardized response formatting:
|
||||
|
||||
1. **Health Routes** - `/api/health/*` (public, critical for operations)
|
||||
2. **Auth Routes** - `/api/auth/*` (public, essential for integration)
|
||||
3. **Gamification Routes** - `/api/achievements/*` (simple, good example)
|
||||
4. **Flyer Routes** - `/api/flyers/*` (core functionality)
|
||||
5. **User Routes** - `/api/users/*` (common CRUD patterns)
|
||||
6. **Remaining Routes** - Budget, Recipe, Admin, etc.
|
||||
```typescript
|
||||
export abstract class BaseController extends Controller {
|
||||
// Success response with data
|
||||
protected success<T>(data: T): SuccessResponse<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
// Success with 201 Created status
|
||||
protected created<T>(data: T): SuccessResponse<T> {
|
||||
this.setStatus(201);
|
||||
return this.success(data);
|
||||
}
|
||||
|
||||
// Paginated response with metadata
|
||||
protected paginated<T>(data: T[], pagination: PaginationInput): PaginatedResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: { pagination: this.calculatePagination(pagination) },
|
||||
};
|
||||
}
|
||||
|
||||
// Message-only response
|
||||
protected message(message: string): SuccessResponse<{ message: string }> {
|
||||
return this.success({ message });
|
||||
}
|
||||
|
||||
// No content response (204)
|
||||
protected noContent(): void {
|
||||
this.setStatus(204);
|
||||
}
|
||||
|
||||
// Error response (prefer throwing errors instead)
|
||||
protected error(code: string, message: string, details?: unknown): ErrorResponse {
|
||||
return { success: false, error: { code, message, details } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
tsoa integrates with the existing passport-jwt strategy via a custom authentication module:
|
||||
|
||||
```typescript
|
||||
// src/middleware/tsoaAuthentication.ts
|
||||
export async function expressAuthentication(
|
||||
request: Request,
|
||||
securityName: string,
|
||||
_scopes?: string[],
|
||||
): Promise<UserProfile> {
|
||||
if (securityName !== 'bearerAuth') {
|
||||
throw new AuthenticationError(`Unknown security scheme: ${securityName}`);
|
||||
}
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const userProfile = await userRepo.findUserProfileById(decoded.user_id);
|
||||
|
||||
if (!userProfile) {
|
||||
throw new AuthenticationError('User not found');
|
||||
}
|
||||
|
||||
request.user = userProfile;
|
||||
return userProfile;
|
||||
}
|
||||
```
|
||||
|
||||
Usage in controllers:
|
||||
|
||||
```typescript
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: Express.Request): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTO Organization
|
||||
|
||||
Shared DTOs are defined in `src/dtos/common.dto.ts` to avoid duplicate type definitions across controllers:
|
||||
|
||||
```typescript
|
||||
// src/dtos/common.dto.ts
|
||||
|
||||
/**
|
||||
* Address with flattened coordinates (tsoa-compatible).
|
||||
* GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle.
|
||||
*/
|
||||
export interface AddressDto {
|
||||
address_id: number;
|
||||
address_line_1: string;
|
||||
city: string;
|
||||
province_state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
latitude?: number | null; // Flattened from GeoJSONPoint
|
||||
longitude?: number | null; // Flattened from GeoJSONPoint
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
user_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfileDto {
|
||||
full_name?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
points: number;
|
||||
user: UserDto;
|
||||
address?: AddressDto | null;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Swagger UI Setup
|
||||
|
||||
In `server.ts`, add the Swagger UI middleware (development/test only):
|
||||
In `server.ts`, the Swagger UI middleware serves the tsoa-generated spec:
|
||||
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
|
||||
|
||||
// Only serve Swagger UI in non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(tsoaSpec));
|
||||
|
||||
// Optionally expose raw JSON spec for tooling
|
||||
// Raw JSON spec for tooling
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
res.send(tsoaSpec);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Build Integration
|
||||
|
||||
tsoa spec and route generation is integrated into the build pipeline:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
|
||||
"build": "tsc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema Standardization
|
||||
|
||||
All API responses follow the standardized format from [ADR-028](./0028-api-response-standardization.md):
|
||||
@@ -160,107 +319,144 @@ All API responses follow the standardized format from [ADR-028](./0028-api-respo
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100,
|
||||
"totalPages": 5,
|
||||
"hasNextPage": true,
|
||||
"hasPrevPage": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable message"
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Define reusable schema components for these patterns:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* components:
|
||||
* schemas:
|
||||
* SuccessResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
|
||||
## API Route Tags
|
||||
|
||||
Organize endpoints using consistent tags:
|
||||
|
||||
| Tag | Description | Routes |
|
||||
| Tag | Description | Route Prefix |
|
||||
| ------------ | ---------------------------------- | --------------------- |
|
||||
| Health | Server health and readiness checks | `/api/health/*` |
|
||||
| Auth | Authentication and authorization | `/api/auth/*` |
|
||||
| Users | User profile management | `/api/users/*` |
|
||||
| Flyers | Flyer uploads and retrieval | `/api/flyers/*` |
|
||||
| Achievements | Gamification and leaderboards | `/api/achievements/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Deals | Deal search and management | `/api/deals/*` |
|
||||
| Stores | Store information | `/api/stores/*` |
|
||||
| Recipes | Recipe management | `/api/recipes/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Inventory | User inventory management | `/api/inventory/*` |
|
||||
| Gamification | Achievements and leaderboards | `/api/achievements/*` |
|
||||
| Admin | Administrative operations | `/api/admin/*` |
|
||||
| System | System status and monitoring | `/api/system/*` |
|
||||
|
||||
## Controller Inventory
|
||||
|
||||
The following controllers have been migrated to tsoa:
|
||||
|
||||
| Controller | Endpoints | Description |
|
||||
| ------------------------------- | --------- | ----------------------------------------- |
|
||||
| `health.controller.ts` | 10 | Health checks, probes, service status |
|
||||
| `auth.controller.ts` | 8 | Login, register, password reset, OAuth |
|
||||
| `user.controller.ts` | 30 | User profiles, preferences, notifications |
|
||||
| `admin.controller.ts` | 32 | System administration, user management |
|
||||
| `ai.controller.ts` | 15 | AI-powered extraction and analysis |
|
||||
| `flyer.controller.ts` | 12 | Flyer upload and management |
|
||||
| `store.controller.ts` | 8 | Store information |
|
||||
| `recipe.controller.ts` | 10 | Recipe CRUD and suggestions |
|
||||
| `upc.controller.ts` | 6 | UPC barcode lookups |
|
||||
| `inventory.controller.ts` | 8 | User inventory management |
|
||||
| `receipt.controller.ts` | 6 | Receipt processing |
|
||||
| `budget.controller.ts` | 8 | Budget tracking |
|
||||
| `category.controller.ts` | 4 | Category management |
|
||||
| `deals.controller.ts` | 8 | Deal search and discovery |
|
||||
| `stats.controller.ts` | 6 | Usage statistics |
|
||||
| `price.controller.ts` | 6 | Price history and tracking |
|
||||
| `system.controller.ts` | 4 | System status |
|
||||
| `gamification.controller.ts` | 10 | Achievements, leaderboards |
|
||||
| `personalization.controller.ts` | 6 | User recommendations |
|
||||
| `reactions.controller.ts` | 4 | Item reactions and ratings |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
4. **Rate Limiting**: Rate limiters are applied via `@Middlewares` decorator.
|
||||
|
||||
## Testing
|
||||
|
||||
Verify API documentation is correct by:
|
||||
|
||||
1. **Manual Review**: Navigate to `/docs/api-docs` and test each endpoint.
|
||||
2. **Spec Validation**: Use OpenAPI validators to check the generated spec.
|
||||
3. **Integration Tests**: Existing integration tests serve as implicit documentation verification.
|
||||
3. **Controller Tests**: Each controller has comprehensive test coverage (369 controller tests total).
|
||||
4. **Integration Tests**: 345 integration tests verify endpoint behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync.
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from the UI.
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation.
|
||||
- **Onboarding**: New developers can quickly understand the API surface.
|
||||
- **Low Overhead**: JSDoc annotations are minimal additions to existing code.
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript, eliminating duplicate definitions
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync
|
||||
- **Active Maintenance**: tsoa is actively maintained with regular releases
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from Swagger UI
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation
|
||||
- **Reduced Boilerplate**: tsoa generates Express routes automatically
|
||||
|
||||
### Negative
|
||||
|
||||
- **Maintenance Required**: Developers must update annotations when routes change.
|
||||
- **Build Dependency**: Adds `swagger-jsdoc` and `swagger-ui-express` packages.
|
||||
- **Initial Investment**: Existing routes need annotations added incrementally.
|
||||
- **Learning Curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Generated Code**: `tsoa-generated.ts` must be regenerated when controllers change
|
||||
- **Build Step**: Adds `tsoa spec && tsoa routes` to the build pipeline
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Include documentation checks in code review process.
|
||||
- Start with high-priority routes and expand coverage over time.
|
||||
- Use TypeScript types to reduce documentation duplication where possible.
|
||||
- **Migration Guide**: Created comprehensive TSOA-MIGRATION-GUIDE.md for developers
|
||||
- **BaseController**: Provides familiar response helpers matching existing patterns
|
||||
- **Incremental Adoption**: Existing Express routes continue to work alongside tsoa controllers
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/swagger.ts` - OpenAPI configuration
|
||||
- `src/routes/*.ts` - Route files with JSDoc annotations
|
||||
- `server.ts` - Swagger UI middleware setup
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with response utilities |
|
||||
| `src/controllers/types.ts` | Shared controller type definitions |
|
||||
| `src/controllers/*.controller.ts` | Individual domain controllers |
|
||||
| `src/dtos/common.dto.ts` | Shared DTO definitions |
|
||||
| `src/middleware/tsoaAuthentication.ts` | JWT authentication handler |
|
||||
| `src/routes/tsoa-generated.ts` | tsoa-generated Express routes |
|
||||
| `src/config/tsoa-spec.json` | Generated OpenAPI 3.0 spec |
|
||||
| `server.ts` | Swagger UI middleware setup |
|
||||
|
||||
## Migration History
|
||||
|
||||
| Date | Change |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| 2025-12-12 | Initial ADR created with swagger-jsdoc approach |
|
||||
| 2026-01-11 | Began implementation with swagger-jsdoc |
|
||||
| 2026-02-12 | Completed migration to tsoa, superseding swagger-jsdoc approach |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-059](./0059-dependency-modernization.md) - Dependency Modernization (tsoa migration plan)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod schemas)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening
|
||||
- [ADR-048](./0048-authentication-strategy.md) - Authentication Strategy
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Supersedes**: [ADR-013](./0013-database-schema-migration-strategy.md)
|
||||
|
||||
## Context
|
||||
|
||||
The `README.md` indicates that the database schema is managed by manually running a large `schema.sql.txt` file. This approach is highly error-prone, makes tracking changes difficult, and is not feasible for updating a live production database without downtime or data loss.
|
||||
|
||||
@@ -1,18 +1,333 @@
|
||||
# ADR-024: Feature Flagging Strategy
|
||||
|
||||
**Date**: 2025-12-12
|
||||
**Status**: Accepted
|
||||
**Implemented**: 2026-01-28
|
||||
**Implementation Plan**: [2026-01-28-adr-024-feature-flags-implementation.md](../plans/2026-01-28-adr-024-feature-flags-implementation.md)
|
||||
|
||||
**Status**: Proposed
|
||||
## Implementation Summary
|
||||
|
||||
Feature flag infrastructure fully implemented with 89 new tests (all passing). Total test suite: 3,616 tests passing.
|
||||
|
||||
**Backend**:
|
||||
|
||||
- Zod-validated schema in `src/config/env.ts` with 6 feature flags
|
||||
- Service module `src/services/featureFlags.server.ts` with `isFeatureEnabled()`, `getFeatureFlags()`, `getEnabledFeatureFlags()`
|
||||
- Admin endpoint `GET /api/v1/admin/feature-flags` (requires admin authentication)
|
||||
- Convenience exports for direct boolean access
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- Config section in `src/config.ts` with `VITE_FEATURE_*` environment variables
|
||||
- Type declarations in `src/vite-env.d.ts`
|
||||
- React hook `useFeatureFlag()` and `useAllFeatureFlags()` in `src/hooks/useFeatureFlag.ts`
|
||||
- Declarative component `<FeatureFlag>` in `src/components/FeatureFlag.tsx`
|
||||
|
||||
**Current Flags**: `bugsinkSync`, `advancedRbac`, `newDashboard`, `betaRecipes`, `experimentalAi`, `debugMode`
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
As the application grows, there is no way to roll out new features to a subset of users (e.g., for beta testing) or to quickly disable a problematic feature in production without a full redeployment.
|
||||
Application lacks controlled feature rollout capability. No mechanism for beta testing, quick production disablement, or gradual rollouts without full redeployment. Need type-safe, configuration-based system integrating with ADR-007 Zod validation.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a feature flagging system. This could start with a simple configuration-based approach (defined in `ADR-007`) and evolve to use a dedicated service like **Flagsmith** or **LaunchDarkly**. This ADR will define how feature flags are created, managed, and checked in both the backend and frontend code.
|
||||
Implement environment-variable-based feature flag system. Backend: Zod-validated schema in `src/config/env.ts` + dedicated service. Frontend: Vite env vars + React hook + declarative component. All flags default `false` (opt-in model). Future migration path to Flagsmith/LaunchDarkly preserved via abstraction layer.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Decouples feature releases from code deployments, reducing risk and allowing for more controlled, gradual rollouts and A/B testing. Enables easier experimentation and faster iteration.
|
||||
**Negative**: Adds complexity to the codebase with conditional logic around features. Requires careful management of feature flag states to avoid technical debt.
|
||||
- **Positive**: Decouples releases from deployments → reduced risk, gradual rollouts, A/B testing capability
|
||||
- **Negative**: Conditional logic complexity → requires sunset policy (3-month max after full rollout)
|
||||
- **Neutral**: Restart required for flag changes (acceptable for current scale, external service removes this constraint)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```text
|
||||
Environment Variables (FEATURE_*, VITE_FEATURE_*)
|
||||
│
|
||||
├── Backend ──► src/config/env.ts (Zod) ──► src/services/featureFlags.server.ts
|
||||
│ │
|
||||
│ ┌──────────┴──────────┐
|
||||
│ │ │
|
||||
│ isFeatureEnabled() getAllFeatureFlags()
|
||||
│ │
|
||||
│ Routes/Services
|
||||
│
|
||||
└── Frontend ─► src/config.ts ──► src/hooks/useFeatureFlag.ts
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
useFeatureFlag() useAllFeatureFlags() <FeatureFlag>
|
||||
│ Component
|
||||
Components
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
| File | Purpose | Layer |
|
||||
| ------------------------------------- | ------------------------ | ---------------- |
|
||||
| `src/config/env.ts` | Zod schema + env loading | Backend config |
|
||||
| `src/services/featureFlags.server.ts` | Flag access service | Backend runtime |
|
||||
| `src/config.ts` | Vite env parsing | Frontend config |
|
||||
| `src/vite-env.d.ts` | TypeScript declarations | Frontend types |
|
||||
| `src/hooks/useFeatureFlag.ts` | React hook | Frontend runtime |
|
||||
| `src/components/FeatureFlag.tsx` | Declarative wrapper | Frontend UI |
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Context | Pattern | Example |
|
||||
| ------------------- | ------------------------- | ---------------------------------- |
|
||||
| Backend env var | `FEATURE_SNAKE_CASE` | `FEATURE_NEW_DASHBOARD` |
|
||||
| Frontend env var | `VITE_FEATURE_SNAKE_CASE` | `VITE_FEATURE_NEW_DASHBOARD` |
|
||||
| Config property | `camelCase` | `config.featureFlags.newDashboard` |
|
||||
| Hook/function param | `camelCase` literal | `isFeatureEnabled('newDashboard')` |
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
#### Schema Definition (`src/config/env.ts`)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Feature flags schema (ADR-024).
|
||||
* All flags default false (disabled) for safety.
|
||||
*/
|
||||
const featureFlagsSchema = z.object({
|
||||
newDashboard: booleanString(false), // FEATURE_NEW_DASHBOARD
|
||||
betaRecipes: booleanString(false), // FEATURE_BETA_RECIPES
|
||||
experimentalAi: booleanString(false), // FEATURE_EXPERIMENTAL_AI
|
||||
debugMode: booleanString(false), // FEATURE_DEBUG_MODE
|
||||
});
|
||||
|
||||
// In loadEnvVars():
|
||||
featureFlags: {
|
||||
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
|
||||
betaRecipes: process.env.FEATURE_BETA_RECIPES,
|
||||
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
|
||||
debugMode: process.env.FEATURE_DEBUG_MODE,
|
||||
},
|
||||
```
|
||||
|
||||
#### Service Module (`src/services/featureFlags.server.ts`)
|
||||
|
||||
```typescript
|
||||
import { config, isDevelopment } from '../config/env';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
export type FeatureFlagName = keyof typeof config.featureFlags;
|
||||
|
||||
/**
|
||||
* Check feature flag state. Logs in development mode.
|
||||
*/
|
||||
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
|
||||
const enabled = config.featureFlags[flagName];
|
||||
if (isDevelopment) {
|
||||
logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all flags (admin/debug endpoints).
|
||||
*/
|
||||
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
|
||||
return { ...config.featureFlags };
|
||||
}
|
||||
|
||||
// Convenience exports (evaluated once at startup)
|
||||
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
|
||||
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
|
||||
```
|
||||
|
||||
#### Usage in Routes
|
||||
|
||||
```typescript
|
||||
import { isFeatureEnabled } from '../services/featureFlags.server';
|
||||
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
if (isFeatureEnabled('newDashboard')) {
|
||||
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
|
||||
}
|
||||
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
#### Config (`src/config.ts`)
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
// ... existing sections ...
|
||||
|
||||
featureFlags: {
|
||||
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
|
||||
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
|
||||
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
|
||||
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### Type Declarations (`src/vite-env.d.ts`)
|
||||
|
||||
```typescript
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
|
||||
readonly VITE_FEATURE_BETA_RECIPES?: string;
|
||||
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
|
||||
readonly VITE_FEATURE_DEBUG_MODE?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### React Hook (`src/hooks/useFeatureFlag.ts`)
|
||||
|
||||
```typescript
|
||||
import { useMemo } from 'react';
|
||||
import config from '../config';
|
||||
|
||||
export type FeatureFlagName = keyof typeof config.featureFlags;
|
||||
|
||||
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
|
||||
return useMemo(() => config.featureFlags[flagName], [flagName]);
|
||||
}
|
||||
|
||||
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
|
||||
return useMemo(() => ({ ...config.featureFlags }), []);
|
||||
}
|
||||
```
|
||||
|
||||
#### Declarative Component (`src/components/FeatureFlag.tsx`)
|
||||
|
||||
```typescript
|
||||
import { ReactNode } from 'react';
|
||||
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';
|
||||
|
||||
interface FeatureFlagProps {
|
||||
name: FeatureFlagName;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
|
||||
const isEnabled = useFeatureFlag(name);
|
||||
return <>{isEnabled ? children : fallback}</>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage in Components
|
||||
|
||||
```tsx
|
||||
// Declarative approach
|
||||
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
|
||||
<NewDashboard />
|
||||
</FeatureFlag>;
|
||||
|
||||
// Hook approach (for logic beyond rendering)
|
||||
const isNewDashboard = useFeatureFlag('newDashboard');
|
||||
useEffect(() => {
|
||||
if (isNewDashboard) analytics.track('new_dashboard_viewed');
|
||||
}, [isNewDashboard]);
|
||||
```
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
#### Backend Test Setup
|
||||
|
||||
```typescript
|
||||
// Reset modules to test different flag states
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.FEATURE_NEW_DASHBOARD = 'true';
|
||||
});
|
||||
|
||||
// src/services/featureFlags.server.test.ts
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('returns false for disabled flags', () => {
|
||||
expect(isFeatureEnabled('newDashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Frontend Test Setup
|
||||
|
||||
```typescript
|
||||
// Mock config module
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
featureFlags: {
|
||||
newDashboard: true,
|
||||
betaRecipes: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Component test
|
||||
describe('FeatureFlag', () => {
|
||||
it('renders fallback when disabled', () => {
|
||||
render(
|
||||
<FeatureFlag name="betaRecipes" fallback={<div>Old</div>}>
|
||||
<div>New</div>
|
||||
</FeatureFlag>
|
||||
);
|
||||
expect(screen.getByText('Old')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Flag Lifecycle
|
||||
|
||||
| Phase | Actions |
|
||||
| ---------- | -------------------------------------------------------------------------------------------- |
|
||||
| **Add** | 1. Add to both schemas (backend + frontend) 2. Default `false` 3. Document in `.env.example` |
|
||||
| **Enable** | Set env var `='true'` → restart application |
|
||||
| **Remove** | 1. Remove conditional code 2. Remove from schemas 3. Remove env vars |
|
||||
| **Sunset** | Max 3 months after full rollout → remove flag |
|
||||
|
||||
### Admin Endpoint (Optional)
|
||||
|
||||
```typescript
|
||||
// GET /api/v1/admin/feature-flags (admin-only)
|
||||
router.get('/feature-flags', requireAdmin, async (req, res) => {
|
||||
sendSuccess(res, { flags: getAllFeatureFlags() });
|
||||
});
|
||||
```
|
||||
|
||||
### Integration with ADR-007
|
||||
|
||||
Feature flags extend existing Zod configuration pattern:
|
||||
|
||||
- **Validation**: Same `booleanString()` transform used by other config
|
||||
- **Loading**: Same `loadEnvVars()` function loads `FEATURE_*` vars
|
||||
- **Type Safety**: `FeatureFlagName` type derived from config schema
|
||||
- **Fail-Fast**: Invalid flag values fail at startup (Zod validation)
|
||||
|
||||
### Future Migration Path
|
||||
|
||||
Current implementation abstracts flag access via `isFeatureEnabled()` function and `useFeatureFlag()` hook. External service migration requires:
|
||||
|
||||
1. Replace implementation internals of these functions
|
||||
2. Add API client for Flagsmith/LaunchDarkly
|
||||
3. No changes to consuming code (routes/components)
|
||||
|
||||
### Explicitly Out of Scope
|
||||
|
||||
- External service integration (Flagsmith/LaunchDarkly)
|
||||
- Database-stored flags
|
||||
- Real-time flag updates (WebSocket/SSE)
|
||||
- User-specific flags (A/B testing percentages)
|
||||
- Flag inheritance/hierarchy
|
||||
- Flag audit logging
|
||||
|
||||
### Key Files Reference
|
||||
|
||||
| Action | Files |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Add new flag | `src/config/env.ts`, `src/config.ts`, `src/vite-env.d.ts`, `.env.example` |
|
||||
| Check flag (backend) | Import from `src/services/featureFlags.server.ts` |
|
||||
| Check flag (frontend) | Import hook from `src/hooks/useFeatureFlag.ts` or component from `src/components/FeatureFlag.tsx` |
|
||||
| Test flag behavior | Mock via `vi.resetModules()` (backend) or `vi.mock('../config')` (frontend) |
|
||||
|
||||
@@ -195,6 +195,12 @@ Do NOT add tests:
|
||||
- Coverage percentages may not satisfy external audits
|
||||
- Requires judgment calls that may be inconsistent
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy and Standards (this ADR extends ADR-010)
|
||||
- [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
|
||||
|
||||
## Key Files
|
||||
|
||||
- `docs/adr/0010-testing-strategy-and-standards.md` - Testing mechanics
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Related**: [ADR-015](0015-application-performance-monitoring-and-error-tracking.md), [ADR-004](0004-standardized-application-wide-structured-logging.md)
|
||||
**Related**: [ADR-015](0015-error-tracking-and-observability.md), [ADR-004](0004-standardized-application-wide-structured-logging.md)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -335,7 +335,7 @@ SELECT award_achievement('user-uuid', 'Nonexistent Badge');
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-015: Application Performance Monitoring](0015-application-performance-monitoring-and-error-tracking.md)
|
||||
- [ADR-015: Error Tracking and Observability](0015-error-tracking-and-observability.md)
|
||||
- [ADR-004: Standardized Structured Logging](0004-standardized-application-wide-structured-logging.md)
|
||||
- [PostgreSQL RAISE Documentation](https://www.postgresql.org/docs/current/plpgsql-errors-and-messages.html)
|
||||
- [PostgreSQL Logging Configuration](https://www.postgresql.org/docs/current/runtime-config-logging.html)
|
||||
|
||||
@@ -332,6 +332,6 @@ Response:
|
||||
## References
|
||||
|
||||
- [ADR-006: Background Job Processing](./0006-background-job-processing-and-task-queues.md)
|
||||
- [ADR-015: Application Performance Monitoring](./0015-application-performance-monitoring-and-error-tracking.md)
|
||||
- [ADR-015: Error Tracking and Observability](./0015-error-tracking-and-observability.md)
|
||||
- [Bugsink API Documentation](https://bugsink.com/docs/api/)
|
||||
- [Gitea API Documentation](https://docs.gitea.io/en-us/api-usage/)
|
||||
|
||||
517
docs/adr/0058-browser-test-performance-optimization.md
Normal file
517
docs/adr/0058-browser-test-performance-optimization.md
Normal file
@@ -0,0 +1,517 @@
|
||||
# ADR-0042: Browser Test Performance Optimization
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-10
|
||||
**Authors**: Claude Code AI Agent
|
||||
|
||||
## Context
|
||||
|
||||
### Current State
|
||||
|
||||
The stock-alert project has 64 Playwright browser tests across 5 spec files taking approximately 240 seconds (~4 minutes) to execute. Analysis reveals three major performance bottlenecks:
|
||||
|
||||
| Metric | Count | Impact |
|
||||
| ----------------------------------- | ----- | -------------------------------------------- |
|
||||
| Hardcoded `waitForTimeout()` calls | 66 | ~120s cumulative wait time |
|
||||
| Redundant login calls per test | 43 | ~2-3s each = 86-129s overhead |
|
||||
| Visual regression tests blocking CI | 4 | Cannot run in parallel with functional tests |
|
||||
|
||||
### Test Distribution
|
||||
|
||||
| File | Tests | `waitForTimeout` Calls | `login()` Calls |
|
||||
| ------------------- | ------ | ---------------------- | ------------------------ |
|
||||
| `dashboard.spec.js` | 10 | 8 | 10 |
|
||||
| `alerts.spec.js` | 14 | 25 | 1 (beforeEach) |
|
||||
| `gaps.spec.js` | 20 | 29 | 1 (beforeEach) |
|
||||
| `login.spec.js` | 11 | 4 | 0 (tests login itself) |
|
||||
| `visual.spec.js` | 4 | 0 | 4 (via navigateWithAuth) |
|
||||
| **Total** | **59** | **66** | **16 patterns** |
|
||||
|
||||
### Root Causes
|
||||
|
||||
1. **Anti-Pattern: Hardcoded Timeouts**
|
||||
- `waitForTimeout(2000)` used to "wait for data to load"
|
||||
- Unnecessarily slow on fast systems, flaky on slow systems
|
||||
- No correlation to actual page readiness
|
||||
|
||||
2. **Anti-Pattern: Per-Test Authentication**
|
||||
- Each test navigates to `/login`, enters password, submits
|
||||
- Session cookie persists across requests but not across tests
|
||||
- `beforeEach` login adds 2-3 seconds per test
|
||||
|
||||
3. **Architecture: Mixed Test Types**
|
||||
- Visual regression tests require different infrastructure (baseline images)
|
||||
- Functional tests and visual tests compete for worker slots
|
||||
- Cannot optimize CI parallelization
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Reduce test suite runtime by 40-55%
|
||||
2. Improve test determinism (eliminate flakiness)
|
||||
3. Maintain test coverage and reliability
|
||||
4. Enable parallel CI execution where possible
|
||||
5. Document patterns for other projects
|
||||
|
||||
## Decision
|
||||
|
||||
Implement three optimization phases:
|
||||
|
||||
### Phase 1: Event-Based Wait Replacement (Primary Impact: ~50% of time savings)
|
||||
|
||||
Replace all 66 `waitForTimeout()` calls with Playwright's event-based waiting APIs.
|
||||
|
||||
**Replacement Patterns:**
|
||||
|
||||
| Current Pattern | Replacement | Rationale |
|
||||
| --------------------------------------- | ------------------------------------------------- | ----------------------------- |
|
||||
| `waitForTimeout(2000)` after navigation | `waitForLoadState('networkidle')` | Waits for network quiescence |
|
||||
| `waitForTimeout(1000)` after click | `waitForSelector('.result')` | Waits for specific DOM change |
|
||||
| `waitForTimeout(3000)` for charts | `waitForSelector('canvas', { state: 'visible' })` | Waits for chart render |
|
||||
| `waitForTimeout(500)` for viewport | `waitForFunction(() => ...)` | Waits for layout reflow |
|
||||
|
||||
**Implementation Examples:**
|
||||
|
||||
```javascript
|
||||
// BEFORE: Hardcoded timeout
|
||||
await page.goto('/alerts');
|
||||
await page.waitForTimeout(2000);
|
||||
const rows = await page.locator('tbody tr').count();
|
||||
|
||||
// AFTER: Event-based wait
|
||||
await page.goto('/alerts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('tbody tr', { state: 'attached' });
|
||||
const rows = await page.locator('tbody tr').count();
|
||||
```
|
||||
|
||||
```javascript
|
||||
// BEFORE: Hardcoded timeout after action
|
||||
await page.click('#runCheckBtn');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// AFTER: Wait for response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/check')),
|
||||
page.click('#runCheckBtn'),
|
||||
]);
|
||||
```
|
||||
|
||||
**Helper Function Addition to `helpers.js`:**
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Waits for page to be fully loaded with data.
|
||||
* Replaces hardcoded waitForTimeout calls.
|
||||
*/
|
||||
async function waitForPageReady(page, options = {}) {
|
||||
const { dataSelector = null, networkIdle = true, minTime = 0 } = options;
|
||||
|
||||
const promises = [];
|
||||
|
||||
if (networkIdle) {
|
||||
promises.push(page.waitForLoadState('networkidle'));
|
||||
}
|
||||
|
||||
if (dataSelector) {
|
||||
promises.push(page.waitForSelector(dataSelector, { state: 'visible' }));
|
||||
}
|
||||
|
||||
if (minTime > 0) {
|
||||
promises.push(page.waitForTimeout(minTime)); // Escape hatch for animations
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
```
|
||||
|
||||
**Estimated Time Savings:** 60-80 seconds (eliminates ~120s of cumulative waits, but event waits have overhead)
|
||||
|
||||
### Phase 2: Global Authentication Setup (Primary Impact: ~35% of time savings)
|
||||
|
||||
Share authenticated session across all tests using Playwright's global setup feature.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ global-setup.js │
|
||||
│ │
|
||||
│ 1. Login once │
|
||||
│ 2. Save storage │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌──────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ dashboard.spec │ │ alerts.spec │ │ gaps.spec │
|
||||
│ (reuses auth) │ │ (reuses auth) │ │ (reuses auth) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Implementation Files:**
|
||||
|
||||
**`tests/browser/global-setup.js`:**
|
||||
|
||||
```javascript
|
||||
const { chromium } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
|
||||
const authFile = path.join(__dirname, '.auth', 'user.json');
|
||||
|
||||
module.exports = async function globalSetup() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Only perform login if authentication is enabled
|
||||
if (process.env.DASHBOARD_PASSWORD) {
|
||||
await page.goto(process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8980');
|
||||
|
||||
// Perform login
|
||||
await page.goto('/login');
|
||||
await page.fill('#password', process.env.DASHBOARD_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: authFile });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
```
|
||||
|
||||
**`playwright.config.js` Updates:**
|
||||
|
||||
```javascript
|
||||
module.exports = defineConfig({
|
||||
// ... existing config ...
|
||||
|
||||
// Global setup runs once before all tests
|
||||
globalSetup: require.resolve('./tests/browser/global-setup.js'),
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
// Reuse authentication state from global setup
|
||||
storageState: './tests/browser/.auth/user.json',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Test File Updates:**
|
||||
|
||||
```javascript
|
||||
// BEFORE: Login in beforeEach
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.consoleErrors = captureConsoleErrors(page);
|
||||
if (isAuthEnabled()) {
|
||||
await login(page);
|
||||
}
|
||||
});
|
||||
|
||||
// AFTER: Remove login (handled by global setup)
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.consoleErrors = captureConsoleErrors(page);
|
||||
// Authentication already applied via storageState
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated Time Savings:** 80-100 seconds (43 logins x ~2-3s each, minus 3s for global setup)
|
||||
|
||||
### Phase 3: Visual Test Separation (Primary Impact: CI parallelization)
|
||||
|
||||
Separate visual regression tests into a dedicated project for parallel CI execution.
|
||||
|
||||
**Project Configuration:**
|
||||
|
||||
```javascript
|
||||
// playwright.config.js
|
||||
module.exports = defineConfig({
|
||||
projects: [
|
||||
// Functional tests - fast, event-based
|
||||
{
|
||||
name: 'functional',
|
||||
testMatch: /^(?!.*visual).*\.spec\.js$/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: './tests/browser/.auth/user.json',
|
||||
},
|
||||
},
|
||||
// Visual tests - separate baseline management
|
||||
{
|
||||
name: 'visual',
|
||||
testMatch: '**/visual.spec.js',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: './tests/browser/.auth/user.json',
|
||||
},
|
||||
// Different snapshot handling
|
||||
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**CI Pipeline Updates:**
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/test.yml
|
||||
jobs:
|
||||
browser-functional:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: npx playwright test --project=functional
|
||||
|
||||
browser-visual:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: npx playwright test --project=visual
|
||||
```
|
||||
|
||||
**Estimated Time Savings:** 30-45 seconds (parallel execution vs sequential)
|
||||
|
||||
## Implementation Schedule
|
||||
|
||||
### Critical Path (Estimated 8-12 hours)
|
||||
|
||||
```
|
||||
Phase 1 (Event Waits) ████████████████ [4-6h]
|
||||
│
|
||||
Phase 2 (Global Auth) ████████ [2-3h]
|
||||
│
|
||||
Phase 3 (Visual Separation) ████ [2-3h]
|
||||
```
|
||||
|
||||
### Effort Summary
|
||||
|
||||
| Phase | Min Hours | Max Hours | Expected Savings |
|
||||
| ------------------------ | --------- | --------- | --------------------- |
|
||||
| 1. Event-Based Waits | 4 | 6 | 60-80s (25-33%) |
|
||||
| 2. Global Authentication | 2 | 3 | 80-100s (33-42%) |
|
||||
| 3. Visual Separation | 2 | 3 | 30-45s (CI parallel) |
|
||||
| **Total** | **8** | **12** | **170-225s (70-94%)** |
|
||||
|
||||
### Expected Results
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
| ------------------ | ------ | --------- | ------------- |
|
||||
| Total Runtime | 240s | 110-140s | 42-54% faster |
|
||||
| Flaky Test Rate | ~5% | <1% | 80% reduction |
|
||||
| CI Parallelization | None | 2 workers | 2x throughput |
|
||||
| Login Operations | 43 | 1 | 98% reduction |
|
||||
| Hardcoded Waits | 66 | <5 | 92% reduction |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Performance**: 40-55% reduction in test runtime
|
||||
2. **Reliability**: Event-based waits eliminate timing flakiness
|
||||
3. **Scalability**: Global setup pattern scales to N tests with O(1) login cost
|
||||
4. **CI Efficiency**: Parallel visual tests enable faster feedback loops
|
||||
5. **Maintainability**: Centralized auth logic reduces code duplication
|
||||
6. **Transferable Knowledge**: Patterns applicable to any Playwright project
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Initial Migration Effort**: 8-12 hours of refactoring
|
||||
2. **Learning Curve**: Team must understand Playwright wait APIs
|
||||
3. **Global Setup Complexity**: Adds shared state between tests
|
||||
4. **Debugging Harder**: Shared auth can mask test isolation issues
|
||||
|
||||
### Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| Global setup fails | Add retry logic; fallback to per-test login |
|
||||
| Event waits flaky | Keep small timeout buffer (100ms) as escape hatch |
|
||||
| Visual tests drift | Separate baseline management per environment |
|
||||
| Test isolation | Run `--project=functional` without auth for isolation testing |
|
||||
|
||||
### Neutral
|
||||
|
||||
- Test count unchanged (59 tests)
|
||||
- Coverage unchanged
|
||||
- Visual baselines unchanged (path changes only)
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Reduce Test Count
|
||||
|
||||
**Rejected:** Sacrifices coverage for speed. Tests exist for a reason.
|
||||
|
||||
### Alternative 2: Increase Worker Parallelism
|
||||
|
||||
**Rejected:** Server cannot handle >2 concurrent sessions reliably; creates resource contention.
|
||||
|
||||
### Alternative 3: Use `page.waitForTimeout()` with Shorter Durations
|
||||
|
||||
**Rejected:** Addresses symptom, not root cause. Still creates timing-dependent tests.
|
||||
|
||||
### Alternative 4: Cookie Injection Instead of Login
|
||||
|
||||
**Rejected:** Requires reverse-engineering session format; brittle if auth changes.
|
||||
|
||||
### Alternative 5: HTTP API Authentication (No Browser)
|
||||
|
||||
**Rejected:** Loses browser session behavior validation; tests login flow.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Wait Replacement Mapping
|
||||
|
||||
| File | Current Timeouts | Replacement Strategy |
|
||||
| ------------------- | ---------------------- | ---------------------------------------------------------------------- |
|
||||
| `dashboard.spec.js` | 1000ms, 2000ms, 3000ms | `waitForSelector` for charts, `waitForLoadState` for navigation |
|
||||
| `alerts.spec.js` | 500ms, 1000ms, 2000ms | `waitForResponse` for API calls, `waitForSelector` for table rows |
|
||||
| `gaps.spec.js` | 500ms, 1000ms, 2000ms | `waitForResponse` for `/api/gaps`, `waitForSelector` for summary cards |
|
||||
| `login.spec.js` | 500ms, 2000ms | `waitForURL` for redirects, `waitForSelector` for error messages |
|
||||
|
||||
### Common Wait Patterns for This Codebase
|
||||
|
||||
| Scenario | Recommended Pattern | Example |
|
||||
| --------------------- | ------------------------------------------------- | ------------------------- |
|
||||
| After page navigation | `waitForLoadState('networkidle')` | Loading dashboard data |
|
||||
| After button click | `waitForResponse()` + `waitForSelector()` | Run Check button |
|
||||
| After filter change | `waitForResponse(/api\/.*/)` | Status filter dropdown |
|
||||
| For chart rendering | `waitForSelector('canvas', { state: 'visible' })` | Chart cards |
|
||||
| For modal appearance | `waitForSelector('.modal', { state: 'visible' })` | Confirmation dialogs |
|
||||
| For layout change | `waitForFunction()` | Responsive viewport tests |
|
||||
|
||||
### Auth Storage Structure
|
||||
|
||||
```
|
||||
tests/browser/
|
||||
├── .auth/
|
||||
│ └── user.json # Generated by global-setup, gitignored
|
||||
├── global-setup.js # Creates user.json
|
||||
├── dashboard.spec.js # Uses storageState
|
||||
├── alerts.spec.js
|
||||
├── gaps.spec.js
|
||||
├── login.spec.js # Tests login itself, may need special handling
|
||||
└── visual.spec.js
|
||||
```
|
||||
|
||||
**`.gitignore` Addition:**
|
||||
|
||||
```
|
||||
tests/browser/.auth/
|
||||
```
|
||||
|
||||
### Login.spec.js Special Handling
|
||||
|
||||
`login.spec.js` tests the login flow itself and must NOT use the shared auth state:
|
||||
|
||||
```javascript
|
||||
// playwright.config.js
|
||||
projects: [
|
||||
{
|
||||
name: 'functional',
|
||||
testMatch: /^(?!.*login).*\.spec\.js$/,
|
||||
use: { storageState: './tests/browser/.auth/user.json' },
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
testMatch: '**/login.spec.js',
|
||||
use: { storageState: undefined }, // No auth - tests login flow
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Testing the Optimization
|
||||
|
||||
### Baseline Measurement
|
||||
|
||||
```bash
|
||||
# Before optimization: establish baseline
|
||||
time npm run test:browser 2>&1 | tee baseline-timing.log
|
||||
grep -E "passed|failed|skipped" baseline-timing.log
|
||||
```
|
||||
|
||||
### Incremental Verification
|
||||
|
||||
```bash
|
||||
# After Phase 1: verify wait replacement
|
||||
npm run test:browser -- --reporter=list 2>&1 | grep -E "passed|failed|slow"
|
||||
|
||||
# After Phase 2: verify global auth
|
||||
npm run test:browser -- --trace on
|
||||
# Check trace for login occurrences (should be 1)
|
||||
|
||||
# After Phase 3: verify parallel execution
|
||||
npm run test:browser -- --project=functional &
|
||||
npm run test:browser -- --project=visual &
|
||||
wait
|
||||
```
|
||||
|
||||
### Success Criteria
|
||||
|
||||
| Metric | Target | Measurement |
|
||||
| ---------------------- | ------ | -------------------------------------- |
|
||||
| Total runtime | <150s | `time npm run test:browser` |
|
||||
| Login count | 1 | Grep traces for `/login` navigation |
|
||||
| Flaky rate | <2% | 50 consecutive CI runs |
|
||||
| `waitForTimeout` count | <5 | `grep -r waitForTimeout tests/browser` |
|
||||
|
||||
## Lessons Learned / Patterns for Other Projects
|
||||
|
||||
### Pattern 1: Always Prefer Event-Based Waits
|
||||
|
||||
```javascript
|
||||
// Bad
|
||||
await page.click('#submit');
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.title()).toBe('Success');
|
||||
|
||||
// Good
|
||||
await Promise.all([page.waitForNavigation(), page.click('#submit')]);
|
||||
expect(await page.title()).toBe('Success');
|
||||
```
|
||||
|
||||
### Pattern 2: Global Setup for Authentication
|
||||
|
||||
Playwright's `storageState` feature should be the default for any authenticated app:
|
||||
|
||||
1. Create `global-setup.js` that performs login once
|
||||
2. Save cookies/storage to JSON file
|
||||
3. Configure `storageState` in `playwright.config.js`
|
||||
4. Tests start authenticated with zero overhead
|
||||
|
||||
### Pattern 3: Separate Test Types by Execution Characteristics
|
||||
|
||||
| Test Type | Characteristics | Strategy |
|
||||
| ---------- | ------------------------ | --------------------------------- |
|
||||
| Functional | Fast, deterministic | Run first, gate deployment |
|
||||
| Visual | Slow, baseline-dependent | Run in parallel, separate project |
|
||||
| E2E | Cross-service, slow | Run nightly, separate workflow |
|
||||
|
||||
### Pattern 4: Measure Before and After
|
||||
|
||||
Always establish baseline metrics before optimization:
|
||||
|
||||
```bash
|
||||
# Essential metrics to capture
|
||||
time npm run test:browser # Total runtime
|
||||
grep -c waitForTimeout *.js # Hardcoded wait count
|
||||
grep -c 'await login' *.js # Login call count
|
||||
```
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-0031](0031-quality-gates-eslint-playwright.md): Quality Gates - ESLint, Pre-commit Hooks, and Playwright Browser Testing
|
||||
- [ADR-0035](0035-browser-test-selector-fixes.md): Browser Test Selector Fixes
|
||||
- [ADR-0008](0008-testing-strategy.md): Testing Strategy
|
||||
|
||||
## References
|
||||
|
||||
- Playwright Best Practices: https://playwright.dev/docs/best-practices
|
||||
- Playwright Authentication: https://playwright.dev/docs/auth
|
||||
- Playwright Wait Strategies: https://playwright.dev/docs/actionability
|
||||
- Test Files: `tests/browser/*.spec.js`
|
||||
- Helper Module: `tests/browser/helpers.js`
|
||||
- Configuration: `playwright.config.js`
|
||||
308
docs/adr/0059-dependency-modernization.md
Normal file
308
docs/adr/0059-dependency-modernization.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# ADR-059: Dependency Modernization Plan
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-12
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
NPM audit and security scanning identified deprecated dependencies requiring modernization:
|
||||
|
||||
| Dependency | Current | Issue | Replacement |
|
||||
| --------------- | ------- | ----------------------- | --------------------------------------- |
|
||||
| `swagger-jsdoc` | 6.2.8 | Unmaintained since 2022 | `tsoa` (decorator-based OpenAPI) |
|
||||
| `rimraf` | 6.1.2 | Legacy cleanup utility | Node.js `fs.rm()` (native since v14.14) |
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- Existing `@openapi` JSDoc annotations in 20 route files
|
||||
- ADR-018 compliance (API documentation strategy)
|
||||
- Zero-downtime migration (phased approach)
|
||||
- Must maintain Express 5.x compatibility
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. swagger-jsdoc → tsoa Migration
|
||||
|
||||
**Architecture**: tsoa controller classes + Express integration (no replacement of Express routing layer).
|
||||
|
||||
```text
|
||||
Current: Route Files → JSDoc Annotations → swagger-jsdoc → OpenAPI Spec
|
||||
Future: Controller Classes → @Route/@Get decorators → tsoa → OpenAPI Spec + Route Registration
|
||||
```
|
||||
|
||||
**Controller Pattern**: Base controller providing common utilities:
|
||||
|
||||
```typescript
|
||||
// src/controllers/base.controller.ts
|
||||
export abstract class BaseController {
|
||||
protected sendSuccess<T>(res: Response, data: T, status = 200) {
|
||||
return sendSuccess(res, data, status);
|
||||
}
|
||||
protected sendError(
|
||||
res: Response,
|
||||
code: ErrorCode,
|
||||
msg: string,
|
||||
status: number,
|
||||
details?: unknown,
|
||||
) {
|
||||
return sendError(res, code, msg, status, details);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Express Integration Strategy**: tsoa generates routes.ts; wrap with Express middleware pipeline:
|
||||
|
||||
```typescript
|
||||
// server.ts integration
|
||||
import { RegisterRoutes } from './src/generated/routes';
|
||||
RegisterRoutes(app); // tsoa registers routes with existing Express app
|
||||
```
|
||||
|
||||
### 2. rimraf → fs.rm() Migration
|
||||
|
||||
**Change**: Replace `rimraf coverage .coverage` script with Node.js native API.
|
||||
|
||||
```json
|
||||
// package.json (before)
|
||||
"clean": "rimraf coverage .coverage"
|
||||
|
||||
// package.json (after)
|
||||
"clean": "node -e \"import('fs/promises').then(fs => Promise.all([fs.rm('coverage', {recursive:true,force:true}), fs.rm('.coverage', {recursive:true,force:true})]))\""
|
||||
```
|
||||
|
||||
**Alternative**: Create `scripts/clean.mjs` for maintainability:
|
||||
|
||||
```javascript
|
||||
// scripts/clean.mjs
|
||||
import { rm } from 'fs/promises';
|
||||
await Promise.all([
|
||||
rm('coverage', { recursive: true, force: true }),
|
||||
rm('.coverage', { recursive: true, force: true }),
|
||||
]);
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure (Tasks 1-4)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | ---------------------------------------------- | ------------ |
|
||||
| 1 | Install tsoa, configure tsoa.json | None |
|
||||
| 2 | Create BaseController with utility methods | Task 1 |
|
||||
| 3 | Configure Express integration (RegisterRoutes) | Task 2 |
|
||||
| 4 | Set up tsoa spec generation in build pipeline | Task 3 |
|
||||
|
||||
### Phase 2: Controller Migration (Tasks 5-14)
|
||||
|
||||
Priority order matches ADR-018:
|
||||
|
||||
| Task | Route File | Controller Class | Dependencies |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------ |
|
||||
| 5 | health.routes.ts | HealthController | Task 4 |
|
||||
| 6 | auth.routes.ts | AuthController | Task 4 |
|
||||
| 7 | gamification.routes.ts | AchievementsController | Task 4 |
|
||||
| 8 | flyer.routes.ts | FlyersController | Task 4 |
|
||||
| 9 | user.routes.ts | UsersController | Task 4 |
|
||||
| 10 | budget.routes.ts | BudgetController | Task 4 |
|
||||
| 11 | recipe.routes.ts | RecipeController | Task 4 |
|
||||
| 12 | store.routes.ts | StoreController | Task 4 |
|
||||
| 13 | admin.routes.ts | AdminController | Task 4 |
|
||||
| 14 | Remaining routes (deals, price, upc, inventory, ai, receipt, category, stats, personalization, reactions, system) | Various | Task 4 |
|
||||
|
||||
### Phase 3: Cleanup and rimraf (Tasks 15-18)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | -------------------------------- | ------------------- |
|
||||
| 15 | Create scripts/clean.mjs | None |
|
||||
| 16 | Update package.json clean script | Task 15 |
|
||||
| 17 | Remove rimraf dependency | Task 16 |
|
||||
| 18 | Remove swagger-jsdoc + types | Tasks 5-14 complete |
|
||||
|
||||
### Phase 4: Verification (Tasks 19-24)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | --------------------------------- | ------------ |
|
||||
| 19 | Run type-check | Tasks 15-18 |
|
||||
| 20 | Run unit tests | Task 19 |
|
||||
| 21 | Run integration tests | Task 20 |
|
||||
| 22 | Verify OpenAPI spec completeness | Task 21 |
|
||||
| 23 | Update ADR-018 (reference tsoa) | Task 22 |
|
||||
| 24 | Update CLAUDE.md (swagger → tsoa) | Task 23 |
|
||||
|
||||
### Task Dependency Graph
|
||||
|
||||
```text
|
||||
[1: Install tsoa]
|
||||
|
|
||||
[2: BaseController]
|
||||
|
|
||||
[3: Express Integration]
|
||||
|
|
||||
[4: Build Pipeline]
|
||||
|
|
||||
+------------------+------------------+
|
||||
| | | | |
|
||||
[5] [6] [7] [8] [9-14]
|
||||
Health Auth Gamif Flyer Others
|
||||
| | | | |
|
||||
+------------------+------------------+
|
||||
|
|
||||
[18: Remove swagger-jsdoc]
|
||||
|
|
||||
[15: clean.mjs] -----> [16: Update pkg.json]
|
||||
|
|
||||
[17: Remove rimraf]
|
||||
|
|
||||
[19: type-check]
|
||||
|
|
||||
[20: unit tests]
|
||||
|
|
||||
[21: integration tests]
|
||||
|
|
||||
[22: Verify OpenAPI]
|
||||
|
|
||||
[23: Update ADR-018]
|
||||
|
|
||||
[24: Update CLAUDE.md]
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
|
||||
**Minimum time to completion**: Tasks 1 → 2 → 3 → 4 → 5 (or any controller) → 18 → 19 → 20 → 21 → 22 → 23 → 24
|
||||
|
||||
**Parallelization opportunities**:
|
||||
|
||||
- Tasks 5-14 (all controller migrations) can run in parallel after Task 4
|
||||
- Tasks 15-17 (rimraf removal) can run in parallel with controller migrations
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### tsoa Configuration
|
||||
|
||||
```json
|
||||
// tsoa.json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/generated",
|
||||
"specVersion": 3,
|
||||
"basePath": "/api/v1"
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/generated",
|
||||
"middleware": "express"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Decorator Migration Example
|
||||
|
||||
**Before** (swagger-jsdoc):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* tags: [Health]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), handler);
|
||||
```
|
||||
|
||||
**After** (tsoa):
|
||||
|
||||
```typescript
|
||||
@Route('health')
|
||||
@Tags('Health')
|
||||
export class HealthController extends BaseController {
|
||||
@Get('ping')
|
||||
@SuccessResponse(200, 'Server is responsive')
|
||||
public async ping(): Promise<{ message: string }> {
|
||||
return { message: 'pong' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Integration
|
||||
|
||||
tsoa uses its own validation. Options:
|
||||
|
||||
1. **Replace Zod with tsoa validation** - Use `@Body`, `@Query`, `@Path` decorators with TypeScript types
|
||||
2. **Hybrid approach** - Keep Zod schemas, call `validateRequest()` within controller methods
|
||||
3. **Custom template** - Generate tsoa routes that call Zod validation middleware
|
||||
|
||||
**Recommended**: Option 1 for new controllers; gradually migrate existing Zod schemas.
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| --------------------------------------- | ---------- | ------ | ------------------------------------------- |
|
||||
| tsoa/Express 5.x incompatibility | Medium | High | Test in dev container before migration |
|
||||
| Missing OpenAPI coverage post-migration | Low | Medium | Compare generated specs before/after |
|
||||
| Authentication middleware integration | Medium | Medium | Test @Security decorator with passport-jwt |
|
||||
| Test regression from route changes | Low | High | Run full test suite after each controller |
|
||||
| Build time increase (tsoa generation) | Low | Low | Add to npm run build; cache generated files |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
- **Modern tooling**: Active tsoa community (vs. unmaintained swagger-jsdoc)
|
||||
- **Native Node.js**: fs.rm() is built-in, no external dependency
|
||||
- **Smaller dependency tree**: Remove rimraf (5 transitive deps) + swagger-jsdoc (8 transitive deps)
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Migration effort**: 20 route files require conversion
|
||||
- **Generated code**: `src/generated/routes.ts` must be version-controlled or regenerated on build
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Build step change**: Add `tsoa spec && tsoa routes` to build pipeline
|
||||
- **Testing approach**: May need to adjust test structure for controller classes
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Update swagger-jsdoc to fork/successor
|
||||
|
||||
**Rejected**: No active fork; community has moved to tsoa, fastify-swagger, or NestJS.
|
||||
|
||||
### 2. NestJS migration
|
||||
|
||||
**Rejected**: Full framework migration (Express → NestJS) is disproportionate to the problem scope.
|
||||
|
||||
### 3. fastify-swagger
|
||||
|
||||
**Rejected**: Requires Express → Fastify migration; out of scope.
|
||||
|
||||
### 4. Keep rimraf, accept deprecation warning
|
||||
|
||||
**Rejected**: Native fs.rm() is trivial replacement; no reason to maintain deprecated dependency.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------ | ------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with utilities |
|
||||
| `src/controllers/*.controller.ts` | Individual domain controllers |
|
||||
| `src/generated/routes.ts` | tsoa-generated Express routes |
|
||||
| `src/generated/swagger.json` | Generated OpenAPI 3.0 spec |
|
||||
| `scripts/clean.mjs` | Native fs.rm() replacement for rimraf |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-018](./0018-api-documentation-strategy.md) - API Documentation Strategy (will be updated)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod integration)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization (BaseController pattern)
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling (error utilities in BaseController)
|
||||
265
docs/adr/ADR-027-application-wide-structured-logging.md
Normal file
265
docs/adr/ADR-027-application-wide-structured-logging.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# ADR-027: Standardized Application-Wide Structured Logging
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-004)
|
||||
|
||||
**Related**: [ADR-017](ADR-017-structured-logging-with-pino.md), [ADR-028](ADR-028-client-side-structured-logging.md), [ADR-029](ADR-029-error-tracking-with-bugsink.md)
|
||||
|
||||
## Context
|
||||
|
||||
While ADR-017 established Pino as our logging framework, this ADR extends that foundation with application-wide standards for request tracing, context propagation, and structured log formats.
|
||||
|
||||
The implementation of logging can vary significantly across different modules. The error handler middleware may produce high-quality, structured JSON logs for errors, but logging within route handlers and service layers can become ad-hoc, using plain strings or inconsistent object structures.
|
||||
|
||||
This inconsistency leads to several problems:
|
||||
|
||||
- **Difficult Debugging**: It is hard to trace a single user request through the system or correlate events related to a specific operation
|
||||
- **Ineffective Log Analysis**: Inconsistent log formats make it difficult to effectively query, filter, and create dashboards in log management systems (like Datadog, Splunk, or the ELK stack)
|
||||
- **Security Risks**: There is no enforced standard for redacting sensitive information (like passwords or tokens) in logs outside of the error handler, increasing the risk of accidental data exposure
|
||||
- **Missing Context**: Logs often lack crucial context, such as a unique request ID, the authenticated user's ID, or the source IP address, making them less useful for diagnosing issues
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a standardized, application-wide structured logging policy. All log entries MUST be in JSON format and adhere to a consistent schema.
|
||||
|
||||
### 1. Request-Scoped Logger with Context
|
||||
|
||||
We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
||||
|
||||
- Generate a unique `request_id` for each incoming request
|
||||
- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates
|
||||
- Attach this child logger to the `req` object (e.g., `req.log`)
|
||||
|
||||
### 2. Mandatory Use of Request-Scoped Logger
|
||||
|
||||
All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
||||
|
||||
### 3. Standardized Log Schema
|
||||
|
||||
All log messages should follow a base schema. The logger configuration will be updated to enforce this.
|
||||
|
||||
**Base Fields**: `level`, `timestamp`, `message`, `request_id`, `user_id`, `ip_address`
|
||||
|
||||
**Error Fields**: When logging an error, the log entry MUST include an `error` object with `name`, `message`, and `stack`.
|
||||
|
||||
### 4. Standardized Logging Practices
|
||||
|
||||
| Level | HTTP Status | Scenario |
|
||||
| ----- | ----------- | -------------------------------------------------- |
|
||||
| DEBUG | Any | Request incoming, internal state, development info |
|
||||
| INFO | 2xx | Successful requests, business events |
|
||||
| WARN | 4xx | Client errors, validation failures, not found |
|
||||
| ERROR | 5xx | Server errors, unhandled exceptions |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Logger Configuration
|
||||
|
||||
Located in `src/services/logger.server.ts`:
|
||||
|
||||
```typescript
|
||||
import pino from 'pino';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
|
||||
export const logger = pino({
|
||||
level: isProduction ? 'info' : 'debug',
|
||||
transport:
|
||||
isProduction || isTest
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'*.body.password',
|
||||
'*.body.newPassword',
|
||||
'*.body.currentPassword',
|
||||
'*.body.confirmPassword',
|
||||
'*.body.refreshToken',
|
||||
'*.body.token',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Request Logger Middleware
|
||||
|
||||
Located in `server.ts`:
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from './services/logger.server';
|
||||
|
||||
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
const requestId = randomUUID();
|
||||
const user = req.user as UserProfile | undefined;
|
||||
const start = process.hrtime();
|
||||
|
||||
// Create request-scoped logger
|
||||
req.log = logger.child({
|
||||
request_id: requestId,
|
||||
user_id: user?.user.user_id,
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
req.log.debug({ method: req.method, originalUrl: req.originalUrl }, 'INCOMING');
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = getDurationInMilliseconds(start);
|
||||
const { statusCode, statusMessage } = res;
|
||||
const logDetails = {
|
||||
user_id: (req.user as UserProfile | undefined)?.user.user_id,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
statusCode,
|
||||
statusMessage,
|
||||
duration: duration.toFixed(2),
|
||||
};
|
||||
|
||||
// Include request details for failed requests (for debugging)
|
||||
if (statusCode >= 400) {
|
||||
logDetails.req = { headers: req.headers, body: req.body };
|
||||
}
|
||||
|
||||
if (statusCode >= 500) req.log.error(logDetails, 'Request completed with server error');
|
||||
else if (statusCode >= 400) req.log.warn(logDetails, 'Request completed with client error');
|
||||
else req.log.info(logDetails, 'Request completed successfully');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
app.use(requestLogger);
|
||||
```
|
||||
|
||||
### TypeScript Support
|
||||
|
||||
The `req.log` property is typed via declaration merging in `src/types/express.d.ts`:
|
||||
|
||||
```typescript
|
||||
import { Logger } from 'pino';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
log: Logger;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Sensitive Data Redaction
|
||||
|
||||
The Pino logger automatically redacts sensitive fields:
|
||||
|
||||
```json
|
||||
// Before redaction
|
||||
{
|
||||
"body": {
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
"newPassword": "newsecret456"
|
||||
}
|
||||
}
|
||||
|
||||
// After redaction (in logs)
|
||||
{
|
||||
"body": {
|
||||
"email": "user@example.com",
|
||||
"password": "[REDACTED]",
|
||||
"newPassword": "[REDACTED]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Layer Logging
|
||||
|
||||
Services accept the request-scoped logger as an optional parameter:
|
||||
|
||||
```typescript
|
||||
export async function registerUser(email: string, password: string, reqLog?: Logger) {
|
||||
const log = reqLog || logger; // Fall back to global logger
|
||||
|
||||
log.info({ email }, 'Registering new user');
|
||||
// ... implementation
|
||||
|
||||
log.debug({ userId: user.user_id }, 'User created successfully');
|
||||
return user;
|
||||
}
|
||||
|
||||
// In route handler
|
||||
router.post('/register', async (req, res, next) => {
|
||||
await authService.registerUser(req.body.email, req.body.password, req.log);
|
||||
});
|
||||
```
|
||||
|
||||
### Log Output Format
|
||||
|
||||
**Development** (pino-pretty):
|
||||
|
||||
```text
|
||||
[2026-01-09 12:34:56.789] INFO (request_id=abc123): Request completed successfully
|
||||
method: "GET"
|
||||
originalUrl: "/api/users"
|
||||
statusCode: 200
|
||||
duration: "45.23"
|
||||
```
|
||||
|
||||
**Production** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 30,
|
||||
"time": 1704812096789,
|
||||
"request_id": "abc123",
|
||||
"user_id": "user_456",
|
||||
"ip_address": "192.168.1.1",
|
||||
"method": "GET",
|
||||
"originalUrl": "/api/users",
|
||||
"statusCode": 200,
|
||||
"duration": "45.23",
|
||||
"msg": "Request completed successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Enhanced Observability**: Every log line from a single request can be instantly grouped and analyzed, dramatically speeding up debugging
|
||||
- **Improved Security**: Centralizing the addition of context (like `user_id`) reduces the chance of developers manually logging sensitive data
|
||||
- **Scalable Log Management**: Consistent JSON logs are easily ingested and indexed by any modern log aggregation tool
|
||||
- **Clearer Code**: Removes the need to manually pass contextual information (like user ID) down to service functions just for logging purposes
|
||||
|
||||
### Negative
|
||||
|
||||
- **Refactoring Effort**: Requires adding the `requestLogger` middleware and refactoring all routes and services to use `req.log` instead of the global `logger`
|
||||
- **Slight Performance Overhead**: Creating a child logger for every request adds a minor performance cost, though this is negligible for most modern logging libraries
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/logger.server.ts` - Pino logger configuration
|
||||
- `src/services/logger.client.ts` - Client-side logger (for frontend)
|
||||
- `src/types/express.d.ts` - TypeScript declaration for `req.log`
|
||||
- `server.ts` - Request logger middleware
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-017: Structured Logging with Pino](ADR-017-structured-logging-with-pino.md)
|
||||
- [ADR-001: Standardized Error Handling](ADR-001-standardized-error-handling.md) - Error handler uses `req.log` for error logging
|
||||
- [ADR-028: Client-Side Structured Logging](ADR-028-client-side-structured-logging.md) - Client-side logging strategy
|
||||
- [Pino Documentation](https://getpino.io/#/)
|
||||
242
docs/adr/ADR-028-client-side-structured-logging.md
Normal file
242
docs/adr/ADR-028-client-side-structured-logging.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# ADR-028: Standardized Client-Side Structured Logging
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-026)
|
||||
|
||||
**Related**: [ADR-027](ADR-027-application-wide-structured-logging.md), [ADR-029](ADR-029-error-tracking-with-bugsink.md)
|
||||
|
||||
## Context
|
||||
|
||||
Following the standardization of backend logging in ADR-027, it is clear that our frontend components also require a consistent logging strategy. Currently, components either use `console.log` directly or a simple wrapper, but without a formal standard, this can lead to inconsistent log formats and difficulty in debugging user-facing issues.
|
||||
|
||||
While the frontend does not have the concept of a "request-scoped" logger, the principles of structured, context-rich logging are equally important for:
|
||||
|
||||
1. **Effective Debugging**: Understanding the state of a component or the sequence of user interactions that led to an error
|
||||
2. **Integration with Monitoring Tools**: Sending structured logs to services like Sentry/Bugsink or LogRocket allows for powerful analysis and error tracking in production
|
||||
3. **Clean Test Outputs**: Uncontrolled logging can pollute test runner output, making it difficult to spot actual test failures
|
||||
|
||||
An existing client-side logger at `src/services/logger.client.ts` provides a simple, structured logging interface. This ADR formalizes its use as the application standard.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a standardized, application-wide structured logging policy for all client-side (React) code.
|
||||
|
||||
### 1. Mandatory Use of the Global Client Logger
|
||||
|
||||
All frontend components, hooks, and services **MUST** use the global logger singleton exported from `src/services/logger.client.ts`. Direct use of `console.log`, `console.error`, etc., is discouraged.
|
||||
|
||||
### 2. Pino-like API for Structured Logging
|
||||
|
||||
The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
||||
|
||||
- `logger.info('A simple message');`
|
||||
- `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||
|
||||
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
||||
|
||||
### 3. Mocking in Tests
|
||||
|
||||
All Jest/Vitest tests for components or hooks that use the logger **MUST** mock the `src/services/logger.client.ts` module. This prevents logs from appearing in test output and allows for assertions that the logger was called correctly.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Client Logger Service
|
||||
|
||||
Located in `src/services/logger.client.ts`:
|
||||
|
||||
```typescript
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LoggerOptions {
|
||||
level?: LogLevel;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
class ClientLogger {
|
||||
private level: LogLevel;
|
||||
private enabled: boolean;
|
||||
|
||||
constructor(options: LoggerOptions = {}) {
|
||||
this.level = options.level ?? 'info';
|
||||
this.enabled = options.enabled ?? import.meta.env.DEV;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return this.enabled && LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
||||
}
|
||||
|
||||
private formatMessage(data: object | string, message?: string): string {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
const payload = JSON.stringify(data, null, 2);
|
||||
return message ? `${message}\n${payload}` : payload;
|
||||
}
|
||||
|
||||
debug(data: object | string, message?: string): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.debug(`[DEBUG] ${this.formatMessage(data, message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
info(data: object | string, message?: string): void {
|
||||
if (this.shouldLog('info')) {
|
||||
console.info(`[INFO] ${this.formatMessage(data, message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
warn(data: object | string, message?: string): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
console.warn(`[WARN] ${this.formatMessage(data, message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
error(data: object | string, message?: string): void {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(`[ERROR] ${this.formatMessage(data, message)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new ClientLogger({
|
||||
level: import.meta.env.DEV ? 'debug' : 'warn',
|
||||
enabled: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
**Logging an Error in a Component:**
|
||||
|
||||
```typescript
|
||||
// In a React component or hook
|
||||
import { logger } from '../services/logger.client';
|
||||
import { notifyError } from '../services/notificationService';
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await apiClient.getData();
|
||||
return data;
|
||||
} catch (err) {
|
||||
// Log the full error object for context, along with a descriptive message.
|
||||
logger.error({ err }, 'Failed to fetch component data');
|
||||
notifyError('Something went wrong. Please try again.');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Logging State Changes:**
|
||||
|
||||
```typescript
|
||||
// In a Zustand store or state hook
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
const useAuthStore = create((set) => ({
|
||||
login: async (credentials) => {
|
||||
logger.info({ email: credentials.email }, 'User login attempt');
|
||||
try {
|
||||
const user = await authService.login(credentials);
|
||||
logger.info({ userId: user.id }, 'User logged in successfully');
|
||||
set({ user, isAuthenticated: true });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Login failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Mocking the Logger in Tests
|
||||
|
||||
```typescript
|
||||
// In a *.test.tsx file
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock the logger at the top of the test file
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MyComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // Clear mocks between tests
|
||||
});
|
||||
|
||||
it('should log an error when fetching fails', async () => {
|
||||
// ... test setup to make fetch fail ...
|
||||
|
||||
// Assert that the logger was called with the expected structure
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to fetch component data',
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Error Tracking
|
||||
|
||||
When using Sentry/Bugsink for error tracking (see ADR-029), the client logger can be extended to send logs as breadcrumbs:
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
class ClientLogger {
|
||||
// ... existing implementation
|
||||
|
||||
error(data: object | string, message?: string): void {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(`[ERROR] ${this.formatMessage(data, message)}`);
|
||||
}
|
||||
|
||||
// Add to Sentry breadcrumbs for error context
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'log',
|
||||
level: 'error',
|
||||
message: typeof data === 'string' ? data : message,
|
||||
data: typeof data === 'object' ? data : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Consistency**: All client-side logs will have a predictable structure, making them easier to read and parse
|
||||
- **Debuggability**: Errors logged with a full object (`{ err }`) capture the stack trace and other properties, which is invaluable for debugging
|
||||
- **Testability**: Components that log are easier to test without polluting CI/CD output. We can also assert that logging occurs when expected
|
||||
- **Future-Proof**: If we later decide to send client-side logs to a remote service, we only need to modify the central `logger.client.ts` file instead of every component
|
||||
- **Error Tracking Integration**: Logs can be used as breadcrumbs in Sentry/Bugsink for better error context
|
||||
|
||||
### Negative
|
||||
|
||||
- **Minor Boilerplate**: Requires importing the logger in every file that needs it and mocking it in every corresponding test file. However, this is a small and consistent effort
|
||||
- **Production Noise**: Care must be taken to configure appropriate log levels in production to avoid performance impact
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/logger.client.ts` - Client-side logger implementation
|
||||
- `src/services/logger.server.ts` - Backend logger (for reference)
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-027: Application-Wide Structured Logging](ADR-027-application-wide-structured-logging.md)
|
||||
- [ADR-029: Error Tracking with Bugsink](ADR-029-error-tracking-with-bugsink.md)
|
||||
- [Pino Documentation](https://getpino.io/#/)
|
||||
389
docs/adr/ADR-029-error-tracking-with-bugsink.md
Normal file
389
docs/adr/ADR-029-error-tracking-with-bugsink.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# ADR-029: Error Tracking and Observability with Bugsink
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-015)
|
||||
|
||||
**Related**: [ADR-027](ADR-027-application-wide-structured-logging.md), [ADR-028](ADR-028-client-side-structured-logging.md), [ADR-030](ADR-030-postgresql-function-observability.md), [ADR-032](ADR-032-application-performance-monitoring.md)
|
||||
|
||||
## Context
|
||||
|
||||
While ADR-027 established structured logging with Pino, the application lacks a high-level, aggregated view of its health and errors. It is difficult to spot trends, identify recurring issues, or be proactively notified of new types of errors.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **Self-hosted**: No external SaaS dependencies for error tracking
|
||||
2. **Sentry SDK compatible**: Leverage mature, well-documented SDKs
|
||||
3. **Lightweight**: Minimal resource overhead in the dev container
|
||||
4. **Production-ready**: Same architecture works on bare-metal production servers
|
||||
5. **AI-accessible**: MCP server integration for Claude Code and other AI tools
|
||||
|
||||
**Note**: Application Performance Monitoring (APM) and distributed tracing are covered separately in [ADR-032](ADR-032-application-performance-monitoring.md).
|
||||
|
||||
## Decision
|
||||
|
||||
We implement a self-hosted error tracking stack using **Bugsink** as the Sentry-compatible backend, with the following components:
|
||||
|
||||
### 1. Error Tracking Backend: Bugsink
|
||||
|
||||
**Bugsink** is a lightweight, self-hosted Sentry alternative that:
|
||||
|
||||
- Runs as a single process (no Kafka, Redis, ClickHouse required)
|
||||
- Is fully compatible with Sentry SDKs
|
||||
- Supports ARM64 and AMD64 architectures
|
||||
- Can use SQLite (dev) or PostgreSQL (production)
|
||||
|
||||
**Deployment**:
|
||||
|
||||
- **Dev container**: Installed as a systemd service inside the container
|
||||
- **Production**: Runs as a systemd service on bare-metal, listening on localhost only
|
||||
- **Database**: Uses PostgreSQL with a dedicated `bugsink` user and `bugsink` database (same PostgreSQL instance as the main application)
|
||||
|
||||
### 2. Backend Integration: @sentry/node
|
||||
|
||||
The Express backend integrates `@sentry/node` SDK to:
|
||||
|
||||
- Capture unhandled exceptions before PM2/process manager restarts
|
||||
- Report errors with full stack traces and context
|
||||
- Integrate with Pino logger for breadcrumbs
|
||||
- Filter errors by severity (only 5xx errors sent by default)
|
||||
|
||||
### 3. Frontend Integration: @sentry/react
|
||||
|
||||
The React frontend integrates `@sentry/react` SDK to:
|
||||
|
||||
- Wrap the app in an Error Boundary for graceful error handling
|
||||
- Capture unhandled JavaScript errors
|
||||
- Report errors with component stack traces
|
||||
- Filter out browser extension errors
|
||||
- **Frontend Error Correlation**: The global API client intercepts 4xx/5xx responses and can attach the `x-request-id` header to Sentry scope for correlation with backend logs
|
||||
|
||||
### 4. Log Aggregation: Logstash
|
||||
|
||||
**Logstash** parses application and infrastructure logs, forwarding error patterns to Bugsink:
|
||||
|
||||
- **Installation**: Installed inside the dev container (and on bare-metal prod servers)
|
||||
- **Inputs**:
|
||||
- Pino JSON logs from the Node.js application (PM2 managed)
|
||||
- Redis logs (connection errors, memory warnings, slow commands)
|
||||
- PostgreSQL function logs (via `fn_log()` - see ADR-030)
|
||||
- NGINX access/error logs
|
||||
- **Filter**: Identifies error-level logs (5xx responses, unhandled exceptions, Redis errors)
|
||||
- **Output**: Sends to Bugsink via Sentry-compatible HTTP API
|
||||
|
||||
This provides a secondary error capture path for:
|
||||
|
||||
- Errors that occur before Sentry SDK initialization
|
||||
- Log-based errors that do not throw exceptions
|
||||
- Redis connection/performance issues
|
||||
- Database function errors and slow queries
|
||||
- Historical error analysis from log files
|
||||
|
||||
### 5. MCP Server Integration: bugsink-mcp
|
||||
|
||||
For AI tool integration (Claude Code, Cursor, etc.), we use the open-source [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp) server:
|
||||
|
||||
- **No code changes required**: Configurable via environment variables
|
||||
- **Capabilities**: List projects, get issues, view events, get stacktraces, manage releases
|
||||
- **Configuration**:
|
||||
- `BUGSINK_URL`: Points to Bugsink instance (`http://localhost:8000` for dev, `https://bugsink.example.com` for prod)
|
||||
- `BUGSINK_API_TOKEN`: API token from Bugsink (created via Django management command)
|
||||
- `BUGSINK_ORG_SLUG`: Organization identifier (usually "sentry")
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
+---------------------------------------------------------------------------+
|
||||
| Dev Container / Production Server |
|
||||
+---------------------------------------------------------------------------+
|
||||
| |
|
||||
| +------------------+ +------------------+ |
|
||||
| | Frontend | | Backend | |
|
||||
| | (React) | | (Express) | |
|
||||
| | @sentry/react | | @sentry/node | |
|
||||
| +--------+---------+ +--------+---------+ |
|
||||
| | | |
|
||||
| | Sentry SDK Protocol | |
|
||||
| +-----------+---------------+ |
|
||||
| | |
|
||||
| v |
|
||||
| +----------------------+ |
|
||||
| | Bugsink | |
|
||||
| | (localhost:8000) |<------------------+ |
|
||||
| | | | |
|
||||
| | PostgreSQL backend | | |
|
||||
| +----------------------+ | |
|
||||
| | |
|
||||
| +----------------------+ | |
|
||||
| | Logstash |-------------------+ |
|
||||
| | (Log Aggregator) | Sentry Output |
|
||||
| | | |
|
||||
| | Inputs: | |
|
||||
| | - PM2/Pino logs | |
|
||||
| | - Redis logs | |
|
||||
| | - PostgreSQL logs | |
|
||||
| | - NGINX logs | |
|
||||
| +----------------------+ |
|
||||
| ^ ^ ^ ^ |
|
||||
| | | | | |
|
||||
| +-----------+ | | +-----------+ |
|
||||
| | | | | |
|
||||
| +----+-----+ +-----+----+ +-----+----+ +-----+----+ |
|
||||
| | PM2 | | Redis | | PostgreSQL| | NGINX | |
|
||||
| | Logs | | Logs | | Logs | | Logs | |
|
||||
| +----------+ +----------+ +-----------+ +---------+ |
|
||||
| |
|
||||
| +----------------------+ |
|
||||
| | PostgreSQL | |
|
||||
| | +----------------+ | |
|
||||
| | | app_database | | (main app database) |
|
||||
| | +----------------+ | |
|
||||
| | | bugsink | | (error tracking database) |
|
||||
| | +----------------+ | |
|
||||
| +----------------------+ |
|
||||
| |
|
||||
+---------------------------------------------------------------------------+
|
||||
|
||||
External (Developer Machine):
|
||||
+--------------------------------------+
|
||||
| Claude Code / Cursor / VS Code |
|
||||
| +--------------------------------+ |
|
||||
| | bugsink-mcp | |
|
||||
| | (MCP Server) | |
|
||||
| | | |
|
||||
| | BUGSINK_URL=http://localhost:8000
|
||||
| | BUGSINK_API_TOKEN=... | |
|
||||
| | BUGSINK_ORG_SLUG=... | |
|
||||
| +--------------------------------+ |
|
||||
+--------------------------------------+
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default (Dev) |
|
||||
| -------------------- | -------------------------------- | -------------------------- |
|
||||
| `SENTRY_DSN` | Sentry-compatible DSN (backend) | Set after project creation |
|
||||
| `VITE_SENTRY_DSN` | Sentry-compatible DSN (frontend) | Set after project creation |
|
||||
| `SENTRY_ENVIRONMENT` | Environment name | `development` |
|
||||
| `SENTRY_DEBUG` | Enable debug logging | `false` |
|
||||
| `SENTRY_ENABLED` | Enable/disable error reporting | `true` |
|
||||
|
||||
### PostgreSQL Setup
|
||||
|
||||
```sql
|
||||
-- Create dedicated Bugsink database and user
|
||||
CREATE USER bugsink WITH PASSWORD 'bugsink_dev_password';
|
||||
CREATE DATABASE bugsink OWNER bugsink;
|
||||
GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink;
|
||||
```
|
||||
|
||||
### Bugsink Configuration
|
||||
|
||||
```bash
|
||||
# Environment variables for Bugsink service
|
||||
SECRET_KEY=<random-50-char-string>
|
||||
DATABASE_URL=postgresql://bugsink:bugsink_dev_password@localhost:5432/bugsink
|
||||
BASE_URL=http://localhost:8000
|
||||
PORT=8000
|
||||
```
|
||||
|
||||
### Backend Sentry Integration
|
||||
|
||||
Located in `src/services/sentry.server.ts`:
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export function initSentry() {
|
||||
if (!config.sentry.enabled || !config.sentry.dsn) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment || config.server.nodeEnv,
|
||||
debug: config.sentry.debug,
|
||||
|
||||
// Performance monitoring - disabled by default (see ADR-032)
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Filter out 4xx errors - only report server errors
|
||||
beforeSend(event) {
|
||||
const statusCode = event.contexts?.response?.status_code;
|
||||
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Set user context after authentication
|
||||
export function setUserContext(user: { id: string; email: string; name?: string }) {
|
||||
Sentry.setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear user context on logout
|
||||
export function clearUserContext() {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Sentry Integration
|
||||
|
||||
Located in `src/services/sentry.client.ts`:
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { config } from '../config';
|
||||
|
||||
export function initSentry() {
|
||||
if (!config.sentry.enabled || !config.sentry.dsn) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment,
|
||||
|
||||
// Performance monitoring - disabled by default (see ADR-032)
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Filter out browser extension errors
|
||||
beforeSend(event) {
|
||||
// Ignore errors from browser extensions
|
||||
if (
|
||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
||||
frame.filename?.includes('extension://'),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Set user context after login
|
||||
export function setUserContext(user: { id: string; email: string; name?: string }) {
|
||||
Sentry.setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear user context on logout
|
||||
export function clearUserContext() {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Boundary Component
|
||||
|
||||
Located in `src/components/ErrorBoundary.tsx`:
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras({ componentStack: errorInfo.componentStack });
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>Please refresh the page or contact support.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logstash Pipeline Configuration
|
||||
|
||||
Key routing for log sources:
|
||||
|
||||
| Source | Bugsink Project |
|
||||
| --------------- | --------------- |
|
||||
| Backend (Pino) | Backend API |
|
||||
| Worker (Pino) | Backend API |
|
||||
| PostgreSQL logs | Backend API |
|
||||
| Vite logs | Infrastructure |
|
||||
| Redis logs | Infrastructure |
|
||||
| NGINX logs | Infrastructure |
|
||||
| Frontend errors | Frontend |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Full observability**: Aggregated view of errors and trends
|
||||
- **Self-hosted**: No external SaaS dependencies or subscription costs
|
||||
- **SDK compatibility**: Leverages mature Sentry SDKs with excellent documentation
|
||||
- **AI integration**: MCP server enables Claude Code to query and analyze errors
|
||||
- **Unified architecture**: Same setup works in dev container and production
|
||||
- **Lightweight**: Bugsink runs in a single process, unlike full Sentry (16GB+ RAM)
|
||||
- **Error correlation**: Request IDs allow correlation between frontend errors and backend logs
|
||||
|
||||
### Negative
|
||||
|
||||
- **Additional services**: Bugsink and Logstash add complexity to the container
|
||||
- **PostgreSQL overhead**: Additional database for error tracking
|
||||
- **Initial setup**: Requires configuration of multiple components
|
||||
- **Logstash learning curve**: Pipeline configuration requires Logstash knowledge
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Full Sentry self-hosted**: Rejected due to complexity (Kafka, Redis, ClickHouse, 16GB+ RAM minimum)
|
||||
2. **GlitchTip**: Considered, but Bugsink is lighter weight and easier to deploy
|
||||
3. **Sentry SaaS**: Rejected due to self-hosted requirement
|
||||
4. **Custom error aggregation**: Rejected in favor of proven Sentry SDK ecosystem
|
||||
|
||||
## References
|
||||
|
||||
- [Bugsink Documentation](https://www.bugsink.com/docs/)
|
||||
- [Bugsink Docker Install](https://www.bugsink.com/docs/docker-install/)
|
||||
- [@sentry/node Documentation](https://docs.sentry.io/platforms/javascript/guides/node/)
|
||||
- [@sentry/react Documentation](https://docs.sentry.io/platforms/javascript/guides/react/)
|
||||
- [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp)
|
||||
- [Logstash Reference](https://www.elastic.co/guide/en/logstash/current/index.html)
|
||||
- [ADR-030: PostgreSQL Function Observability](ADR-030-postgresql-function-observability.md)
|
||||
- [ADR-032: Application Performance Monitoring](ADR-032-application-performance-monitoring.md)
|
||||
336
docs/adr/ADR-030-postgresql-function-observability.md
Normal file
336
docs/adr/ADR-030-postgresql-function-observability.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# ADR-030: PostgreSQL Function Observability
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-050)
|
||||
|
||||
**Related**: [ADR-029](ADR-029-error-tracking-with-bugsink.md), [ADR-027](ADR-027-application-wide-structured-logging.md)
|
||||
|
||||
## Context
|
||||
|
||||
Applications often use PostgreSQL functions and triggers for business logic, including:
|
||||
|
||||
- Data transformations and validations
|
||||
- Complex query encapsulation
|
||||
- Trigger-based side effects
|
||||
- Audit logging
|
||||
|
||||
**Current Problem**: These database functions can fail silently in several ways:
|
||||
|
||||
1. **`ON CONFLICT DO NOTHING`** - Swallows constraint violations without notification
|
||||
2. **`IF NOT FOUND THEN RETURN;`** - Silently exits when data is missing
|
||||
3. **Trigger functions returning `NULL`** - No indication of partial failures
|
||||
4. **No logging inside functions** - No visibility into function execution
|
||||
|
||||
When these silent failures occur:
|
||||
|
||||
- The application layer receives no error (function "succeeds" but does nothing)
|
||||
- No logs are generated for debugging
|
||||
- Issues are only discovered when users report missing data
|
||||
- Root cause analysis is extremely difficult
|
||||
|
||||
**Example of Silent Failure**:
|
||||
|
||||
```sql
|
||||
-- This function silently does nothing if record doesn't exist
|
||||
CREATE OR REPLACE FUNCTION public.process_item(p_user_id UUID, p_item_name TEXT)
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
SELECT item_id INTO v_item_id FROM items WHERE name = p_item_name;
|
||||
IF v_item_id IS NULL THEN
|
||||
RETURN; -- Silent failure - no log, no error
|
||||
END IF;
|
||||
-- ...
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
ADR-029 established Logstash + Bugsink for error tracking, with PostgreSQL log integration. This ADR defines the implementation.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a standardized PostgreSQL function observability strategy with three tiers of logging severity.
|
||||
|
||||
### 1. Function Logging Helper
|
||||
|
||||
Create a reusable logging function that outputs structured JSON to PostgreSQL logs:
|
||||
|
||||
```sql
|
||||
-- Function to emit structured log messages from PL/pgSQL
|
||||
CREATE OR REPLACE FUNCTION public.fn_log(
|
||||
p_level TEXT, -- 'DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'
|
||||
p_function_name TEXT, -- The calling function name
|
||||
p_message TEXT, -- Human-readable message
|
||||
p_context JSONB DEFAULT NULL -- Additional context (user_id, params, etc.)
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
log_line TEXT;
|
||||
BEGIN
|
||||
-- Build structured JSON log line
|
||||
log_line := jsonb_build_object(
|
||||
'timestamp', now(),
|
||||
'level', p_level,
|
||||
'source', 'postgresql',
|
||||
'function', p_function_name,
|
||||
'message', p_message,
|
||||
'context', COALESCE(p_context, '{}'::jsonb)
|
||||
)::text;
|
||||
|
||||
-- Use appropriate RAISE level
|
||||
CASE p_level
|
||||
WHEN 'DEBUG' THEN RAISE DEBUG '%', log_line;
|
||||
WHEN 'INFO' THEN RAISE INFO '%', log_line;
|
||||
WHEN 'NOTICE' THEN RAISE NOTICE '%', log_line;
|
||||
WHEN 'WARNING' THEN RAISE WARNING '%', log_line;
|
||||
WHEN 'ERROR' THEN RAISE LOG '%', log_line; -- Use LOG for errors to ensure capture
|
||||
ELSE RAISE NOTICE '%', log_line;
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### 2. Logging Tiers
|
||||
|
||||
#### Tier 1: Critical Functions (Always Log)
|
||||
|
||||
Functions where silent failure causes data corruption or user-facing issues:
|
||||
|
||||
| Function Type | Log Events |
|
||||
| ---------------------------- | --------------------------------------- |
|
||||
| User creation/management | User creation, profile creation, errors |
|
||||
| Permission/role changes | Role not found, permission denied |
|
||||
| Financial transactions | Transaction not found, balance issues |
|
||||
| Data approval workflows | Record not found, permission denied |
|
||||
| Critical business operations | Items added, operations completed |
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.process_critical_operation(p_user_id UUID, p_operation_name TEXT)
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
v_operation_id BIGINT;
|
||||
v_context JSONB;
|
||||
BEGIN
|
||||
v_context := jsonb_build_object('user_id', p_user_id, 'operation_name', p_operation_name);
|
||||
|
||||
SELECT operation_id INTO v_operation_id
|
||||
FROM public.operations WHERE name = p_operation_name;
|
||||
|
||||
IF v_operation_id IS NULL THEN
|
||||
-- Log the issue instead of silent return
|
||||
PERFORM fn_log('WARNING', 'process_critical_operation',
|
||||
'Operation not found: ' || p_operation_name, v_context);
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Perform operation
|
||||
INSERT INTO public.user_operations (user_id, operation_id)
|
||||
VALUES (p_user_id, v_operation_id)
|
||||
ON CONFLICT (user_id, operation_id) DO NOTHING;
|
||||
|
||||
IF FOUND THEN
|
||||
PERFORM fn_log('INFO', 'process_critical_operation',
|
||||
'Operation completed: ' || p_operation_name, v_context);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
#### Tier 2: Business Logic Functions (Log on Anomalies)
|
||||
|
||||
Functions where unexpected conditions should be logged but are not critical:
|
||||
|
||||
| Function Type | Log Events |
|
||||
| --------------------------- | -------------------------------- |
|
||||
| Search/suggestion functions | No match found (below threshold) |
|
||||
| Recommendation engines | No recommendations generated |
|
||||
| Data lookup functions | Empty results, no matches found |
|
||||
| Price/analytics queries | No data available, stale data |
|
||||
|
||||
**Pattern**: Log when results are unexpectedly empty or inputs are invalid.
|
||||
|
||||
#### Tier 3: Triggers (Log Errors Only)
|
||||
|
||||
Triggers should be fast, so only log when something goes wrong:
|
||||
|
||||
| Trigger Type | Log Events |
|
||||
| --------------------- | ---------------------------- |
|
||||
| Audit triggers | Failed to update audit trail |
|
||||
| Aggregation triggers | Calculation failed |
|
||||
| Cascade triggers | Related record lookup failed |
|
||||
| Notification triggers | External service call failed |
|
||||
|
||||
### 3. PostgreSQL Configuration
|
||||
|
||||
Enable logging in `postgresql.conf`:
|
||||
|
||||
```ini
|
||||
# Log all function notices and above
|
||||
log_min_messages = notice
|
||||
|
||||
# Include function name in log prefix
|
||||
log_line_prefix = '%t [%p] %u@%d '
|
||||
|
||||
# Log to file for Logstash pickup
|
||||
logging_collector = on
|
||||
log_directory = '/var/log/postgresql'
|
||||
log_filename = 'postgresql-%Y-%m-%d.log'
|
||||
log_rotation_age = 1d
|
||||
log_rotation_size = 100MB
|
||||
|
||||
# Capture slow queries from functions
|
||||
log_min_duration_statement = 1000 # Log queries over 1 second
|
||||
```
|
||||
|
||||
### 4. Logstash Integration
|
||||
|
||||
Update the Logstash pipeline (extends ADR-029 configuration):
|
||||
|
||||
```conf
|
||||
# PostgreSQL function log input
|
||||
input {
|
||||
file {
|
||||
path => "/var/log/postgresql/*.log"
|
||||
type => "postgres"
|
||||
tags => ["postgres"]
|
||||
start_position => "beginning"
|
||||
sincedb_path => "/var/lib/logstash/sincedb_postgres"
|
||||
}
|
||||
}
|
||||
|
||||
filter {
|
||||
if [type] == "postgres" {
|
||||
# Extract timestamp and process ID from PostgreSQL log prefix
|
||||
grok {
|
||||
match => { "message" => "%{TIMESTAMP_ISO8601:pg_timestamp} \[%{POSINT:pg_pid}\] %{USER:pg_user}@%{WORD:pg_database} %{GREEDYDATA:pg_message}" }
|
||||
}
|
||||
|
||||
# Check if this is a structured JSON log from fn_log()
|
||||
if [pg_message] =~ /^\{.*"source":"postgresql".*\}$/ {
|
||||
json {
|
||||
source => "pg_message"
|
||||
target => "fn_log"
|
||||
}
|
||||
|
||||
# Mark as error if level is WARNING or ERROR
|
||||
if [fn_log][level] in ["WARNING", "ERROR"] {
|
||||
mutate { add_tag => ["error", "db_function"] }
|
||||
}
|
||||
}
|
||||
|
||||
# Also catch native PostgreSQL errors
|
||||
if [pg_message] =~ /^ERROR:/ or [pg_message] =~ /^FATAL:/ {
|
||||
mutate { add_tag => ["error", "postgres_native"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
if "error" in [tags] and "postgres" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Dual-File Update Requirement
|
||||
|
||||
**IMPORTANT**: All SQL function changes must be applied to BOTH files:
|
||||
|
||||
1. `sql/Initial_triggers_and_functions.sql` - Used for incremental updates
|
||||
2. `sql/master_schema_rollup.sql` - Used for fresh database setup
|
||||
|
||||
Both files must remain in sync for triggers and functions.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `fn_log()` helper function**:
|
||||
- Add to both SQL files
|
||||
- Test with `SELECT fn_log('INFO', 'test', 'Test message', '{"key": "value"}'::jsonb);`
|
||||
|
||||
2. **Update Tier 1 critical functions** (highest priority):
|
||||
- Identify functions with silent failures
|
||||
- Add appropriate logging calls
|
||||
- Test error paths
|
||||
|
||||
3. **Update Tier 2 business logic functions**:
|
||||
- Add anomaly logging to suggestion/recommendation functions
|
||||
- Log empty result sets with context
|
||||
|
||||
4. **Update Tier 3 trigger functions**:
|
||||
- Add error-only logging to critical triggers
|
||||
- Wrap complex trigger logic in exception handlers
|
||||
|
||||
5. **Configure PostgreSQL logging**:
|
||||
- Update `postgresql.conf` in dev container
|
||||
- Update production PostgreSQL configuration
|
||||
- Verify logs appear in expected location
|
||||
|
||||
6. **Update Logstash pipeline**:
|
||||
- Add PostgreSQL input to Logstash config
|
||||
- Add filter rules for structured JSON extraction
|
||||
- Test end-to-end: function log -> Logstash -> Bugsink
|
||||
|
||||
7. **Verify in Bugsink**:
|
||||
- Confirm database function errors appear as issues
|
||||
- Verify context (user_id, function name, params) is captured
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Visibility**: Silent failures become visible in error tracking
|
||||
- **Debugging**: Function execution context captured for root cause analysis
|
||||
- **Proactive detection**: Anomalies logged before users report issues
|
||||
- **Unified monitoring**: Database errors appear alongside application errors in Bugsink
|
||||
- **Structured logs**: JSON format enables filtering and aggregation
|
||||
|
||||
### Negative
|
||||
|
||||
- **Performance overhead**: Logging adds latency to function execution
|
||||
- **Log volume**: Tier 1/2 functions may generate significant log volume
|
||||
- **Maintenance**: Two SQL files must be kept in sync
|
||||
- **PostgreSQL configuration**: Requires access to `postgresql.conf`
|
||||
|
||||
### Mitigations
|
||||
|
||||
- **Performance**: Only log meaningful events, not every function call
|
||||
- **Log volume**: Use appropriate log levels; Logstash filters reduce noise
|
||||
- **Sync**: Add CI check to verify SQL files match for function definitions
|
||||
- **Configuration**: Document PostgreSQL settings in deployment runbook
|
||||
|
||||
## Examples
|
||||
|
||||
### Before (Silent Failure)
|
||||
|
||||
```sql
|
||||
-- User thinks operation completed, but it silently failed
|
||||
SELECT process_item('user-uuid', 'Nonexistent Item');
|
||||
-- Returns: void (no error, no log)
|
||||
-- Result: User never gets expected result, nobody knows why
|
||||
```
|
||||
|
||||
### After (Observable Failure)
|
||||
|
||||
```sql
|
||||
SELECT process_item('user-uuid', 'Nonexistent Item');
|
||||
-- Returns: void
|
||||
-- PostgreSQL log: {"timestamp":"2026-01-11T10:30:00Z","level":"WARNING","source":"postgresql","function":"process_item","message":"Item not found: Nonexistent Item","context":{"user_id":"user-uuid","item_name":"Nonexistent Item"}}
|
||||
-- Bugsink: New issue created with full context
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-029: Error Tracking with Bugsink](ADR-029-error-tracking-with-bugsink.md)
|
||||
- [ADR-027: Application-Wide Structured Logging](ADR-027-application-wide-structured-logging.md)
|
||||
- [PostgreSQL RAISE Documentation](https://www.postgresql.org/docs/current/plpgsql-errors-and-messages.html)
|
||||
- [PostgreSQL Logging Configuration](https://www.postgresql.org/docs/current/runtime-config-logging.html)
|
||||
262
docs/adr/ADR-031-granular-debug-logging-strategy.md
Normal file
262
docs/adr/ADR-031-granular-debug-logging-strategy.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# ADR-031: Granular Debug Logging Strategy
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-052)
|
||||
|
||||
**Related**: [ADR-027](ADR-027-application-wide-structured-logging.md), [ADR-017](ADR-017-structured-logging-with-pino.md)
|
||||
|
||||
## Context
|
||||
|
||||
Global log levels (INFO vs DEBUG) are too coarse. Developers need to inspect detailed debug information for specific subsystems (e.g., `ai-service`, `db-pool`, `auth-service`) without being flooded by logs from the entire application.
|
||||
|
||||
When debugging a specific feature:
|
||||
|
||||
- Setting `LOG_LEVEL=debug` globally produces too much noise
|
||||
- Manually adding/removing debug statements is error-prone
|
||||
- No standard way to enable targeted debugging in production
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a namespace-based debug filter pattern, similar to the `debug` npm package, but integrated into our Pino logger.
|
||||
|
||||
1. **Logger Namespaces**: Every service/module logger must be initialized with a `module` property (e.g., `logger.child({ module: 'ai-service' })`).
|
||||
2. **Environment Filter**: We will support a `DEBUG_MODULES` environment variable that overrides the log level for matching modules.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Core Implementation
|
||||
|
||||
Implemented in `src/services/logger.server.ts`:
|
||||
|
||||
```typescript
|
||||
import pino from 'pino';
|
||||
|
||||
// Parse DEBUG_MODULES from environment
|
||||
const debugModules = (process.env.DEBUG_MODULES || '').split(',').map((s) => s.trim());
|
||||
|
||||
// Base logger configuration
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||
// ... other configuration
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a scoped logger for a specific module.
|
||||
* If DEBUG_MODULES includes this module or '*', debug level is enabled.
|
||||
*/
|
||||
export const createScopedLogger = (moduleName: string) => {
|
||||
// If DEBUG_MODULES contains the module name or "*", force level to 'debug'
|
||||
const isDebugEnabled = debugModules.includes('*') || debugModules.includes(moduleName);
|
||||
|
||||
return logger.child({
|
||||
module: moduleName,
|
||||
level: isDebugEnabled ? 'debug' : logger.level,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Service Usage Examples
|
||||
|
||||
```typescript
|
||||
// src/services/aiService.server.ts
|
||||
import { createScopedLogger } from './logger.server';
|
||||
|
||||
const logger = createScopedLogger('ai-service');
|
||||
|
||||
export async function processWithAI(data: unknown) {
|
||||
logger.debug({ data }, 'Starting AI processing');
|
||||
// ... implementation
|
||||
logger.info({ result }, 'AI processing completed');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/services/authService.server.ts
|
||||
import { createScopedLogger } from './logger.server';
|
||||
|
||||
const logger = createScopedLogger('auth-service');
|
||||
|
||||
export async function validateToken(token: string) {
|
||||
logger.debug({ tokenLength: token.length }, 'Validating token');
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Module Naming Convention
|
||||
|
||||
Use kebab-case suffixed with `-service` or `-worker`:
|
||||
|
||||
| Module Name | Purpose | File |
|
||||
| --------------- | -------------------------------- | ------------------------------------- |
|
||||
| `ai-service` | AI/external API interactions | `src/services/aiService.server.ts` |
|
||||
| `auth-service` | Authentication and authorization | `src/services/authService.server.ts` |
|
||||
| `db-pool` | Database connection pooling | `src/services/database.server.ts` |
|
||||
| `cache-service` | Redis/caching operations | `src/services/cacheService.server.ts` |
|
||||
| `queue-worker` | Background job processing | `src/workers/queueWorker.ts` |
|
||||
| `email-service` | Email sending | `src/services/emailService.server.ts` |
|
||||
|
||||
## Usage
|
||||
|
||||
### Enable Debug Logging for Specific Modules
|
||||
|
||||
To debug only AI and authentication:
|
||||
|
||||
```bash
|
||||
DEBUG_MODULES=ai-service,auth-service npm run dev
|
||||
```
|
||||
|
||||
### Enable All Debug Logging
|
||||
|
||||
Use wildcard to enable debug logging for all modules:
|
||||
|
||||
```bash
|
||||
DEBUG_MODULES=* npm run dev
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
|
||||
In `.env.development`:
|
||||
|
||||
```bash
|
||||
# Enable debug logging for specific modules during development
|
||||
DEBUG_MODULES=ai-service
|
||||
```
|
||||
|
||||
### Production Troubleshooting
|
||||
|
||||
Temporarily enable debug logging for a specific subsystem:
|
||||
|
||||
```bash
|
||||
# SSH into production server
|
||||
ssh root@example.com
|
||||
|
||||
# Set environment variable and restart
|
||||
DEBUG_MODULES=ai-service pm2 restart app-api
|
||||
|
||||
# View logs
|
||||
pm2 logs app-api --lines 100
|
||||
|
||||
# Disable debug logging
|
||||
pm2 unset DEBUG_MODULES app-api
|
||||
pm2 restart app-api
|
||||
```
|
||||
|
||||
### With PM2 Configuration
|
||||
|
||||
In `ecosystem.config.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'app-api',
|
||||
script: 'dist/server.js',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
// DEBUG_MODULES is unset by default
|
||||
},
|
||||
env_debug: {
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_MODULES: 'ai-service,auth-service',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Start with debug logging:
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js --env debug
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Scoped Loggers for Long-Running Services
|
||||
|
||||
Services with complex workflows or external API calls should use `createScopedLogger` to allow targeted debugging:
|
||||
|
||||
```typescript
|
||||
const logger = createScopedLogger('payment-service');
|
||||
|
||||
export async function processPayment(payment: Payment) {
|
||||
logger.debug({ paymentId: payment.id }, 'Starting payment processing');
|
||||
|
||||
try {
|
||||
const result = await externalPaymentAPI.process(payment);
|
||||
logger.debug({ result }, 'External API response');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ error, paymentId: payment.id }, 'Payment processing failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Child Loggers for Contextual Data
|
||||
|
||||
Even within scoped loggers, create child loggers with job/request-specific context:
|
||||
|
||||
```typescript
|
||||
const logger = createScopedLogger('queue-worker');
|
||||
|
||||
async function processJob(job: Job) {
|
||||
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
|
||||
|
||||
jobLogger.debug('Starting job processing');
|
||||
// ... processing
|
||||
jobLogger.info('Job completed successfully');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Consistent Debug Message Patterns
|
||||
|
||||
Use consistent patterns for debug messages:
|
||||
|
||||
```typescript
|
||||
// Function entry
|
||||
logger.debug({ params: sanitizedParams }, 'Function entry: processOrder');
|
||||
|
||||
// External API calls
|
||||
logger.debug({ url, method }, 'External API request');
|
||||
logger.debug({ statusCode, duration }, 'External API response');
|
||||
|
||||
// State changes
|
||||
logger.debug({ before, after }, 'State transition');
|
||||
|
||||
// Decision points
|
||||
logger.debug({ condition, result }, 'Branch decision');
|
||||
```
|
||||
|
||||
### 4. Production Usage Guidelines
|
||||
|
||||
- `DEBUG_MODULES` can be set in production for temporary debugging
|
||||
- Should not be used continuously due to increased log volume
|
||||
- Always unset after troubleshooting is complete
|
||||
- Monitor log storage when debug logging is enabled
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Developers can inspect detailed logs for specific subsystems without log flooding
|
||||
- Production debugging becomes more targeted and efficient
|
||||
- No performance impact when debug logging is disabled
|
||||
- Compatible with existing Pino logging infrastructure
|
||||
- Follows familiar pattern from `debug` npm package
|
||||
|
||||
### Negative
|
||||
|
||||
- Requires developers to know module names (mitigated by documentation)
|
||||
- Not all services have adopted scoped loggers yet (gradual migration)
|
||||
- Additional configuration complexity
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-027: Application-Wide Structured Logging](ADR-027-application-wide-structured-logging.md)
|
||||
- [ADR-017: Structured Logging with Pino](ADR-017-structured-logging-with-pino.md)
|
||||
- [debug npm package](https://www.npmjs.com/package/debug) - Inspiration for namespace pattern
|
||||
- [Pino Child Loggers](https://getpino.io/#/docs/child-loggers)
|
||||
263
docs/adr/ADR-032-application-performance-monitoring.md
Normal file
263
docs/adr/ADR-032-application-performance-monitoring.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# ADR-032: Application Performance Monitoring (APM)
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-056)
|
||||
|
||||
**Related**: [ADR-029](ADR-029-error-tracking-with-bugsink.md) (Error Tracking with Bugsink)
|
||||
|
||||
## Context
|
||||
|
||||
Application Performance Monitoring (APM) provides visibility into application behavior through:
|
||||
|
||||
- **Distributed Tracing**: Track requests across services, queues, and database calls
|
||||
- **Performance Metrics**: Response times, throughput, error rates
|
||||
- **Resource Monitoring**: Memory usage, CPU, database connections
|
||||
- **Transaction Analysis**: Identify slow endpoints and bottlenecks
|
||||
|
||||
While ADR-029 covers error tracking and observability, APM is a distinct concern focused on performance rather than errors. The Sentry SDK supports APM through its tracing features, but this capability is currently **intentionally disabled** in our application.
|
||||
|
||||
### Current State
|
||||
|
||||
The Sentry SDK is installed and configured for error tracking (see ADR-029), but APM features are disabled:
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.client.ts
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment,
|
||||
// Performance monitoring - disabled for now to keep it simple
|
||||
tracesSampleRate: 0,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.server.ts
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment || config.server.nodeEnv,
|
||||
// Performance monitoring - disabled for now to keep it simple
|
||||
tracesSampleRate: 0,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Why APM is Currently Disabled
|
||||
|
||||
1. **Complexity**: APM adds overhead and complexity to debugging
|
||||
2. **Bugsink Limitations**: Bugsink's APM support is less mature than its error tracking
|
||||
3. **Resource Overhead**: Tracing adds memory and CPU overhead
|
||||
4. **Focus**: Error tracking provides more immediate value for our current scale
|
||||
5. **Cost**: High sample rates can significantly increase storage requirements
|
||||
|
||||
## Decision
|
||||
|
||||
We propose a **staged approach** to APM implementation:
|
||||
|
||||
### Phase 1: Selective Backend Tracing (Low Priority)
|
||||
|
||||
Enable tracing for specific high-value operations:
|
||||
|
||||
```typescript
|
||||
// Enable tracing for specific transactions only
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
tracesSampleRate: 0, // Keep default at 0
|
||||
|
||||
// Trace only specific high-value transactions
|
||||
tracesSampler: (samplingContext) => {
|
||||
const transactionName = samplingContext.transactionContext?.name;
|
||||
|
||||
// Always trace long-running jobs
|
||||
if (transactionName?.includes('job-processing')) {
|
||||
return 0.1; // 10% sample rate
|
||||
}
|
||||
|
||||
// Always trace AI/external API calls
|
||||
if (transactionName?.includes('external-api')) {
|
||||
return 0.5; // 50% sample rate
|
||||
}
|
||||
|
||||
// Trace slow endpoints (determined by custom logic)
|
||||
if (samplingContext.parentSampled) {
|
||||
return 0.1; // 10% for child transactions
|
||||
}
|
||||
|
||||
return 0; // Don't trace other transactions
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 2: Custom Performance Metrics
|
||||
|
||||
Add custom metrics without full tracing overhead:
|
||||
|
||||
```typescript
|
||||
// Custom metric for slow database queries
|
||||
import { metrics } from '@sentry/node';
|
||||
|
||||
// In repository methods
|
||||
const startTime = performance.now();
|
||||
const result = await pool.query(sql, params);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
metrics.distribution('db.query.duration', duration, {
|
||||
tags: { query_type: 'select', table: 'users' },
|
||||
});
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn({ duration, sql }, 'Slow query detected');
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Full APM Integration (Future)
|
||||
|
||||
When/if full APM is needed:
|
||||
|
||||
```typescript
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
tracesSampleRate: 0.1, // 10% of transactions
|
||||
profilesSampleRate: 0.1, // 10% of traced transactions get profiled
|
||||
|
||||
integrations: [
|
||||
// Database tracing
|
||||
Sentry.postgresIntegration(),
|
||||
// Redis tracing
|
||||
Sentry.redisIntegration(),
|
||||
// BullMQ job tracing (custom integration)
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### To Enable Basic APM
|
||||
|
||||
1. **Update Sentry Configuration**:
|
||||
- Set `tracesSampleRate` > 0 in `src/services/sentry.server.ts`
|
||||
- Set `tracesSampleRate` > 0 in `src/services/sentry.client.ts`
|
||||
- Add environment variable `SENTRY_TRACES_SAMPLE_RATE` (default: 0)
|
||||
|
||||
2. **Add Instrumentation**:
|
||||
- Enable automatic Express instrumentation
|
||||
- Add manual spans for BullMQ job processing
|
||||
- Add database query instrumentation
|
||||
|
||||
3. **Frontend Tracing**:
|
||||
- Add Browser Tracing integration
|
||||
- Configure page load and navigation tracing
|
||||
|
||||
4. **Environment Variables**:
|
||||
|
||||
```bash
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10% sampling
|
||||
SENTRY_PROFILES_SAMPLE_RATE=0 # Profiling disabled
|
||||
```
|
||||
|
||||
5. **Bugsink Configuration**:
|
||||
- Verify Bugsink supports performance data ingestion
|
||||
- Configure retention policies for performance data
|
||||
|
||||
### Configuration Changes Required
|
||||
|
||||
```typescript
|
||||
// src/config/env.ts - Add new config
|
||||
sentry: {
|
||||
dsn: env.SENTRY_DSN,
|
||||
environment: env.SENTRY_ENVIRONMENT,
|
||||
debug: env.SENTRY_DEBUG === 'true',
|
||||
tracesSampleRate: parseFloat(env.SENTRY_TRACES_SAMPLE_RATE || '0'),
|
||||
profilesSampleRate: parseFloat(env.SENTRY_PROFILES_SAMPLE_RATE || '0'),
|
||||
},
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.server.ts - Updated init
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment,
|
||||
tracesSampleRate: config.sentry.tracesSampleRate,
|
||||
profilesSampleRate: config.sentry.profilesSampleRate,
|
||||
// ... rest of config
|
||||
});
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### Enabling APM
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Identify performance bottlenecks
|
||||
- Track distributed transactions across services
|
||||
- Profile slow endpoints
|
||||
- Monitor resource utilization trends
|
||||
|
||||
**Costs**:
|
||||
|
||||
- Increased memory usage (~5-15% overhead)
|
||||
- Additional CPU for trace processing
|
||||
- Increased storage in Bugsink/Sentry
|
||||
- More complex debugging (noise in traces)
|
||||
- Potential latency from tracing overhead
|
||||
|
||||
### Keeping APM Disabled
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Simpler operation and debugging
|
||||
- Lower resource overhead
|
||||
- Focused on error tracking (higher priority)
|
||||
- No additional storage costs
|
||||
|
||||
**Costs**:
|
||||
|
||||
- No automated performance insights
|
||||
- Manual profiling required for bottleneck detection
|
||||
- Limited visibility into slow transactions
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **OpenTelemetry**: More vendor-neutral, but adds another dependency and complexity
|
||||
2. **Prometheus + Grafana**: Good for metrics, but doesn't provide distributed tracing
|
||||
3. **Jaeger/Zipkin**: Purpose-built for tracing, but requires additional infrastructure
|
||||
4. **New Relic/Datadog SaaS**: Full-featured but conflicts with self-hosted requirement
|
||||
|
||||
## Current Recommendation
|
||||
|
||||
**Keep APM disabled** (`tracesSampleRate: 0`) until:
|
||||
|
||||
1. Specific performance issues are identified that require tracing
|
||||
2. Bugsink's APM support is verified and tested
|
||||
3. Infrastructure can support the additional overhead
|
||||
4. There is a clear business need for performance visibility
|
||||
|
||||
When enabling APM becomes necessary, start with Phase 1 (selective tracing) to minimize overhead while gaining targeted insights.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive (When Implemented)
|
||||
|
||||
- Automated identification of slow endpoints
|
||||
- Distributed trace visualization across async operations
|
||||
- Correlation between errors and performance issues
|
||||
- Proactive alerting on performance degradation
|
||||
|
||||
### Negative
|
||||
|
||||
- Additional infrastructure complexity
|
||||
- Storage overhead for trace data
|
||||
- Potential performance impact from tracing itself
|
||||
- Learning curve for trace analysis
|
||||
|
||||
## References
|
||||
|
||||
- [Sentry Performance Monitoring](https://docs.sentry.io/product/performance/)
|
||||
- [@sentry/node Performance](https://docs.sentry.io/platforms/javascript/guides/node/performance/)
|
||||
- [@sentry/react Performance](https://docs.sentry.io/platforms/javascript/guides/react/performance/)
|
||||
- [OpenTelemetry](https://opentelemetry.io/) (alternative approach)
|
||||
- [ADR-029: Error Tracking with Bugsink](ADR-029-error-tracking-with-bugsink.md)
|
||||
340
docs/adr/ADR-033-bugsink-gitea-issue-sync.md
Normal file
340
docs/adr/ADR-033-bugsink-gitea-issue-sync.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# ADR-033: Bugsink to Gitea Issue Synchronization
|
||||
|
||||
**Date**: 2026-02-10
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Source**: Imported from flyer-crawler project (ADR-054)
|
||||
|
||||
**Related**: [ADR-029](ADR-029-error-tracking-with-bugsink.md), [ADR-012](ADR-012-bullmq-background-job-processing.md)
|
||||
|
||||
## Context
|
||||
|
||||
The application uses Bugsink (Sentry-compatible self-hosted error tracking) to capture runtime errors across multiple projects:
|
||||
|
||||
| Project Type | Environment | Description |
|
||||
| -------------- | ------------ | ---------------------------------------- |
|
||||
| Backend | Production | Main API server errors |
|
||||
| Backend | Test/Staging | Pre-production API errors |
|
||||
| Frontend | Production | Client-side JavaScript errors |
|
||||
| Frontend | Test/Staging | Pre-production frontend errors |
|
||||
| Infrastructure | Production | Infrastructure-level errors (Redis, PM2) |
|
||||
| Infrastructure | Test/Staging | Pre-production infrastructure errors |
|
||||
|
||||
Currently, errors remain in Bugsink until manually reviewed. There is no automated workflow to:
|
||||
|
||||
1. Create trackable tickets for errors
|
||||
2. Assign errors to developers
|
||||
3. Track resolution progress
|
||||
4. Prevent errors from being forgotten
|
||||
|
||||
## Decision
|
||||
|
||||
Implement an automated background worker that synchronizes unresolved Bugsink issues to Gitea as trackable tickets. The sync worker will:
|
||||
|
||||
1. **Run only on the test/staging server** (not production, not dev container)
|
||||
2. **Poll all Bugsink projects** for unresolved issues
|
||||
3. **Create Gitea issues** with full error context
|
||||
4. **Mark synced issues as resolved** in Bugsink (to prevent re-polling)
|
||||
5. **Track sync state in Redis** to ensure idempotency
|
||||
|
||||
### Why Test/Staging Only?
|
||||
|
||||
- The sync worker is a background service that needs API tokens for both Bugsink and Gitea
|
||||
- Running on test/staging provides a single sync point without duplicating infrastructure
|
||||
- All Bugsink projects (including production) are synced from this one worker
|
||||
- Production server stays focused on serving users, not running sync jobs
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| TEST/STAGING SERVER |
|
||||
| |
|
||||
| +------------------+ +------------------+ +-------------------+ |
|
||||
| | BullMQ Queue |--->| Sync Worker |--->| Redis DB 15 | |
|
||||
| | bugsink-sync | | (15min repeat) | | Sync State | |
|
||||
| +------------------+ +--------+---------+ +-------------------+ |
|
||||
| | |
|
||||
+-----------------------------------+------------------------------------+
|
||||
|
|
||||
+---------------+---------------+
|
||||
v v
|
||||
+------------------+ +------------------+
|
||||
| Bugsink | | Gitea |
|
||||
| (all projects) | | (1 repo) |
|
||||
+------------------+ +------------------+
|
||||
```
|
||||
|
||||
### Queue Configuration
|
||||
|
||||
| Setting | Value | Rationale |
|
||||
| --------------- | ---------------------- | -------------------------------------------- |
|
||||
| Queue Name | `bugsink-sync` | Follows existing naming pattern |
|
||||
| Repeat Interval | 15 minutes | Balances responsiveness with API rate limits |
|
||||
| Retry Attempts | 3 | Standard retry policy |
|
||||
| Backoff | Exponential (30s base) | Handles temporary API failures |
|
||||
| Concurrency | 1 | Serial processing prevents race conditions |
|
||||
|
||||
### Redis Database Allocation
|
||||
|
||||
| Database | Usage | Owner |
|
||||
| -------- | ------------------- | --------------- |
|
||||
| 0 | BullMQ (Production) | Existing queues |
|
||||
| 1 | BullMQ (Test) | Existing queues |
|
||||
| 2-14 | Reserved | Future use |
|
||||
| 15 | Bugsink Sync State | This feature |
|
||||
|
||||
### Redis Key Schema
|
||||
|
||||
```
|
||||
bugsink:synced:{bugsink_issue_id}
|
||||
+-- Value: JSON {
|
||||
gitea_issue_number: number,
|
||||
synced_at: ISO timestamp,
|
||||
project: string,
|
||||
title: string
|
||||
}
|
||||
```
|
||||
|
||||
### Gitea Labels
|
||||
|
||||
The following labels should be created in the repository:
|
||||
|
||||
| Label | Color | Purpose |
|
||||
| -------------------- | ------------------ | ---------------------------------- |
|
||||
| `bug:frontend` | #e11d48 (Red) | Frontend JavaScript/React errors |
|
||||
| `bug:backend` | #ea580c (Orange) | Backend Node.js/API errors |
|
||||
| `bug:infrastructure` | #7c3aed (Purple) | Infrastructure errors (Redis, PM2) |
|
||||
| `env:production` | #dc2626 (Dark Red) | Production environment |
|
||||
| `env:test` | #2563eb (Blue) | Test/staging environment |
|
||||
| `env:development` | #6b7280 (Gray) | Development environment |
|
||||
| `source:bugsink` | #10b981 (Green) | Auto-synced from Bugsink |
|
||||
|
||||
### Label Mapping
|
||||
|
||||
| Bugsink Project Type | Bug Label | Env Label |
|
||||
| --------------------- | ------------------ | -------------- |
|
||||
| backend (prod) | bug:backend | env:production |
|
||||
| backend (test) | bug:backend | env:test |
|
||||
| frontend (prod) | bug:frontend | env:production |
|
||||
| frontend (test) | bug:frontend | env:test |
|
||||
| infrastructure (prod) | bug:infrastructure | env:production |
|
||||
| infrastructure (test) | bug:infrastructure | env:test |
|
||||
|
||||
All synced issues also receive the `source:bugsink` label.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | ------------------------------------------- |
|
||||
| `src/services/bugsinkSync.server.ts` | Core synchronization logic |
|
||||
| `src/services/bugsinkClient.server.ts` | HTTP client for Bugsink API |
|
||||
| `src/services/giteaClient.server.ts` | HTTP client for Gitea API |
|
||||
| `src/types/bugsink.ts` | TypeScript interfaces for Bugsink responses |
|
||||
| `src/routes/admin/bugsink-sync.ts` | Admin endpoints for manual trigger |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
| -------------------------------- | ------------------------------------- |
|
||||
| `src/services/queues.server.ts` | Add `bugsinkSyncQueue` definition |
|
||||
| `src/services/workers.server.ts` | Add sync worker implementation |
|
||||
| `src/config/env.ts` | Add bugsink sync configuration schema |
|
||||
| `.env.example` | Document new environment variables |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Bugsink Configuration
|
||||
BUGSINK_URL=https://bugsink.example.com
|
||||
BUGSINK_API_TOKEN=... # Created via Django management command
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_API_TOKEN=... # Personal access token with repo scope
|
||||
GITEA_OWNER=org-name
|
||||
GITEA_REPO=project-repo
|
||||
|
||||
# Sync Control
|
||||
BUGSINK_SYNC_ENABLED=false # Set true only in test environment
|
||||
BUGSINK_SYNC_INTERVAL=15 # Minutes between sync runs
|
||||
```
|
||||
|
||||
### Gitea Issue Template
|
||||
|
||||
```markdown
|
||||
## Error Details
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | --------------- |
|
||||
| **Type** | {error_type} |
|
||||
| **Message** | {error_message} |
|
||||
| **Platform** | {platform} |
|
||||
| **Level** | {level} |
|
||||
|
||||
## Occurrence Statistics
|
||||
|
||||
- **First Seen**: {first_seen}
|
||||
- **Last Seen**: {last_seen}
|
||||
- **Total Occurrences**: {count}
|
||||
|
||||
## Request Context
|
||||
|
||||
- **URL**: {request_url}
|
||||
- **Additional Context**: {context}
|
||||
|
||||
## Stacktrace
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
{stacktrace}
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
**Bugsink Issue**: {bugsink_url}
|
||||
**Project**: {project_slug}
|
||||
**Trace ID**: {trace_id}
|
||||
```
|
||||
|
||||
### Sync Workflow
|
||||
|
||||
```
|
||||
1. Worker triggered (every 15 min or manual)
|
||||
2. For each Bugsink project:
|
||||
a. List issues with status='unresolved'
|
||||
b. For each issue:
|
||||
i. Check Redis for existing sync record
|
||||
ii. If already synced -> skip
|
||||
iii. Fetch issue details + stacktrace
|
||||
iv. Create Gitea issue with labels
|
||||
v. Store sync record in Redis
|
||||
vi. Mark issue as 'resolved' in Bugsink
|
||||
3. Log summary (synced: N, skipped: N, failed: N)
|
||||
```
|
||||
|
||||
### Idempotency Guarantees
|
||||
|
||||
1. **Redis check before creation**: Prevents duplicate Gitea issues
|
||||
2. **Atomic Redis write after Gitea create**: Ensures state consistency
|
||||
3. **Query only unresolved issues**: Resolved issues won't appear in polls
|
||||
4. **No TTL on Redis keys**: Permanent sync history
|
||||
|
||||
## Admin Interface
|
||||
|
||||
### Manual Sync Endpoint
|
||||
|
||||
```
|
||||
POST /api/admin/bugsink/sync
|
||||
Authorization: Bearer {admin_jwt}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"synced": 3,
|
||||
"skipped": 12,
|
||||
"failed": 0,
|
||||
"duration_ms": 2340
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sync Status Endpoint
|
||||
|
||||
```
|
||||
GET /api/admin/bugsink/sync/status
|
||||
Authorization: Bearer {admin_jwt}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"last_run": "2026-01-17T10:30:00Z",
|
||||
"next_run": "2026-01-17T10:45:00Z",
|
||||
"total_synced": 47,
|
||||
"projects": [
|
||||
{ "slug": "backend-prod", "synced_count": 12 },
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
- Add environment variables to `env.ts` schema
|
||||
- Create `BugsinkClient` service (HTTP client)
|
||||
- Create `GiteaClient` service (HTTP client)
|
||||
- Add Redis db 15 connection for sync tracking
|
||||
|
||||
### Phase 2: Sync Logic
|
||||
|
||||
- Create `BugsinkSyncService` with sync logic
|
||||
- Add `bugsink-sync` queue to `queues.server.ts`
|
||||
- Add sync worker to `workers.server.ts`
|
||||
- Create TypeScript types for API responses
|
||||
|
||||
### Phase 3: Integration
|
||||
|
||||
- Add admin endpoints for manual sync trigger
|
||||
- Update CI/CD with new secrets
|
||||
- Add secrets to repository settings
|
||||
- Test end-to-end in staging environment
|
||||
|
||||
### Phase 4: Documentation
|
||||
|
||||
- Update CLAUDE.md with sync information
|
||||
- Create operational runbook for sync issues
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Visibility**: All application errors become trackable tickets
|
||||
2. **Accountability**: Errors can be assigned to developers
|
||||
3. **History**: Complete audit trail of when errors were discovered and resolved
|
||||
4. **Integration**: Errors appear alongside feature work in Gitea
|
||||
5. **Automation**: No manual error triage required
|
||||
|
||||
### Negative
|
||||
|
||||
1. **API Dependencies**: Requires both Bugsink and Gitea APIs to be available
|
||||
2. **Token Management**: Additional secrets to manage in CI/CD
|
||||
3. **Potential Noise**: High-frequency errors could create many tickets (mitigated by Bugsink's issue grouping)
|
||||
4. **Single Point**: Sync only runs on test server (if test server is down, no sync occurs)
|
||||
|
||||
### Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ----------------------- | ------------------------------------------------- |
|
||||
| Bugsink API rate limits | 15-minute polling interval |
|
||||
| Gitea API rate limits | Sequential processing with delays |
|
||||
| Redis connection issues | Reuse existing connection patterns |
|
||||
| Duplicate issues | Redis tracking + idempotent checks |
|
||||
| Missing stacktrace | Graceful degradation (create issue without trace) |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Bi-directional sync**: Update Bugsink when Gitea issue is closed
|
||||
2. **Smart deduplication**: Detect similar errors across projects
|
||||
3. **Priority mapping**: High occurrence count -> high priority label
|
||||
4. **Slack/Discord notifications**: Alert on new critical errors
|
||||
5. **Metrics dashboard**: Track error trends over time
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-012: BullMQ Background Job Processing](ADR-012-bullmq-background-job-processing.md)
|
||||
- [ADR-029: Error Tracking with Bugsink](ADR-029-error-tracking-with-bugsink.md)
|
||||
- [Bugsink API Documentation](https://bugsink.com/docs/api/)
|
||||
- [Gitea API Documentation](https://docs.gitea.io/en-us/api-usage/)
|
||||
@@ -15,9 +15,10 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 40 |
|
||||
| Accepted (Fully Implemented) | 42 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 14 |
|
||||
| Proposed (Not Started) | 12 |
|
||||
| Superseded | 1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,13 +35,13 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
### Category 2: Data Management
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------- | ------------------------ | -------- | ------ | ------------------------------ |
|
||||
| [ADR-009](./0009-caching-strategy-for-read-heavy-operations.md) | Caching Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-013](./0013-database-schema-migration-strategy.md) | Schema Migrations v1 | Proposed | M | Superseded by ADR-023 |
|
||||
| [ADR-019](./0019-data-backup-and-recovery-strategy.md) | Backup & Recovery | Accepted | - | Fully implemented |
|
||||
| [ADR-023](./0023-database-schema-migration-strategy.md) | Schema Migrations v2 | Proposed | L | Requires tooling setup |
|
||||
| [ADR-031](./0031-data-retention-and-privacy-compliance.md) | Data Retention & Privacy | Proposed | XL | Legal/compliance review needed |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------- | ------------------------ | ---------- | ------ | ------------------------------ |
|
||||
| [ADR-009](./0009-caching-strategy-for-read-heavy-operations.md) | Caching Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-013](./0013-database-schema-migration-strategy.md) | Schema Migrations v1 | Superseded | - | Superseded by ADR-023 |
|
||||
| [ADR-019](./0019-data-backup-and-recovery-strategy.md) | Backup & Recovery | Accepted | - | Fully implemented |
|
||||
| [ADR-023](./0023-database-schema-migration-strategy.md) | Schema Migrations v2 | Proposed | L | Requires tooling setup |
|
||||
| [ADR-031](./0031-data-retention-and-privacy-compliance.md) | Data Retention & Privacy | Proposed | XL | Legal/compliance review needed |
|
||||
|
||||
### Category 3: API & Integration
|
||||
|
||||
@@ -77,16 +78,16 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
### Category 6: Deployment & Operations
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ------------------ | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
|
||||
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ------------------ | -------- | ------ | ------------------------ |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Accepted | - | Fully implemented |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
|
||||
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
|
||||
|
||||
### Category 7: Frontend / User Interface
|
||||
|
||||
@@ -108,6 +109,7 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
| [ADR-040](./0040-testing-economics-and-priorities.md) | Testing Economics | Accepted | - | Fully implemented |
|
||||
| [ADR-045](./0045-test-data-factories-and-fixtures.md) | Test Data Factories | Accepted | - | Fully implemented |
|
||||
| [ADR-047](./0047-project-file-and-folder-organization.md) | Project Organization | Proposed | XL | Major reorganization |
|
||||
| [ADR-057](./0057-test-remediation-post-api-versioning.md) | Test Remediation | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 9: Architecture Patterns
|
||||
|
||||
@@ -132,15 +134,14 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
|
||||
|
||||
| Priority | ADR | Title | Status | Effort | Rationale |
|
||||
| -------- | ------- | ------------------------ | -------- | ------ | ------------------------------------ |
|
||||
| 1 | ADR-024 | Feature Flags | Proposed | M | Safer deployments, A/B testing |
|
||||
| 2 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
|
||||
| 3 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
|
||||
| 4 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
|
||||
| 5 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 6 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 7 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 8 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 9 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
| 1 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
|
||||
| 2 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
|
||||
| 3 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
|
||||
| 4 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 5 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 6 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 7 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 8 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
@@ -148,6 +149,9 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| 2026-01-28 | ADR-024 | Fully implemented - Backend/frontend feature flags, 89 tests, admin endpoint |
|
||||
| 2026-01-28 | ADR-057 | Created - Test remediation documentation for ADR-008 Phase 2 migration |
|
||||
| 2026-01-28 | ADR-013 | Marked as Superseded by ADR-023 |
|
||||
| 2026-01-27 | ADR-008 | Test path migration complete - 23 files, ~70 paths updated, 274->345 tests passing |
|
||||
| 2026-01-27 | ADR-008 | Phase 2 Complete - Version router factory, deprecation headers, 82 versioning tests |
|
||||
| 2026-01-26 | ADR-015 | Completed - Added Sentry user context in AuthProvider, now fully implemented |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This directory contains a log of the architectural decisions made for the Flyer Crawler project.
|
||||
|
||||
**[Implementation Tracker](./adr-implementation-tracker.md)**: Track implementation status and effort estimates for all ADRs.
|
||||
|
||||
## 1. Foundational / Core Infrastructure
|
||||
|
||||
**[ADR-002](./0002-standardized-transaction-management.md)**: Standardized Transaction Management and Unit of Work Pattern (Accepted)
|
||||
@@ -12,7 +14,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
## 2. Data Management
|
||||
|
||||
**[ADR-009](./0009-caching-strategy-for-read-heavy-operations.md)**: Caching Strategy for Read-Heavy Operations (Accepted)
|
||||
**[ADR-013](./0013-database-schema-migration-strategy.md)**: Database Schema Migration Strategy (Proposed)
|
||||
**[ADR-013](./0013-database-schema-migration-strategy.md)**: Database Schema Migration Strategy (Superseded by ADR-023)
|
||||
**[ADR-019](./0019-data-backup-and-recovery-strategy.md)**: Data Backup and Recovery Strategy (Accepted)
|
||||
**[ADR-023](./0023-database-schema-migration-strategy.md)**: Database Schema Migration Strategy (Proposed)
|
||||
**[ADR-031](./0031-data-retention-and-privacy-compliance.md)**: Data Retention and Privacy Compliance (Proposed)
|
||||
@@ -20,9 +22,9 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
## 3. API & Integration
|
||||
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Accepted)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 1 Complete)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Accepted)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Proposed)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 2 Complete)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Superseded - tsoa migration complete)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Accepted)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
## 4. Security & Compliance
|
||||
@@ -33,12 +35,12 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-029](./0029-secret-rotation-and-key-management.md)**: Secret Rotation and Key Management Strategy (Proposed)
|
||||
**[ADR-032](./0032-rate-limiting-strategy.md)**: Rate Limiting Strategy (Accepted)
|
||||
**[ADR-033](./0033-file-upload-and-storage-strategy.md)**: File Upload and Storage Strategy (Accepted)
|
||||
**[ADR-048](./0048-authentication-strategy.md)**: Authentication Strategy (Partially Implemented)
|
||||
**[ADR-048](./0048-authentication-strategy.md)**: Authentication Strategy (Accepted)
|
||||
|
||||
## 5. Observability & Monitoring
|
||||
|
||||
**[ADR-004](./0004-standardized-application-wide-structured-logging.md)**: Standardized Application-Wide Structured Logging (Accepted)
|
||||
**[ADR-015](./0015-error-tracking-and-observability.md)**: Error Tracking and Observability (Partial)
|
||||
**[ADR-015](./0015-error-tracking-and-observability.md)**: Error Tracking and Observability (Accepted)
|
||||
**[ADR-050](./0050-postgresql-function-observability.md)**: PostgreSQL Function Observability (Accepted)
|
||||
**[ADR-051](./0051-asynchronous-context-propagation.md)**: Asynchronous Context Propagation (Accepted)
|
||||
**[ADR-052](./0052-granular-debug-logging-strategy.md)**: Granular Debug Logging Strategy (Accepted)
|
||||
@@ -52,7 +54,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-024](./0024-feature-flagging-strategy.md)**: Feature Flagging Strategy (Proposed)
|
||||
**[ADR-037](./0037-scheduled-jobs-and-cron-pattern.md)**: Scheduled Jobs and Cron Pattern (Accepted)
|
||||
**[ADR-038](./0038-graceful-shutdown-pattern.md)**: Graceful Shutdown Pattern (Accepted)
|
||||
**[ADR-053](./0053-worker-health-checks-and-monitoring.md)**: Worker Health Checks and Monitoring (Proposed)
|
||||
**[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)
|
||||
|
||||
## 7. Frontend / User Interface
|
||||
@@ -72,6 +74,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted)
|
||||
**[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)
|
||||
|
||||
## 9. Architecture Patterns
|
||||
|
||||
|
||||
@@ -1,10 +1,168 @@
|
||||
# Database Setup
|
||||
# Database Architecture
|
||||
|
||||
Flyer Crawler uses PostgreSQL with several extensions for full-text search, geographic data, and UUID generation.
|
||||
**Version**: 0.12.20
|
||||
**Last Updated**: 2026-01-28
|
||||
|
||||
Flyer Crawler uses PostgreSQL 16 with PostGIS for geographic data, pg_trgm for fuzzy text search, and uuid-ossp for UUID generation. The database contains 65 tables organized into logical domains.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Schema Overview](#schema-overview)
|
||||
2. [Database Setup](#database-setup)
|
||||
3. [Schema Reference](#schema-reference)
|
||||
4. [Related Documentation](#related-documentation)
|
||||
|
||||
---
|
||||
|
||||
## Required Extensions
|
||||
## Schema Overview
|
||||
|
||||
The database is organized into the following domains:
|
||||
|
||||
### Core Infrastructure (6 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ----------------------- | ----------------------------------------- | ----------------- |
|
||||
| `users` | Authentication credentials and login data | `user_id` (UUID) |
|
||||
| `profiles` | Public user data, preferences, points | `user_id` (UUID) |
|
||||
| `addresses` | Normalized address storage with geocoding | `address_id` |
|
||||
| `activity_log` | User activity audit trail | `activity_log_id` |
|
||||
| `password_reset_tokens` | Temporary tokens for password reset | `token_id` |
|
||||
| `schema_info` | Schema deployment metadata | `environment` |
|
||||
|
||||
### Stores and Locations (4 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ------------------------ | --------------------------------------- | ------------------- |
|
||||
| `stores` | Grocery store chains (Safeway, Kroger) | `store_id` |
|
||||
| `store_locations` | Physical store locations with addresses | `store_location_id` |
|
||||
| `favorite_stores` | User store favorites | `user_id, store_id` |
|
||||
| `store_receipt_patterns` | Receipt text patterns for store ID | `pattern_id` |
|
||||
|
||||
### Flyers and Items (7 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ----------------------- | -------------------------------------- | ------------------------ |
|
||||
| `flyers` | Uploaded flyer metadata and status | `flyer_id` |
|
||||
| `flyer_items` | Individual deals extracted from flyers | `flyer_item_id` |
|
||||
| `flyer_locations` | Flyer-to-location associations | `flyer_location_id` |
|
||||
| `categories` | Item categorization (Produce, Dairy) | `category_id` |
|
||||
| `master_grocery_items` | Canonical grocery item dictionary | `master_grocery_item_id` |
|
||||
| `master_item_aliases` | Alternative names for master items | `alias_id` |
|
||||
| `unmatched_flyer_items` | Items pending master item matching | `unmatched_item_id` |
|
||||
|
||||
### Products and Brands (2 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ---------- | ---------------------------------------------- | ------------ |
|
||||
| `brands` | Brand names (Coca-Cola, Kraft) | `brand_id` |
|
||||
| `products` | Specific products (master item + brand + size) | `product_id` |
|
||||
|
||||
### Price Tracking (3 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ----------------------- | ---------------------------------- | ------------------ |
|
||||
| `item_price_history` | Historical prices for master items | `price_history_id` |
|
||||
| `user_submitted_prices` | User-contributed price reports | `submission_id` |
|
||||
| `suggested_corrections` | Suggested edits to flyer items | `correction_id` |
|
||||
|
||||
### User Features (8 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| -------------------- | ------------------------------------ | --------------------------- |
|
||||
| `user_watched_items` | Items user wants to track prices for | `user_watched_item_id` |
|
||||
| `user_alerts` | Price alert thresholds | `alert_id` |
|
||||
| `notifications` | User notifications | `notification_id` |
|
||||
| `user_item_aliases` | User-defined item name aliases | `alias_id` |
|
||||
| `user_follows` | User-to-user follow relationships | `follower_id, following_id` |
|
||||
| `user_reactions` | Reactions to content (likes, etc.) | `reaction_id` |
|
||||
| `budgets` | User-defined spending budgets | `budget_id` |
|
||||
| `search_queries` | Search history for analytics | `query_id` |
|
||||
|
||||
### Shopping Lists (4 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ----------------------- | ------------------------ | ------------------------- |
|
||||
| `shopping_lists` | User shopping lists | `shopping_list_id` |
|
||||
| `shopping_list_items` | Items on shopping lists | `shopping_list_item_id` |
|
||||
| `shared_shopping_lists` | Shopping list sharing | `shared_shopping_list_id` |
|
||||
| `shopping_trips` | Completed shopping trips | `trip_id` |
|
||||
| `shopping_trip_items` | Items purchased on trips | `trip_item_id` |
|
||||
|
||||
### Recipes (11 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| --------------------------------- | -------------------------------- | ------------------------- |
|
||||
| `recipes` | User recipes with metadata | `recipe_id` |
|
||||
| `recipe_ingredients` | Recipe ingredient list | `recipe_ingredient_id` |
|
||||
| `recipe_ingredient_substitutions` | Ingredient alternatives | `substitution_id` |
|
||||
| `tags` | Recipe tags (vegan, quick, etc.) | `tag_id` |
|
||||
| `recipe_tags` | Recipe-to-tag associations | `recipe_id, tag_id` |
|
||||
| `appliances` | Kitchen appliances | `appliance_id` |
|
||||
| `recipe_appliances` | Appliances needed for recipes | `recipe_id, appliance_id` |
|
||||
| `recipe_ratings` | User ratings for recipes | `rating_id` |
|
||||
| `recipe_comments` | User comments on recipes | `comment_id` |
|
||||
| `favorite_recipes` | User recipe favorites | `user_id, recipe_id` |
|
||||
| `recipe_collections` | User recipe collections | `collection_id` |
|
||||
|
||||
### Meal Planning (3 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ------------------- | -------------------------- | ----------------- |
|
||||
| `menu_plans` | Weekly/monthly meal plans | `menu_plan_id` |
|
||||
| `shared_menu_plans` | Menu plan sharing | `share_id` |
|
||||
| `planned_meals` | Individual meals in a plan | `planned_meal_id` |
|
||||
|
||||
### Pantry and Inventory (4 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| -------------------- | ------------------------------------ | ----------------- |
|
||||
| `pantry_items` | User pantry inventory | `pantry_item_id` |
|
||||
| `pantry_locations` | Storage locations (fridge, freezer) | `location_id` |
|
||||
| `expiry_date_ranges` | Reference shelf life data | `expiry_range_id` |
|
||||
| `expiry_alerts` | User expiry notification preferences | `expiry_alert_id` |
|
||||
| `expiry_alert_log` | Sent expiry notifications | `alert_log_id` |
|
||||
|
||||
### Receipts (4 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ------------------------ | ----------------------------- | ----------------- |
|
||||
| `receipts` | Scanned receipt metadata | `receipt_id` |
|
||||
| `receipt_items` | Items parsed from receipts | `receipt_item_id` |
|
||||
| `receipt_processing_log` | OCR/AI processing audit trail | `log_id` |
|
||||
|
||||
### UPC Scanning (2 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ---------------------- | ------------------------------- | ----------- |
|
||||
| `upc_scan_history` | User barcode scan history | `scan_id` |
|
||||
| `upc_external_lookups` | External UPC API response cache | `lookup_id` |
|
||||
|
||||
### Gamification (2 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ------------------- | ---------------------------- | ------------------------- |
|
||||
| `achievements` | Defined achievements | `achievement_id` |
|
||||
| `user_achievements` | Achievements earned by users | `user_id, achievement_id` |
|
||||
|
||||
### User Preferences (3 tables)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| --------------------------- | ---------------------------- | ------------------------- |
|
||||
| `dietary_restrictions` | Defined dietary restrictions | `restriction_id` |
|
||||
| `user_dietary_restrictions` | User dietary preferences | `user_id, restriction_id` |
|
||||
| `user_appliances` | Appliances user owns | `user_id, appliance_id` |
|
||||
|
||||
### Reference Data (1 table)
|
||||
|
||||
| Table | Purpose | Primary Key |
|
||||
| ------------------ | ----------------------- | --------------- |
|
||||
| `unit_conversions` | Unit conversion factors | `conversion_id` |
|
||||
|
||||
---
|
||||
|
||||
## Database Setup
|
||||
|
||||
### Required Extensions
|
||||
|
||||
| Extension | Purpose |
|
||||
| ----------- | ------------------------------------------- |
|
||||
@@ -14,7 +172,7 @@ Flyer Crawler uses PostgreSQL with several extensions for full-text search, geog
|
||||
|
||||
---
|
||||
|
||||
## Database Users
|
||||
### Database Users
|
||||
|
||||
This project uses **environment-specific database users** to isolate production and test environments:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Flyer Crawler - System Architecture Overview
|
||||
|
||||
**Version**: 0.12.5
|
||||
**Last Updated**: 2026-01-22
|
||||
**Version**: 0.12.20
|
||||
**Last Updated**: 2026-01-28
|
||||
**Platform**: Linux (Production and Development)
|
||||
|
||||
---
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
## System Architecture Diagram
|
||||
|
||||
```
|
||||
```text
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| CLIENT LAYER |
|
||||
+-----------------------------------------------------------------------------------+
|
||||
@@ -153,10 +153,10 @@
|
||||
| Component | Technology | Version | Purpose |
|
||||
| ---------------------- | ---------- | -------- | -------------------------------- |
|
||||
| **Runtime** | Node.js | 22.x LTS | Server-side JavaScript runtime |
|
||||
| **Language** | TypeScript | 5.9.x | Type-safe JavaScript superset |
|
||||
| **Web Framework** | Express.js | 5.1.x | HTTP server and routing |
|
||||
| **Frontend Framework** | React | 19.2.x | UI component library |
|
||||
| **Build Tool** | Vite | 7.2.x | Frontend bundling and dev server |
|
||||
| **Language** | TypeScript | 5.9.3 | Type-safe JavaScript superset |
|
||||
| **Web Framework** | Express.js | 5.1.0 | HTTP server and routing |
|
||||
| **Frontend Framework** | React | 19.2.0 | UI component library |
|
||||
| **Build Tool** | Vite | 7.2.4 | Frontend bundling and dev server |
|
||||
|
||||
### Data Storage
|
||||
|
||||
@@ -176,23 +176,23 @@
|
||||
| **OAuth** | Google, GitHub | Social authentication |
|
||||
| **Email** | Nodemailer (SMTP) | Transactional emails |
|
||||
|
||||
### Background Processing
|
||||
### Background Processing Stack
|
||||
|
||||
| Component | Technology | Version | Purpose |
|
||||
| ------------------- | ---------- | ------- | --------------------------------- |
|
||||
| **Job Queues** | BullMQ | 5.65.x | Reliable async job processing |
|
||||
| **Job Queues** | BullMQ | 5.65.1 | Reliable async job processing |
|
||||
| **Process Manager** | PM2 | Latest | Process management and clustering |
|
||||
| **Scheduler** | node-cron | 4.2.x | Scheduled tasks |
|
||||
| **Scheduler** | node-cron | 4.2.1 | Scheduled tasks |
|
||||
|
||||
### Frontend Stack
|
||||
|
||||
| Component | Technology | Version | Purpose |
|
||||
| -------------------- | -------------- | ------- | ---------------------------------------- |
|
||||
| **State Management** | TanStack Query | 5.90.x | Server state caching and synchronization |
|
||||
| **Routing** | React Router | 7.9.x | Client-side routing |
|
||||
| **Styling** | Tailwind CSS | 4.1.x | Utility-first CSS framework |
|
||||
| **Icons** | Lucide React | 0.555.x | Icon components |
|
||||
| **Charts** | Recharts | 3.4.x | Data visualization |
|
||||
| **State Management** | TanStack Query | 5.90.12 | Server state caching and synchronization |
|
||||
| **Routing** | React Router | 7.9.6 | Client-side routing |
|
||||
| **Styling** | Tailwind CSS | 4.1.17 | Utility-first CSS framework |
|
||||
| **Icons** | Lucide React | 0.555.0 | Icon components |
|
||||
| **Charts** | Recharts | 3.4.1 | Data visualization |
|
||||
|
||||
### Observability and Quality
|
||||
|
||||
@@ -221,7 +221,7 @@ The frontend is a single-page application (SPA) built with React 19 and Vite.
|
||||
|
||||
**Directory Structure**:
|
||||
|
||||
```
|
||||
```text
|
||||
src/
|
||||
+-- components/ # Reusable UI components
|
||||
+-- contexts/ # React context providers
|
||||
@@ -244,17 +244,30 @@ The backend is a RESTful API server built with Express.js 5.
|
||||
- Structured logging with Pino
|
||||
- Standardized error handling (ADR-001)
|
||||
|
||||
**API Route Modules**:
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/api/auth` | Authentication (login, register, OAuth) |
|
||||
| `/api/users` | User profile management |
|
||||
| `/api/flyers` | Flyer CRUD and processing |
|
||||
| `/api/recipes` | Recipe management |
|
||||
| `/api/deals` | Best prices and deal discovery |
|
||||
| `/api/stores` | Store management |
|
||||
| `/api/admin` | Administrative functions |
|
||||
| `/api/health` | Health checks and monitoring |
|
||||
**API Route Modules** (all versioned under `/api/v1/*`):
|
||||
|
||||
| Route | Purpose |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `/api/v1/auth` | Authentication (login, register, OAuth) |
|
||||
| `/api/v1/health` | Health checks and monitoring |
|
||||
| `/api/v1/system` | System administration (PM2 status, server info) |
|
||||
| `/api/v1/users` | User profile management |
|
||||
| `/api/v1/ai` | AI-powered features and flyer processing |
|
||||
| `/api/v1/admin` | Administrative functions |
|
||||
| `/api/v1/budgets` | Budget management and spending analysis |
|
||||
| `/api/v1/achievements` | Gamification and achievement system |
|
||||
| `/api/v1/flyers` | Flyer CRUD and processing |
|
||||
| `/api/v1/recipes` | Recipe management and recommendations |
|
||||
| `/api/v1/personalization` | Master items and user preferences |
|
||||
| `/api/v1/price-history` | Price tracking and trend analysis |
|
||||
| `/api/v1/stats` | Public statistics and analytics |
|
||||
| `/api/v1/upc` | UPC barcode scanning and product lookup |
|
||||
| `/api/v1/inventory` | Inventory and expiry tracking |
|
||||
| `/api/v1/receipts` | Receipt scanning and purchase history |
|
||||
| `/api/v1/deals` | Best prices and deal discovery |
|
||||
| `/api/v1/reactions` | Social features (reactions, sharing) |
|
||||
| `/api/v1/stores` | Store management and location services |
|
||||
| `/api/v1/categories` | Category browsing and product categorization |
|
||||
|
||||
### Database (PostgreSQL/PostGIS)
|
||||
|
||||
@@ -331,7 +344,7 @@ BullMQ workers handle asynchronous processing tasks. PM2 manages both the API se
|
||||
|
||||
### Flyer Processing Pipeline
|
||||
|
||||
```
|
||||
```text
|
||||
+-------------+ +----------------+ +------------------+ +---------------+
|
||||
| User | | Express | | BullMQ | | PostgreSQL |
|
||||
| Upload +---->+ Route +---->+ Queue +---->+ Storage |
|
||||
@@ -395,7 +408,7 @@ BullMQ workers handle asynchronous processing tasks. PM2 manages both the API se
|
||||
|
||||
The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
```
|
||||
```text
|
||||
+-----------------------------------------------------------------------+
|
||||
| ROUTES LAYER |
|
||||
| Responsibilities: |
|
||||
@@ -458,7 +471,7 @@ The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
### Entity Relationship Overview
|
||||
|
||||
```
|
||||
```text
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| users | | profiles | | addresses |
|
||||
|------------------| |------------------| |------------------|
|
||||
@@ -537,7 +550,7 @@ The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
### JWT Token Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
+-------------------+ +-------------------+ +-------------------+
|
||||
| Login Request | | Server | | Database |
|
||||
| (email/pass) +---->+ Validates +---->+ Verify User |
|
||||
@@ -576,7 +589,7 @@ The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
### Protected Route Flow
|
||||
|
||||
```
|
||||
```text
|
||||
+-------------------+ +-------------------+ +-------------------+
|
||||
| API Request | | requireAuth | | JWT Strategy |
|
||||
| + Bearer Token +---->+ Middleware +---->+ Validate |
|
||||
@@ -603,7 +616,7 @@ The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
### Worker Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
+-------------------+ +-------------------+ +-------------------+
|
||||
| API Server | | Redis | | Worker Process |
|
||||
| (Queue Producer)| | (Job Storage) | | (Consumer) |
|
||||
@@ -635,7 +648,7 @@ The application follows a strict layered architecture as defined in ADR-035.
|
||||
|
||||
Jobs use exponential backoff for retries:
|
||||
|
||||
```
|
||||
```text
|
||||
Attempt 1: Immediate
|
||||
Attempt 2: Initial delay (e.g., 5 seconds)
|
||||
Attempt 3: 2x delay (e.g., 10 seconds)
|
||||
@@ -658,7 +671,7 @@ Attempt 4: 4x delay (e.g., 20 seconds)
|
||||
|
||||
### Environment Overview
|
||||
|
||||
```
|
||||
```text
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| DEVELOPMENT |
|
||||
+-----------------------------------------------------------------------------------+
|
||||
@@ -710,7 +723,7 @@ Attempt 4: 4x delay (e.g., 20 seconds)
|
||||
|
||||
### Deployment Pipeline (ADR-017)
|
||||
|
||||
```
|
||||
```text
|
||||
+------------+ +------------+ +------------+ +------------+
|
||||
| Push to | | Gitea | | Build & | | Deploy |
|
||||
| main +---->+ Actions +---->+ Test +---->+ to Prod |
|
||||
@@ -839,22 +852,55 @@ The system architecture is governed by Architecture Decision Records (ADRs). Key
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------- |
|
||||
| `src/services/flyerProcessingService.server.ts` | Flyer processing pipeline orchestration |
|
||||
| `src/services/flyerAiProcessor.server.ts` | AI extraction for flyers |
|
||||
| `src/services/aiService.server.ts` | Google Gemini AI integration |
|
||||
| `src/services/cacheService.server.ts` | Redis caching abstraction |
|
||||
| `src/services/emailService.server.ts` | Email sending |
|
||||
| `src/services/queues.server.ts` | BullMQ queue definitions |
|
||||
| `src/services/queueService.server.ts` | Queue management and scheduling |
|
||||
| `src/services/workers.server.ts` | BullMQ worker definitions |
|
||||
| `src/services/websocketService.server.ts` | Real-time WebSocket notifications |
|
||||
| `src/services/receiptService.server.ts` | Receipt scanning and OCR |
|
||||
| `src/services/upcService.server.ts` | UPC barcode lookup |
|
||||
| `src/services/expiryService.server.ts` | Pantry expiry tracking |
|
||||
| `src/services/geocodingService.server.ts` | Address geocoding |
|
||||
| `src/services/analyticsService.server.ts` | Analytics and reporting |
|
||||
| `src/services/monitoringService.server.ts` | Health monitoring |
|
||||
| `src/services/barcodeService.server.ts` | Barcode detection |
|
||||
| `src/services/logger.server.ts` | Structured logging (Pino) |
|
||||
| `src/services/redis.server.ts` | Redis connection management |
|
||||
| `src/services/sentry.server.ts` | Error tracking (Sentry/Bugsink) |
|
||||
|
||||
### Database Files
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------- | -------------------------------------------- |
|
||||
| `src/services/db/connection.db.ts` | Database pool and transaction management |
|
||||
| `src/services/db/errors.db.ts` | Database error types |
|
||||
| `src/services/db/user.db.ts` | User repository |
|
||||
| `src/services/db/flyer.db.ts` | Flyer repository |
|
||||
| `sql/master_schema_rollup.sql` | Complete database schema (for test DB setup) |
|
||||
| `sql/initial_schema.sql` | Fresh installation schema |
|
||||
| File | Purpose |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `src/services/db/connection.db.ts` | Database pool and transaction management |
|
||||
| `src/services/db/errors.db.ts` | Database error types |
|
||||
| `src/services/db/index.db.ts` | Repository exports |
|
||||
| `src/services/db/user.db.ts` | User repository |
|
||||
| `src/services/db/flyer.db.ts` | Flyer repository |
|
||||
| `src/services/db/store.db.ts` | Store repository |
|
||||
| `src/services/db/storeLocation.db.ts` | Store location repository |
|
||||
| `src/services/db/recipe.db.ts` | Recipe repository |
|
||||
| `src/services/db/category.db.ts` | Category repository |
|
||||
| `src/services/db/personalization.db.ts` | Master items and personalization |
|
||||
| `src/services/db/shopping.db.ts` | Shopping lists repository |
|
||||
| `src/services/db/deals.db.ts` | Deals and best prices repository |
|
||||
| `src/services/db/price.db.ts` | Price history repository |
|
||||
| `src/services/db/receipt.db.ts` | Receipt repository |
|
||||
| `src/services/db/upc.db.ts` | UPC scan history repository |
|
||||
| `src/services/db/expiry.db.ts` | Expiry tracking repository |
|
||||
| `src/services/db/gamification.db.ts` | Achievements repository |
|
||||
| `src/services/db/budget.db.ts` | Budget repository |
|
||||
| `src/services/db/reaction.db.ts` | User reactions repository |
|
||||
| `src/services/db/notification.db.ts` | Notifications repository |
|
||||
| `src/services/db/address.db.ts` | Address repository |
|
||||
| `src/services/db/admin.db.ts` | Admin operations repository |
|
||||
| `src/services/db/conversion.db.ts` | Unit conversion repository |
|
||||
| `src/services/db/flyerLocation.db.ts` | Flyer locations repository |
|
||||
| `sql/master_schema_rollup.sql` | Complete database schema (for test DB setup) |
|
||||
| `sql/initial_schema.sql` | Fresh installation schema |
|
||||
|
||||
### Type Definitions
|
||||
|
||||
|
||||
@@ -2,8 +2,26 @@
|
||||
|
||||
Common code patterns extracted from Architecture Decision Records (ADRs). Use these as templates when writing new code.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| -------------------- | ------------------------------------------------- | ------------------------------------- |
|
||||
| **tsoa Controllers** | `BaseController`, `@Route`, `@Security` | `src/controllers/base.controller.ts` |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT`, `@Security('bearerAuth')` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [tsoa Controllers](#tsoa-controllers)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Repository Patterns](#repository-patterns)
|
||||
- [API Response Patterns](#api-response-patterns)
|
||||
@@ -12,12 +30,166 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
- [Authentication](#authentication)
|
||||
- [Caching](#caching)
|
||||
- [Background Jobs](#background-jobs)
|
||||
- [Feature Flags](#feature-flags)
|
||||
|
||||
---
|
||||
|
||||
## tsoa Controllers
|
||||
|
||||
**ADR**: [ADR-018](../adr/0018-api-documentation-strategy.md), [ADR-059](../adr/0059-dependency-modernization.md)
|
||||
|
||||
All API endpoints are implemented as tsoa controller classes that extend `BaseController`. This pattern provides type-safe OpenAPI documentation generation and standardized response formatting.
|
||||
|
||||
### Basic Controller Structure
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateItemRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ItemResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Route('items')
|
||||
@Tags('Items')
|
||||
export class ItemController extends BaseController {
|
||||
/**
|
||||
* Get an item by ID.
|
||||
* @summary Get item
|
||||
* @param id Item ID
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Item retrieved')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async getItem(@Path() id: number): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const item = await itemService.getItemById(id);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new item. Requires authentication.
|
||||
* @summary Create item
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Item created')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async createItem(
|
||||
@Body() body: CreateItemRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const user = request.user as UserProfile;
|
||||
const item = await itemService.createItem(body, user.user.user_id);
|
||||
return this.created(item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BaseController Response Helpers
|
||||
|
||||
```typescript
|
||||
// Success response (200)
|
||||
return this.success(data);
|
||||
|
||||
// Created response (201)
|
||||
return this.created(data);
|
||||
|
||||
// Paginated response
|
||||
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
||||
return this.paginated(items, { page, limit, total });
|
||||
|
||||
// Message-only response
|
||||
return this.message('Operation completed');
|
||||
|
||||
// No content (204)
|
||||
return this.noContent();
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
```typescript
|
||||
import { Security, Request } from 'tsoa';
|
||||
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
||||
|
||||
// Require authentication
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: ExpressRequest): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
// Require admin role
|
||||
@Delete('users/{id}')
|
||||
@Security('bearerAuth')
|
||||
public async deleteUser(@Path() id: string, @Request() req: ExpressRequest): Promise<void> {
|
||||
requireAdminRole(req.user as UserProfile);
|
||||
await userService.deleteUser(id);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Controllers
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
||||
|
||||
// Throw errors - they're handled by the global error handler
|
||||
throw new NotFoundError('Item', id); // 404
|
||||
throw new ValidationError([], 'Invalid'); // 400
|
||||
throw new ForbiddenError('Admin only'); // 403
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { Middlewares } from 'tsoa';
|
||||
import { loginLimiter } from '../config/rateLimiters';
|
||||
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@Response<ErrorResponse>(429, 'Too many attempts')
|
||||
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Regenerating Routes
|
||||
|
||||
After modifying controllers, regenerate the tsoa routes:
|
||||
|
||||
```bash
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
```
|
||||
|
||||
**Full Guide**: See [TSOA-MIGRATION-GUIDE.md](./TSOA-MIGRATION-GUIDE.md) for comprehensive documentation.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
**ADR**: [ADR-001](../adr/0001-standardized-error-handling-for-database-operations.md)
|
||||
**ADR**: [ADR-001](../adr/0001-standardized-error-handling.md)
|
||||
|
||||
### Repository Layer Error Handling
|
||||
|
||||
@@ -78,7 +250,7 @@ throw new DatabaseError('Failed to insert flyer', originalError);
|
||||
|
||||
## Repository Patterns
|
||||
|
||||
**ADR**: [ADR-034](../adr/0034-repository-layer-method-naming-conventions.md)
|
||||
**ADR**: [ADR-034](../adr/0034-repository-pattern-standards.md)
|
||||
|
||||
### Method Naming Conventions
|
||||
|
||||
@@ -155,16 +327,17 @@ export async function listActiveFlyers(client?: PoolClient): Promise<Flyer[]> {
|
||||
|
||||
## API Response Patterns
|
||||
|
||||
**ADR**: [ADR-028](../adr/0028-consistent-api-response-format.md)
|
||||
**ADR**: [ADR-028](../adr/0028-api-response-standardization.md)
|
||||
|
||||
### Success Response
|
||||
|
||||
```typescript
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
app.post('/api/flyers', async (req, res) => {
|
||||
app.post('/api/v1/flyers', async (req, res) => {
|
||||
const flyer = await flyerService.createFlyer(req.body);
|
||||
return sendSuccess(res, flyer, 'Flyer created successfully', 201);
|
||||
// sendSuccess(res, data, statusCode?, meta?)
|
||||
return sendSuccess(res, flyer, 201);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -173,30 +346,32 @@ app.post('/api/flyers', async (req, res) => {
|
||||
```typescript
|
||||
import { sendPaginated } from '../utils/apiResponse';
|
||||
|
||||
app.get('/api/flyers', async (req, res) => {
|
||||
const { page = 1, pageSize = 20 } = req.query;
|
||||
const { items, total } = await flyerService.listFlyers(page, pageSize);
|
||||
app.get('/api/v1/flyers', async (req, res) => {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const { items, total } = await flyerService.listFlyers(page, limit);
|
||||
|
||||
return sendPaginated(res, {
|
||||
items,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
});
|
||||
// sendPaginated(res, data[], { page, limit, total }, meta?)
|
||||
return sendPaginated(res, items, { page, limit, total });
|
||||
});
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```typescript
|
||||
import { sendError } from '../utils/apiResponse';
|
||||
import { sendError, sendSuccess, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
app.get('/api/flyers/:id', async (req, res) => {
|
||||
app.get('/api/v1/flyers/:id', async (req, res) => {
|
||||
try {
|
||||
const flyer = await flyerDb.getFlyerById(parseInt(req.params.id));
|
||||
return sendSuccess(res, flyer);
|
||||
} catch (error) {
|
||||
return sendError(res, error); // Automatically maps error to correct status
|
||||
// sendError(res, code, message, statusCode?, details?, meta?)
|
||||
if (error instanceof NotFoundError) {
|
||||
return sendError(res, ErrorCode.NOT_FOUND, error.message, 404);
|
||||
}
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
return sendError(res, ErrorCode.INTERNAL_ERROR, 'An error occurred', 500);
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -205,12 +380,12 @@ app.get('/api/flyers/:id', async (req, res) => {
|
||||
|
||||
## Transaction Management
|
||||
|
||||
**ADR**: [ADR-002](../adr/0002-transaction-management-pattern.md)
|
||||
**ADR**: [ADR-002](../adr/0002-standardized-transaction-management.md)
|
||||
|
||||
### Basic Transaction
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from '../services/db/transaction.db';
|
||||
import { withTransaction } from '../services/db/connection.db';
|
||||
|
||||
export async function createFlyerWithItems(
|
||||
flyerData: FlyerInput,
|
||||
@@ -262,7 +437,7 @@ export async function bulkImportFlyers(flyersData: FlyerInput[]): Promise<Import
|
||||
|
||||
## Input Validation
|
||||
|
||||
**ADR**: [ADR-003](../adr/0003-input-validation-framework.md)
|
||||
**ADR**: [ADR-003](../adr/0003-standardized-input-validation-using-middleware.md)
|
||||
|
||||
### Zod Schema Definition
|
||||
|
||||
@@ -298,10 +473,10 @@ export type CreateFlyerInput = z.infer<typeof createFlyerSchema>;
|
||||
import { validateRequest } from '../middleware/validation';
|
||||
import { createFlyerSchema } from '../schemas/flyer.schemas';
|
||||
|
||||
app.post('/api/flyers', validateRequest(createFlyerSchema), async (req, res) => {
|
||||
app.post('/api/v1/flyers', validateRequest(createFlyerSchema), async (req, res) => {
|
||||
// req.body is now type-safe and validated
|
||||
const flyer = await flyerService.createFlyer(req.body);
|
||||
return sendSuccess(res, flyer, 'Flyer created successfully', 201);
|
||||
return sendSuccess(res, flyer, 201);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -331,7 +506,7 @@ export async function processFlyer(data: unknown): Promise<Flyer> {
|
||||
import { authenticateJWT } from '../middleware/auth';
|
||||
|
||||
app.get(
|
||||
'/api/profile',
|
||||
'/api/v1/profile',
|
||||
authenticateJWT, // Middleware adds req.user
|
||||
async (req, res) => {
|
||||
// req.user is guaranteed to exist
|
||||
@@ -347,7 +522,7 @@ app.get(
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
|
||||
app.get(
|
||||
'/api/flyers',
|
||||
'/api/v1/flyers',
|
||||
optionalAuth, // req.user may or may not exist
|
||||
async (req, res) => {
|
||||
const flyers = req.user
|
||||
@@ -374,7 +549,7 @@ export function generateToken(user: User): string {
|
||||
|
||||
## Caching
|
||||
|
||||
**ADR**: [ADR-029](../adr/0029-redis-caching-strategy.md)
|
||||
**ADR**: [ADR-009](../adr/0009-caching-strategy-for-read-heavy-operations.md)
|
||||
|
||||
### Cache Pattern
|
||||
|
||||
@@ -414,7 +589,7 @@ export async function updateFlyer(id: number, data: UpdateFlyerInput): Promise<F
|
||||
|
||||
## Background Jobs
|
||||
|
||||
**ADR**: [ADR-036](../adr/0036-background-job-processing-architecture.md)
|
||||
**ADR**: [ADR-006](../adr/0006-background-job-processing-and-task-queues.md)
|
||||
|
||||
### Queue Job
|
||||
|
||||
@@ -473,6 +648,153 @@ const flyerWorker = new Worker(
|
||||
|
||||
---
|
||||
|
||||
## Feature Flags
|
||||
|
||||
**ADR**: [ADR-024](../adr/0024-feature-flagging-strategy.md)
|
||||
|
||||
Feature flags enable controlled feature rollout, A/B testing, and quick production disablement without redeployment. All flags default to `false` (opt-in model).
|
||||
|
||||
### Backend Usage
|
||||
|
||||
```typescript
|
||||
import { isFeatureEnabled, getFeatureFlags } from '../services/featureFlags.server';
|
||||
|
||||
// Check a specific flag in route handler
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
if (isFeatureEnabled('newDashboard')) {
|
||||
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
|
||||
}
|
||||
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
|
||||
});
|
||||
|
||||
// Check flag in service layer
|
||||
function processFlyer(flyer: Flyer): ProcessedFlyer {
|
||||
if (isFeatureEnabled('experimentalAi')) {
|
||||
return processWithExperimentalAi(flyer);
|
||||
}
|
||||
return processWithStandardAi(flyer);
|
||||
}
|
||||
|
||||
// Get all flags (admin endpoint)
|
||||
router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
|
||||
sendSuccess(res, { flags: getFeatureFlags() });
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Usage
|
||||
|
||||
```tsx
|
||||
import { useFeatureFlag, useAllFeatureFlags } from '../hooks/useFeatureFlag';
|
||||
import { FeatureFlag } from '../components/FeatureFlag';
|
||||
|
||||
// Hook approach - for logic beyond rendering
|
||||
function Dashboard() {
|
||||
const isNewDashboard = useFeatureFlag('newDashboard');
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewDashboard) {
|
||||
analytics.track('new_dashboard_viewed');
|
||||
}
|
||||
}, [isNewDashboard]);
|
||||
|
||||
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
|
||||
}
|
||||
|
||||
// Declarative component approach
|
||||
function App() {
|
||||
return (
|
||||
<FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
|
||||
<NewDashboard />
|
||||
</FeatureFlag>
|
||||
);
|
||||
}
|
||||
|
||||
// Debug panel showing all flags
|
||||
function DebugPanel() {
|
||||
const flags = useAllFeatureFlags();
|
||||
return (
|
||||
<ul>
|
||||
{Object.entries(flags).map(([name, enabled]) => (
|
||||
<li key={name}>
|
||||
{name}: {enabled ? 'ON' : 'OFF'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Flag
|
||||
|
||||
1. **Backend** (`src/config/env.ts`):
|
||||
|
||||
```typescript
|
||||
// In featureFlagsSchema
|
||||
myNewFeature: booleanString(false), // FEATURE_MY_NEW_FEATURE
|
||||
|
||||
// In loadEnvVars()
|
||||
myNewFeature: process.env.FEATURE_MY_NEW_FEATURE,
|
||||
```
|
||||
|
||||
2. **Frontend** (`src/config.ts` and `src/vite-env.d.ts`):
|
||||
|
||||
```typescript
|
||||
// In config.ts featureFlags section
|
||||
myNewFeature: import.meta.env.VITE_FEATURE_MY_NEW_FEATURE === 'true',
|
||||
|
||||
// In vite-env.d.ts
|
||||
readonly VITE_FEATURE_MY_NEW_FEATURE?: string;
|
||||
```
|
||||
|
||||
3. **Environment** (`.env.example`):
|
||||
|
||||
```bash
|
||||
# FEATURE_MY_NEW_FEATURE=false
|
||||
# VITE_FEATURE_MY_NEW_FEATURE=false
|
||||
```
|
||||
|
||||
### Testing Feature Flags
|
||||
|
||||
```typescript
|
||||
// Backend - reset modules to test different states
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.FEATURE_NEW_DASHBOARD = 'true';
|
||||
});
|
||||
|
||||
// Frontend - mock config module
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
featureFlags: {
|
||||
newDashboard: true,
|
||||
betaRecipes: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Flag Lifecycle
|
||||
|
||||
| Phase | Actions |
|
||||
| ---------- | -------------------------------------------------------------- |
|
||||
| **Add** | Add to schemas (backend + frontend), default `false`, document |
|
||||
| **Enable** | Set env var `='true'`, restart application |
|
||||
| **Remove** | Remove conditional code, remove from schemas, remove env vars |
|
||||
| **Sunset** | Max 3 months after full rollout - remove flag |
|
||||
|
||||
### Current Flags
|
||||
|
||||
| Flag | Backend Env Var | Frontend Env Var | Purpose |
|
||||
| ---------------- | ------------------------- | ------------------------------ | ------------------------ |
|
||||
| `bugsinkSync` | `FEATURE_BUGSINK_SYNC` | `VITE_FEATURE_BUGSINK_SYNC` | Bugsink error sync |
|
||||
| `advancedRbac` | `FEATURE_ADVANCED_RBAC` | `VITE_FEATURE_ADVANCED_RBAC` | Advanced RBAC features |
|
||||
| `newDashboard` | `FEATURE_NEW_DASHBOARD` | `VITE_FEATURE_NEW_DASHBOARD` | New dashboard experience |
|
||||
| `betaRecipes` | `FEATURE_BETA_RECIPES` | `VITE_FEATURE_BETA_RECIPES` | Beta recipe features |
|
||||
| `experimentalAi` | `FEATURE_EXPERIMENTAL_AI` | `VITE_FEATURE_EXPERIMENTAL_AI` | Experimental AI features |
|
||||
| `debugMode` | `FEATURE_DEBUG_MODE` | `VITE_FEATURE_DEBUG_MODE` | Debug mode |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR Index](../adr/index.md) - All architecture decision records
|
||||
|
||||
@@ -229,7 +229,7 @@ SELECT * FROM flyers WHERE store_id = 1;
|
||||
- Add missing indexes
|
||||
- Optimize WHERE clauses
|
||||
- Use connection pooling
|
||||
- See [ADR-034](../adr/0034-repository-layer-method-naming-conventions.md)
|
||||
- See [ADR-034](../adr/0034-repository-pattern-standards.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -237,7 +237,7 @@ SELECT * FROM flyers WHERE store_id = 1;
|
||||
|
||||
### Tests Pass on Windows, Fail in Container
|
||||
|
||||
**Cause**: Platform-specific behavior (ADR-014)
|
||||
**Cause**: Platform-specific behavior ([ADR-014](../adr/0014-containerization-and-deployment-strategy.md))
|
||||
|
||||
**Rule**: Container results are authoritative. Windows results are unreliable.
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ When the container starts (`scripts/dev-entrypoint.sh`):
|
||||
|
||||
PM2 manages three processes in the dev container:
|
||||
|
||||
```
|
||||
```text
|
||||
+--------------------+ +------------------------+ +--------------------+
|
||||
| flyer-crawler- | | flyer-crawler- | | flyer-crawler- |
|
||||
| api-dev | | worker-dev | | vite-dev |
|
||||
@@ -404,5 +404,5 @@ podman exec -it flyer-crawler-dev pm2 restart flyer-crawler-api-dev
|
||||
- [DEBUGGING.md](DEBUGGING.md) - Debugging strategies
|
||||
- [LOGSTASH-QUICK-REF.md](../operations/LOGSTASH-QUICK-REF.md) - Logstash quick reference
|
||||
- [DEV-CONTAINER-BUGSINK.md](../DEV-CONTAINER-BUGSINK.md) - Bugsink setup in dev container
|
||||
- [ADR-014](../adr/0014-linux-only-platform.md) - Linux-only platform decision
|
||||
- [ADR-050](../adr/0050-postgresql-function-observability.md) - PostgreSQL function observability
|
||||
- [ADR-014](../adr/0014-containerization-and-deployment-strategy.md) - Containerization and deployment strategy
|
||||
- [ADR-050](../adr/0050-postgresql-function-observability.md) - PostgreSQL function observability (includes log aggregation)
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Testing Guide
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------------------------------------------ | ---------------------------- |
|
||||
| `podman exec -it flyer-crawler-dev npm test` | Run all tests |
|
||||
| `podman exec -it flyer-crawler-dev npm run test:unit` | Unit tests (~2900) |
|
||||
| `podman exec -it flyer-crawler-dev npm run test:integration` | Integration tests (28 files) |
|
||||
| `podman exec -it flyer-crawler-dev npm run test:e2e` | E2E tests (11 files) |
|
||||
| `podman exec -it flyer-crawler-dev npm run type-check` | TypeScript check |
|
||||
|
||||
**Critical**: Always run tests in the dev container. Windows results are unreliable.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This project has comprehensive test coverage including unit tests, integration tests, and E2E tests. All tests must be run in the **Linux dev container environment** for reliable results.
|
||||
@@ -76,7 +90,7 @@ To verify type-check is working correctly:
|
||||
|
||||
Example error output:
|
||||
|
||||
```
|
||||
```text
|
||||
src/pages/MyDealsPage.tsx:68:31 - error TS2339: Property 'store_name' does not exist on type 'WatchedItemDeal'.
|
||||
|
||||
68 <span>{deal.store_name}</span>
|
||||
@@ -113,15 +127,26 @@ Located throughout `src/` directory alongside source files with `.test.ts` or `.
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Integration Tests (5 test files)
|
||||
### Integration Tests (28 test files)
|
||||
|
||||
Located in `src/tests/integration/`:
|
||||
Located in `src/tests/integration/`. Key test files include:
|
||||
|
||||
- `admin.integration.test.ts`
|
||||
- `flyer.integration.test.ts`
|
||||
- `price.integration.test.ts`
|
||||
- `public.routes.integration.test.ts`
|
||||
- `receipt.integration.test.ts`
|
||||
| Test File | Domain |
|
||||
| -------------------------------------- | -------------------------- |
|
||||
| `admin.integration.test.ts` | Admin dashboard operations |
|
||||
| `auth.integration.test.ts` | Authentication flows |
|
||||
| `budget.integration.test.ts` | Budget management |
|
||||
| `flyer.integration.test.ts` | Flyer CRUD operations |
|
||||
| `flyer-processing.integration.test.ts` | AI flyer processing |
|
||||
| `gamification.integration.test.ts` | Achievements and points |
|
||||
| `inventory.integration.test.ts` | Inventory management |
|
||||
| `notification.integration.test.ts` | User notifications |
|
||||
| `receipt.integration.test.ts` | Receipt processing |
|
||||
| `recipe.integration.test.ts` | Recipe management |
|
||||
| `shopping-list.integration.test.ts` | Shopping list operations |
|
||||
| `user.integration.test.ts` | User profile operations |
|
||||
|
||||
See `src/tests/integration/` for the complete list.
|
||||
|
||||
Requires PostgreSQL and Redis services running.
|
||||
|
||||
@@ -129,13 +154,23 @@ Requires PostgreSQL and Redis services running.
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### E2E Tests (3 test files)
|
||||
### E2E Tests (11 test files)
|
||||
|
||||
Located in `src/tests/e2e/`:
|
||||
Located in `src/tests/e2e/`. Full user journey tests:
|
||||
|
||||
- `deals-journey.e2e.test.ts`
|
||||
- `budget-journey.e2e.test.ts`
|
||||
- `receipt-journey.e2e.test.ts`
|
||||
| Test File | Journey |
|
||||
| --------------------------------- | ----------------------------- |
|
||||
| `admin-authorization.e2e.test.ts` | Admin access control |
|
||||
| `admin-dashboard.e2e.test.ts` | Admin dashboard flows |
|
||||
| `auth.e2e.test.ts` | Login/logout/registration |
|
||||
| `budget-journey.e2e.test.ts` | Budget tracking workflow |
|
||||
| `deals-journey.e2e.test.ts` | Finding and saving deals |
|
||||
| `error-reporting.e2e.test.ts` | Error handling verification |
|
||||
| `flyer-upload.e2e.test.ts` | Flyer upload and processing |
|
||||
| `inventory-journey.e2e.test.ts` | Pantry management |
|
||||
| `receipt-journey.e2e.test.ts` | Receipt scanning and tracking |
|
||||
| `upc-journey.e2e.test.ts` | UPC barcode scanning |
|
||||
| `user-journey.e2e.test.ts` | User profile management |
|
||||
|
||||
Requires all services (PostgreSQL, Redis, BullMQ workers) running.
|
||||
|
||||
@@ -157,20 +192,18 @@ Located in `src/tests/utils/storeHelpers.ts`:
|
||||
|
||||
```typescript
|
||||
// Create a store with a location in one call
|
||||
const store = await createStoreWithLocation({
|
||||
storeName: 'Test Store',
|
||||
address: {
|
||||
address_line_1: '123 Main St',
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M1M 1M1',
|
||||
},
|
||||
pool,
|
||||
log,
|
||||
const store = await createStoreWithLocation(pool, {
|
||||
name: 'Test Store',
|
||||
address: '123 Main St',
|
||||
city: 'Toronto',
|
||||
province: 'ON',
|
||||
postalCode: 'M1M 1M1',
|
||||
});
|
||||
|
||||
// Returns: { storeId, addressId, storeLocationId }
|
||||
|
||||
// Cleanup stores and their locations
|
||||
await cleanupStoreLocations([storeId1, storeId2], pool, log);
|
||||
await cleanupStoreLocation(pool, store);
|
||||
```
|
||||
|
||||
### Mock Factories
|
||||
|
||||
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# tsoa Migration Guide
|
||||
|
||||
This guide documents the migration from `swagger-jsdoc` to `tsoa` for API documentation and route generation in the Flyer Crawler project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Creating a New Controller](#creating-a-new-controller)
|
||||
- [BaseController Pattern](#basecontroller-pattern)
|
||||
- [Authentication](#authentication)
|
||||
- [Request Handling](#request-handling)
|
||||
- [Response Formatting](#response-formatting)
|
||||
- [DTOs and Type Definitions](#dtos-and-type-definitions)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Testing Controllers](#testing-controllers)
|
||||
- [Build and Development](#build-and-development)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Migration Lessons Learned](#migration-lessons-learned)
|
||||
|
||||
## Overview
|
||||
|
||||
### What Changed
|
||||
|
||||
| Before (swagger-jsdoc) | After (tsoa) |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| JSDoc `@openapi` comments in route files | TypeScript decorators on controller classes |
|
||||
| Manual Express route registration | tsoa generates routes automatically |
|
||||
| Separate Zod validation middleware | tsoa validates from TypeScript types |
|
||||
| OpenAPI spec from comments | OpenAPI spec from decorators and types |
|
||||
|
||||
### Why tsoa?
|
||||
|
||||
1. **Type Safety**: OpenAPI spec is generated from TypeScript types, eliminating drift
|
||||
2. **Active Maintenance**: tsoa is actively maintained (vs. unmaintained swagger-jsdoc)
|
||||
3. **Reduced Duplication**: No more parallel JSDoc + TypeScript definitions
|
||||
4. **Route Generation**: tsoa generates Express routes, reducing boilerplate
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with response utilities |
|
||||
| `src/controllers/types.ts` | Shared controller type definitions |
|
||||
| `src/controllers/*.controller.ts` | Domain controllers |
|
||||
| `src/dtos/common.dto.ts` | Shared DTO definitions |
|
||||
| `src/middleware/tsoaAuthentication.ts` | JWT authentication handler |
|
||||
| `src/routes/tsoa-generated.ts` | Generated Express routes |
|
||||
| `src/config/tsoa-spec.json` | Generated OpenAPI 3.0 spec |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
|
|
||||
v
|
||||
Express Middleware (logging, CORS, body parsing)
|
||||
|
|
||||
v
|
||||
tsoa-generated routes (src/routes/tsoa-generated.ts)
|
||||
|
|
||||
v
|
||||
tsoaAuthentication (if @Security decorator present)
|
||||
|
|
||||
v
|
||||
Controller Method
|
||||
|
|
||||
v
|
||||
Service Layer
|
||||
|
|
||||
v
|
||||
Repository Layer
|
||||
|
|
||||
v
|
||||
Database
|
||||
```
|
||||
|
||||
### Controller Structure
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
base.controller.ts # Base class with response helpers
|
||||
types.ts # Shared type definitions
|
||||
health.controller.ts # Health check endpoints
|
||||
auth.controller.ts # Authentication endpoints
|
||||
user.controller.ts # User management endpoints
|
||||
...
|
||||
```
|
||||
|
||||
## Creating a New Controller
|
||||
|
||||
### Step 1: Create the Controller File
|
||||
|
||||
```typescript
|
||||
// src/controllers/example.controller.ts
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
} from './base.controller';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface CreateExampleRequest {
|
||||
/**
|
||||
* Name of the example item.
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
* @example "My Example"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Optional description.
|
||||
* @example "This is an example item"
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ExampleResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Example controller demonstrating tsoa patterns.
|
||||
*/
|
||||
@Route('examples')
|
||||
@Tags('Examples')
|
||||
export class ExampleController extends BaseController {
|
||||
/**
|
||||
* List all examples with pagination.
|
||||
* @summary List examples
|
||||
* @param page Page number (1-indexed)
|
||||
* @param limit Items per page (max 100)
|
||||
* @returns Paginated list of examples
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Examples retrieved')
|
||||
public async listExamples(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
): Promise<PaginatedResponse<ExampleResponse>> {
|
||||
const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
|
||||
// Call service layer
|
||||
const { items, total } = await exampleService.listExamples(p, l);
|
||||
|
||||
return this.paginated(items, { page: p, limit: l, total });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single example by ID.
|
||||
* @summary Get example
|
||||
* @param id Example ID
|
||||
* @returns The example
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Example retrieved')
|
||||
@Response<ErrorResponse>(404, 'Example not found')
|
||||
public async getExample(@Path() id: number): Promise<SuccessResponseType<ExampleResponse>> {
|
||||
const example = await exampleService.getExampleById(id);
|
||||
return this.success(example);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new example.
|
||||
* Requires authentication.
|
||||
* @summary Create example
|
||||
* @param requestBody Example data
|
||||
* @param request Express request
|
||||
* @returns Created example
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Example created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async createExample(
|
||||
@Body() requestBody: CreateExampleRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExampleResponse>> {
|
||||
const user = request.user as UserProfile;
|
||||
const example = await exampleService.createExample(requestBody, user.user.user_id);
|
||||
return this.created(example);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an example.
|
||||
* Requires authentication.
|
||||
* @summary Delete example
|
||||
* @param id Example ID
|
||||
* @param request Express request
|
||||
*/
|
||||
@Delete('{id}')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(204, 'Example deleted')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
@Response<ErrorResponse>(404, 'Example not found')
|
||||
public async deleteExample(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const user = request.user as UserProfile;
|
||||
await exampleService.deleteExample(id, user.user.user_id);
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Regenerate Routes
|
||||
|
||||
After creating or modifying a controller:
|
||||
|
||||
```bash
|
||||
# Generate OpenAPI spec and routes
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
|
||||
# Or use the combined command
|
||||
npm run prebuild
|
||||
```
|
||||
|
||||
### Step 3: Add Tests
|
||||
|
||||
Create a test file at `src/controllers/__tests__/example.controller.test.ts`.
|
||||
|
||||
## BaseController Pattern
|
||||
|
||||
All controllers extend `BaseController` which provides:
|
||||
|
||||
### Response Helpers
|
||||
|
||||
```typescript
|
||||
// Success response (200)
|
||||
return this.success(data);
|
||||
|
||||
// Created response (201)
|
||||
return this.created(data);
|
||||
|
||||
// Paginated response (200 with pagination metadata)
|
||||
return this.paginated(items, { page, limit, total });
|
||||
|
||||
// Message-only response
|
||||
return this.message('Operation completed successfully');
|
||||
|
||||
// No content response (204)
|
||||
return this.noContent();
|
||||
|
||||
// Error response (prefer throwing errors)
|
||||
this.setStatus(400);
|
||||
return this.error('BAD_REQUEST', 'Invalid input', details);
|
||||
```
|
||||
|
||||
### Pagination Helpers
|
||||
|
||||
```typescript
|
||||
// Normalize pagination with defaults and bounds
|
||||
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
||||
// page defaults to 1, limit defaults to 20, max 100
|
||||
|
||||
// Calculate pagination metadata
|
||||
const meta = this.calculatePagination({ page, limit, total });
|
||||
// Returns: { page, limit, total, totalPages, hasNextPage, hasPrevPage }
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
```typescript
|
||||
// Access standard error codes
|
||||
this.ErrorCode.VALIDATION_ERROR; // 'VALIDATION_ERROR'
|
||||
this.ErrorCode.NOT_FOUND; // 'NOT_FOUND'
|
||||
this.ErrorCode.UNAUTHORIZED; // 'UNAUTHORIZED'
|
||||
this.ErrorCode.FORBIDDEN; // 'FORBIDDEN'
|
||||
this.ErrorCode.CONFLICT; // 'CONFLICT'
|
||||
this.ErrorCode.BAD_REQUEST; // 'BAD_REQUEST'
|
||||
this.ErrorCode.INTERNAL_ERROR; // 'INTERNAL_ERROR'
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Using @Security Decorator
|
||||
|
||||
```typescript
|
||||
import { Security, Request } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
// request.user is populated by tsoaAuthentication.ts
|
||||
const user = request.user as UserProfile;
|
||||
return this.success(toUserProfileDto(user));
|
||||
}
|
||||
```
|
||||
|
||||
### Requiring Admin Role
|
||||
|
||||
```typescript
|
||||
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
||||
|
||||
@Delete('users/{id}')
|
||||
@Security('bearerAuth')
|
||||
public async deleteUser(
|
||||
@Path() id: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const user = request.user as UserProfile;
|
||||
requireAdminRole(user); // Throws 403 if not admin
|
||||
|
||||
await userService.deleteUser(id);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### How Authentication Works
|
||||
|
||||
1. tsoa sees `@Security('bearerAuth')` decorator
|
||||
2. tsoa calls `expressAuthentication()` from `src/middleware/tsoaAuthentication.ts`
|
||||
3. The function extracts and validates the JWT token
|
||||
4. User profile is fetched from database and attached to `request.user`
|
||||
5. If authentication fails, an `AuthenticationError` is thrown
|
||||
|
||||
## Request Handling
|
||||
|
||||
### Path Parameters
|
||||
|
||||
```typescript
|
||||
@Get('{id}')
|
||||
public async getItem(@Path() id: number): Promise<...> { ... }
|
||||
|
||||
// Multiple path params
|
||||
@Get('{userId}/items/{itemId}')
|
||||
public async getUserItem(
|
||||
@Path() userId: string,
|
||||
@Path() itemId: number,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
public async listItems(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
@Query() status?: 'active' | 'inactive',
|
||||
@Query() search?: string,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```typescript
|
||||
interface CreateItemRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async createItem(
|
||||
@Body() requestBody: CreateItemRequest,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
public async getWithHeader(
|
||||
@Header('X-Custom-Header') customHeader?: string,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Accessing Express Request/Response
|
||||
|
||||
```typescript
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
@Post()
|
||||
public async handleRequest(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<...> {
|
||||
const reqLog = request.log; // Pino logger
|
||||
const cookies = request.cookies; // Cookies
|
||||
const ip = request.ip; // Client IP
|
||||
const res = request.res!; // Express response
|
||||
|
||||
// Set cookie
|
||||
res.cookie('name', 'value', { httpOnly: true });
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Response Formatting
|
||||
|
||||
### Standard Success Response
|
||||
|
||||
```typescript
|
||||
// Returns: { "success": true, "data": {...} }
|
||||
return this.success({ id: 1, name: 'Item' });
|
||||
```
|
||||
|
||||
### Created Response (201)
|
||||
|
||||
```typescript
|
||||
// Sets status 201 and returns success response
|
||||
return this.created(newItem);
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```typescript
|
||||
// Returns: { "success": true, "data": [...], "meta": { "pagination": {...} } }
|
||||
return this.paginated(items, { page: 1, limit: 20, total: 100 });
|
||||
```
|
||||
|
||||
### No Content (204)
|
||||
|
||||
```typescript
|
||||
// Sets status 204 with no body
|
||||
return this.noContent();
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
Prefer throwing errors rather than returning error responses:
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
||||
|
||||
// Throw for not found
|
||||
throw new NotFoundError('Item', id);
|
||||
|
||||
// Throw for validation errors
|
||||
throw new ValidationError([], 'Invalid input');
|
||||
|
||||
// Throw for forbidden
|
||||
throw new ForbiddenError('Admin access required');
|
||||
```
|
||||
|
||||
If you need manual error response:
|
||||
|
||||
```typescript
|
||||
this.setStatus(400);
|
||||
return this.error(this.ErrorCode.BAD_REQUEST, 'Invalid operation', { reason: '...' });
|
||||
```
|
||||
|
||||
## DTOs and Type Definitions
|
||||
|
||||
### Why DTOs?
|
||||
|
||||
tsoa generates OpenAPI specs from TypeScript types. Some types cannot be serialized:
|
||||
|
||||
- Tuples: `[number, number]` (e.g., GeoJSON coordinates)
|
||||
- Complex generics
|
||||
- Circular references
|
||||
|
||||
DTOs flatten these into tsoa-compatible structures.
|
||||
|
||||
### Shared DTOs
|
||||
|
||||
Define shared DTOs in `src/dtos/common.dto.ts`:
|
||||
|
||||
```typescript
|
||||
// src/dtos/common.dto.ts
|
||||
|
||||
/**
|
||||
* Address with flattened coordinates.
|
||||
* GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle.
|
||||
*/
|
||||
export interface AddressDto {
|
||||
address_id: number;
|
||||
address_line_1: string;
|
||||
city: string;
|
||||
province_state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
// Flattened from GeoJSONPoint.coordinates
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
user_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Conversion Functions
|
||||
|
||||
Create conversion functions to map domain types to DTOs:
|
||||
|
||||
```typescript
|
||||
// In controller file
|
||||
function toAddressDto(address: Address): AddressDto {
|
||||
return {
|
||||
address_id: address.address_id,
|
||||
address_line_1: address.address_line_1,
|
||||
city: address.city,
|
||||
province_state: address.province_state,
|
||||
postal_code: address.postal_code,
|
||||
country: address.country,
|
||||
latitude: address.location?.coordinates[1] ?? null,
|
||||
longitude: address.location?.coordinates[0] ?? null,
|
||||
created_at: address.created_at,
|
||||
updated_at: address.updated_at,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Important: Avoid Duplicate Type Names
|
||||
|
||||
tsoa requires unique type names across all controllers. If two controllers define an interface with the same name, tsoa will fail.
|
||||
|
||||
**Solution**: Define shared types in `src/dtos/common.dto.ts` and import them.
|
||||
|
||||
## File Uploads
|
||||
|
||||
tsoa supports file uploads via `@UploadedFile` and `@FormField` decorators:
|
||||
|
||||
```typescript
|
||||
import { Post, Route, UploadedFile, FormField, Security } from 'tsoa';
|
||||
import multer from 'multer';
|
||||
|
||||
// Configure multer
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: '/tmp/uploads',
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
});
|
||||
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyerController extends BaseController {
|
||||
/**
|
||||
* Upload a flyer image.
|
||||
* @summary Upload flyer
|
||||
* @param file The flyer image file
|
||||
* @param storeId Associated store ID
|
||||
* @param request Express request
|
||||
*/
|
||||
@Post('upload')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(upload.single('file'))
|
||||
@SuccessResponse(201, 'Flyer uploaded')
|
||||
public async uploadFlyer(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@FormField() storeId?: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
const user = request.user as UserProfile;
|
||||
const flyer = await flyerService.processUpload(file, storeId, user.user.user_id);
|
||||
return this.created(flyer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Apply rate limiters using the `@Middlewares` decorator:
|
||||
|
||||
```typescript
|
||||
import { Middlewares } from 'tsoa';
|
||||
import { loginLimiter, registerLimiter } from '../config/rateLimiters';
|
||||
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@SuccessResponse(200, 'Login successful')
|
||||
@Response<ErrorResponse>(429, 'Too many login attempts')
|
||||
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
||||
|
||||
@Post('register')
|
||||
@Middlewares(registerLimiter)
|
||||
@SuccessResponse(201, 'User registered')
|
||||
@Response<ErrorResponse>(429, 'Too many registration attempts')
|
||||
public async register(@Body() body: RegisterRequest): Promise<...> { ... }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
Use the error classes from `base.controller.ts`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ForbiddenError,
|
||||
UniqueConstraintError,
|
||||
} from './base.controller';
|
||||
|
||||
// Not found (404)
|
||||
throw new NotFoundError('User', userId);
|
||||
|
||||
// Validation error (400)
|
||||
throw new ValidationError([], 'Invalid email format');
|
||||
|
||||
// Forbidden (403)
|
||||
throw new ForbiddenError('Admin access required');
|
||||
|
||||
// Conflict (409) - e.g., duplicate email
|
||||
throw new UniqueConstraintError('email', 'Email already registered');
|
||||
```
|
||||
|
||||
### Global Error Handler
|
||||
|
||||
Errors are caught by the global error handler in `server.ts` which formats them according to ADR-028:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
The `tsoaAuthentication.ts` module throws `AuthenticationError` with appropriate HTTP status codes:
|
||||
|
||||
- 401: Missing token, invalid token, expired token
|
||||
- 403: User lacks required role
|
||||
- 500: Server configuration error
|
||||
|
||||
## Testing Controllers
|
||||
|
||||
### Test File Location
|
||||
|
||||
```
|
||||
src/controllers/__tests__/
|
||||
example.controller.test.ts
|
||||
auth.controller.test.ts
|
||||
user.controller.test.ts
|
||||
...
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```typescript
|
||||
// src/controllers/__tests__/example.controller.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExampleController } from '../example.controller';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/exampleService', () => ({
|
||||
exampleService: {
|
||||
listExamples: vi.fn(),
|
||||
getExampleById: vi.fn(),
|
||||
createExample: vi.fn(),
|
||||
deleteExample: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { exampleService } from '../../services/exampleService';
|
||||
|
||||
describe('ExampleController', () => {
|
||||
let controller: ExampleController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new ExampleController();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('listExamples', () => {
|
||||
it('should return paginated examples', async () => {
|
||||
const mockItems = [{ id: 1, name: 'Test' }];
|
||||
vi.mocked(exampleService.listExamples).mockResolvedValue({
|
||||
items: mockItems,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listExamples(1, 20);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockItems);
|
||||
expect(result.meta?.pagination).toBeDefined();
|
||||
expect(result.meta?.pagination?.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createExample', () => {
|
||||
it('should create example and return 201', async () => {
|
||||
const mockExample = { id: 1, name: 'New', created_at: '2026-01-01' };
|
||||
vi.mocked(exampleService.createExample).mockResolvedValue(mockExample);
|
||||
|
||||
const mockRequest = {
|
||||
user: { user: { user_id: 'user-123' } },
|
||||
} as any;
|
||||
|
||||
const result = await controller.createExample({ name: 'New' }, mockRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockExample);
|
||||
// Note: setStatus is called internally, verify with spy if needed
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Authentication
|
||||
|
||||
```typescript
|
||||
describe('authenticated endpoints', () => {
|
||||
it('should use user from request', async () => {
|
||||
const mockRequest = {
|
||||
user: {
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
role: 'user',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await controller.getProfile(mockRequest);
|
||||
|
||||
expect(result.data.user.user_id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Known Test Limitations
|
||||
|
||||
Some test files have type errors with mock objects that are acceptable:
|
||||
|
||||
```typescript
|
||||
// Type error: 'any' is not assignable to 'Express.Request'
|
||||
// This is acceptable in tests - the mock has the properties we need
|
||||
const mockRequest = { user: mockUser } as any;
|
||||
```
|
||||
|
||||
These type errors do not affect test correctness. The 4603 unit tests and 345 integration tests all pass.
|
||||
|
||||
## Build and Development
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Create or modify controller
|
||||
2. Run `npm run tsoa:spec && npm run tsoa:routes`
|
||||
3. Run `npm run type-check` to verify
|
||||
4. Run tests
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
|
||||
"build": "tsc"
|
||||
}
|
||||
```
|
||||
|
||||
### Watching for Changes
|
||||
|
||||
Currently, tsoa routes must be regenerated manually when controllers change. Consider adding a watch script:
|
||||
|
||||
```bash
|
||||
# In development, regenerate on save
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
```
|
||||
|
||||
### Generated Files
|
||||
|
||||
| File | Regenerate When |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `src/routes/tsoa-generated.ts` | Controller changes |
|
||||
| `src/config/tsoa-spec.json` | Controller changes, DTO changes |
|
||||
|
||||
These files are committed to the repository for faster builds.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Duplicate identifier" Error
|
||||
|
||||
**Problem**: tsoa fails with "Duplicate identifier" for a type.
|
||||
|
||||
**Solution**: Move the type to `src/dtos/common.dto.ts` and import it in all controllers.
|
||||
|
||||
### "Unable to resolve type" Error
|
||||
|
||||
**Problem**: tsoa cannot serialize a complex type (tuples, generics).
|
||||
|
||||
**Solution**: Create a DTO with flattened/simplified structure.
|
||||
|
||||
```typescript
|
||||
// Before: GeoJSONPoint with coordinates: [number, number]
|
||||
// After: AddressDto with latitude, longitude as separate fields
|
||||
```
|
||||
|
||||
### Route Not Found (404)
|
||||
|
||||
**Problem**: New endpoint returns 404.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Ensure controller file matches glob pattern: `src/controllers/**/*.controller.ts`
|
||||
2. Regenerate routes: `npm run tsoa:routes`
|
||||
3. Verify the route is in `src/routes/tsoa-generated.ts`
|
||||
|
||||
### Authentication Not Working
|
||||
|
||||
**Problem**: `request.user` is undefined.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Ensure `@Security('bearerAuth')` decorator is on the method
|
||||
2. Verify `tsoaAuthentication.ts` is correctly configured in `tsoa.json`
|
||||
3. Check the Authorization header format: `Bearer <token>`
|
||||
|
||||
### Type Mismatch in Tests
|
||||
|
||||
**Problem**: TypeScript errors when mocking Express.Request.
|
||||
|
||||
**Solution**: Use `as any` cast for mock objects in tests. This is acceptable and does not affect test correctness.
|
||||
|
||||
```typescript
|
||||
const mockRequest = {
|
||||
user: mockUserProfile,
|
||||
log: mockLogger,
|
||||
} as any;
|
||||
```
|
||||
|
||||
## Migration Lessons Learned
|
||||
|
||||
### What Worked Well
|
||||
|
||||
1. **BaseController Pattern**: Provides consistent response formatting and familiar helpers
|
||||
2. **Incremental Migration**: Controllers can be migrated one at a time
|
||||
3. **Type-First Design**: Defining request/response types first makes implementation clearer
|
||||
4. **Shared DTOs**: Centralizing DTOs in `common.dto.ts` prevents duplicate type errors
|
||||
|
||||
### Challenges Encountered
|
||||
|
||||
1. **Tuple Types**: tsoa cannot serialize TypeScript tuples. Solution: Flatten to separate fields.
|
||||
2. **Passport Integration**: OAuth callbacks use redirect-based flows that don't fit tsoa's JSON model. Solution: Keep OAuth callbacks in Express routes.
|
||||
3. **Test Type Errors**: Mock objects don't perfectly match Express types. Solution: Accept `as any` casts in tests.
|
||||
4. **Build Pipeline**: Must regenerate routes when controllers change. Solution: Add to prebuild script.
|
||||
|
||||
### Recommendations for Future Controllers
|
||||
|
||||
1. Start with the DTO/request/response types
|
||||
2. Use `@SuccessResponse` and `@Response` decorators for all status codes
|
||||
3. Add JSDoc comments for OpenAPI descriptions
|
||||
4. Keep controller methods thin - delegate to service layer
|
||||
5. Test controllers in isolation by mocking services
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-018: API Documentation Strategy](../adr/0018-api-documentation-strategy.md)
|
||||
- [ADR-059: Dependency Modernization](../adr/0059-dependency-modernization.md)
|
||||
- [ADR-028: API Response Standardization](../adr/0028-api-response-standardization.md)
|
||||
- [CODE-PATTERNS.md](./CODE-PATTERNS.md)
|
||||
- [TESTING.md](./TESTING.md)
|
||||
- [tsoa Documentation](https://tsoa-community.github.io/docs/)
|
||||
@@ -2,134 +2,259 @@
|
||||
|
||||
Complete guide to environment variables used in Flyer Crawler.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Minimum Required Variables (Development)
|
||||
|
||||
| Variable | Example | Purpose |
|
||||
| ---------------- | ------------------------ | -------------------- |
|
||||
| `DB_HOST` | `localhost` | PostgreSQL host |
|
||||
| `DB_USER` | `postgres` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | `postgres` | PostgreSQL password |
|
||||
| `DB_NAME` | `flyer_crawler_dev` | Database name |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL |
|
||||
| `JWT_SECRET` | (32+ character string) | JWT signing key |
|
||||
| `GEMINI_API_KEY` | `AIzaSy...` | Google Gemini API |
|
||||
|
||||
### Source of Truth
|
||||
|
||||
The Zod schema at `src/config/env.ts` is the authoritative source for all environment variables. If a variable is not in this file, it is not used by the application.
|
||||
|
||||
---
|
||||
|
||||
## Configuration by Environment
|
||||
|
||||
### Production
|
||||
|
||||
**Location**: Gitea CI/CD secrets injected during deployment
|
||||
**Path**: `/var/www/flyer-crawler.projectium.com/`
|
||||
**Note**: No `.env` file exists - all variables come from CI/CD
|
||||
| Aspect | Details |
|
||||
| -------- | ------------------------------------------ |
|
||||
| Location | Gitea CI/CD secrets injected at deployment |
|
||||
| Path | `/var/www/flyer-crawler.projectium.com/` |
|
||||
| File | No `.env` file - all from CI/CD secrets |
|
||||
|
||||
### Test
|
||||
|
||||
**Location**: Gitea CI/CD secrets + `.env.test` file
|
||||
**Path**: `/var/www/flyer-crawler-test.projectium.com/`
|
||||
**Note**: `.env.test` overrides for test-specific values
|
||||
| Aspect | Details |
|
||||
| -------- | --------------------------------------------- |
|
||||
| Location | Gitea CI/CD secrets + `.env.test` overrides |
|
||||
| Path | `/var/www/flyer-crawler-test.projectium.com/` |
|
||||
| File | `.env.test` for test-specific values |
|
||||
|
||||
### Development Container
|
||||
|
||||
**Location**: `.env.local` file in project root
|
||||
**Note**: Overrides default DSNs in `compose.dev.yml`
|
||||
| Aspect | Details |
|
||||
| -------- | --------------------------------------- |
|
||||
| Location | `.env.local` file in project root |
|
||||
| Priority | Overrides defaults in `compose.dev.yml` |
|
||||
| File | `.env.local` (gitignored) |
|
||||
|
||||
## Required Variables
|
||||
---
|
||||
|
||||
### Database
|
||||
## Complete Variable Reference
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------ | ---------------------------- | ------------------------------------------ |
|
||||
| `DB_HOST` | PostgreSQL host | `localhost` (dev), `projectium.com` (prod) |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_USER_PROD` | Production database user | `flyer_crawler_prod` |
|
||||
| `DB_PASSWORD_PROD` | Production database password | (secret) |
|
||||
| `DB_DATABASE_PROD` | Production database name | `flyer-crawler-prod` |
|
||||
| `DB_USER_TEST` | Test database user | `flyer_crawler_test` |
|
||||
| `DB_PASSWORD_TEST` | Test database password | (secret) |
|
||||
| `DB_DATABASE_TEST` | Test database name | `flyer-crawler-test` |
|
||||
| `DB_USER` | Dev database user | `postgres` |
|
||||
| `DB_PASSWORD` | Dev database password | `postgres` |
|
||||
| `DB_NAME` | Dev database name | `flyer_crawler_dev` |
|
||||
### Database Configuration
|
||||
|
||||
**Note**: Production and test use separate `_PROD` and `_TEST` suffixed variables. Development uses unsuffixed variables.
|
||||
| Variable | Required | Default | Description |
|
||||
| ------------- | -------- | ------- | ----------------- |
|
||||
| `DB_HOST` | Yes | - | PostgreSQL host |
|
||||
| `DB_PORT` | No | `5432` | PostgreSQL port |
|
||||
| `DB_USER` | Yes | - | Database username |
|
||||
| `DB_PASSWORD` | Yes | - | Database password |
|
||||
| `DB_NAME` | Yes | - | Database name |
|
||||
|
||||
### Redis
|
||||
**Environment-Specific Variables** (Gitea Secrets):
|
||||
|
||||
| Variable | Description | Example |
|
||||
| --------------------- | ------------------------- | ------------------------------ |
|
||||
| `REDIS_URL` | Redis connection URL | `redis://localhost:6379` (dev) |
|
||||
| `REDIS_PASSWORD_PROD` | Production Redis password | (secret) |
|
||||
| `REDIS_PASSWORD_TEST` | Test Redis password | (secret) |
|
||||
| Variable | Environment | Description |
|
||||
| ------------------ | ----------- | ------------------------ |
|
||||
| `DB_USER_PROD` | Production | Production database user |
|
||||
| `DB_PASSWORD_PROD` | Production | Production database pass |
|
||||
| `DB_DATABASE_PROD` | Production | Production database name |
|
||||
| `DB_USER_TEST` | Test | Test database user |
|
||||
| `DB_PASSWORD_TEST` | Test | Test database password |
|
||||
| `DB_DATABASE_TEST` | Test | Test database name |
|
||||
|
||||
### Redis Configuration
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ---------------- | -------- | ------- | ------------------------- |
|
||||
| `REDIS_URL` | Yes | - | Redis connection URL |
|
||||
| `REDIS_PASSWORD` | No | - | Redis password (optional) |
|
||||
|
||||
**URL Format**: `redis://[user:password@]host:port`
|
||||
|
||||
**Examples**:
|
||||
|
||||
```bash
|
||||
# Development (no auth)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Production (with auth)
|
||||
REDIS_URL=redis://:${REDIS_PASSWORD_PROD}@localhost:6379
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ---------------------- | -------------------------- | -------------------------------- |
|
||||
| `JWT_SECRET` | JWT token signing key | (minimum 32 characters) |
|
||||
| `SESSION_SECRET` | Session encryption key | (minimum 32 characters) |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | (secret) |
|
||||
| `GH_CLIENT_ID` | GitHub OAuth client ID | `xxx` |
|
||||
| `GH_CLIENT_SECRET` | GitHub OAuth client secret | (secret) |
|
||||
| Variable | Required | Min Length | Description |
|
||||
| ---------------------- | -------- | ---------- | ----------------------- |
|
||||
| `JWT_SECRET` | Yes | 32 chars | JWT token signing key |
|
||||
| `JWT_SECRET_PREVIOUS` | No | - | Previous key (rotation) |
|
||||
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth secret |
|
||||
| `GITHUB_CLIENT_ID` | No | - | GitHub OAuth client ID |
|
||||
| `GITHUB_CLIENT_SECRET` | No | - | GitHub OAuth secret |
|
||||
|
||||
**Generate Secure Secret**:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
### AI Services
|
||||
|
||||
| Variable | Description | Example |
|
||||
| -------------------------------- | ---------------------------- | ----------- |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY` | Google Gemini API key (prod) | `AIzaSy...` |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY_TEST` | Google Gemini API key (test) | `AIzaSy...` |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps Geocoding API | `AIzaSy...` |
|
||||
| Variable | Required | Description |
|
||||
| ---------------------------- | -------- | -------------------------------- |
|
||||
| `GEMINI_API_KEY` | Yes\* | Google Gemini API key |
|
||||
| `GEMINI_RPM` | No | Rate limit (default: 5) |
|
||||
| `AI_PRICE_QUALITY_THRESHOLD` | No | Quality threshold (default: 0.5) |
|
||||
|
||||
### Application
|
||||
\*Required for flyer processing. Application works without it but cannot extract flyer data.
|
||||
|
||||
| Variable | Description | Example |
|
||||
| -------------- | ------------------------ | ----------------------------------- |
|
||||
| `NODE_ENV` | Environment mode | `development`, `test`, `production` |
|
||||
| `PORT` | Backend server port | `3001` |
|
||||
| `FRONTEND_URL` | Frontend application URL | `http://localhost:5173` (dev) |
|
||||
**Get API Key**: [Google AI Studio](https://aistudio.google.com/app/apikey)
|
||||
|
||||
### Error Tracking
|
||||
### Google Services
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ---------------------- | -------------------------------- | --------------------------- |
|
||||
| `SENTRY_DSN` | Sentry DSN (production) | `https://xxx@sentry.io/xxx` |
|
||||
| `VITE_SENTRY_DSN` | Frontend Sentry DSN (production) | `https://xxx@sentry.io/xxx` |
|
||||
| `SENTRY_DSN_TEST` | Sentry DSN (test) | `https://xxx@sentry.io/xxx` |
|
||||
| `VITE_SENTRY_DSN_TEST` | Frontend Sentry DSN (test) | `https://xxx@sentry.io/xxx` |
|
||||
| `SENTRY_AUTH_TOKEN` | Sentry API token for releases | (secret) |
|
||||
| Variable | Required | Description |
|
||||
| ---------------------- | -------- | -------------------------------- |
|
||||
| `GOOGLE_MAPS_API_KEY` | No | Google Maps Geocoding API |
|
||||
| `GOOGLE_CLIENT_ID` | No | OAuth (see Authentication above) |
|
||||
| `GOOGLE_CLIENT_SECRET` | No | OAuth (see Authentication above) |
|
||||
|
||||
## Optional Variables
|
||||
### UPC Lookup APIs
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------- | ----------------------- | ----------------- |
|
||||
| `LOG_LEVEL` | Logging verbosity | `info` |
|
||||
| `REDIS_TTL` | Cache TTL in seconds | `3600` |
|
||||
| `MAX_UPLOAD_SIZE` | Max file upload size | `10mb` |
|
||||
| `RATE_LIMIT_WINDOW` | Rate limit window (ms) | `900000` (15 min) |
|
||||
| `RATE_LIMIT_MAX` | Max requests per window | `100` |
|
||||
| Variable | Required | Description |
|
||||
| ------------------------ | -------- | ---------------------- |
|
||||
| `UPC_ITEM_DB_API_KEY` | No | UPC Item DB API key |
|
||||
| `BARCODE_LOOKUP_API_KEY` | No | Barcode Lookup API key |
|
||||
|
||||
### Application Settings
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| -------------- | -------- | ------------- | ------------------------ |
|
||||
| `NODE_ENV` | No | `development` | Environment mode |
|
||||
| `PORT` | No | `3001` | Backend server port |
|
||||
| `FRONTEND_URL` | No | - | Frontend URL (CORS) |
|
||||
| `BASE_URL` | No | - | API base URL |
|
||||
| `STORAGE_PATH` | No | (see below) | Flyer image storage path |
|
||||
|
||||
**NODE_ENV Values**: `development`, `test`, `staging`, `production`
|
||||
|
||||
**Default STORAGE_PATH**: `/var/www/flyer-crawler.projectium.com/flyer-images`
|
||||
|
||||
### Email/SMTP Configuration
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------- | -------- | ------- | ----------------------- |
|
||||
| `SMTP_HOST` | No | - | SMTP server hostname |
|
||||
| `SMTP_PORT` | No | `587` | SMTP server port |
|
||||
| `SMTP_USER` | No | - | SMTP username |
|
||||
| `SMTP_PASS` | No | - | SMTP password |
|
||||
| `SMTP_SECURE` | No | `false` | Use TLS |
|
||||
| `SMTP_FROM_EMAIL` | No | - | From address for emails |
|
||||
|
||||
**Note**: Email functionality degrades gracefully if not configured.
|
||||
|
||||
### Worker Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------------- | ------- | ---------------------------- |
|
||||
| `WORKER_CONCURRENCY` | `1` | Main worker concurrency |
|
||||
| `WORKER_LOCK_DURATION` | `30000` | Lock duration (ms) |
|
||||
| `EMAIL_WORKER_CONCURRENCY` | `10` | Email worker concurrency |
|
||||
| `ANALYTICS_WORKER_CONCURRENCY` | `1` | Analytics worker concurrency |
|
||||
| `CLEANUP_WORKER_CONCURRENCY` | `10` | Cleanup worker concurrency |
|
||||
| `WEEKLY_ANALYTICS_WORKER_CONCURRENCY` | `1` | Weekly analytics concurrency |
|
||||
|
||||
### Error Tracking (Bugsink/Sentry)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| --------------------- | -------- | -------- | ------------------------------- |
|
||||
| `SENTRY_DSN` | No | - | Backend Sentry DSN |
|
||||
| `SENTRY_ENABLED` | No | `true` | Enable error tracking |
|
||||
| `SENTRY_ENVIRONMENT` | No | NODE_ENV | Environment name for errors |
|
||||
| `SENTRY_DEBUG` | No | `false` | Enable Sentry SDK debug logging |
|
||||
| `VITE_SENTRY_DSN` | No | - | Frontend Sentry DSN |
|
||||
| `VITE_SENTRY_ENABLED` | No | `true` | Enable frontend error tracking |
|
||||
| `VITE_SENTRY_DEBUG` | No | `false` | Frontend SDK debug logging |
|
||||
|
||||
**DSN Format**: `http://[key]@[host]:[port]/[project_id]`
|
||||
|
||||
**Dev Container DSNs**:
|
||||
|
||||
```bash
|
||||
# Backend (internal)
|
||||
SENTRY_DSN=http://<key>@localhost:8000/1
|
||||
|
||||
# Frontend (via nginx proxy)
|
||||
VITE_SENTRY_DSN=https://<key>@localhost/bugsink-api/2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------- | ------------------------------------------- |
|
||||
| `src/config/env.ts` | Zod schema validation - **source of truth** |
|
||||
| `ecosystem.config.cjs` | PM2 process manager config |
|
||||
| `ecosystem.config.cjs` | PM2 process manager (production) |
|
||||
| `ecosystem.dev.config.cjs` | PM2 process manager (development) |
|
||||
| `.gitea/workflows/deploy-to-prod.yml` | Production deployment workflow |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | Test deployment workflow |
|
||||
| `.env.example` | Template with all variables |
|
||||
| `.env.local` | Dev container overrides (not in git) |
|
||||
| `.env.test` | Test environment overrides (not in git) |
|
||||
|
||||
---
|
||||
|
||||
## Adding New Variables
|
||||
|
||||
### 1. Update Zod Schema
|
||||
### Checklist
|
||||
|
||||
1. [ ] **Update Zod Schema** - Edit `src/config/env.ts`
|
||||
2. [ ] **Add to Gitea Secrets** - For prod/test environments
|
||||
3. [ ] **Update Deployment Workflows** - `.gitea/workflows/*.yml`
|
||||
4. [ ] **Update PM2 Config** - `ecosystem.config.cjs`
|
||||
5. [ ] **Update .env.example** - Template for developers
|
||||
6. [ ] **Update this document** - Add to appropriate section
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
#### 1. Update Zod Schema
|
||||
|
||||
Edit `src/config/env.ts`:
|
||||
|
||||
```typescript
|
||||
const envSchema = z.object({
|
||||
// ... existing variables ...
|
||||
NEW_VARIABLE: z.string().min(1),
|
||||
newSection: z.object({
|
||||
newVariable: z.string().min(1, 'NEW_VARIABLE is required'),
|
||||
}),
|
||||
});
|
||||
|
||||
// In loadEnvVars():
|
||||
newSection: {
|
||||
newVariable: process.env.NEW_VARIABLE,
|
||||
},
|
||||
```
|
||||
|
||||
### 2. Add to Gitea Secrets
|
||||
|
||||
For prod/test environments:
|
||||
#### 2. Add to Gitea Secrets
|
||||
|
||||
1. Go to Gitea repository Settings > Secrets
|
||||
2. Add `NEW_VARIABLE` with value
|
||||
2. Add `NEW_VARIABLE` with production value
|
||||
3. Add `NEW_VARIABLE_TEST` if test needs different value
|
||||
|
||||
### 3. Update Deployment Workflows
|
||||
#### 3. Update Deployment Workflows
|
||||
|
||||
Edit `.gitea/workflows/deploy-to-prod.yml`:
|
||||
|
||||
@@ -145,7 +270,7 @@ env:
|
||||
NEW_VARIABLE: ${{ secrets.NEW_VARIABLE_TEST }}
|
||||
```
|
||||
|
||||
### 4. Update PM2 Config
|
||||
#### 4. Update PM2 Config
|
||||
|
||||
Edit `ecosystem.config.cjs`:
|
||||
|
||||
@@ -161,31 +286,36 @@ module.exports = {
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Update Documentation
|
||||
|
||||
- Add to `.env.example`
|
||||
- Update this document
|
||||
- Document in relevant feature docs
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Secrets Management
|
||||
### Do
|
||||
|
||||
- **NEVER** commit secrets to git
|
||||
- Use Gitea Secrets for prod/test
|
||||
- Use `.env.local` for dev (gitignored)
|
||||
- Generate secrets with cryptographic randomness
|
||||
- Rotate secrets regularly
|
||||
- Use environment-specific database users
|
||||
|
||||
### Do Not
|
||||
|
||||
- Commit secrets to git
|
||||
- Use short or predictable secrets
|
||||
- Share secrets across environments
|
||||
- Log sensitive values
|
||||
|
||||
### Secret Generation
|
||||
|
||||
```bash
|
||||
# Generate secure random secrets
|
||||
# Generate secure random secrets (64 hex characters)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# Example output:
|
||||
# a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
```
|
||||
|
||||
### Database Users
|
||||
|
||||
Each environment has its own PostgreSQL user:
|
||||
### Database Users by Environment
|
||||
|
||||
| Environment | User | Database |
|
||||
| ----------- | -------------------- | -------------------- |
|
||||
@@ -193,44 +323,61 @@ Each environment has its own PostgreSQL user:
|
||||
| Test | `flyer_crawler_test` | `flyer-crawler-test` |
|
||||
| Development | `postgres` | `flyer_crawler_dev` |
|
||||
|
||||
**Setup Commands** (as postgres superuser):
|
||||
|
||||
```sql
|
||||
-- Production
|
||||
CREATE DATABASE "flyer-crawler-prod";
|
||||
CREATE USER flyer_crawler_prod WITH PASSWORD 'secure-password';
|
||||
ALTER DATABASE "flyer-crawler-prod" OWNER TO flyer_crawler_prod;
|
||||
\c "flyer-crawler-prod"
|
||||
ALTER SCHEMA public OWNER TO flyer_crawler_prod;
|
||||
GRANT CREATE, USAGE ON SCHEMA public TO flyer_crawler_prod;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- Test (similar commands with _test suffix)
|
||||
```
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
Environment variables are validated at startup via `src/config/env.ts`. If validation fails:
|
||||
Environment variables are validated at startup via `src/config/env.ts`.
|
||||
|
||||
1. Check the error message for missing/invalid variables
|
||||
2. Verify `.env.local` (dev) or Gitea Secrets (prod/test)
|
||||
3. Ensure values match schema requirements (min length, format, etc.)
|
||||
### Startup Validation
|
||||
|
||||
If validation fails, you will see:
|
||||
|
||||
```text
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ CONFIGURATION ERROR - APPLICATION STARTUP ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
The following environment variables are missing or invalid:
|
||||
|
||||
- database.host: DB_HOST is required
|
||||
- auth.jwtSecret: JWT_SECRET must be at least 32 characters
|
||||
|
||||
Please check your .env file or environment configuration.
|
||||
```
|
||||
|
||||
### Debugging Configuration
|
||||
|
||||
```bash
|
||||
# Check what variables are set (dev container)
|
||||
podman exec flyer-crawler-dev env | grep -E "^(DB_|REDIS_|JWT_|SENTRY_)"
|
||||
|
||||
# Test database connection
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "SELECT 1;"
|
||||
|
||||
# Test Redis connection
|
||||
podman exec flyer-crawler-redis redis-cli ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Variable Not Found
|
||||
|
||||
```
|
||||
```text
|
||||
Error: Missing required environment variable: JWT_SECRET
|
||||
```
|
||||
|
||||
**Solution**: Add the variable to your environment configuration.
|
||||
**Solutions**:
|
||||
|
||||
1. Check `.env.local` exists and has the variable
|
||||
2. Verify variable name matches schema exactly
|
||||
3. Restart the application after changes
|
||||
|
||||
### Invalid Value
|
||||
|
||||
```
|
||||
```text
|
||||
Error: JWT_SECRET must be at least 32 characters
|
||||
```
|
||||
|
||||
@@ -240,32 +387,36 @@ Error: JWT_SECRET must be at least 32 characters
|
||||
|
||||
Check `NODE_ENV` is set correctly:
|
||||
|
||||
- `development` - Local dev container
|
||||
- `test` - CI/CD test server
|
||||
- `production` - Production server
|
||||
| Value | Purpose |
|
||||
| ------------- | ---------------------- |
|
||||
| `development` | Local dev container |
|
||||
| `test` | CI/CD test server |
|
||||
| `staging` | Pre-production testing |
|
||||
| `production` | Production server |
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
Verify database credentials:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "SELECT 1;"
|
||||
|
||||
# Production (via SSH)
|
||||
ssh root@projectium.com "psql -U flyer_crawler_prod -d flyer-crawler-prod -c 'SELECT 1;'"
|
||||
# If connection fails, check:
|
||||
# 1. Container is running: podman ps
|
||||
# 2. DB_HOST matches container network
|
||||
# 3. DB_PASSWORD is correct
|
||||
```
|
||||
|
||||
## Reference
|
||||
---
|
||||
|
||||
- **Validation Schema**: [src/config/env.ts](../../src/config/env.ts)
|
||||
- **Template**: [.env.example](../../.env.example)
|
||||
- **Deployment Workflows**: [.gitea/workflows/](../../.gitea/workflows/)
|
||||
- **PM2 Config**: [ecosystem.config.cjs](../../ecosystem.config.cjs)
|
||||
|
||||
## See Also
|
||||
## Related Documentation
|
||||
|
||||
- [QUICKSTART.md](QUICKSTART.md) - Quick setup guide
|
||||
- [INSTALL.md](INSTALL.md) - Detailed installation
|
||||
- [DEV-CONTAINER.md](../development/DEV-CONTAINER.md) - Dev container setup
|
||||
- [DEPLOYMENT.md](../operations/DEPLOYMENT.md) - Production deployment
|
||||
- [AUTHENTICATION.md](../architecture/AUTHENTICATION.md) - OAuth setup
|
||||
- [ADR-007](../adr/0007-configuration-and-secrets-management.md) - Configuration decisions
|
||||
|
||||
---
|
||||
|
||||
Last updated: January 2026
|
||||
|
||||
@@ -1,203 +1,453 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide covers setting up a local development environment for Flyer Crawler.
|
||||
Complete setup instructions for the Flyer Crawler local development environment.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Setup Method | Best For | Time | Document Section |
|
||||
| ----------------- | --------------------------- | ------ | --------------------------------------------------- |
|
||||
| Quick Start | Already have Postgres/Redis | 5 min | [Quick Start](#quick-start) |
|
||||
| Dev Container | Full production-like setup | 15 min | [Dev Container](#development-container-recommended) |
|
||||
| Manual Containers | Learning the components | 20 min | [Podman Setup](#podman-setup-manual) |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- Access to a PostgreSQL database (local or remote)
|
||||
- Redis instance (for session management)
|
||||
- Google Gemini API key
|
||||
- Google Maps API key (for geocoding)
|
||||
### Required Software
|
||||
|
||||
| Software | Minimum Version | Purpose | Download |
|
||||
| -------------- | --------------- | -------------------- | ----------------------------------------------- |
|
||||
| Node.js | 20.x | Runtime | [nodejs.org](https://nodejs.org/) |
|
||||
| Podman Desktop | 4.x | Container management | [podman-desktop.io](https://podman-desktop.io/) |
|
||||
| Git | 2.x | Version control | [git-scm.com](https://git-scm.com/) |
|
||||
|
||||
### Windows-Specific Requirements
|
||||
|
||||
| Requirement | Purpose | Setup Command |
|
||||
| ----------- | ------------------------------ | ---------------------------------- |
|
||||
| WSL 2 | Linux compatibility for Podman | `wsl --install` (admin PowerShell) |
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Check all prerequisites
|
||||
node --version # Expected: v20.x or higher
|
||||
podman --version # Expected: podman version 4.x or higher
|
||||
git --version # Expected: git version 2.x or higher
|
||||
wsl --list -v # Expected: Shows WSL 2 distro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you already have PostgreSQL and Redis configured:
|
||||
If you already have PostgreSQL and Redis configured externally:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
# 1. Clone the repository
|
||||
git clone https://gitea.projectium.com/flyer-crawler/flyer-crawler.git
|
||||
cd flyer-crawler
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
# 3. Create .env.local (see Environment section below)
|
||||
|
||||
# 4. Run in development mode
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Access Points**:
|
||||
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend API: `http://localhost:3001`
|
||||
|
||||
---
|
||||
|
||||
## Development Environment with Podman (Recommended for Windows)
|
||||
## Development Container (Recommended)
|
||||
|
||||
This approach uses Podman with an Ubuntu container for a consistent development environment.
|
||||
The dev container provides a complete, production-like environment.
|
||||
|
||||
### What's Included
|
||||
|
||||
| Service | Purpose | Port |
|
||||
| ---------- | ------------------------ | ---------- |
|
||||
| Node.js | API server, worker, Vite | 3001, 5173 |
|
||||
| PostgreSQL | Database with PostGIS | 5432 |
|
||||
| Redis | Cache and job queues | 6379 |
|
||||
| NGINX | HTTPS reverse proxy | 443 |
|
||||
| Bugsink | Error tracking | 8443 |
|
||||
| Logstash | Log aggregation | - |
|
||||
| PM2 | Process management | - |
|
||||
|
||||
### Setup Steps
|
||||
|
||||
#### Step 1: Initialize Podman
|
||||
|
||||
```bash
|
||||
# Windows: Start Podman Desktop, or from terminal:
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
#### Step 2: Start Dev Container
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
podman-compose -f compose.dev.yml up -d
|
||||
|
||||
# View logs (optional)
|
||||
podman-compose -f compose.dev.yml logs -f
|
||||
```
|
||||
|
||||
**Expected Output**:
|
||||
|
||||
```text
|
||||
[+] Running 3/3
|
||||
- Container flyer-crawler-postgres Started
|
||||
- Container flyer-crawler-redis Started
|
||||
- Container flyer-crawler-dev Started
|
||||
```
|
||||
|
||||
#### Step 3: Verify Services
|
||||
|
||||
```bash
|
||||
# Check containers are running
|
||||
podman ps
|
||||
|
||||
# Check PM2 processes
|
||||
podman exec -it flyer-crawler-dev pm2 status
|
||||
```
|
||||
|
||||
**Expected PM2 Status**:
|
||||
|
||||
```text
|
||||
+---------------------------+--------+-------+
|
||||
| name | status | cpu |
|
||||
+---------------------------+--------+-------+
|
||||
| flyer-crawler-api-dev | online | 0% |
|
||||
| flyer-crawler-worker-dev | online | 0% |
|
||||
| flyer-crawler-vite-dev | online | 0% |
|
||||
+---------------------------+--------+-------+
|
||||
```
|
||||
|
||||
#### Step 4: Access Application
|
||||
|
||||
| Service | URL | Notes |
|
||||
| ----------- | ------------------------ | ---------------------------- |
|
||||
| Frontend | `https://localhost` | NGINX proxies to Vite |
|
||||
| Backend API | `http://localhost:3001` | Express server |
|
||||
| Bugsink | `https://localhost:8443` | Login: admin@localhost/admin |
|
||||
|
||||
### SSL Certificate Setup (Optional but Recommended)
|
||||
|
||||
To eliminate browser security warnings:
|
||||
|
||||
**Windows**:
|
||||
|
||||
1. Double-click `certs/mkcert-ca.crt`
|
||||
2. Click "Install Certificate..."
|
||||
3. Select "Local Machine" > Next
|
||||
4. Select "Place all certificates in the following store"
|
||||
5. Browse > Select "Trusted Root Certification Authorities" > OK
|
||||
6. Click Next > Finish
|
||||
7. Restart browser
|
||||
|
||||
**Other Platforms**: See [`certs/README.md`](../../certs/README.md)
|
||||
|
||||
### Managing the Dev Container
|
||||
|
||||
| Action | Command |
|
||||
| --------- | ------------------------------------------- |
|
||||
| Start | `podman-compose -f compose.dev.yml up -d` |
|
||||
| Stop | `podman-compose -f compose.dev.yml down` |
|
||||
| View logs | `podman-compose -f compose.dev.yml logs -f` |
|
||||
| Restart | `podman-compose -f compose.dev.yml restart` |
|
||||
| Rebuild | `podman-compose -f compose.dev.yml build` |
|
||||
|
||||
---
|
||||
|
||||
## Podman Setup (Manual)
|
||||
|
||||
For understanding the individual components or custom configurations.
|
||||
|
||||
### Step 1: Install Prerequisites on Windows
|
||||
|
||||
1. **Install WSL 2**: Podman on Windows relies on the Windows Subsystem for Linux.
|
||||
```powershell
|
||||
# Run in administrator PowerShell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
Restart computer after WSL installation.
|
||||
|
||||
Run this in an administrator PowerShell.
|
||||
### Step 2: Initialize Podman
|
||||
|
||||
2. **Install Podman Desktop**: Download and install [Podman Desktop for Windows](https://podman-desktop.io/).
|
||||
1. Launch **Podman Desktop**
|
||||
2. Follow the setup wizard to initialize Podman machine
|
||||
3. Start the Podman machine
|
||||
|
||||
### Step 2: Set Up Podman
|
||||
|
||||
1. **Initialize Podman**: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||
2. **Start Podman**: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||
|
||||
### Step 3: Set Up the Ubuntu Container
|
||||
|
||||
1. **Pull Ubuntu Image**:
|
||||
|
||||
```bash
|
||||
podman pull ubuntu:latest
|
||||
```
|
||||
|
||||
2. **Create a Podman Volume** (persists node_modules between container restarts):
|
||||
|
||||
```bash
|
||||
podman volume create node_modules_cache
|
||||
```
|
||||
|
||||
3. **Run the Ubuntu Container**:
|
||||
|
||||
Open a terminal in your project's root directory and run:
|
||||
|
||||
```bash
|
||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev \
|
||||
-v "$(pwd):/app" \
|
||||
-v "node_modules_cache:/app/node_modules" \
|
||||
ubuntu:latest
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------------------------------- | ------------------------------------------------ |
|
||||
| `-p 3001:3001` | Forwards the backend server port |
|
||||
| `-p 5173:5173` | Forwards the Vite frontend server port |
|
||||
| `--name flyer-dev` | Names the container for easy reference |
|
||||
| `-v "...:/app"` | Mounts your project directory into the container |
|
||||
| `-v "node_modules_cache:/app/node_modules"` | Mounts the named volume for node_modules |
|
||||
|
||||
### Step 4: Configure the Ubuntu Environment
|
||||
|
||||
You are now inside the Ubuntu container's shell.
|
||||
|
||||
1. **Update Package Lists**:
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
```
|
||||
|
||||
2. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
apt-get install -y curl git
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
```
|
||||
|
||||
3. **Navigate to Project Directory**:
|
||||
|
||||
```bash
|
||||
cd /app
|
||||
```
|
||||
|
||||
4. **Install Project Dependencies**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 5: Run the Development Server
|
||||
Or from terminal:
|
||||
|
||||
```bash
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### Step 3: Create Podman Network
|
||||
|
||||
```bash
|
||||
podman network create flyer-crawler-net
|
||||
```
|
||||
|
||||
### Step 4: Create PostgreSQL Container
|
||||
|
||||
```bash
|
||||
podman run -d \
|
||||
--name flyer-crawler-postgres \
|
||||
--network flyer-crawler-net \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=flyer_crawler_dev \
|
||||
-p 5432:5432 \
|
||||
-v flyer-crawler-pgdata:/var/lib/postgresql/data \
|
||||
docker.io/postgis/postgis:15-3.3
|
||||
```
|
||||
|
||||
### Step 5: Create Redis Container
|
||||
|
||||
```bash
|
||||
podman run -d \
|
||||
--name flyer-crawler-redis \
|
||||
--network flyer-crawler-net \
|
||||
-p 6379:6379 \
|
||||
-v flyer-crawler-redis:/data \
|
||||
docker.io/library/redis:alpine
|
||||
```
|
||||
|
||||
### Step 6: Initialize Database
|
||||
|
||||
```bash
|
||||
# Wait for PostgreSQL to be ready
|
||||
podman exec flyer-crawler-postgres pg_isready -U postgres
|
||||
|
||||
# Install required extensions
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";
|
||||
"
|
||||
|
||||
# Apply schema
|
||||
podman exec -i flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev < sql/master_schema_rollup.sql
|
||||
```
|
||||
|
||||
### Step 7: Create Node.js Container
|
||||
|
||||
```bash
|
||||
# Create volume for node_modules
|
||||
podman volume create node_modules_cache
|
||||
|
||||
# Run Ubuntu container with project mounted
|
||||
podman run -it \
|
||||
--name flyer-dev \
|
||||
--network flyer-crawler-net \
|
||||
-p 3001:3001 \
|
||||
-p 5173:5173 \
|
||||
-v "$(pwd):/app" \
|
||||
-v "node_modules_cache:/app/node_modules" \
|
||||
ubuntu:latest
|
||||
```
|
||||
|
||||
### Step 8: Configure Container Environment
|
||||
|
||||
Inside the container:
|
||||
|
||||
```bash
|
||||
# Update and install dependencies
|
||||
apt-get update
|
||||
apt-get install -y curl git
|
||||
|
||||
# Install Node.js 20
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
# Navigate to project and install
|
||||
cd /app
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Step 6: Access the Application
|
||||
### Container Management Commands
|
||||
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:3001
|
||||
|
||||
### Dev Container with HTTPS (Full Stack)
|
||||
|
||||
When using the full dev container stack with NGINX (via `compose.dev.yml`), access the application over HTTPS:
|
||||
|
||||
- **Frontend**: https://localhost or https://127.0.0.1
|
||||
- **Backend API**: http://localhost:3001
|
||||
|
||||
**SSL Certificate Notes:**
|
||||
|
||||
- The dev container uses self-signed certificates generated by mkcert
|
||||
- Both `localhost` and `127.0.0.1` are valid hostnames (certificate includes both as SANs)
|
||||
- If images fail to load with SSL errors, see [FLYER-URL-CONFIGURATION.md](../FLYER-URL-CONFIGURATION.md#ssl-certificate-configuration-dev-container)
|
||||
|
||||
**Eliminate SSL Warnings (Recommended):**
|
||||
|
||||
To avoid browser security warnings for self-signed certificates, install the mkcert CA certificate on your system. The CA certificate is located at `certs/mkcert-ca.crt` in the project root.
|
||||
|
||||
See [`certs/README.md`](../../certs/README.md) for platform-specific installation instructions (Windows, macOS, Linux, Firefox).
|
||||
|
||||
After installation:
|
||||
|
||||
- Your browser will trust all mkcert certificates without warnings
|
||||
- Both `https://localhost/` and `https://127.0.0.1/` will work without SSL errors
|
||||
- Flyer images will load without `ERR_CERT_AUTHORITY_INVALID` errors
|
||||
|
||||
### Managing the Container
|
||||
|
||||
| Action | Command |
|
||||
| --------------------- | -------------------------------- |
|
||||
| Stop the container | Press `Ctrl+C`, then type `exit` |
|
||||
| Restart the container | `podman start -a -i flyer-dev` |
|
||||
| Remove the container | `podman rm flyer-dev` |
|
||||
| Action | Command |
|
||||
| -------------- | ------------------------------ |
|
||||
| Stop container | Press `Ctrl+C`, then `exit` |
|
||||
| Restart | `podman start -a -i flyer-dev` |
|
||||
| Remove | `podman rm flyer-dev` |
|
||||
| List running | `podman ps` |
|
||||
| List all | `podman ps -a` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
## Environment Configuration
|
||||
|
||||
This project is configured to run in a CI/CD environment and does not use `.env` files. All configuration must be provided as environment variables.
|
||||
### Create .env.local
|
||||
|
||||
For local development, you can export these in your shell or use your IDE's environment configuration:
|
||||
Create `.env.local` in the project root with your configuration:
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------------- | ------------------------------------- |
|
||||
| `DB_HOST` | PostgreSQL server hostname |
|
||||
| `DB_USER` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `DB_DATABASE_PROD` | Production database name |
|
||||
| `JWT_SECRET` | Secret string for signing auth tokens |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY` | Google Gemini API key |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps Geocoding API key |
|
||||
| `REDIS_PASSWORD_PROD` | Production Redis password |
|
||||
| `REDIS_PASSWORD_TEST` | Test Redis password |
|
||||
```bash
|
||||
# Database (adjust host based on your setup)
|
||||
DB_HOST=localhost # Use 'postgres' if inside dev container
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=flyer_crawler_dev
|
||||
|
||||
# Redis (adjust host based on your setup)
|
||||
REDIS_URL=redis://localhost:6379 # Use 'redis://redis:6379' inside container
|
||||
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Authentication (generate secure values)
|
||||
JWT_SECRET=your-secret-at-least-32-characters-long
|
||||
|
||||
# AI Services
|
||||
GEMINI_API_KEY=your-google-gemini-api-key
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key # Optional
|
||||
```
|
||||
|
||||
**Generate Secure Secrets**:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
### Environment Differences
|
||||
|
||||
| Variable | Host Development | Inside Dev Container |
|
||||
| ----------- | ------------------------ | -------------------- |
|
||||
| `DB_HOST` | `localhost` | `postgres` |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | `redis://redis:6379` |
|
||||
|
||||
See [ENVIRONMENT.md](ENVIRONMENT.md) for complete variable reference.
|
||||
|
||||
---
|
||||
|
||||
## Seeding Development Data
|
||||
|
||||
To create initial test accounts (`admin@example.com` and `user@example.com`) and sample data:
|
||||
Create test accounts and sample data:
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
The seed script performs the following actions:
|
||||
### What the Seed Script Does
|
||||
|
||||
1. Rebuilds the database schema from `sql/master_schema_rollup.sql`
|
||||
2. Creates test user accounts (admin and regular user)
|
||||
3. Copies test flyer images from `src/tests/assets/` to `public/flyer-images/`
|
||||
4. Creates a sample flyer with items linked to the test images
|
||||
5. Seeds watched items and a shopping list for the test user
|
||||
1. Rebuilds database schema from `sql/master_schema_rollup.sql`
|
||||
2. Creates test user accounts:
|
||||
- `admin@example.com` (admin user)
|
||||
- `user@example.com` (regular user)
|
||||
3. Copies test flyer images to `public/flyer-images/`
|
||||
4. Creates sample flyer with items
|
||||
5. Seeds watched items and shopping list
|
||||
|
||||
**Test Images**: The seed script copies `test-flyer-image.jpg` and `test-flyer-icon.png` to the `public/flyer-images/` directory, which is served by NGINX at `/flyer-images/`.
|
||||
### Test Images
|
||||
|
||||
After running, you may need to restart your IDE's TypeScript server to pick up any generated types.
|
||||
The seed script copies these files from `src/tests/assets/`:
|
||||
|
||||
- `test-flyer-image.jpg`
|
||||
- `test-flyer-icon.png`
|
||||
|
||||
Images are served by NGINX at `/flyer-images/`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After installation, verify everything works:
|
||||
|
||||
- [ ] **Containers running**: `podman ps` shows postgres and redis
|
||||
- [ ] **Database accessible**: `podman exec flyer-crawler-postgres psql -U postgres -c "SELECT 1;"`
|
||||
- [ ] **Frontend loads**: Open `http://localhost:5173` (or `https://localhost` for dev container)
|
||||
- [ ] **API responds**: `curl http://localhost:3001/health`
|
||||
- [ ] **Tests pass**: `npm run test:unit` (or in container: `podman exec -it flyer-crawler-dev npm run test:unit`)
|
||||
- [ ] **Type check passes**: `npm run type-check`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Podman Machine Won't Start
|
||||
|
||||
```bash
|
||||
# Reset Podman machine
|
||||
podman machine rm
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```bash
|
||||
# Find process using port
|
||||
netstat -ano | findstr :5432
|
||||
|
||||
# Option: Use different port
|
||||
podman run -d --name flyer-crawler-postgres -p 5433:5432 ...
|
||||
# Then set DB_PORT=5433 in .env.local
|
||||
```
|
||||
|
||||
### Database Extensions Missing
|
||||
|
||||
```bash
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";
|
||||
"
|
||||
```
|
||||
|
||||
### Permission Denied on Windows Paths
|
||||
|
||||
Use `MSYS_NO_PATHCONV=1` prefix:
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 podman exec flyer-crawler-dev /path/to/script.sh
|
||||
```
|
||||
|
||||
### Tests Fail with Timezone Errors
|
||||
|
||||
Tests must run in the dev container, not on Windows host:
|
||||
|
||||
```bash
|
||||
# CORRECT
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# INCORRECT (may fail with TZ errors)
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Database Setup](DATABASE.md) - Set up PostgreSQL with required extensions
|
||||
- [Authentication Setup](AUTHENTICATION.md) - Configure OAuth providers
|
||||
- [Deployment Guide](DEPLOYMENT.md) - Deploy to production
|
||||
| Goal | Document |
|
||||
| --------------------- | ------------------------------------------------------ |
|
||||
| Quick setup guide | [QUICKSTART.md](QUICKSTART.md) |
|
||||
| Environment variables | [ENVIRONMENT.md](ENVIRONMENT.md) |
|
||||
| Database schema | [DATABASE.md](../architecture/DATABASE.md) |
|
||||
| Authentication setup | [AUTHENTICATION.md](../architecture/AUTHENTICATION.md) |
|
||||
| Dev container details | [DEV-CONTAINER.md](../development/DEV-CONTAINER.md) |
|
||||
| Deployment | [DEPLOYMENT.md](../operations/DEPLOYMENT.md) |
|
||||
|
||||
---
|
||||
|
||||
Last updated: January 2026
|
||||
|
||||
@@ -2,13 +2,38 @@
|
||||
|
||||
Get Flyer Crawler running in 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
- **Windows 10/11** with WSL 2
|
||||
- **Podman Desktop** installed
|
||||
- **Node.js 20+** installed
|
||||
## Prerequisites Checklist
|
||||
|
||||
## 1. Start Containers (1 minute)
|
||||
Before starting, verify you have:
|
||||
|
||||
- [ ] **Windows 10/11** with WSL 2 enabled
|
||||
- [ ] **Podman Desktop** installed ([download](https://podman-desktop.io/))
|
||||
- [ ] **Node.js 20+** installed
|
||||
- [ ] **Git** for cloning the repository
|
||||
|
||||
**Verify Prerequisites**:
|
||||
|
||||
```bash
|
||||
# Check Podman
|
||||
podman --version
|
||||
# Expected: podman version 4.x or higher
|
||||
|
||||
# Check Node.js
|
||||
node --version
|
||||
# Expected: v20.x or higher
|
||||
|
||||
# Check WSL
|
||||
wsl --list --verbose
|
||||
# Expected: Shows WSL 2 distro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup (5 Steps)
|
||||
|
||||
### Step 1: Start Containers (1 minute)
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL and Redis
|
||||
@@ -27,11 +52,18 @@ podman run -d --name flyer-crawler-redis \
|
||||
docker.io/library/redis:alpine
|
||||
```
|
||||
|
||||
## 2. Initialize Database (2 minutes)
|
||||
**Expected Output**:
|
||||
|
||||
```text
|
||||
# Container IDs displayed, no errors
|
||||
```
|
||||
|
||||
### Step 2: Initialize Database (2 minutes)
|
||||
|
||||
```bash
|
||||
# Wait for PostgreSQL to be ready
|
||||
podman exec flyer-crawler-postgres pg_isready -U postgres
|
||||
# Expected: localhost:5432 - accepting connections
|
||||
|
||||
# Install extensions
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev \
|
||||
@@ -41,7 +73,17 @@ podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev \
|
||||
podman exec -i flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev < sql/master_schema_rollup.sql
|
||||
```
|
||||
|
||||
## 3. Configure Environment (1 minute)
|
||||
**Expected Output**:
|
||||
|
||||
```text
|
||||
CREATE EXTENSION
|
||||
CREATE EXTENSION
|
||||
CREATE EXTENSION
|
||||
CREATE TABLE
|
||||
... (many tables created)
|
||||
```
|
||||
|
||||
### Step 3: Configure Environment (1 minute)
|
||||
|
||||
Create `.env.local` in the project root:
|
||||
|
||||
@@ -61,16 +103,22 @@ NODE_ENV=development
|
||||
PORT=3001
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Secrets (generate your own)
|
||||
# Secrets (generate your own - see command below)
|
||||
JWT_SECRET=your-dev-jwt-secret-at-least-32-chars-long
|
||||
SESSION_SECRET=your-dev-session-secret-at-least-32-chars-long
|
||||
|
||||
# AI Services (get your own keys)
|
||||
VITE_GOOGLE_GENAI_API_KEY=your-google-genai-api-key
|
||||
GEMINI_API_KEY=your-google-gemini-api-key
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
||||
```
|
||||
|
||||
## 4. Install & Run (1 minute)
|
||||
**Generate Secure Secrets**:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
### Step 4: Install and Run (1 minute)
|
||||
|
||||
```bash
|
||||
# Install dependencies (first time only)
|
||||
@@ -80,35 +128,61 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 5. Access Application
|
||||
**Expected Output**:
|
||||
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:3001
|
||||
- **Health Check**: http://localhost:3001/health
|
||||
```text
|
||||
> flyer-crawler@x.x.x dev
|
||||
> concurrently ...
|
||||
|
||||
### Dev Container (HTTPS)
|
||||
[API] Server listening on port 3001
|
||||
[Vite] VITE ready at http://localhost:5173
|
||||
```
|
||||
|
||||
When using the full dev container with NGINX, access via HTTPS:
|
||||
### Step 5: Verify Installation
|
||||
|
||||
- **Frontend**: https://localhost or https://127.0.0.1
|
||||
- **Backend API**: http://localhost:3001
|
||||
- **Bugsink**: `https://localhost:8443` (error tracking)
|
||||
| Check | URL/Command | Expected Result |
|
||||
| ----------- | ------------------------------ | ----------------------------------- |
|
||||
| Frontend | `http://localhost:5173` | Flyer Crawler app loads |
|
||||
| Backend API | `http://localhost:3001/health` | `{ "status": "ok", ... }` |
|
||||
| Database | `podman exec ... psql -c ...` | `SELECT version()` returns Postgres |
|
||||
| Containers | `podman ps` | Shows postgres and redis running |
|
||||
|
||||
**Note:** The dev container accepts both `localhost` and `127.0.0.1` for HTTPS connections. The self-signed certificate is valid for both hostnames.
|
||||
---
|
||||
|
||||
**SSL Certificate Warnings:** To eliminate browser security warnings for self-signed certificates, install the mkcert CA certificate. See [`certs/README.md`](../../certs/README.md) for platform-specific installation instructions. This is optional but recommended for a better development experience.
|
||||
## Full Dev Container (Recommended)
|
||||
|
||||
### Dev Container Architecture
|
||||
For a production-like environment with NGINX, Bugsink error tracking, and PM2 process management:
|
||||
|
||||
The dev container uses PM2 for process management, matching production (ADR-014):
|
||||
### Starting the Dev Container
|
||||
|
||||
| Process | Description | Port |
|
||||
| -------------------------- | ------------------------ | ---- |
|
||||
| `flyer-crawler-api-dev` | API server (tsx watch) | 3001 |
|
||||
| `flyer-crawler-worker-dev` | Background job worker | - |
|
||||
| `flyer-crawler-vite-dev` | Vite frontend dev server | 5173 |
|
||||
```bash
|
||||
# Start all services
|
||||
podman-compose -f compose.dev.yml up -d
|
||||
|
||||
**PM2 Commands** (run inside container):
|
||||
# View logs
|
||||
podman-compose -f compose.dev.yml logs -f
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
| Service | URL | Notes |
|
||||
| ----------- | ------------------------ | ---------------------------- |
|
||||
| Frontend | `https://localhost` | NGINX proxy to Vite |
|
||||
| Backend API | `http://localhost:3001` | Express server |
|
||||
| Bugsink | `https://localhost:8443` | Error tracking (admin/admin) |
|
||||
| PostgreSQL | `localhost:5432` | Database |
|
||||
| Redis | `localhost:6379` | Cache |
|
||||
|
||||
**SSL Certificate Setup (Recommended)**:
|
||||
|
||||
To eliminate browser security warnings, install the mkcert CA certificate:
|
||||
|
||||
```bash
|
||||
# Windows: Double-click certs/mkcert-ca.crt and install to Trusted Root CAs
|
||||
# See certs/README.md for detailed instructions per platform
|
||||
```
|
||||
|
||||
### PM2 Commands
|
||||
|
||||
```bash
|
||||
# View process status
|
||||
@@ -124,63 +198,152 @@ podman exec -it flyer-crawler-dev pm2 restart all
|
||||
podman exec -it flyer-crawler-dev pm2 restart flyer-crawler-api-dev
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
### Dev Container Processes
|
||||
|
||||
| Process | Description | Port |
|
||||
| -------------------------- | ------------------------ | ---- |
|
||||
| `flyer-crawler-api-dev` | API server (tsx watch) | 3001 |
|
||||
| `flyer-crawler-worker-dev` | Background job worker | - |
|
||||
| `flyer-crawler-vite-dev` | Vite frontend dev server | 5173 |
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run these to confirm everything is working:
|
||||
|
||||
```bash
|
||||
# Check containers are running
|
||||
podman ps
|
||||
# Expected: flyer-crawler-postgres and flyer-crawler-redis both running
|
||||
|
||||
# Test database connection
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "SELECT version();"
|
||||
# Expected: PostgreSQL 15.x with PostGIS
|
||||
|
||||
# Run tests (in dev container)
|
||||
podman exec -it flyer-crawler-dev npm run test:unit
|
||||
# Expected: All tests pass
|
||||
|
||||
# Run type check
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
# Expected: No type errors
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
---
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### "Unable to connect to Podman socket"
|
||||
|
||||
**Cause**: Podman machine not running
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### "Connection refused" to PostgreSQL
|
||||
|
||||
Wait a few seconds for PostgreSQL to initialize:
|
||||
**Cause**: PostgreSQL still initializing
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Wait for PostgreSQL to be ready
|
||||
podman exec flyer-crawler-postgres pg_isready -U postgres
|
||||
# Retry after "accepting connections" message
|
||||
```
|
||||
|
||||
### Port 5432 or 6379 already in use
|
||||
|
||||
Stop conflicting services or change port mappings:
|
||||
**Cause**: Another service using the port
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Use different host port
|
||||
# Option 1: Stop conflicting service
|
||||
# Option 2: Use different host port
|
||||
podman run -d --name flyer-crawler-postgres -p 5433:5432 ...
|
||||
# Then update DB_PORT=5433 in .env.local
|
||||
```
|
||||
|
||||
Then update `DB_PORT=5433` in `.env.local`.
|
||||
### "JWT_SECRET must be at least 32 characters"
|
||||
|
||||
**Cause**: Secret too short in .env.local
|
||||
|
||||
**Solution**: Generate a longer secret:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
### Tests fail with "TZ environment variable" errors
|
||||
|
||||
**Cause**: Timezone setting interfering with Node.js async hooks
|
||||
|
||||
**Solution**: Tests must run in dev container (not Windows host):
|
||||
|
||||
```bash
|
||||
# CORRECT - run in container
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# INCORRECT - do not run on Windows host
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Read the docs**: [docs/README.md](../README.md)
|
||||
- **Understand the architecture**: [docs/architecture/DATABASE.md](../architecture/DATABASE.md)
|
||||
- **Learn testing**: [docs/development/TESTING.md](../development/TESTING.md)
|
||||
- **Explore ADRs**: [docs/adr/index.md](../adr/index.md)
|
||||
- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
| Goal | Document |
|
||||
| ----------------------- | ----------------------------------------------------- |
|
||||
| Understand the codebase | [Architecture Overview](../architecture/OVERVIEW.md) |
|
||||
| Configure environment | [Environment Variables](ENVIRONMENT.md) |
|
||||
| Set up MCP tools | [MCP Configuration](../tools/MCP-CONFIGURATION.md) |
|
||||
| Learn testing | [Testing Guide](../development/TESTING.md) |
|
||||
| Understand DB schema | [Database Documentation](../architecture/DATABASE.md) |
|
||||
| Read ADRs | [ADR Index](../adr/index.md) |
|
||||
| Full installation guide | [Installation Guide](INSTALL.md) |
|
||||
|
||||
## Development Workflow
|
||||
---
|
||||
|
||||
## Daily Development Workflow
|
||||
|
||||
```bash
|
||||
# Daily workflow
|
||||
# 1. Start containers
|
||||
podman start flyer-crawler-postgres flyer-crawler-redis
|
||||
|
||||
# 2. Start dev server
|
||||
npm run dev
|
||||
# ... make changes ...
|
||||
|
||||
# 3. Make changes and test
|
||||
npm test
|
||||
|
||||
# 4. Type check before commit
|
||||
npm run type-check
|
||||
|
||||
# 5. Commit changes
|
||||
git commit
|
||||
```
|
||||
|
||||
For detailed setup instructions, see [INSTALL.md](INSTALL.md).
|
||||
**For dev container users**:
|
||||
|
||||
```bash
|
||||
# 1. Start dev container
|
||||
podman-compose -f compose.dev.yml up -d
|
||||
|
||||
# 2. View logs
|
||||
podman exec -it flyer-crawler-dev pm2 logs
|
||||
|
||||
# 3. Run tests
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# 4. Stop when done
|
||||
podman-compose -f compose.dev.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Last updated: January 2026
|
||||
|
||||
@@ -2,8 +2,54 @@
|
||||
|
||||
This guide covers the manual installation of Flyer Crawler and its dependencies on a bare-metal Ubuntu server (e.g., a colocation server). This is the definitive reference for setting up a production environment without containers.
|
||||
|
||||
**Last verified**: 2026-01-28
|
||||
|
||||
**Target Environment**: Ubuntu 22.04 LTS (or newer)
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [ADR-014: Containerization and Deployment Strategy](../adr/0014-containerization-and-deployment-strategy.md)
|
||||
- [ADR-015: Error Tracking and Observability](../adr/0015-error-tracking-and-observability.md)
|
||||
- [ADR-050: PostgreSQL Function Observability](../adr/0050-postgresql-function-observability.md)
|
||||
- [Deployment Guide](DEPLOYMENT.md)
|
||||
- [Monitoring Guide](MONITORING.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Installation Time Estimates
|
||||
|
||||
| Component | Estimated Time | Notes |
|
||||
| ----------- | --------------- | ----------------------------- |
|
||||
| PostgreSQL | 10-15 minutes | Including PostGIS extensions |
|
||||
| Redis | 5 minutes | Quick install |
|
||||
| Node.js | 5 minutes | Via NodeSource repository |
|
||||
| Application | 15-20 minutes | Clone, install, build |
|
||||
| PM2 | 5 minutes | Global install + config |
|
||||
| NGINX | 10-15 minutes | Including SSL via Certbot |
|
||||
| Bugsink | 20-30 minutes | Python venv, systemd services |
|
||||
| Logstash | 15-20 minutes | Including pipeline config |
|
||||
| **Total** | **~90 minutes** | For complete fresh install |
|
||||
|
||||
### Post-Installation Verification
|
||||
|
||||
After completing setup, verify all services:
|
||||
|
||||
```bash
|
||||
# Check all services are running
|
||||
systemctl status postgresql nginx redis-server gunicorn-bugsink snappea logstash
|
||||
|
||||
# Verify application health
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
|
||||
|
||||
# Check PM2 processes
|
||||
pm2 list
|
||||
|
||||
# Verify Bugsink is accessible
|
||||
curl -s https://bugsink.projectium.com/accounts/login/ | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Access Model
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
This guide covers deploying Flyer Crawler to a production server.
|
||||
|
||||
**Last verified**: 2026-01-28
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [ADR-014: Containerization and Deployment Strategy](../adr/0014-containerization-and-deployment-strategy.md)
|
||||
- [ADR-015: Error Tracking and Observability](../adr/0015-error-tracking-and-observability.md)
|
||||
- [Bare-Metal Setup Guide](BARE-METAL-SETUP.md)
|
||||
- [Monitoring Guide](MONITORING.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Command Reference Table
|
||||
|
||||
| Task | Command |
|
||||
| -------------------- | ----------------------------------------------------------------------- |
|
||||
| Deploy to production | Gitea Actions workflow (manual trigger) |
|
||||
| Deploy to test | Automatic on push to `main` |
|
||||
| Check PM2 status | `pm2 list` |
|
||||
| View logs | `pm2 logs flyer-crawler-api --lines 100` |
|
||||
| Restart all | `pm2 restart all` |
|
||||
| Check NGINX | `sudo nginx -t && sudo systemctl status nginx` |
|
||||
| Check health | `curl -s https://flyer-crawler.projectium.com/api/health/ready \| jq .` |
|
||||
|
||||
### Deployment URLs
|
||||
|
||||
| Environment | URL | API Port |
|
||||
| ------------- | ------------------------------------------- | -------- |
|
||||
| Production | `https://flyer-crawler.projectium.com` | 3001 |
|
||||
| Test | `https://flyer-crawler-test.projectium.com` | 3002 |
|
||||
| Dev Container | `https://localhost` | 3001 |
|
||||
|
||||
---
|
||||
|
||||
## Server Access Model
|
||||
|
||||
**Important**: Claude Code (and AI tools) have **READ-ONLY** access to production/test servers. The deployment workflow is:
|
||||
@@ -24,12 +59,24 @@ When troubleshooting deployment issues:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ubuntu server (22.04 LTS recommended)
|
||||
- PostgreSQL 14+ with PostGIS extension
|
||||
- Redis
|
||||
- Node.js 20.x
|
||||
- NGINX (reverse proxy)
|
||||
- PM2 (process manager)
|
||||
| Component | Version | Purpose |
|
||||
| ---------- | --------- | ------------------------------- |
|
||||
| Ubuntu | 22.04 LTS | Operating system |
|
||||
| PostgreSQL | 14+ | Database with PostGIS extension |
|
||||
| Redis | 6+ | Caching and job queues |
|
||||
| Node.js | 20.x LTS | Application runtime |
|
||||
| NGINX | 1.18+ | Reverse proxy and static files |
|
||||
| PM2 | Latest | Process manager |
|
||||
|
||||
**Verify prerequisites**:
|
||||
|
||||
```bash
|
||||
node --version # Should be v20.x.x
|
||||
psql --version # Should be 14+
|
||||
redis-cli ping # Should return PONG
|
||||
nginx -v # Should be 1.18+
|
||||
pm2 --version # Any recent version
|
||||
```
|
||||
|
||||
## Dev Container Parity (ADR-014)
|
||||
|
||||
@@ -210,7 +257,7 @@ types {
|
||||
|
||||
**Option 2**: Edit `/etc/nginx/mime.types` globally:
|
||||
|
||||
```
|
||||
```text
|
||||
# Change this line:
|
||||
application/javascript js;
|
||||
|
||||
@@ -341,9 +388,78 @@ The Sentry SDK v10+ enforces HTTPS-only DSNs by default. Since Bugsink runs loca
|
||||
|
||||
---
|
||||
|
||||
## Deployment Troubleshooting
|
||||
|
||||
### Decision Tree: Deployment Issues
|
||||
|
||||
```text
|
||||
Deployment failed?
|
||||
|
|
||||
+-- Build step failed?
|
||||
| |
|
||||
| +-- TypeScript errors --> Fix type issues, run `npm run type-check`
|
||||
| +-- Missing dependencies --> Run `npm ci`
|
||||
| +-- Out of memory --> Increase Node heap size
|
||||
|
|
||||
+-- Tests failed?
|
||||
| |
|
||||
| +-- Database connection --> Check DB_HOST, credentials
|
||||
| +-- Redis connection --> Check REDIS_URL
|
||||
| +-- Test isolation --> Check for race conditions
|
||||
|
|
||||
+-- SSH/Deploy failed?
|
||||
|
|
||||
+-- Permission denied --> Check SSH keys in Gitea secrets
|
||||
+-- Host unreachable --> Check firewall, VPN
|
||||
+-- PM2 error --> Check PM2 logs on server
|
||||
```
|
||||
|
||||
### Common Deployment Issues
|
||||
|
||||
| Symptom | Diagnosis | Solution |
|
||||
| ------------------------------------ | ----------------------- | ------------------------------------------------ |
|
||||
| "Connection refused" on health check | API not started | Check `pm2 logs flyer-crawler-api` |
|
||||
| 502 Bad Gateway | NGINX cannot reach API | Verify API port (3001), restart PM2 |
|
||||
| CSS/JS not loading | Build artifacts missing | Re-run `npm run build`, check NGINX static paths |
|
||||
| Database migrations failed | Schema mismatch | Run migrations manually, check DB connectivity |
|
||||
| "ENOSPC" error | Disk full | Clear old logs: `pm2 flush`, clean npm cache |
|
||||
| SSL certificate error | Cert expired/missing | Run `certbot renew`, check NGINX config |
|
||||
|
||||
### Post-Deployment Verification Checklist
|
||||
|
||||
After every deployment, verify:
|
||||
|
||||
- [ ] Health check passes: `curl -s https://flyer-crawler.projectium.com/api/health/ready`
|
||||
- [ ] PM2 processes running: `pm2 list` shows `online` status
|
||||
- [ ] No recent errors: Check Bugsink for new issues
|
||||
- [ ] Frontend loads: Browser shows login page
|
||||
- [ ] API responds: `curl https://flyer-crawler.projectium.com/api/health/ping`
|
||||
|
||||
### Rollback Procedure
|
||||
|
||||
If deployment causes issues:
|
||||
|
||||
```bash
|
||||
# 1. Check current release
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
git log --oneline -5
|
||||
|
||||
# 2. Revert to previous commit
|
||||
git checkout HEAD~1
|
||||
|
||||
# 3. Rebuild and restart
|
||||
npm ci && npm run build
|
||||
pm2 restart all
|
||||
|
||||
# 4. Verify health
|
||||
curl -s http://localhost:3001/api/health/ready | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Database Setup](DATABASE.md) - PostgreSQL and PostGIS configuration
|
||||
- [Authentication Setup](AUTHENTICATION.md) - OAuth provider configuration
|
||||
- [Installation Guide](INSTALL.md) - Local development setup
|
||||
- [Bare-Metal Server Setup](docs/BARE-METAL-SETUP.md) - Manual server installation guide
|
||||
- [Database Setup](../architecture/DATABASE.md) - PostgreSQL and PostGIS configuration
|
||||
- [Monitoring Guide](MONITORING.md) - Health checks and error tracking
|
||||
- [Logstash Quick Reference](LOGSTASH-QUICK-REF.md) - Log aggregation
|
||||
- [Bare-Metal Server Setup](BARE-METAL-SETUP.md) - Manual server installation guide
|
||||
|
||||
@@ -2,10 +2,47 @@
|
||||
|
||||
Aggregates logs from PostgreSQL, PM2, Redis, NGINX; forwards errors to Bugsink.
|
||||
|
||||
**Last verified**: 2026-01-28
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [ADR-050: PostgreSQL Function Observability](../adr/0050-postgresql-function-observability.md)
|
||||
- [ADR-015: Error Tracking and Observability](../adr/0015-error-tracking-and-observability.md)
|
||||
- [Monitoring Guide](MONITORING.md)
|
||||
- [Logstash Troubleshooting Runbook](LOGSTASH-TROUBLESHOOTING.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Bugsink Project Routing
|
||||
|
||||
| Source Type | Environment | Bugsink Project | Project ID |
|
||||
| -------------- | ----------- | -------------------- | ---------- |
|
||||
| PM2 API/Worker | Dev | Backend API (Dev) | 1 |
|
||||
| PostgreSQL | Dev | Backend API (Dev) | 1 |
|
||||
| Frontend JS | Dev | Frontend (Dev) | 2 |
|
||||
| Redis/NGINX | Dev | Infrastructure (Dev) | 4 |
|
||||
| PM2 API/Worker | Production | Backend API (Prod) | 1 |
|
||||
| PostgreSQL | Production | Backend API (Prod) | 1 |
|
||||
| PM2 API/Worker | Test | Backend API (Test) | 3 |
|
||||
|
||||
### Key DSN Keys (Dev Container)
|
||||
|
||||
| Project | DSN Key |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| Backend API (Dev) | `cea01396c56246adb5878fa5ee6b1d22` |
|
||||
| Frontend (Dev) | `d92663cb73cf4145b677b84029e4b762` |
|
||||
| Infrastructure (Dev) | `14e8791da3d347fa98073261b596cab9` |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
**Primary config**: `/etc/logstash/conf.d/bugsink.conf`
|
||||
|
||||
**Dev container config**: `docker/logstash/bugsink.conf`
|
||||
|
||||
### Related Files
|
||||
|
||||
| Path | Purpose |
|
||||
@@ -89,6 +126,34 @@ MSYS_NO_PATHCONV=1 podman exec flyer-crawler-dev ls -la /var/log/redis/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Decision Tree: Logs Not Appearing in Bugsink
|
||||
|
||||
```text
|
||||
Errors not showing in Bugsink?
|
||||
|
|
||||
+-- Logstash running?
|
||||
| |
|
||||
| +-- No --> systemctl start logstash
|
||||
| +-- Yes --> Check pipeline stats
|
||||
| |
|
||||
| +-- Events in = 0?
|
||||
| | |
|
||||
| | +-- Log files exist? --> ls /var/log/pm2/*.log
|
||||
| | +-- Permissions OK? --> groups logstash
|
||||
| |
|
||||
| +-- Events filtered = high?
|
||||
| | |
|
||||
| | +-- Grok failures --> Check log format matches pattern
|
||||
| |
|
||||
| +-- Events out but no Bugsink?
|
||||
| |
|
||||
| +-- 403 error --> Wrong DSN key
|
||||
| +-- 500 error --> Invalid event format (check sentry_level)
|
||||
| +-- Connection refused --> Bugsink not running
|
||||
```
|
||||
|
||||
### Common Issues Table
|
||||
|
||||
| Issue | Check | Solution |
|
||||
| --------------------- | ---------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| No Bugsink errors | Logstash running | `systemctl status logstash` |
|
||||
@@ -103,6 +168,25 @@ MSYS_NO_PATHCONV=1 podman exec flyer-crawler-dev ls -la /var/log/redis/
|
||||
| High disk usage | Log rotation | Verify `/etc/logrotate.d/logstash` configured |
|
||||
| varchar(7) error | Level validation | Add Ruby filter to validate/normalize `sentry_level` before output |
|
||||
|
||||
### Expected Output Examples
|
||||
|
||||
**Successful Logstash pipeline stats**:
|
||||
|
||||
```json
|
||||
{
|
||||
"in": 1523,
|
||||
"out": 1520,
|
||||
"filtered": 1520,
|
||||
"queue_push_duration_in_millis": 45
|
||||
}
|
||||
```
|
||||
|
||||
**Healthy Bugsink HTTP response**:
|
||||
|
||||
```json
|
||||
{ "id": "a1b2c3d4e5f6..." }
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Dev Container Guide**: [DEV-CONTAINER.md](../development/DEV-CONTAINER.md) - PM2 and log aggregation in dev
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
This runbook provides step-by-step diagnostics and solutions for common Logstash issues in the PostgreSQL observability pipeline (ADR-050).
|
||||
|
||||
**Last verified**: 2026-01-28
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [ADR-050: PostgreSQL Function Observability](../adr/0050-postgresql-function-observability.md)
|
||||
- [Logstash Quick Reference](LOGSTASH-QUICK-REF.md)
|
||||
- [Monitoring Guide](MONITORING.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Symptom | Most Likely Cause | Quick Check |
|
||||
|
||||
@@ -2,6 +2,72 @@
|
||||
|
||||
This guide covers all aspects of monitoring the Flyer Crawler application across development, test, and production environments.
|
||||
|
||||
**Last verified**: 2026-01-28
|
||||
|
||||
**Related documentation**:
|
||||
|
||||
- [ADR-015: Error Tracking and Observability](../adr/0015-error-tracking-and-observability.md)
|
||||
- [ADR-020: Health Checks](../adr/0020-health-checks-and-liveness-readiness-probes.md)
|
||||
- [ADR-050: PostgreSQL Function Observability](../adr/0050-postgresql-function-observability.md)
|
||||
- [Logstash Quick Reference](LOGSTASH-QUICK-REF.md)
|
||||
- [Deployment Guide](DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Monitoring URLs
|
||||
|
||||
| Service | Production URL | Dev Container URL |
|
||||
| ------------ | ------------------------------------------------------- | ---------------------------------------- |
|
||||
| Health Check | `https://flyer-crawler.projectium.com/api/health/ready` | `http://localhost:3001/api/health/ready` |
|
||||
| Bugsink | `https://bugsink.projectium.com` | `https://localhost:8443` |
|
||||
| Bull Board | `https://flyer-crawler.projectium.com/api/admin/jobs` | `http://localhost:3001/api/admin/jobs` |
|
||||
|
||||
### Quick Diagnostic Commands
|
||||
|
||||
```bash
|
||||
# Check all services at once (production)
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq '.data.services'
|
||||
|
||||
# Dev container health check
|
||||
podman exec flyer-crawler-dev curl -s http://localhost:3001/api/health/ready | jq .
|
||||
|
||||
# PM2 process overview
|
||||
pm2 list
|
||||
|
||||
# Recent errors in Bugsink (via MCP)
|
||||
# mcp__bugsink__list_issues --project_id 1 --status unresolved
|
||||
```
|
||||
|
||||
### Monitoring Decision Tree
|
||||
|
||||
```text
|
||||
Application seems slow or unresponsive?
|
||||
|
|
||||
+-- Check health endpoint first
|
||||
| |
|
||||
| +-- Returns unhealthy?
|
||||
| | |
|
||||
| | +-- Database unhealthy --> Check DB pool, connections
|
||||
| | +-- Redis unhealthy --> Check Redis memory, connection
|
||||
| | +-- Storage unhealthy --> Check disk space, permissions
|
||||
| |
|
||||
| +-- Returns healthy but slow?
|
||||
| |
|
||||
| +-- Check PM2 memory/CPU usage
|
||||
| +-- Check database slow query log
|
||||
| +-- Check Redis queue depth
|
||||
|
|
||||
+-- Health endpoint not responding?
|
||||
|
|
||||
+-- Check PM2 status --> Process crashed?
|
||||
+-- Check NGINX --> 502 errors?
|
||||
+-- Check network --> Firewall/DNS issues?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Health Checks](#health-checks)
|
||||
@@ -294,7 +360,7 @@ The command outputs a 40-character hex token.
|
||||
|
||||
**Error Anatomy**:
|
||||
|
||||
```
|
||||
```text
|
||||
TypeError: Cannot read properties of undefined (reading 'map')
|
||||
├── Exception Type: TypeError
|
||||
├── Message: Cannot read properties of undefined (reading 'map')
|
||||
@@ -357,7 +423,7 @@ Logstash aggregates logs from multiple sources and forwards errors to Bugsink (A
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
Log Sources Logstash Outputs
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ PostgreSQL │──────────────│ │───────────│ Bugsink │
|
||||
@@ -520,7 +586,7 @@ pm2 stop flyer-crawler-api
|
||||
|
||||
**Healthy Process**:
|
||||
|
||||
```
|
||||
```text
|
||||
┌─────────────────────┬────┬─────────┬─────────┬───────┬────────┬─────────┬──────────┐
|
||||
│ Name │ id │ mode │ status │ cpu │ mem │ uptime │ restarts │
|
||||
├─────────────────────┼────┼─────────┼─────────┼───────┼────────┼─────────┼──────────┤
|
||||
@@ -833,7 +899,7 @@ Configure alerts in your monitoring tool (UptimeRobot, Datadog, etc.):
|
||||
2. Review during business hours
|
||||
3. Create Gitea issue for tracking
|
||||
|
||||
### Quick Diagnostic Commands
|
||||
### On-Call Diagnostic Commands
|
||||
|
||||
> **Note**: User executes these commands on the server. Claude Code provides commands but cannot run them directly.
|
||||
|
||||
|
||||
849
docs/plans/2026-01-28-adr-024-feature-flags-implementation.md
Normal file
849
docs/plans/2026-01-28-adr-024-feature-flags-implementation.md
Normal file
@@ -0,0 +1,849 @@
|
||||
# ADR-024 Implementation Plan: Feature Flagging Strategy
|
||||
|
||||
**Date**: 2026-01-28
|
||||
**Type**: Technical Implementation Plan
|
||||
**Related**: [ADR-024: Feature Flagging Strategy](../adr/0024-feature-flagging-strategy.md), [ADR-007: Configuration and Secrets Management](../adr/0007-configuration-and-secrets-management.md)
|
||||
**Status**: Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
Implement a simple, configuration-based feature flag system that integrates with the existing Zod-validated configuration in `src/config/env.ts`. The system will support both backend and frontend feature flags through environment variables, with type-safe access patterns and helper utilities.
|
||||
|
||||
### Key Success Criteria
|
||||
|
||||
1. Feature flags accessible via type-safe API on both backend and frontend
|
||||
2. Zero runtime overhead when flag is disabled (compile-time elimination where possible)
|
||||
3. Consistent naming convention (environment variables and code access)
|
||||
4. Graceful degradation (missing flag defaults to disabled)
|
||||
5. Easy migration path to external service (Flagsmith/LaunchDarkly) in the future
|
||||
6. Full test coverage with mocking utilities
|
||||
|
||||
### Estimated Total Effort
|
||||
|
||||
| Phase | Estimate |
|
||||
| --------------------------------- | -------------- |
|
||||
| Phase 1: Backend Infrastructure | 3-5 hours |
|
||||
| Phase 2: Frontend Infrastructure | 2-3 hours |
|
||||
| Phase 3: Documentation & Examples | 1-2 hours |
|
||||
| **Total** | **6-10 hours** |
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Backend Configuration (`src/config/env.ts`)
|
||||
|
||||
- Zod-based schema validation at startup
|
||||
- Organized into logical groups (database, redis, auth, smtp, ai, etc.)
|
||||
- Helper exports for service availability (`isSmtpConfigured`, `isAiConfigured`, etc.)
|
||||
- Environment helpers (`isProduction`, `isTest`, `isDevelopment`)
|
||||
- Fail-fast on invalid configuration
|
||||
|
||||
### Frontend Configuration (`src/config.ts`)
|
||||
|
||||
- Uses `import.meta.env` (Vite environment variables)
|
||||
- Organized into sections (app, google, sentry)
|
||||
- Boolean parsing for string env vars
|
||||
- Type declarations in `src/vite-env.d.ts`
|
||||
|
||||
### Existing Patterns to Follow
|
||||
|
||||
```typescript
|
||||
// Backend - service availability check pattern
|
||||
export const isSmtpConfigured =
|
||||
!!config.smtp.host && !!config.smtp.user && !!config.smtp.pass;
|
||||
|
||||
// Frontend - boolean parsing pattern
|
||||
enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Phase 1: Backend Feature Flag Infrastructure
|
||||
|
||||
#### [1.1] Define Feature Flag Schema in env.ts
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30-45 minutes
|
||||
**Dependencies**: None
|
||||
**Parallelizable**: Yes
|
||||
|
||||
**Description**: Add a new `featureFlags` section to the Zod schema in `src/config/env.ts`.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] New `featureFlagsSchema` Zod object defined
|
||||
- [ ] Schema supports boolean flags with defaults to `false` (opt-in model)
|
||||
- [ ] Schema added to main `envSchema` object
|
||||
- [ ] Type exported as part of `EnvConfig`
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/config/env.ts
|
||||
|
||||
/**
|
||||
* Feature flags configuration schema (ADR-024).
|
||||
* All flags default to false (disabled) for safety.
|
||||
* Set to 'true' in environment to enable.
|
||||
*/
|
||||
const featureFlagsSchema = z.object({
|
||||
// Example flags - replace with actual feature flags as needed
|
||||
newDashboard: booleanString(false), // FEATURE_NEW_DASHBOARD
|
||||
betaRecipes: booleanString(false), // FEATURE_BETA_RECIPES
|
||||
experimentalAi: booleanString(false), // FEATURE_EXPERIMENTAL_AI
|
||||
debugMode: booleanString(false), // FEATURE_DEBUG_MODE
|
||||
});
|
||||
|
||||
// In loadEnvVars():
|
||||
featureFlags: {
|
||||
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
|
||||
betaRecipes: process.env.FEATURE_BETA_RECIPES,
|
||||
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
|
||||
debugMode: process.env.FEATURE_DEBUG_MODE,
|
||||
},
|
||||
```
|
||||
|
||||
**Risks/Notes**:
|
||||
|
||||
- Naming convention: `FEATURE_*` prefix for all feature flag env vars
|
||||
- Default to `false` ensures features are opt-in, preventing accidental exposure
|
||||
|
||||
---
|
||||
|
||||
#### [1.2] Create Feature Flag Service Module
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimate**: 1-2 hours
|
||||
**Dependencies**: [1.1]
|
||||
**Parallelizable**: No (depends on 1.1)
|
||||
|
||||
**Description**: Create a dedicated service module for feature flag access with helper functions.
|
||||
|
||||
**File**: `src/services/featureFlags.server.ts`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] `isFeatureEnabled(flagName)` function for checking flags
|
||||
- [ ] `getAllFeatureFlags()` function for debugging/admin endpoints
|
||||
- [ ] Type-safe flag name parameter (union type or enum)
|
||||
- [ ] Exported helper booleans for common flags (similar to `isSmtpConfigured`)
|
||||
- [ ] Logging when feature flag is checked in development mode
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/services/featureFlags.server.ts
|
||||
import { config, isDevelopment } from '../config/env';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
export type FeatureFlagName = keyof typeof config.featureFlags;
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
* @param flagName - The name of the feature flag to check
|
||||
* @returns boolean indicating if the feature is enabled
|
||||
*/
|
||||
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
|
||||
const enabled = config.featureFlags[flagName];
|
||||
|
||||
if (isDevelopment) {
|
||||
logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature flags and their current states.
|
||||
* Useful for debugging and admin endpoints.
|
||||
*/
|
||||
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
|
||||
return { ...config.featureFlags };
|
||||
}
|
||||
|
||||
// Convenience exports for common flag checks
|
||||
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
|
||||
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
|
||||
export const isExperimentalAiEnabled = config.featureFlags.experimentalAi;
|
||||
export const isDebugModeEnabled = config.featureFlags.debugMode;
|
||||
```
|
||||
|
||||
**Risks/Notes**:
|
||||
|
||||
- Keep logging minimal to avoid performance impact
|
||||
- Convenience exports are evaluated once at startup (not dynamic)
|
||||
|
||||
---
|
||||
|
||||
#### [1.3] Add Admin Endpoint for Feature Flag Status
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30-45 minutes
|
||||
**Dependencies**: [1.2]
|
||||
**Parallelizable**: No (depends on 1.2)
|
||||
|
||||
**Description**: Add an admin/health endpoint to view current feature flag states.
|
||||
|
||||
**File**: `src/routes/admin.routes.ts` (or `stats.routes.ts` if admin routes don't exist)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] `GET /api/v1/admin/feature-flags` endpoint (admin-only)
|
||||
- [ ] Returns JSON object with all flags and their states
|
||||
- [ ] Requires admin authentication
|
||||
- [ ] Endpoint documented in Swagger
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// In appropriate routes file
|
||||
router.get('/feature-flags', requireAdmin, async (req, res) => {
|
||||
const flags = getAllFeatureFlags();
|
||||
sendSuccess(res, { flags });
|
||||
});
|
||||
```
|
||||
|
||||
**Risks/Notes**:
|
||||
|
||||
- Ensure endpoint is protected (admin-only)
|
||||
- Consider caching response if called frequently
|
||||
|
||||
---
|
||||
|
||||
#### [1.4] Backend Unit Tests
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimate**: 1-2 hours
|
||||
**Dependencies**: [1.1], [1.2]
|
||||
**Parallelizable**: Yes (can start after 1.1, in parallel with 1.3)
|
||||
|
||||
**Description**: Write unit tests for feature flag configuration and service.
|
||||
|
||||
**Files**:
|
||||
|
||||
- `src/config/env.test.ts` (add feature flag tests)
|
||||
- `src/services/featureFlags.server.test.ts` (new file)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Test default values (all false)
|
||||
- [ ] Test parsing 'true'/'false' strings
|
||||
- [ ] Test `isFeatureEnabled()` function
|
||||
- [ ] Test `getAllFeatureFlags()` function
|
||||
- [ ] Test type safety (TypeScript compile-time checks)
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/config/env.test.ts - add to existing file
|
||||
describe('featureFlags configuration', () => {
|
||||
it('should default all feature flags to false', async () => {
|
||||
setValidEnv();
|
||||
const { config } = await import('./env');
|
||||
|
||||
expect(config.featureFlags.newDashboard).toBe(false);
|
||||
expect(config.featureFlags.betaRecipes).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse FEATURE_NEW_DASHBOARD as true when set', async () => {
|
||||
setValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
|
||||
const { config } = await import('./env');
|
||||
|
||||
expect(config.featureFlags.newDashboard).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// src/services/featureFlags.server.test.ts - new file
|
||||
describe('featureFlags service', () => {
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('should return false for disabled flags', () => {
|
||||
expect(isFeatureEnabled('newDashboard')).toBe(false);
|
||||
});
|
||||
|
||||
// ... more tests
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Frontend Feature Flag Infrastructure
|
||||
|
||||
#### [2.1] Add Frontend Feature Flag Config
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30-45 minutes
|
||||
**Dependencies**: None (can run in parallel with Phase 1)
|
||||
**Parallelizable**: Yes
|
||||
|
||||
**Description**: Add feature flags to the frontend config module.
|
||||
|
||||
**Files**:
|
||||
|
||||
- `src/config.ts` - Add featureFlags section
|
||||
- `src/vite-env.d.ts` - Add type declarations
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Feature flags section added to `src/config.ts`
|
||||
- [ ] TypeScript declarations updated in `vite-env.d.ts`
|
||||
- [ ] Boolean parsing consistent with existing pattern
|
||||
- [ ] Default to false when env var not set
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/config.ts
|
||||
const config = {
|
||||
// ... existing sections ...
|
||||
|
||||
/**
|
||||
* Feature flags for conditional feature rendering (ADR-024).
|
||||
* All flags default to false (disabled) when not explicitly set.
|
||||
*/
|
||||
featureFlags: {
|
||||
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
|
||||
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
|
||||
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
|
||||
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
|
||||
},
|
||||
};
|
||||
|
||||
// src/vite-env.d.ts
|
||||
interface ImportMetaEnv {
|
||||
// ... existing declarations ...
|
||||
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
|
||||
readonly VITE_FEATURE_BETA_RECIPES?: string;
|
||||
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
|
||||
readonly VITE_FEATURE_DEBUG_MODE?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### [2.2] Create useFeatureFlag React Hook
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimate**: 1-1.5 hours
|
||||
**Dependencies**: [2.1]
|
||||
**Parallelizable**: No (depends on 2.1)
|
||||
|
||||
**Description**: Create a React hook for checking feature flags in components.
|
||||
|
||||
**File**: `src/hooks/useFeatureFlag.ts`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] `useFeatureFlag(flagName)` hook returns boolean
|
||||
- [ ] Type-safe flag name parameter
|
||||
- [ ] Memoized to prevent unnecessary re-renders
|
||||
- [ ] Optional `FeatureFlag` component for conditional rendering
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/hooks/useFeatureFlag.ts
|
||||
import { useMemo } from 'react';
|
||||
import config from '../config';
|
||||
|
||||
export type FeatureFlagName = keyof typeof config.featureFlags;
|
||||
|
||||
/**
|
||||
* Hook to check if a feature flag is enabled.
|
||||
*
|
||||
* @param flagName - The name of the feature flag to check
|
||||
* @returns boolean indicating if the feature is enabled
|
||||
*
|
||||
* @example
|
||||
* const isNewDashboard = useFeatureFlag('newDashboard');
|
||||
* if (isNewDashboard) {
|
||||
* return <NewDashboard />;
|
||||
* }
|
||||
*/
|
||||
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
|
||||
return useMemo(() => config.featureFlags[flagName], [flagName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature flags (useful for debugging).
|
||||
*/
|
||||
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
|
||||
return useMemo(() => ({ ...config.featureFlags }), []);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### [2.3] Create FeatureFlag Component
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30-45 minutes
|
||||
**Dependencies**: [2.2]
|
||||
**Parallelizable**: No (depends on 2.2)
|
||||
|
||||
**Description**: Create a declarative component for feature flag conditional rendering.
|
||||
|
||||
**File**: `src/components/FeatureFlag.tsx`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] `<FeatureFlag name="flagName">` component
|
||||
- [ ] Children rendered only when flag is enabled
|
||||
- [ ] Optional `fallback` prop for disabled state
|
||||
- [ ] TypeScript-enforced flag names
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/components/FeatureFlag.tsx
|
||||
import { ReactNode } from 'react';
|
||||
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';
|
||||
|
||||
interface FeatureFlagProps {
|
||||
/** The name of the feature flag to check */
|
||||
name: FeatureFlagName;
|
||||
/** Content to render when feature is enabled */
|
||||
children: ReactNode;
|
||||
/** Optional content to render when feature is disabled */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally renders children based on feature flag state.
|
||||
*
|
||||
* @example
|
||||
* <FeatureFlag name="newDashboard" fallback={<OldDashboard />}>
|
||||
* <NewDashboard />
|
||||
* </FeatureFlag>
|
||||
*/
|
||||
export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
|
||||
const isEnabled = useFeatureFlag(name);
|
||||
return <>{isEnabled ? children : fallback}</>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### [2.4] Frontend Unit Tests
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimate**: 1-1.5 hours
|
||||
**Dependencies**: [2.1], [2.2], [2.3]
|
||||
**Parallelizable**: No (depends on previous frontend tasks)
|
||||
|
||||
**Description**: Write unit tests for frontend feature flag utilities.
|
||||
|
||||
**Files**:
|
||||
|
||||
- `src/config.test.ts` (add feature flag tests)
|
||||
- `src/hooks/useFeatureFlag.test.ts` (new file)
|
||||
- `src/components/FeatureFlag.test.tsx` (new file)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Test config structure includes featureFlags
|
||||
- [ ] Test default values (all false)
|
||||
- [ ] Test hook returns correct values
|
||||
- [ ] Test component renders/hides children correctly
|
||||
- [ ] Test fallback rendering
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// src/hooks/useFeatureFlag.test.ts
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useFeatureFlag, useAllFeatureFlags } from './useFeatureFlag';
|
||||
|
||||
describe('useFeatureFlag', () => {
|
||||
it('should return false for disabled flags', () => {
|
||||
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// src/components/FeatureFlag.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FeatureFlag } from './FeatureFlag';
|
||||
|
||||
describe('FeatureFlag', () => {
|
||||
it('should not render children when flag is disabled', () => {
|
||||
render(
|
||||
<FeatureFlag name="newDashboard">
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>
|
||||
);
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallback when flag is disabled', () => {
|
||||
render(
|
||||
<FeatureFlag name="newDashboard" fallback={<div>Old Feature</div>}>
|
||||
<div>New Feature</div>
|
||||
</FeatureFlag>
|
||||
);
|
||||
expect(screen.getByText('Old Feature')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Documentation & Integration
|
||||
|
||||
#### [3.1] Update ADR-024 with Implementation Status
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30 minutes
|
||||
**Dependencies**: [1.1], [1.2], [2.1], [2.2]
|
||||
**Parallelizable**: Yes (can be done after core implementation)
|
||||
|
||||
**Description**: Update ADR-024 to mark it as implemented and add implementation details.
|
||||
|
||||
**File**: `docs/adr/0024-feature-flagging-strategy.md`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Status changed from "Proposed" to "Accepted"
|
||||
- [ ] Implementation status section added
|
||||
- [ ] Key files documented
|
||||
- [ ] Usage examples included
|
||||
|
||||
---
|
||||
|
||||
#### [3.2] Update Environment Documentation
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30 minutes
|
||||
**Dependencies**: [1.1], [2.1]
|
||||
**Parallelizable**: Yes
|
||||
|
||||
**Description**: Add feature flag environment variables to documentation.
|
||||
|
||||
**Files**:
|
||||
|
||||
- `docs/getting-started/ENVIRONMENT.md`
|
||||
- `.env.example`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Feature flag variables documented in ENVIRONMENT.md
|
||||
- [ ] New section "Feature Flags" added
|
||||
- [ ] `.env.example` updated with commented feature flag examples
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```bash
|
||||
# .env.example addition
|
||||
# ===================
|
||||
# Feature Flags (ADR-024)
|
||||
# ===================
|
||||
# All feature flags default to disabled (false) when not set.
|
||||
# Set to 'true' to enable a feature.
|
||||
#
|
||||
# FEATURE_NEW_DASHBOARD=false
|
||||
# FEATURE_BETA_RECIPES=false
|
||||
# FEATURE_EXPERIMENTAL_AI=false
|
||||
# FEATURE_DEBUG_MODE=false
|
||||
#
|
||||
# Frontend equivalents (prefix with VITE_):
|
||||
# VITE_FEATURE_NEW_DASHBOARD=false
|
||||
# VITE_FEATURE_BETA_RECIPES=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### [3.3] Create CODE-PATTERNS Entry
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 30 minutes
|
||||
**Dependencies**: All implementation tasks
|
||||
**Parallelizable**: Yes
|
||||
|
||||
**Description**: Add feature flag usage patterns to CODE-PATTERNS.md.
|
||||
|
||||
**File**: `docs/development/CODE-PATTERNS.md`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Feature flag section added with examples
|
||||
- [ ] Backend usage pattern documented
|
||||
- [ ] Frontend usage pattern documented
|
||||
- [ ] Testing pattern documented
|
||||
|
||||
---
|
||||
|
||||
#### [3.4] Update CLAUDE.md Quick Reference
|
||||
|
||||
**Complexity**: Low
|
||||
**Estimate**: 15 minutes
|
||||
**Dependencies**: All implementation tasks
|
||||
**Parallelizable**: Yes
|
||||
|
||||
**Description**: Add feature flags to the CLAUDE.md quick reference tables.
|
||||
|
||||
**File**: `CLAUDE.md`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- [ ] Feature flags added to "Key Patterns" table
|
||||
- [ ] Reference to featureFlags service added
|
||||
|
||||
---
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
### Phase 1 (Backend) - Can Start Immediately
|
||||
|
||||
```text
|
||||
[1.1] Schema ──────────┬──> [1.2] Service ──> [1.3] Admin Endpoint
|
||||
│
|
||||
└──> [1.4] Backend Tests (can start after 1.1)
|
||||
```
|
||||
|
||||
### Phase 2 (Frontend) - Can Start Immediately (Parallel with Phase 1)
|
||||
|
||||
```text
|
||||
[2.1] Config ──> [2.2] Hook ──> [2.3] Component ──> [2.4] Frontend Tests
|
||||
```
|
||||
|
||||
### Phase 3 (Documentation) - After Implementation
|
||||
|
||||
```text
|
||||
All Phase 1 & 2 Tasks ──> [3.1] ADR Update
|
||||
├──> [3.2] Env Docs
|
||||
├──> [3.3] Code Patterns
|
||||
└──> [3.4] CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Path
|
||||
|
||||
The minimum path to a working feature flag system:
|
||||
|
||||
1. **[1.1] Schema** (30 min) - Required for backend
|
||||
2. **[1.2] Service** (1.5 hr) - Required for backend access
|
||||
3. **[2.1] Frontend Config** (30 min) - Required for frontend
|
||||
4. **[2.2] Hook** (1 hr) - Required for React integration
|
||||
|
||||
**Critical path duration**: ~3.5 hours
|
||||
|
||||
Non-critical but recommended:
|
||||
|
||||
- Admin endpoint (debugging)
|
||||
- FeatureFlag component (developer convenience)
|
||||
- Tests (quality assurance)
|
||||
- Documentation (maintainability)
|
||||
|
||||
---
|
||||
|
||||
## Scope Recommendations
|
||||
|
||||
### MVP (Minimum Viable Implementation)
|
||||
|
||||
Include in initial implementation:
|
||||
|
||||
- [1.1] Backend schema with 2-3 example flags
|
||||
- [1.2] Feature flag service
|
||||
- [2.1] Frontend config
|
||||
- [2.2] useFeatureFlag hook
|
||||
- [1.4] Core backend tests
|
||||
- [2.4] Core frontend tests
|
||||
|
||||
### Enhancements (Future Iterations)
|
||||
|
||||
Defer to follow-up work:
|
||||
|
||||
- Admin endpoint for flag visibility
|
||||
- FeatureFlag component (nice-to-have)
|
||||
- Dynamic flag updates without restart (requires external service)
|
||||
- User-specific flags (A/B testing)
|
||||
- Flag analytics/usage tracking
|
||||
- Gradual rollout percentages
|
||||
|
||||
### Explicitly Out of Scope
|
||||
|
||||
- Integration with Flagsmith/LaunchDarkly (future ADR)
|
||||
- Database-stored flags (requires schema changes)
|
||||
- Real-time flag updates (WebSocket/SSE)
|
||||
- Flag inheritance/hierarchy
|
||||
- Flag audit logging
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend Tests
|
||||
|
||||
| Test Type | Coverage Target | Location |
|
||||
| ----------------- | ---------------------------------------- | ------------------------------------------ |
|
||||
| Schema validation | Parse true/false, defaults | `src/config/env.test.ts` |
|
||||
| Service functions | `isFeatureEnabled`, `getAllFeatureFlags` | `src/services/featureFlags.server.test.ts` |
|
||||
| Integration | Admin endpoint (if added) | `src/routes/admin.routes.test.ts` |
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
| Test Type | Coverage Target | Location |
|
||||
| ------------------- | --------------------------- | ------------------------------------- |
|
||||
| Config structure | featureFlags section exists | `src/config.test.ts` |
|
||||
| Hook behavior | Returns correct values | `src/hooks/useFeatureFlag.test.ts` |
|
||||
| Component rendering | Conditional children | `src/components/FeatureFlag.test.tsx` |
|
||||
|
||||
### Mocking Pattern for Tests
|
||||
|
||||
```typescript
|
||||
// Backend - reset modules to test different flag states
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.FEATURE_NEW_DASHBOARD = 'true';
|
||||
});
|
||||
|
||||
// Frontend - mock config module
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
featureFlags: {
|
||||
newDashboard: true,
|
||||
betaRecipes: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
| ------------------------------------------- | ------ | ---------- | ------------------------------------------------------------- |
|
||||
| Flag state inconsistency (backend/frontend) | Medium | Low | Use same env var naming, document sync requirements |
|
||||
| Performance impact from flag checks | Low | Low | Flags cached at startup, no runtime DB calls |
|
||||
| Stale flags after deployment | Medium | Medium | Document restart requirement, consider future dynamic loading |
|
||||
| Feature creep (too many flags) | Medium | Medium | Require ADR for new flags, sunset policy |
|
||||
| Missing flag causes crash | High | Low | Default to false, graceful degradation |
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------ | ---------------------------- |
|
||||
| `src/services/featureFlags.server.ts` | Backend feature flag service |
|
||||
| `src/services/featureFlags.server.test.ts` | Backend tests |
|
||||
| `src/hooks/useFeatureFlag.ts` | React hook for flag access |
|
||||
| `src/hooks/useFeatureFlag.test.ts` | Hook tests |
|
||||
| `src/components/FeatureFlag.tsx` | Declarative flag component |
|
||||
| `src/components/FeatureFlag.test.tsx` | Component tests |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| -------------------------------------------- | ---------------------------------- |
|
||||
| `src/config/env.ts` | Add featureFlagsSchema and loading |
|
||||
| `src/config/env.test.ts` | Add feature flag tests |
|
||||
| `src/config.ts` | Add featureFlags section |
|
||||
| `src/config.test.ts` | Add feature flag tests |
|
||||
| `src/vite-env.d.ts` | Add VITE*FEATURE*\* declarations |
|
||||
| `.env.example` | Add feature flag examples |
|
||||
| `docs/adr/0024-feature-flagging-strategy.md` | Update status and details |
|
||||
| `docs/getting-started/ENVIRONMENT.md` | Document feature flag vars |
|
||||
| `docs/development/CODE-PATTERNS.md` | Add usage patterns |
|
||||
| `CLAUDE.md` | Add to quick reference |
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
After implementation, run these commands in the dev container:
|
||||
|
||||
```bash
|
||||
# Type checking
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Backend unit tests
|
||||
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "featureFlag"
|
||||
|
||||
# Frontend tests (includes hook and component tests)
|
||||
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "FeatureFlag"
|
||||
|
||||
# Full test suite
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Usage (Post-Implementation)
|
||||
|
||||
### Backend Route Handler
|
||||
|
||||
```typescript
|
||||
// src/routes/flyers.routes.ts
|
||||
import { isFeatureEnabled } from '../services/featureFlags.server';
|
||||
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
if (isFeatureEnabled('newDashboard')) {
|
||||
// New dashboard logic
|
||||
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
|
||||
}
|
||||
// Legacy dashboard
|
||||
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
|
||||
});
|
||||
```
|
||||
|
||||
### React Component
|
||||
|
||||
```tsx
|
||||
// src/pages/Dashboard.tsx
|
||||
import { FeatureFlag } from '../components/FeatureFlag';
|
||||
import { useFeatureFlag } from '../hooks/useFeatureFlag';
|
||||
|
||||
// Option 1: Declarative component
|
||||
function Dashboard() {
|
||||
return (
|
||||
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
|
||||
<NewDashboard />
|
||||
</FeatureFlag>
|
||||
);
|
||||
}
|
||||
|
||||
// Option 2: Hook for logic
|
||||
function DashboardWithLogic() {
|
||||
const isNewDashboard = useFeatureFlag('newDashboard');
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewDashboard) {
|
||||
analytics.track('new_dashboard_viewed');
|
||||
}
|
||||
}, [isNewDashboard]);
|
||||
|
||||
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Context | Pattern | Example |
|
||||
| ---------------- | ------------------------- | ---------------------------------- |
|
||||
| Backend env var | `FEATURE_SNAKE_CASE` | `FEATURE_NEW_DASHBOARD` |
|
||||
| Frontend env var | `VITE_FEATURE_SNAKE_CASE` | `VITE_FEATURE_NEW_DASHBOARD` |
|
||||
| Config property | `camelCase` | `config.featureFlags.newDashboard` |
|
||||
| Type/Hook param | `camelCase` | `isFeatureEnabled('newDashboard')` |
|
||||
|
||||
### Flag Lifecycle
|
||||
|
||||
1. **Adding a flag**: Add to both schemas, set default to `false`, document
|
||||
2. **Enabling a flag**: Set env var to `'true'`, restart application
|
||||
3. **Removing a flag**: Remove conditional code first, then remove flag from schemas
|
||||
4. **Sunset policy**: Flags should be removed within 3 months of full rollout
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2026-01-28
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
The **ai-usage** subagent specializes in LLM APIs (Gemini, Claude), prompt engineering, and AI-powered features in the Flyer Crawler project.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | Details |
|
||||
| ------------------ | ----------------------------------------------------------------------------------- |
|
||||
| **Primary Use** | Gemini API integration, prompt engineering, AI extraction |
|
||||
| **Key Files** | `src/services/aiService.server.ts`, `src/services/flyerProcessingService.server.ts` |
|
||||
| **Key ADRs** | ADR-041 (AI Integration), ADR-046 (Image Processing) |
|
||||
| **API Key Env** | `VITE_GOOGLE_GENAI_API_KEY` (prod), `VITE_GOOGLE_GENAI_API_KEY_TEST` (test) |
|
||||
| **Error Handling** | Rate limits (429), JSON parse errors, timeout handling |
|
||||
| **Delegate To** | `coder` (implementation), `testwriter` (tests), `integrations-specialist` |
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the **ai-usage** subagent when you need to:
|
||||
@@ -295,6 +306,9 @@ const fixtureResponse = await fs.readFile('fixtures/gemini-response.json');
|
||||
## Related Documentation
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - For implementing AI features
|
||||
- [TESTER-GUIDE.md](./TESTER-GUIDE.md) - Testing AI features
|
||||
- [INTEGRATIONS-GUIDE.md](./INTEGRATIONS-GUIDE.md) - External API patterns
|
||||
- [../adr/0041-ai-gemini-integration-architecture.md](../adr/0041-ai-gemini-integration-architecture.md) - AI integration ADR
|
||||
- [../adr/0046-image-processing-pipeline.md](../adr/0046-image-processing-pipeline.md) - Image processing
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - For implementing AI features
|
||||
- [../getting-started/ENVIRONMENT.md](../getting-started/ENVIRONMENT.md) - Environment configuration
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
The **coder** subagent is your primary tool for writing and modifying production Node.js/TypeScript code in the Flyer Crawler project. This guide explains how to work effectively with the coder subagent.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | Details |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| **Primary Use** | Write/modify production TypeScript code |
|
||||
| **Key Files** | `src/routes/*.routes.ts`, `src/services/**/*.ts`, `src/components/*.tsx` |
|
||||
| **Key ADRs** | ADR-034 (Repository), ADR-035 (Services), ADR-028 (API Response) |
|
||||
| **Test Command** | `podman exec -it flyer-crawler-dev npm run test:unit` |
|
||||
| **Type Check** | `podman exec -it flyer-crawler-dev npm run type-check` |
|
||||
| **Delegate To** | `db-dev` (database), `frontend-specialist` (UI), `testwriter` (tests) |
|
||||
|
||||
## When to Use the Coder Subagent
|
||||
|
||||
Use the coder subagent when you need to:
|
||||
@@ -307,6 +318,8 @@ error classes for all database operations"
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [TESTER-GUIDE.md](./TESTER-GUIDE.md) - Testing strategies
|
||||
- [DATABASE-GUIDE.md](./DATABASE-GUIDE.md) - Database development workflows
|
||||
- [../adr/0034-repository-pattern-standards.md](../adr/0034-repository-pattern-standards.md) - Repository patterns
|
||||
- [../adr/0035-service-layer-architecture.md](../adr/0035-service-layer-architecture.md) - Service layer architecture
|
||||
- [../adr/0028-api-response-standardization.md](../adr/0028-api-response-standardization.md) - API response patterns
|
||||
- [../development/CODE-PATTERNS.md](../development/CODE-PATTERNS.md) - Code patterns reference
|
||||
|
||||
@@ -5,6 +5,17 @@ This guide covers two database-focused subagents:
|
||||
- **db-dev**: Database development - schemas, queries, migrations, optimization
|
||||
- **db-admin**: Database administration - PostgreSQL/Redis admin, security, backups
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | db-dev | db-admin |
|
||||
| ---------------- | -------------------------------------------- | ------------------------------------------ |
|
||||
| **Primary Use** | Schemas, queries, migrations | Performance tuning, backups, security |
|
||||
| **Key Files** | `src/services/db/*.db.ts`, `sql/migrations/` | `postgresql.conf`, `pg_hba.conf` |
|
||||
| **Key ADRs** | ADR-034 (Repository), ADR-002 (Transactions) | ADR-019 (Backups), ADR-050 (Observability) |
|
||||
| **Test Command** | `podman exec -it flyer-crawler-dev npm test` | N/A |
|
||||
| **MCP Tool** | `mcp__devdb__query` | SSH to production |
|
||||
| **Delegate To** | `coder` (service layer), `db-admin` (perf) | `devops` (infrastructure) |
|
||||
|
||||
## Understanding the Difference
|
||||
|
||||
| Aspect | db-dev | db-admin |
|
||||
@@ -412,8 +423,9 @@ This is useful for:
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - Working with the coder subagent
|
||||
- [DEVOPS-GUIDE.md](./DEVOPS-GUIDE.md) - DevOps and deployment workflows
|
||||
- [../adr/0034-repository-pattern-standards.md](../adr/0034-repository-pattern-standards.md) - Repository patterns
|
||||
- [../adr/0002-standardized-transaction-management.md](../adr/0002-standardized-transaction-management.md) - Transaction management
|
||||
- [../adr/0019-data-backup-and-recovery-strategy.md](../adr/0019-data-backup-and-recovery-strategy.md) - Backup strategy
|
||||
- [../adr/0050-postgresql-function-observability.md](../adr/0050-postgresql-function-observability.md) - Database observability
|
||||
- [../BARE-METAL-SETUP.md](../BARE-METAL-SETUP.md) - Production database setup
|
||||
- [../operations/BARE-METAL-SETUP.md](../operations/BARE-METAL-SETUP.md) - Production database setup
|
||||
|
||||
@@ -6,6 +6,17 @@ This guide covers DevOps-related subagents for deployment, infrastructure, and o
|
||||
- **infra-architect**: Resource optimization, capacity planning
|
||||
- **bg-worker**: Background jobs, PM2 workers, BullMQ queues
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | devops | infra-architect | bg-worker |
|
||||
| ---------------- | ------------------------------------------ | --------------------------- | ------------------------------- |
|
||||
| **Primary Use** | Containers, CI/CD, deployments | Resource optimization | BullMQ queues, PM2 workers |
|
||||
| **Key Files** | `compose.dev.yml`, `.gitea/workflows/` | `ecosystem.config.cjs` | `src/services/queues.server.ts` |
|
||||
| **Key ADRs** | ADR-014 (Containers), ADR-017 (CI/CD) | N/A | ADR-006 (Background Jobs) |
|
||||
| **Commands** | `podman-compose`, `pm2` | `pm2 monit`, system metrics | Redis CLI, `pm2 logs` |
|
||||
| **MCP Tools** | `mcp__podman__*` | N/A | N/A |
|
||||
| **Access Model** | Read-only on production (provide commands) | Same | Same |
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: Server Access Model
|
||||
@@ -543,8 +554,13 @@ podman exec -it flyer-crawler-dev npm test
|
||||
## Related Documentation
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [../BARE-METAL-SETUP.md](../BARE-METAL-SETUP.md) - Production setup guide
|
||||
- [DATABASE-GUIDE.md](./DATABASE-GUIDE.md) - Database administration
|
||||
- [SECURITY-DEBUG-GUIDE.md](./SECURITY-DEBUG-GUIDE.md) - Production debugging
|
||||
- [../operations/BARE-METAL-SETUP.md](../operations/BARE-METAL-SETUP.md) - Production setup guide
|
||||
- [../operations/DEPLOYMENT.md](../operations/DEPLOYMENT.md) - Deployment guide
|
||||
- [../operations/MONITORING.md](../operations/MONITORING.md) - Monitoring guide
|
||||
- [../development/DEV-CONTAINER.md](../development/DEV-CONTAINER.md) - Dev container guide
|
||||
- [../adr/0014-containerization-and-deployment-strategy.md](../adr/0014-containerization-and-deployment-strategy.md) - Containerization ADR
|
||||
- [../adr/0006-background-job-processing-and-task-queues.md](../adr/0006-background-job-processing-and-task-queues.md) - Background jobs ADR
|
||||
- [../adr/0017-ci-cd-and-branching-strategy.md](../adr/0017-ci-cd-and-branching-strategy.md) - CI/CD strategy
|
||||
- [../adr/0053-worker-health-checks.md](../adr/0053-worker-health-checks.md) - Worker health checks
|
||||
- [../adr/0053-worker-health-checks-and-monitoring.md](../adr/0053-worker-health-checks-and-monitoring.md) - Worker health checks
|
||||
|
||||
@@ -7,6 +7,15 @@ This guide covers documentation-focused subagents:
|
||||
- **planner**: Feature breakdown, roadmaps, scope management
|
||||
- **product-owner**: Requirements, user stories, backlog prioritization
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | documenter | describer-for-ai | planner | product-owner |
|
||||
| --------------- | -------------------- | ------------------------ | --------------------- | ---------------------- |
|
||||
| **Primary Use** | User docs, API specs | ADRs, technical specs | Feature breakdown | User stories, backlog |
|
||||
| **Key Files** | `docs/`, API docs | `docs/adr/`, `CLAUDE.md` | `docs/plans/` | Issue tracker |
|
||||
| **Output** | Markdown guides | ADRs, context docs | Task lists, roadmaps | User stories, criteria |
|
||||
| **Delegate To** | `coder` (implement) | `documenter` (user docs) | `coder` (build tasks) | `planner` (breakdown) |
|
||||
|
||||
## The documenter Subagent
|
||||
|
||||
### When to Use
|
||||
@@ -437,6 +446,8 @@ Include dates on documentation that may become stale:
|
||||
## Related Documentation
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - For implementing documented features
|
||||
- [../adr/index.md](../adr/index.md) - ADR index
|
||||
- [../TESTING.md](../TESTING.md) - Testing guide
|
||||
- [../development/TESTING.md](../development/TESTING.md) - Testing guide
|
||||
- [../development/CODE-PATTERNS.md](../development/CODE-PATTERNS.md) - Code patterns reference
|
||||
- [../../CLAUDE.md](../../CLAUDE.md) - AI instructions
|
||||
|
||||
@@ -5,6 +5,17 @@ This guide covers frontend-focused subagents:
|
||||
- **frontend-specialist**: UI components, Neo-Brutalism, Core Web Vitals, accessibility
|
||||
- **uiux-designer**: UI/UX decisions, component design, user experience
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | frontend-specialist | uiux-designer |
|
||||
| ----------------- | ---------------------------------------------- | -------------------------------------- |
|
||||
| **Primary Use** | React components, performance, accessibility | Design decisions, user flows |
|
||||
| **Key Files** | `src/components/`, `src/features/` | Design specs, mockups |
|
||||
| **Key ADRs** | ADR-012 (Design System), ADR-044 (Feature Org) | ADR-012 (Design System) |
|
||||
| **Design System** | Neo-Brutalism (bold borders, high contrast) | Same |
|
||||
| **State Mgmt** | TanStack Query (server), Zustand (client) | N/A |
|
||||
| **Delegate To** | `coder` (backend), `tester` (test coverage) | `frontend-specialist` (implementation) |
|
||||
|
||||
## The frontend-specialist Subagent
|
||||
|
||||
### When to Use
|
||||
@@ -406,7 +417,8 @@ const handleSelect = useCallback((id: string) => {
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - For implementing features
|
||||
- [../DESIGN_TOKENS.md](../DESIGN_TOKENS.md) - Design token reference
|
||||
- [TESTER-GUIDE.md](./TESTER-GUIDE.md) - Component testing patterns
|
||||
- [../development/DESIGN_TOKENS.md](../development/DESIGN_TOKENS.md) - Design token reference
|
||||
- [../adr/0012-frontend-component-library-and-design-system.md](../adr/0012-frontend-component-library-and-design-system.md) - Design system ADR
|
||||
- [../adr/0005-frontend-state-management-and-server-cache-strategy.md](../adr/0005-frontend-state-management-and-server-cache-strategy.md) - State management ADR
|
||||
- [../adr/0044-frontend-feature-organization.md](../adr/0044-frontend-feature-organization.md) - Feature organization
|
||||
|
||||
396
docs/subagents/INTEGRATIONS-GUIDE.md
Normal file
396
docs/subagents/INTEGRATIONS-GUIDE.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# Integrations Subagent Guide
|
||||
|
||||
The **integrations-specialist** subagent handles third-party services, webhooks, and external API integrations in the Flyer Crawler project.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | Details |
|
||||
| --------------- | --------------------------------------------------------------------------- |
|
||||
| **Primary Use** | External APIs, webhooks, OAuth, third-party services |
|
||||
| **Key Files** | `src/services/external/`, `src/routes/webhooks.routes.ts` |
|
||||
| **Key ADRs** | ADR-041 (AI Integration), ADR-016 (API Security), ADR-048 (Auth) |
|
||||
| **MCP Tools** | `mcp__gitea-projectium__*`, `mcp__bugsink__*` |
|
||||
| **Security** | API key storage, webhook signatures, OAuth state param |
|
||||
| **Delegate To** | `coder` (implementation), `security-engineer` (review), `ai-usage` (Gemini) |
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the **integrations-specialist** subagent when you need to:
|
||||
|
||||
- Integrate with external APIs (OAuth, REST, GraphQL)
|
||||
- Implement webhook handlers
|
||||
- Configure third-party services
|
||||
- Debug external service connectivity
|
||||
- Handle API authentication flows
|
||||
- Manage external service rate limits
|
||||
|
||||
## What integrations-specialist Knows
|
||||
|
||||
The integrations-specialist subagent understands:
|
||||
|
||||
- OAuth 2.0 flows (authorization code, client credentials)
|
||||
- REST API integration patterns
|
||||
- Webhook security (signature verification)
|
||||
- External service error handling
|
||||
- Rate limiting and retry strategies
|
||||
- API key management
|
||||
|
||||
## Current Integrations
|
||||
|
||||
| Service | Purpose | Integration Type | Key Files |
|
||||
| ------------- | ---------------------- | ---------------- | ---------------------------------- |
|
||||
| Google Gemini | AI flyer extraction | REST API | `src/services/aiService.server.ts` |
|
||||
| Bugsink | Error tracking | REST API | MCP: `mcp__bugsink__*` |
|
||||
| Gitea | Repository and CI/CD | REST API | MCP: `mcp__gitea-projectium__*` |
|
||||
| Redis | Caching and job queues | Native client | `src/services/redis.server.ts` |
|
||||
| PostgreSQL | Primary database | Native client | `src/services/db/pool.db.ts` |
|
||||
|
||||
## Example Requests
|
||||
|
||||
### Adding External API Integration
|
||||
|
||||
```
|
||||
"Use integrations-specialist to integrate with the Store API
|
||||
to automatically fetch store location data. Include proper
|
||||
error handling, rate limiting, and caching."
|
||||
```
|
||||
|
||||
### OAuth Implementation
|
||||
|
||||
```
|
||||
"Use integrations-specialist to implement Google OAuth for
|
||||
user authentication. Include token refresh handling and
|
||||
session management."
|
||||
```
|
||||
|
||||
### Webhook Handler
|
||||
|
||||
```
|
||||
"Use integrations-specialist to create a webhook handler for
|
||||
receiving store inventory updates. Include signature verification
|
||||
and idempotency handling."
|
||||
```
|
||||
|
||||
### Debugging External Service Issues
|
||||
|
||||
```
|
||||
"Use integrations-specialist to debug why the Gemini API calls
|
||||
are intermittently failing with timeout errors. Check connection
|
||||
pooling, retry logic, and error handling."
|
||||
```
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### REST API Client Pattern
|
||||
|
||||
```typescript
|
||||
// src/services/external/storeApi.server.ts
|
||||
import { env } from '@/config/env';
|
||||
import { log } from '@/services/logger.server';
|
||||
|
||||
interface StoreApiConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
class StoreApiClient {
|
||||
private config: StoreApiConfig;
|
||||
|
||||
constructor(config: StoreApiConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async getStoreLocations(storeId: string): Promise<StoreLocation[]> {
|
||||
const url = `${this.config.baseUrl}/stores/${storeId}/locations`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(this.config.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ExternalApiError(`Store API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
log.error({ error, storeId }, 'Failed to fetch store locations');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const storeApiClient = new StoreApiClient({
|
||||
baseUrl: env.STORE_API_BASE_URL,
|
||||
apiKey: env.STORE_API_KEY,
|
||||
timeout: 10000,
|
||||
});
|
||||
```
|
||||
|
||||
### Webhook Handler Pattern
|
||||
|
||||
```typescript
|
||||
// src/routes/webhooks.routes.ts
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { env } from '@/config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
|
||||
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`));
|
||||
}
|
||||
|
||||
router.post('/store-updates', async (req, res, next) => {
|
||||
try {
|
||||
const signature = req.headers['x-webhook-signature'] as string;
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
if (!verifyWebhookSignature(payload, signature, env.WEBHOOK_SECRET)) {
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
// Process webhook with idempotency check
|
||||
const eventId = req.headers['x-event-id'] as string;
|
||||
const alreadyProcessed = await checkIdempotencyKey(eventId);
|
||||
|
||||
if (alreadyProcessed) {
|
||||
return res.status(200).json({ status: 'already_processed' });
|
||||
}
|
||||
|
||||
await processStoreUpdate(req.body);
|
||||
await markEventProcessed(eventId);
|
||||
|
||||
res.status(200).json({ status: 'processed' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth Flow Pattern
|
||||
|
||||
```typescript
|
||||
// src/services/oauth/googleOAuth.server.ts
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { env } from '@/config/env';
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
env.GOOGLE_CLIENT_ID,
|
||||
env.GOOGLE_CLIENT_SECRET,
|
||||
env.GOOGLE_REDIRECT_URI,
|
||||
);
|
||||
|
||||
export function getAuthorizationUrl(): string {
|
||||
return oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: ['email', 'profile'],
|
||||
prompt: 'consent',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(code: string) {
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string) {
|
||||
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||
return credentials;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling for External Services
|
||||
|
||||
### Custom Error Classes
|
||||
|
||||
```typescript
|
||||
// src/services/external/errors.ts
|
||||
export class ExternalApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number,
|
||||
public retryable: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ExternalApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends ExternalApiError {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryAfter: number,
|
||||
) {
|
||||
super(message, 429, true);
|
||||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Retry with Exponential Backoff
|
||||
|
||||
```typescript
|
||||
async function fetchWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { maxRetries: number; baseDelay: number },
|
||||
): Promise<T> {
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (error instanceof ExternalApiError && !error.retryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt < options.maxRetries) {
|
||||
const delay = options.baseDelay * Math.pow(2, attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting Strategies
|
||||
|
||||
### Token Bucket Pattern
|
||||
|
||||
```typescript
|
||||
class RateLimiter {
|
||||
private tokens: number;
|
||||
private lastRefill: number;
|
||||
private readonly maxTokens: number;
|
||||
private readonly refillRate: number; // tokens per second
|
||||
|
||||
constructor(maxTokens: number, refillRate: number) {
|
||||
this.maxTokens = maxTokens;
|
||||
this.tokens = maxTokens;
|
||||
this.refillRate = refillRate;
|
||||
this.lastRefill = Date.now();
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
this.refill();
|
||||
|
||||
if (this.tokens < 1) {
|
||||
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
this.refill();
|
||||
}
|
||||
|
||||
this.tokens -= 1;
|
||||
}
|
||||
|
||||
private refill(): void {
|
||||
const now = Date.now();
|
||||
const elapsed = (now - this.lastRefill) / 1000;
|
||||
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
|
||||
this.lastRefill = now;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Integrations
|
||||
|
||||
### Mocking External Services
|
||||
|
||||
```typescript
|
||||
// src/tests/mocks/storeApi.mock.ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockStoreApiClient = {
|
||||
getStoreLocations: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/services/external/storeApi.server', () => ({
|
||||
storeApiClient: mockStoreApiClient,
|
||||
}));
|
||||
```
|
||||
|
||||
### Integration Test with Real Service
|
||||
|
||||
```typescript
|
||||
// src/tests/integration/storeApi.integration.test.ts
|
||||
describe('Store API Integration', () => {
|
||||
it.skipIf(!env.STORE_API_KEY)('fetches real store locations', async () => {
|
||||
const locations = await storeApiClient.getStoreLocations('test-store');
|
||||
expect(locations).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## MCP Tools for Integrations
|
||||
|
||||
### Gitea Integration
|
||||
|
||||
```
|
||||
// List repositories
|
||||
mcp__gitea-projectium__list_my_repos()
|
||||
|
||||
// Create issue
|
||||
mcp__gitea-projectium__create_issue({
|
||||
owner: "projectium",
|
||||
repo: "flyer-crawler",
|
||||
title: "Issue title",
|
||||
body: "Issue description"
|
||||
})
|
||||
```
|
||||
|
||||
### Bugsink Integration
|
||||
|
||||
```
|
||||
// List projects
|
||||
mcp__bugsink__list_projects()
|
||||
|
||||
// Get issue details
|
||||
mcp__bugsink__get_issue({ issue_id: "..." })
|
||||
|
||||
// Get stacktrace
|
||||
mcp__bugsink__get_stacktrace({ event_id: "..." })
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Storage
|
||||
|
||||
- Never commit API keys to version control
|
||||
- Use environment variables via `src/config/env.ts`
|
||||
- Rotate keys periodically
|
||||
- Use separate keys for dev/test/prod
|
||||
|
||||
### Webhook Security
|
||||
|
||||
- Always verify webhook signatures
|
||||
- Use HTTPS for webhook endpoints
|
||||
- Implement idempotency
|
||||
- Log webhook events for audit
|
||||
|
||||
### OAuth Security
|
||||
|
||||
- Use state parameter to prevent CSRF
|
||||
- Store tokens securely (encrypted at rest)
|
||||
- Implement token refresh before expiration
|
||||
- Validate token scopes
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [SECURITY-DEBUG-GUIDE.md](./SECURITY-DEBUG-GUIDE.md) - Security patterns
|
||||
- [AI-USAGE-GUIDE.md](./AI-USAGE-GUIDE.md) - Gemini API integration
|
||||
- [../adr/0041-ai-gemini-integration-architecture.md](../adr/0041-ai-gemini-integration-architecture.md) - AI integration ADR
|
||||
- [../adr/0016-api-security-hardening.md](../adr/0016-api-security-hardening.md) - API security
|
||||
- [../adr/0048-authentication-strategy.md](../adr/0048-authentication-strategy.md) - Authentication
|
||||
@@ -89,6 +89,47 @@ Or:
|
||||
|
||||
Claude will automatically invoke the appropriate subagent with the relevant context.
|
||||
|
||||
## Quick Reference Decision Tree
|
||||
|
||||
Use this flowchart to quickly identify the right subagent:
|
||||
|
||||
```
|
||||
What do you need to do?
|
||||
|
|
||||
+-- Write/modify code? ----------------> Is it database-related?
|
||||
| |
|
||||
| +-- Yes -> db-dev
|
||||
| +-- No --> Is it frontend?
|
||||
| |
|
||||
| +-- Yes -> frontend-specialist
|
||||
| +-- No --> Is it AI/Gemini?
|
||||
| |
|
||||
| +-- Yes -> ai-usage
|
||||
| +-- No --> coder
|
||||
|
|
||||
+-- Test something? -------------------> Write new tests? -> testwriter
|
||||
| Find bugs/vulnerabilities? -> tester
|
||||
| Review existing code? -> code-reviewer
|
||||
|
|
||||
+-- Debug an issue? -------------------> Production error? -> log-debug
|
||||
| Database slow? -> db-admin
|
||||
| External API failing? -> integrations-specialist
|
||||
| AI extraction failing? -> ai-usage
|
||||
|
|
||||
+-- Infrastructure/Deployment? --------> Container/CI/CD? -> devops
|
||||
| Resource optimization? -> infra-architect
|
||||
| Background jobs? -> bg-worker
|
||||
|
|
||||
+-- Documentation? --------------------> User-facing docs? -> documenter
|
||||
| ADRs/Technical specs? -> describer-for-ai
|
||||
| Feature planning? -> planner
|
||||
| User stories? -> product-owner
|
||||
|
|
||||
+-- Security? -------------------------> security-engineer
|
||||
|
|
||||
+-- Design/UX? ------------------------> uiux-designer
|
||||
```
|
||||
|
||||
## Subagent Selection Guide
|
||||
|
||||
### Which Subagent Should I Use?
|
||||
@@ -183,12 +224,26 @@ Subagents can pass information back to the main conversation and to each other t
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - Working with the coder subagent
|
||||
- [TESTER-GUIDE.md](./TESTER-GUIDE.md) - Testing strategies and patterns
|
||||
- [DATABASE-GUIDE.md](./DATABASE-GUIDE.md) - Database development workflows
|
||||
- [DEVOPS-GUIDE.md](./DEVOPS-GUIDE.md) - DevOps and deployment workflows
|
||||
### Subagent Guides
|
||||
|
||||
| Guide | Subagents Covered |
|
||||
| ---------------------------------------------------- | ----------------------------------------------------- |
|
||||
| [CODER-GUIDE.md](./CODER-GUIDE.md) | coder |
|
||||
| [TESTER-GUIDE.md](./TESTER-GUIDE.md) | tester, testwriter |
|
||||
| [DATABASE-GUIDE.md](./DATABASE-GUIDE.md) | db-dev, db-admin |
|
||||
| [DEVOPS-GUIDE.md](./DEVOPS-GUIDE.md) | devops, infra-architect, bg-worker |
|
||||
| [FRONTEND-GUIDE.md](./FRONTEND-GUIDE.md) | frontend-specialist, uiux-designer |
|
||||
| [SECURITY-DEBUG-GUIDE.md](./SECURITY-DEBUG-GUIDE.md) | security-engineer, log-debug, code-reviewer |
|
||||
| [AI-USAGE-GUIDE.md](./AI-USAGE-GUIDE.md) | ai-usage |
|
||||
| [INTEGRATIONS-GUIDE.md](./INTEGRATIONS-GUIDE.md) | integrations-specialist, tools-integration-specialist |
|
||||
| [DOCUMENTATION-GUIDE.md](./DOCUMENTATION-GUIDE.md) | documenter, describer-for-ai, planner, product-owner |
|
||||
|
||||
### Project Documentation
|
||||
|
||||
- [../adr/index.md](../adr/index.md) - Architecture Decision Records
|
||||
- [../TESTING.md](../TESTING.md) - Testing guide
|
||||
- [../development/TESTING.md](../development/TESTING.md) - Testing guide
|
||||
- [../development/CODE-PATTERNS.md](../development/CODE-PATTERNS.md) - Code patterns reference
|
||||
- [../architecture/OVERVIEW.md](../architecture/OVERVIEW.md) - System architecture
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ This guide covers security and debugging-focused subagents:
|
||||
- **log-debug**: Production errors, observability, Bugsink/Sentry analysis
|
||||
- **code-reviewer**: Code quality, security review, best practices
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | security-engineer | log-debug | code-reviewer |
|
||||
| --------------- | ---------------------------------- | ---------------------------------------- | --------------------------- |
|
||||
| **Primary Use** | Security audits, OWASP | Production debugging | Code quality review |
|
||||
| **Key ADRs** | ADR-016 (Security), ADR-032 (Rate) | ADR-050 (Observability) | ADR-034, ADR-035 (Patterns) |
|
||||
| **MCP Tools** | N/A | `mcp__bugsink__*`, `mcp__localerrors__*` | N/A |
|
||||
| **Key Checks** | Auth, input validation, CORS | Logs, stacktraces, error patterns | Patterns, tests, security |
|
||||
| **Delegate To** | `coder` (fix issues) | `devops` (infra), `coder` (fixes) | `coder`, `testwriter` |
|
||||
|
||||
## The security-engineer Subagent
|
||||
|
||||
### When to Use
|
||||
@@ -432,8 +442,10 @@ tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep "duration:"
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [DEVOPS-GUIDE.md](./DEVOPS-GUIDE.md) - Infrastructure debugging
|
||||
- [TESTER-GUIDE.md](./TESTER-GUIDE.md) - Security testing
|
||||
- [../adr/0016-api-security-hardening.md](../adr/0016-api-security-hardening.md) - Security ADR
|
||||
- [../adr/0032-rate-limiting-strategy.md](../adr/0032-rate-limiting-strategy.md) - Rate limiting
|
||||
- [../adr/0015-application-performance-monitoring-and-error-tracking.md](../adr/0015-application-performance-monitoring-and-error-tracking.md) - Monitoring ADR
|
||||
- [../adr/0015-error-tracking-and-observability.md](../adr/0015-error-tracking-and-observability.md) - Monitoring ADR
|
||||
- [../adr/0050-postgresql-function-observability.md](../adr/0050-postgresql-function-observability.md) - Database observability
|
||||
- [../BARE-METAL-SETUP.md](../BARE-METAL-SETUP.md) - Production setup
|
||||
- [../operations/BARE-METAL-SETUP.md](../operations/BARE-METAL-SETUP.md) - Production setup
|
||||
- [../tools/BUGSINK-SETUP.md](../tools/BUGSINK-SETUP.md) - Bugsink configuration
|
||||
|
||||
@@ -5,6 +5,17 @@ This guide covers two related but distinct subagents for testing in the Flyer Cr
|
||||
- **tester**: Adversarial testing to find edge cases, race conditions, and vulnerabilities
|
||||
- **testwriter**: Creating comprehensive test suites for features and fixes
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Aspect | tester | testwriter |
|
||||
| ---------------- | -------------------------------------------- | ------------------------------------------ |
|
||||
| **Primary Use** | Find bugs, security issues, edge cases | Create test suites, improve coverage |
|
||||
| **Key Files** | N/A (analysis-focused) | `*.test.ts`, `src/tests/utils/` |
|
||||
| **Key ADRs** | ADR-010 (Testing), ADR-040 (Test Economics) | ADR-010 (Testing), ADR-045 (Test Fixtures) |
|
||||
| **Test Command** | `podman exec -it flyer-crawler-dev npm test` | Same |
|
||||
| **Test Stack** | Vitest, Supertest, Testing Library | Same |
|
||||
| **Delegate To** | `testwriter` (write tests for findings) | `coder` (fix failing tests) |
|
||||
|
||||
## Understanding the Difference
|
||||
|
||||
| Aspect | tester | testwriter |
|
||||
@@ -399,6 +410,7 @@ A typical workflow for thorough testing:
|
||||
|
||||
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
||||
- [CODER-GUIDE.md](./CODER-GUIDE.md) - Working with the coder subagent
|
||||
- [../TESTING.md](../TESTING.md) - Testing guide
|
||||
- [SECURITY-DEBUG-GUIDE.md](./SECURITY-DEBUG-GUIDE.md) - Security testing and code review
|
||||
- [../development/TESTING.md](../development/TESTING.md) - Testing guide
|
||||
- [../adr/0010-testing-strategy-and-standards.md](../adr/0010-testing-strategy-and-standards.md) - Testing ADR
|
||||
- [../adr/0040-testing-economics-and-priorities.md](../adr/0040-testing-economics-and-priorities.md) - Testing priorities
|
||||
|
||||
6090
package-lock.json
generated
6090
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.12.20",
|
||||
"version": "0.14.3",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
"dev:container": "concurrently \"npm:start:dev\" \"vite --host\"",
|
||||
@@ -24,14 +27,17 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"validate": "(prettier --check . || true) && npm run type-check && (npm run lint || true)",
|
||||
"clean": "rimraf coverage .coverage",
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"start:dev": "NODE_ENV=development tsx watch server.ts",
|
||||
"start:prod": "NODE_ENV=production tsx server.ts",
|
||||
"start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts",
|
||||
"db:reset:dev": "NODE_ENV=development tsx src/db/seed.ts",
|
||||
"db:reset:test": "NODE_ENV=test tsx src/db/seed.ts",
|
||||
"worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts",
|
||||
"prepare": "node -e \"try { require.resolve('husky') } catch (e) { process.exit(0) }\" && husky || true"
|
||||
"prepare": "node -e \"try { require.resolve('husky') } catch (e) { process.exit(0) }\" && husky || true",
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"tsoa:build": "tsoa spec-and-routes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
@@ -74,8 +80,8 @@
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsoa": "^6.6.0",
|
||||
"tsx": "^4.20.6",
|
||||
"zod": "^4.2.1",
|
||||
"zxcvbn": "^4.4.2",
|
||||
@@ -110,7 +116,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
@@ -139,7 +144,6 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.3.2",
|
||||
"rimraf": "^6.1.2",
|
||||
"supertest": "^7.1.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"testcontainers": "^11.8.1",
|
||||
|
||||
70
scripts/clean.mjs
Normal file
70
scripts/clean.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Clean script to remove coverage directories.
|
||||
* Replaces rimraf dependency with native Node.js fs.rm API.
|
||||
*
|
||||
* Usage: node scripts/clean.mjs
|
||||
*
|
||||
* Behavior matches rimraf: errors are logged but script exits successfully.
|
||||
* This allows build pipelines to continue even if directories don't exist.
|
||||
*/
|
||||
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Directories to clean, relative to project root.
|
||||
* Add additional directories here as needed.
|
||||
*/
|
||||
const DIRECTORIES_TO_CLEAN = ['coverage', '.coverage'];
|
||||
|
||||
/**
|
||||
* Removes a directory recursively, handling errors gracefully.
|
||||
*
|
||||
* @param {string} dirPath - Absolute path to the directory to remove
|
||||
* @returns {Promise<boolean>} - True if removed successfully, false if error occurred
|
||||
*/
|
||||
async function removeDirectory(dirPath) {
|
||||
try {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
console.log(`Removed: ${dirPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Log error but don't fail - matches rimraf behavior
|
||||
// force: true should handle ENOENT, but log other errors
|
||||
console.error(`Warning: Could not remove ${dirPath}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point. Cleans all configured directories.
|
||||
*/
|
||||
async function main() {
|
||||
// Get project root (parent of scripts directory)
|
||||
const projectRoot = resolve(import.meta.dirname, '..');
|
||||
|
||||
console.log('Cleaning coverage directories...');
|
||||
|
||||
const results = await Promise.all(
|
||||
DIRECTORIES_TO_CLEAN.map((dir) => {
|
||||
const absolutePath = resolve(projectRoot, dir);
|
||||
return removeDirectory(absolutePath);
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(Boolean).length;
|
||||
console.log(
|
||||
`Clean complete: ${successCount}/${DIRECTORIES_TO_CLEAN.length} directories processed.`
|
||||
);
|
||||
|
||||
// Always exit successfully (matches rimraf behavior)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Catch any unexpected errors in main
|
||||
console.error('Unexpected error during clean:', error.message);
|
||||
// Still exit successfully to not break build pipelines
|
||||
process.exit(0);
|
||||
});
|
||||
34
server.ts
34
server.ts
@@ -25,9 +25,12 @@ import { backgroundJobService, startBackgroundJobs } from './src/services/backgr
|
||||
import { websocketService } from './src/services/websocketService.server';
|
||||
import type { UserProfile } from './src/types';
|
||||
|
||||
// API Documentation (ADR-018)
|
||||
// API Documentation (ADR-018) - tsoa-generated OpenAPI spec
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
|
||||
|
||||
// tsoa-generated routes
|
||||
import { RegisterRoutes } from './src/routes/tsoa-generated';
|
||||
import {
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
@@ -197,11 +200,13 @@ if (!process.env.JWT_SECRET) {
|
||||
|
||||
// --- API Documentation (ADR-018) ---
|
||||
// Only serve Swagger UI in non-production environments to prevent information disclosure.
|
||||
// Uses tsoa-generated OpenAPI specification.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Serve tsoa-generated OpenAPI documentation
|
||||
app.use(
|
||||
'/docs/api-docs',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(swaggerSpec, {
|
||||
swaggerUi.setup(tsoaSpec, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'Flyer Crawler API Documentation',
|
||||
}),
|
||||
@@ -210,7 +215,7 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
// Expose raw OpenAPI JSON spec for tooling (SDK generation, testing, etc.)
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
res.send(tsoaSpec);
|
||||
});
|
||||
|
||||
logger.info('API Documentation available at /docs/api-docs');
|
||||
@@ -230,12 +235,27 @@ app.get('/api/v1/health/queues', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- tsoa-generated Routes ---
|
||||
// Register routes generated by tsoa from controllers.
|
||||
// These routes run in parallel with existing routes during migration.
|
||||
// tsoa routes are mounted directly on the app (basePath in tsoa.json is '/api').
|
||||
// The RegisterRoutes function adds routes at /api/health/*, /api/_tsoa/*, etc.
|
||||
//
|
||||
// IMPORTANT: tsoa routes are registered BEFORE the backwards compatibility redirect
|
||||
// middleware so that tsoa routes (like /api/health/ping, /api/_tsoa/verify) are
|
||||
// matched directly without being redirected to /api/v1/*.
|
||||
// During migration, both tsoa routes and versioned routes coexist:
|
||||
// - /api/health/ping -> handled by tsoa HealthController
|
||||
// - /api/v1/health/ping -> handled by versioned health.routes.ts
|
||||
// As controllers are migrated, the versioned routes will be removed.
|
||||
RegisterRoutes(app);
|
||||
|
||||
// --- Backwards Compatibility Redirect (ADR-008: API Versioning Strategy) ---
|
||||
// Redirect old /api/* paths to /api/v1/* for backwards compatibility.
|
||||
// This allows clients to gradually migrate to the versioned API.
|
||||
// IMPORTANT: This middleware MUST be mounted BEFORE createApiRouter() so that
|
||||
// unversioned paths like /api/users are redirected to /api/v1/users BEFORE
|
||||
// the versioned router's detectApiVersion middleware rejects them as invalid versions.
|
||||
// IMPORTANT: This middleware MUST be mounted:
|
||||
// - AFTER tsoa routes (so tsoa routes are matched directly)
|
||||
// - BEFORE createApiRouter() (so unversioned paths are redirected to /api/v1/*)
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Check if the path starts with a version-like prefix (/v followed by digits).
|
||||
// This includes both supported versions (v1, v2) and unsupported ones (v99).
|
||||
|
||||
378
src/components/FeatureFlag.test.tsx
Normal file
378
src/components/FeatureFlag.test.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
// src/components/FeatureFlag.test.tsx
|
||||
/**
|
||||
* Unit tests for the FeatureFlag component (ADR-024).
|
||||
*
|
||||
* These tests verify:
|
||||
* - Component renders children when feature is enabled
|
||||
* - Component hides children when feature is disabled
|
||||
* - Component renders fallback when feature is disabled
|
||||
* - Component returns null when disabled and no fallback provided
|
||||
* - All feature flag names are properly handled
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the useFeatureFlag hook
|
||||
const mockUseFeatureFlag = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useFeatureFlag', () => ({
|
||||
useFeatureFlag: (flagName: string) => mockUseFeatureFlag(flagName),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { FeatureFlag } from './FeatureFlag';
|
||||
|
||||
describe('FeatureFlag component', () => {
|
||||
beforeEach(() => {
|
||||
mockUseFeatureFlag.mockReset();
|
||||
// Default to disabled
|
||||
mockUseFeatureFlag.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('when feature is enabled', () => {
|
||||
beforeEach(() => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="new-feature">New Feature Content</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Feature Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render fallback', () => {
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render text content', () => {
|
||||
render(<FeatureFlag feature="newDashboard">Just some text</FeatureFlag>);
|
||||
|
||||
expect(screen.getByText('Just some text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call useFeatureFlag with correct flag name', () => {
|
||||
render(
|
||||
<FeatureFlag feature="betaRecipes">
|
||||
<div>Content</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(mockUseFeatureFlag).toHaveBeenCalledWith('betaRecipes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when feature is disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockUseFeatureFlag.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should not render children', () => {
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="new-feature">New Feature Content</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('New Feature Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallback when provided', () => {
|
||||
render(
|
||||
<FeatureFlag
|
||||
feature="newDashboard"
|
||||
fallback={<div data-testid="fallback">Legacy Feature</div>}
|
||||
>
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy Feature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when no fallback is provided', () => {
|
||||
const { container } = render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
// Container should be empty (just the wrapper)
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should render complex fallback components', () => {
|
||||
const FallbackComponent = () => (
|
||||
<div data-testid="complex-fallback">
|
||||
<h1>Legacy Dashboard</h1>
|
||||
<p>This is the old version</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard" fallback={<FallbackComponent />}>
|
||||
<div data-testid="new-feature">New Dashboard</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('complex-fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is the old version')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render text fallback', () => {
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard" fallback="Feature not available">
|
||||
<div>New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Feature not available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with different feature flags', () => {
|
||||
it('should work with newDashboard flag', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="dashboard">Dashboard</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(mockUseFeatureFlag).toHaveBeenCalledWith('newDashboard');
|
||||
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with betaRecipes flag', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="betaRecipes">
|
||||
<div data-testid="recipes">Recipes</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(mockUseFeatureFlag).toHaveBeenCalledWith('betaRecipes');
|
||||
expect(screen.getByTestId('recipes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with experimentalAi flag', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="experimentalAi">
|
||||
<div data-testid="ai">AI Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(mockUseFeatureFlag).toHaveBeenCalledWith('experimentalAi');
|
||||
expect(screen.getByTestId('ai')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with debugMode flag', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="debugMode">
|
||||
<div data-testid="debug">Debug Panel</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(mockUseFeatureFlag).toHaveBeenCalledWith('debugMode');
|
||||
expect(screen.getByTestId('debug')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world usage patterns', () => {
|
||||
it('should work for A/B testing pattern', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<FeatureFlag feature="newDashboard" fallback={<div data-testid="old-ui">Old UI</div>}>
|
||||
<div data-testid="new-ui">New UI</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-ui')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('old-ui')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work for gradual rollout pattern', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<div>
|
||||
<nav data-testid="nav">Navigation</nav>
|
||||
<FeatureFlag feature="betaRecipes">
|
||||
<aside data-testid="recipe-suggestions">Recipe Suggestions</aside>
|
||||
</FeatureFlag>
|
||||
<main data-testid="main">Main Content</main>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('nav')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('recipe-suggestions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work nested within conditional logic', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
const isLoggedIn = true;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{isLoggedIn && (
|
||||
<FeatureFlag
|
||||
feature="experimentalAi"
|
||||
fallback={<div data-testid="standard">Standard</div>}
|
||||
>
|
||||
<div data-testid="ai-search">AI Search</div>
|
||||
</FeatureFlag>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with multiple FeatureFlag components', () => {
|
||||
// First call for newDashboard returns true
|
||||
// Second call for debugMode returns false
|
||||
mockUseFeatureFlag.mockImplementation((flag: string) => {
|
||||
if (flag === 'newDashboard') return true;
|
||||
if (flag === 'debugMode') return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
render(
|
||||
<div>
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<div data-testid="new-dashboard">New Dashboard</div>
|
||||
</FeatureFlag>
|
||||
<FeatureFlag feature="debugMode" fallback={<div data-testid="no-debug">No Debug</div>}>
|
||||
<div data-testid="debug-panel">Debug Panel</div>
|
||||
</FeatureFlag>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('new-dashboard')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('debug-panel')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-debug')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined fallback gracefully', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(false);
|
||||
|
||||
const { container } = render(
|
||||
<FeatureFlag feature="newDashboard" fallback={undefined}>
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null children gracefully when enabled', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
const { container } = render(<FeatureFlag feature="newDashboard">{null}</FeatureFlag>);
|
||||
|
||||
// Should render nothing (null)
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty children when enabled', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
const { container } = render(
|
||||
<FeatureFlag feature="newDashboard">
|
||||
<></>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
// Should render the empty fragment
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should handle boolean children', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
// React ignores boolean children, so nothing should render
|
||||
const { container } = render(
|
||||
<FeatureFlag feature="newDashboard">{true as unknown as React.ReactNode}</FeatureFlag>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should handle number children', () => {
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
render(<FeatureFlag feature="newDashboard">{42}</FeatureFlag>);
|
||||
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-rendering behavior', () => {
|
||||
it('should update when feature flag value changes', () => {
|
||||
const { rerender } = render(
|
||||
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
// Initially disabled
|
||||
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('fallback')).toBeInTheDocument();
|
||||
|
||||
// Enable the flag
|
||||
mockUseFeatureFlag.mockReturnValue(true);
|
||||
|
||||
rerender(
|
||||
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
|
||||
<div data-testid="new-feature">New Feature</div>
|
||||
</FeatureFlag>,
|
||||
);
|
||||
|
||||
// Now enabled
|
||||
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
75
src/components/FeatureFlag.tsx
Normal file
75
src/components/FeatureFlag.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/components/FeatureFlag.tsx
|
||||
import type { ReactNode } from 'react';
|
||||
import { useFeatureFlag, type FeatureFlagName } from '../hooks/useFeatureFlag';
|
||||
|
||||
/**
|
||||
* Props for the FeatureFlag component.
|
||||
*/
|
||||
export interface FeatureFlagProps {
|
||||
/**
|
||||
* The name of the feature flag to check.
|
||||
* Must be a valid FeatureFlagName defined in config.featureFlags.
|
||||
*/
|
||||
feature: FeatureFlagName;
|
||||
|
||||
/**
|
||||
* Content to render when the feature flag is enabled.
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* Optional content to render when the feature flag is disabled.
|
||||
* If not provided, nothing is rendered when the flag is disabled.
|
||||
* @default null
|
||||
*/
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative component for conditional rendering based on feature flag state.
|
||||
*
|
||||
* This component provides a clean, declarative API for rendering content based
|
||||
* on whether a feature flag is enabled or disabled. It uses the useFeatureFlag
|
||||
* hook internally and supports an optional fallback for disabled features.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.feature - The feature flag name to check
|
||||
* @param props.children - Content rendered when feature is enabled
|
||||
* @param props.fallback - Content rendered when feature is disabled (default: null)
|
||||
*
|
||||
* @example
|
||||
* // Basic usage - show new feature when enabled
|
||||
* <FeatureFlag feature="newDashboard">
|
||||
* <NewDashboard />
|
||||
* </FeatureFlag>
|
||||
*
|
||||
* @example
|
||||
* // With fallback - show alternative when feature is disabled
|
||||
* <FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
|
||||
* <NewDashboard />
|
||||
* </FeatureFlag>
|
||||
*
|
||||
* @example
|
||||
* // Wrap a section of UI that should only appear when flag is enabled
|
||||
* <div className="sidebar">
|
||||
* <Navigation />
|
||||
* <FeatureFlag feature="betaRecipes">
|
||||
* <RecipeSuggestions />
|
||||
* </FeatureFlag>
|
||||
* <Footer />
|
||||
* </div>
|
||||
*
|
||||
* @example
|
||||
* // Combine with other conditional logic
|
||||
* {isLoggedIn && (
|
||||
* <FeatureFlag feature="experimentalAi" fallback={<StandardSearch />}>
|
||||
* <AiPoweredSearch />
|
||||
* </FeatureFlag>
|
||||
* )}
|
||||
*
|
||||
* @see docs/adr/0024-feature-flagging-strategy.md
|
||||
*/
|
||||
export function FeatureFlag({ feature, children, fallback = null }: FeatureFlagProps): ReactNode {
|
||||
const isEnabled = useFeatureFlag(feature);
|
||||
return isEnabled ? children : fallback;
|
||||
}
|
||||
@@ -24,6 +24,28 @@ const config = {
|
||||
debug: import.meta.env.VITE_SENTRY_DEBUG === 'true',
|
||||
enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
|
||||
},
|
||||
/**
|
||||
* Feature flags for conditional feature rendering (ADR-024).
|
||||
*
|
||||
* All flags default to false (disabled) when the environment variable is not set
|
||||
* or is set to any value other than 'true'. This opt-in model ensures features
|
||||
* are explicitly enabled, preventing accidental exposure of incomplete features.
|
||||
*
|
||||
* Environment variables follow the naming convention: VITE_FEATURE_SNAKE_CASE
|
||||
* Config properties use camelCase for consistency with JavaScript conventions.
|
||||
*
|
||||
* @see docs/adr/0024-feature-flagging-strategy.md
|
||||
*/
|
||||
featureFlags: {
|
||||
/** Enable the redesigned dashboard UI (VITE_FEATURE_NEW_DASHBOARD) */
|
||||
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
|
||||
/** Enable beta recipe features (VITE_FEATURE_BETA_RECIPES) */
|
||||
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
|
||||
/** Enable experimental AI features (VITE_FEATURE_EXPERIMENTAL_AI) */
|
||||
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
|
||||
/** Enable debug mode UI elements (VITE_FEATURE_DEBUG_MODE) */
|
||||
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -155,6 +155,38 @@ const sentrySchema = z.object({
|
||||
debug: booleanString(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature flags configuration schema (ADR-024).
|
||||
*
|
||||
* All flags default to `false` (disabled) for safety, following an opt-in model.
|
||||
* Set the corresponding environment variable to 'true' to enable a feature.
|
||||
*
|
||||
* Environment variable naming convention: `FEATURE_SNAKE_CASE`
|
||||
* Config property naming convention: `camelCase`
|
||||
*
|
||||
* @example
|
||||
* // Enable via environment:
|
||||
* FEATURE_BUGSINK_SYNC=true
|
||||
*
|
||||
* // Check in code:
|
||||
* import { config } from './config/env';
|
||||
* if (config.featureFlags.bugsinkSync) { ... }
|
||||
*/
|
||||
const featureFlagsSchema = z.object({
|
||||
/** Enable Bugsink error sync integration (FEATURE_BUGSINK_SYNC) */
|
||||
bugsinkSync: booleanString(false),
|
||||
/** Enable advanced RBAC features (FEATURE_ADVANCED_RBAC) */
|
||||
advancedRbac: booleanString(false),
|
||||
/** Enable new dashboard experience (FEATURE_NEW_DASHBOARD) */
|
||||
newDashboard: booleanString(false),
|
||||
/** Enable beta recipe features (FEATURE_BETA_RECIPES) */
|
||||
betaRecipes: booleanString(false),
|
||||
/** Enable experimental AI features (FEATURE_EXPERIMENTAL_AI) */
|
||||
experimentalAi: booleanString(false),
|
||||
/** Enable debug mode for development (FEATURE_DEBUG_MODE) */
|
||||
debugMode: booleanString(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete environment configuration schema.
|
||||
*/
|
||||
@@ -170,6 +202,7 @@ const envSchema = z.object({
|
||||
worker: workerSchema,
|
||||
server: serverSchema,
|
||||
sentry: sentrySchema,
|
||||
featureFlags: featureFlagsSchema,
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
@@ -244,6 +277,14 @@ function loadEnvVars(): unknown {
|
||||
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
|
||||
debug: process.env.SENTRY_DEBUG,
|
||||
},
|
||||
featureFlags: {
|
||||
bugsinkSync: process.env.FEATURE_BUGSINK_SYNC,
|
||||
advancedRbac: process.env.FEATURE_ADVANCED_RBAC,
|
||||
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
|
||||
betaRecipes: process.env.FEATURE_BETA_RECIPES,
|
||||
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
|
||||
debugMode: process.env.FEATURE_DEBUG_MODE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,3 +432,33 @@ export const isGoogleOAuthConfigured = !!config.google.clientId && !!config.goog
|
||||
* Returns true if GitHub OAuth is configured (both client ID and secret present).
|
||||
*/
|
||||
export const isGithubOAuthConfigured = !!config.github.clientId && !!config.github.clientSecret;
|
||||
|
||||
// --- Feature Flag Helpers (ADR-024) ---
|
||||
|
||||
/**
|
||||
* Type representing valid feature flag names.
|
||||
* Derived from the featureFlagsSchema for type safety.
|
||||
*/
|
||||
export type FeatureFlagName = keyof typeof config.featureFlags;
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
*
|
||||
* This is a convenience function for checking feature flag state.
|
||||
* For more advanced usage (logging, all flags), use the featureFlags service.
|
||||
*
|
||||
* @param flagName - The name of the feature flag to check
|
||||
* @returns boolean indicating if the feature is enabled
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { isFeatureFlagEnabled } from './config/env';
|
||||
*
|
||||
* if (isFeatureFlagEnabled('newDashboard')) {
|
||||
* // Use new dashboard
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isFeatureFlagEnabled(flagName: FeatureFlagName): boolean {
|
||||
return config.featureFlags[flagName];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// src/config/swagger.test.ts
|
||||
/**
|
||||
* Tests for tsoa-generated OpenAPI specification.
|
||||
*
|
||||
* These tests verify the tsoa specification structure and content
|
||||
* as generated from controllers decorated with tsoa decorators.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swaggerSpec } from './swagger';
|
||||
import tsoaSpec from './tsoa-spec.json';
|
||||
|
||||
// Type definition for OpenAPI 3.0 spec structure used in tests
|
||||
interface OpenAPISpec {
|
||||
@@ -10,18 +16,11 @@ interface OpenAPISpec {
|
||||
version: string;
|
||||
description?: string;
|
||||
contact?: { name: string };
|
||||
license?: { name: string };
|
||||
license?: { name: string | { name: string } };
|
||||
};
|
||||
servers: Array<{ url: string; description?: string }>;
|
||||
components: {
|
||||
securitySchemes?: {
|
||||
bearerAuth?: {
|
||||
type: string;
|
||||
scheme: string;
|
||||
bearerFormat?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
securitySchemes?: Record<string, unknown>;
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
tags: Array<{ name: string; description?: string }>;
|
||||
@@ -29,19 +28,13 @@ interface OpenAPISpec {
|
||||
}
|
||||
|
||||
// Cast to typed spec for property access
|
||||
const spec = swaggerSpec as OpenAPISpec;
|
||||
const spec = tsoaSpec as unknown as OpenAPISpec;
|
||||
|
||||
/**
|
||||
* Tests for src/config/swagger.ts - OpenAPI/Swagger configuration.
|
||||
*
|
||||
* These tests verify the swagger specification structure and content
|
||||
* without testing the swagger-jsdoc library itself.
|
||||
*/
|
||||
describe('swagger configuration', () => {
|
||||
describe('swaggerSpec export', () => {
|
||||
describe('tsoa OpenAPI specification', () => {
|
||||
describe('spec export', () => {
|
||||
it('should export a swagger specification object', () => {
|
||||
expect(swaggerSpec).toBeDefined();
|
||||
expect(typeof swaggerSpec).toBe('object');
|
||||
expect(tsoaSpec).toBeDefined();
|
||||
expect(typeof tsoaSpec).toBe('object');
|
||||
});
|
||||
|
||||
it('should have openapi version 3.0.0', () => {
|
||||
@@ -63,12 +56,11 @@ describe('swagger configuration', () => {
|
||||
|
||||
it('should have contact information', () => {
|
||||
expect(spec.info.contact).toBeDefined();
|
||||
expect(spec.info.contact?.name).toBe('API Support');
|
||||
expect(spec.info.contact?.name).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have license information', () => {
|
||||
expect(spec.info.license).toBeDefined();
|
||||
expect(spec.info.license?.name).toBe('Private');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,10 +71,9 @@ describe('swagger configuration', () => {
|
||||
expect(spec.servers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have /api/v1 as the server URL (ADR-008)', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api/v1');
|
||||
it('should have /api as the server URL (tsoa basePath)', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api');
|
||||
expect(apiServer).toBeDefined();
|
||||
expect(apiServer?.description).toBe('API server (v1)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,96 +82,42 @@ describe('swagger configuration', () => {
|
||||
expect(spec.components).toBeDefined();
|
||||
});
|
||||
|
||||
describe('securitySchemes', () => {
|
||||
it('should have bearerAuth security scheme', () => {
|
||||
expect(spec.components.securitySchemes).toBeDefined();
|
||||
expect(spec.components.securitySchemes?.bearerAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure bearerAuth as HTTP bearer with JWT format', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.type).toBe('http');
|
||||
expect(bearerAuth?.scheme).toBe('bearer');
|
||||
expect(bearerAuth?.bearerFormat).toBe('JWT');
|
||||
});
|
||||
|
||||
it('should have description for bearerAuth', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.description).toContain('JWT token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas', () => {
|
||||
const schemas = () => spec.components.schemas as Record<string, any>;
|
||||
const schemas = () => spec.components.schemas as Record<string, unknown>;
|
||||
|
||||
it('should have schemas object', () => {
|
||||
expect(spec.components.schemas).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have SuccessResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().SuccessResponse;
|
||||
it('should have PaginationMeta schema (ADR-028)', () => {
|
||||
const schema = schemas().PaginationMeta as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.data).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('data');
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.page).toBeDefined();
|
||||
expect(properties.limit).toBeDefined();
|
||||
expect(properties.total).toBeDefined();
|
||||
expect(properties.totalPages).toBeDefined();
|
||||
expect(properties.hasNextPage).toBeDefined();
|
||||
expect(properties.hasPrevPage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have ErrorResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().ErrorResponse;
|
||||
it('should have ResponseMeta schema', () => {
|
||||
const schema = schemas().ResponseMeta as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.error).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('error');
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.requestId).toBeDefined();
|
||||
expect(properties.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have ErrorResponse error object with code and message', () => {
|
||||
const errorSchema = schemas().ErrorResponse.properties.error;
|
||||
expect(errorSchema.properties.code).toBeDefined();
|
||||
expect(errorSchema.properties.message).toBeDefined();
|
||||
expect(errorSchema.required).toContain('code');
|
||||
expect(errorSchema.required).toContain('message');
|
||||
});
|
||||
|
||||
it('should have ServiceHealth schema', () => {
|
||||
const schema = schemas().ServiceHealth;
|
||||
it('should have ErrorDetails schema for error responses (ADR-028)', () => {
|
||||
const schema = schemas().ErrorDetails as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.status).toBeDefined();
|
||||
expect(schema.properties.status.enum).toContain('healthy');
|
||||
expect(schema.properties.status.enum).toContain('degraded');
|
||||
expect(schema.properties.status.enum).toContain('unhealthy');
|
||||
});
|
||||
|
||||
it('should have Achievement schema', () => {
|
||||
const schema = schemas().Achievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.achievement_id).toBeDefined();
|
||||
expect(schema.properties.name).toBeDefined();
|
||||
expect(schema.properties.description).toBeDefined();
|
||||
expect(schema.properties.icon).toBeDefined();
|
||||
expect(schema.properties.points_value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have UserAchievement schema extending Achievement', () => {
|
||||
const schema = schemas().UserAchievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.allOf).toBeDefined();
|
||||
expect(schema.allOf[0].$ref).toBe('#/components/schemas/Achievement');
|
||||
});
|
||||
|
||||
it('should have LeaderboardUser schema', () => {
|
||||
const schema = schemas().LeaderboardUser;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.user_id).toBeDefined();
|
||||
expect(schema.properties.full_name).toBeDefined();
|
||||
expect(schema.properties.points).toBeDefined();
|
||||
expect(schema.properties.rank).toBeDefined();
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.code).toBeDefined();
|
||||
expect(properties.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -194,7 +131,7 @@ describe('swagger configuration', () => {
|
||||
it('should have Health tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Health');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('health');
|
||||
expect(tag?.description).toContain('Health');
|
||||
});
|
||||
|
||||
it('should have Auth tag', () => {
|
||||
@@ -206,13 +143,6 @@ describe('swagger configuration', () => {
|
||||
it('should have Users tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Users');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('User');
|
||||
});
|
||||
|
||||
it('should have Achievements tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Achievements');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('Gamification');
|
||||
});
|
||||
|
||||
it('should have Flyers tag', () => {
|
||||
@@ -220,45 +150,37 @@ describe('swagger configuration', () => {
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Recipes tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Recipes');
|
||||
it('should have Deals tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Deals');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Budgets tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Budgets');
|
||||
it('should have Stores tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Stores');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have Admin tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Admin');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('admin');
|
||||
describe('paths section', () => {
|
||||
it('should have paths object with endpoints', () => {
|
||||
expect(spec.paths).toBeDefined();
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
expect(Object.keys(spec.paths as object).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have System tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'System');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have 9 tags total', () => {
|
||||
expect(spec.tags.length).toBe(9);
|
||||
it('should have health ping endpoint', () => {
|
||||
const paths = spec.paths as Record<string, unknown>;
|
||||
expect(paths['/health/ping']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('specification validity', () => {
|
||||
it('should have paths object (may be empty if no JSDoc annotations parsed)', () => {
|
||||
// swagger-jsdoc creates paths from JSDoc annotations in route files
|
||||
// In test environment, this may be empty if routes aren't scanned
|
||||
expect(swaggerSpec).toHaveProperty('paths');
|
||||
});
|
||||
|
||||
it('should be a valid JSON-serializable object', () => {
|
||||
expect(() => JSON.stringify(swaggerSpec)).not.toThrow();
|
||||
expect(() => JSON.stringify(tsoaSpec)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce valid JSON output', () => {
|
||||
const json = JSON.stringify(swaggerSpec);
|
||||
const json = JSON.stringify(tsoaSpec);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// src/config/swagger.ts
|
||||
/**
|
||||
* @file OpenAPI/Swagger configuration for API documentation.
|
||||
* Implements ADR-018: API Documentation Strategy.
|
||||
*
|
||||
* This file configures swagger-jsdoc to generate an OpenAPI 3.0 specification
|
||||
* from JSDoc annotations in route files. The specification is used by
|
||||
* swagger-ui-express to serve interactive API documentation.
|
||||
*/
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'API for the Flyer Crawler application - a platform for discovering grocery deals, managing recipes, and tracking budgets.',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
license: {
|
||||
name: 'Private',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API server (v1)',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token obtained from /auth/login or /auth/register',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
// Standard success response wrapper (ADR-028)
|
||||
SuccessResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Response payload - structure varies by endpoint',
|
||||
},
|
||||
},
|
||||
required: ['success', 'data'],
|
||||
},
|
||||
// Standard error response wrapper (ADR-028)
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
},
|
||||
error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'Machine-readable error code',
|
||||
example: 'VALIDATION_ERROR',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Human-readable error message',
|
||||
example: 'Invalid request parameters',
|
||||
},
|
||||
},
|
||||
required: ['code', 'message'],
|
||||
},
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
},
|
||||
// Common service health status
|
||||
ServiceHealth: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['healthy', 'degraded', 'unhealthy'],
|
||||
},
|
||||
latency: {
|
||||
type: 'number',
|
||||
description: 'Response time in milliseconds',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Additional status information',
|
||||
},
|
||||
details: {
|
||||
type: 'object',
|
||||
description: 'Service-specific details',
|
||||
},
|
||||
},
|
||||
required: ['status'],
|
||||
},
|
||||
// Achievement schema
|
||||
Achievement: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
achievement_id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
example: 'First-Upload',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
example: 'Upload your first flyer',
|
||||
},
|
||||
icon: {
|
||||
type: 'string',
|
||||
example: 'upload-cloud',
|
||||
},
|
||||
points_value: {
|
||||
type: 'integer',
|
||||
example: 25,
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
// User achievement (with achieved_at)
|
||||
UserAchievement: {
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/Achievement' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
achieved_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Leaderboard entry
|
||||
LeaderboardUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
full_name: {
|
||||
type: 'string',
|
||||
example: 'John Doe',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
points: {
|
||||
type: 'integer',
|
||||
example: 150,
|
||||
},
|
||||
rank: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Health',
|
||||
description: 'Server health and readiness checks',
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
description: 'Authentication and authorization',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
description: 'User profile management',
|
||||
},
|
||||
{
|
||||
name: 'Achievements',
|
||||
description: 'Gamification and leaderboards',
|
||||
},
|
||||
{
|
||||
name: 'Flyers',
|
||||
description: 'Flyer uploads and retrieval',
|
||||
},
|
||||
{
|
||||
name: 'Recipes',
|
||||
description: 'Recipe management',
|
||||
},
|
||||
{
|
||||
name: 'Budgets',
|
||||
description: 'Budget tracking and analysis',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Administrative operations (requires admin role)',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
description: 'System status and monitoring',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Path to the API routes files with JSDoc annotations
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
420
src/controllers/README.md
Normal file
420
src/controllers/README.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# tsoa Controller Standards
|
||||
|
||||
This document defines the coding standards and patterns for implementing tsoa controllers in the Flyer Crawler API.
|
||||
|
||||
## Overview
|
||||
|
||||
Controllers are the API layer that handles HTTP requests and responses. They use [tsoa](https://tsoa-community.github.io/docs/) decorators to define routes and generate OpenAPI specifications automatically.
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- Controllers handle HTTP concerns (parsing requests, formatting responses)
|
||||
- Business logic belongs in the service layer
|
||||
- All controllers extend `BaseController` for consistent response formatting
|
||||
- Response formats follow ADR-028 (API Response Standards)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Route, Get, Post, Tags, Body, Path, Query, Security, Request } from 'tsoa';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse,
|
||||
PaginatedResponse,
|
||||
RequestContext,
|
||||
} from './base.controller';
|
||||
import { userService } from '../services/userService';
|
||||
import type { User, CreateUserRequest } from '../types';
|
||||
|
||||
@Route('users')
|
||||
@Tags('Users')
|
||||
export class UsersController extends BaseController {
|
||||
/**
|
||||
* Get a user by ID.
|
||||
* @param id The user's unique identifier
|
||||
*/
|
||||
@Get('{id}')
|
||||
@Security('jwt')
|
||||
public async getUser(
|
||||
@Path() id: string,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<User>> {
|
||||
ctx.logger.info({ userId: id }, 'Fetching user');
|
||||
const user = await userService.getUserById(id, ctx.logger);
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination.
|
||||
*/
|
||||
@Get()
|
||||
@Security('jwt', ['admin'])
|
||||
public async listUsers(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
@Request() ctx?: RequestContext,
|
||||
): Promise<PaginatedResponse<User>> {
|
||||
const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
const { users, total } = await userService.listUsers({ page: p, limit: l }, ctx?.logger);
|
||||
return this.paginated(users, { page: p, limit: l, total });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user.
|
||||
*/
|
||||
@Post()
|
||||
@Security('jwt', ['admin'])
|
||||
public async createUser(
|
||||
@Body() body: CreateUserRequest,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<User>> {
|
||||
const user = await userService.createUser(body, ctx.logger);
|
||||
return this.created(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
├── base.controller.ts # Base class with response helpers
|
||||
├── types.ts # Shared types for controllers
|
||||
├── README.md # This file
|
||||
├── health.controller.ts # Health check endpoints
|
||||
├── auth.controller.ts # Authentication endpoints
|
||||
├── users.controller.ts # User management endpoints
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
All responses follow ADR-028:
|
||||
|
||||
### Success Response
|
||||
|
||||
```typescript
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: {
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
pagination?: PaginationMeta;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string; // e.g., 'NOT_FOUND', 'VALIDATION_ERROR'
|
||||
message: string; // Human-readable message
|
||||
details?: unknown; // Additional error details
|
||||
};
|
||||
meta?: {
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```typescript
|
||||
interface PaginatedResponse<T> {
|
||||
success: true;
|
||||
data: T[];
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## BaseController Methods
|
||||
|
||||
### Response Helpers
|
||||
|
||||
| Method | Description | HTTP Status |
|
||||
| ------------------------------------ | --------------------------- | ----------- |
|
||||
| `success(data, meta?)` | Standard success response | 200 |
|
||||
| `created(data, meta?)` | Resource created | 201 |
|
||||
| `noContent()` | Success with no body | 204 |
|
||||
| `paginated(data, pagination, meta?)` | Paginated list response | 200 |
|
||||
| `message(msg)` | Success with just a message | 200 |
|
||||
|
||||
### Pagination Helpers
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------ | ----------------------------------------------------- |
|
||||
| `normalizePagination(page?, limit?)` | Apply defaults and bounds (page=1, limit=20, max=100) |
|
||||
| `calculatePagination(input)` | Calculate totalPages, hasNextPage, etc. |
|
||||
|
||||
### Error Handling
|
||||
|
||||
Controllers should throw typed errors rather than constructing error responses manually:
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ForbiddenError, ValidationError } from './base.controller';
|
||||
|
||||
// Throw when resource not found
|
||||
throw new NotFoundError('User not found');
|
||||
|
||||
// Throw when access denied
|
||||
throw new ForbiddenError('Cannot access this resource');
|
||||
|
||||
// Throw for validation errors (from Zod)
|
||||
throw new ValidationError(zodError.issues);
|
||||
```
|
||||
|
||||
The global error handler converts these to proper HTTP responses.
|
||||
|
||||
## tsoa Decorators
|
||||
|
||||
### Route Decorators
|
||||
|
||||
| Decorator | Description |
|
||||
| ------------------------------------------------------ | ----------------------------------------- |
|
||||
| `@Route('path')` | Base path for all endpoints in controller |
|
||||
| `@Tags('TagName')` | OpenAPI tag for grouping |
|
||||
| `@Get()`, `@Post()`, `@Put()`, `@Patch()`, `@Delete()` | HTTP methods |
|
||||
| `@Security('jwt')` | Require authentication |
|
||||
| `@Security('jwt', ['admin'])` | Require specific roles |
|
||||
|
||||
### Parameter Decorators
|
||||
|
||||
| Decorator | Description | Example |
|
||||
| ------------ | ---------------------- | ------------------------------------- |
|
||||
| `@Path()` | URL path parameter | `@Path() id: string` |
|
||||
| `@Query()` | Query string parameter | `@Query() search?: string` |
|
||||
| `@Body()` | Request body | `@Body() data: CreateUserRequest` |
|
||||
| `@Header()` | Request header | `@Header('X-Custom') custom?: string` |
|
||||
| `@Request()` | Full request context | `@Request() ctx: RequestContext` |
|
||||
|
||||
### Response Decorators
|
||||
|
||||
| Decorator | Description |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `@Response<ErrorResponse>(404, 'Not Found')` | Document possible error response |
|
||||
| `@SuccessResponse(201, 'Created')` | Document success response |
|
||||
|
||||
## RequestContext
|
||||
|
||||
The `RequestContext` interface provides access to request-scoped resources:
|
||||
|
||||
```typescript
|
||||
interface RequestContext {
|
||||
logger: Logger; // Request-scoped Pino logger (ADR-004)
|
||||
requestId: string; // Unique request ID for correlation
|
||||
user?: AuthenticatedUser; // Authenticated user (from JWT)
|
||||
dbClient?: PoolClient; // Database client for transactions
|
||||
}
|
||||
|
||||
interface AuthenticatedUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Controller Class Names
|
||||
|
||||
- PascalCase with `Controller` suffix
|
||||
- Example: `UsersController`, `HealthController`, `AuthController`
|
||||
|
||||
### Method Names
|
||||
|
||||
- camelCase, describing the action
|
||||
- Use verbs: `getUser`, `listUsers`, `createUser`, `updateUser`, `deleteUser`
|
||||
|
||||
### File Names
|
||||
|
||||
- kebab-case with `.controller.ts` suffix
|
||||
- Example: `users.controller.ts`, `health.controller.ts`
|
||||
|
||||
## Service Layer Integration
|
||||
|
||||
Controllers delegate to the service layer for business logic:
|
||||
|
||||
```typescript
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyersController extends BaseController {
|
||||
@Get('{id}')
|
||||
public async getFlyer(
|
||||
@Path() id: number,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<Flyer>> {
|
||||
// Service handles business logic and database access
|
||||
const flyer = await flyerService.getFlyerById(id, ctx.logger);
|
||||
// Controller only formats the response
|
||||
return this.success(flyer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Never access repositories directly from controllers
|
||||
2. Always pass the logger to service methods
|
||||
3. Let services throw domain errors (NotFoundError, etc.)
|
||||
4. Controllers catch and transform errors only when needed
|
||||
|
||||
## Validation
|
||||
|
||||
tsoa performs automatic validation based on TypeScript types. For complex validation, define request types:
|
||||
|
||||
```typescript
|
||||
// In types.ts or a dedicated types file
|
||||
interface CreateUserRequest {
|
||||
/**
|
||||
* User's email address
|
||||
* @format email
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password
|
||||
* @minLength 8
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* Full name (optional)
|
||||
*/
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
// In controller
|
||||
@Post()
|
||||
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
// body is already validated by tsoa
|
||||
return this.created(await userService.createUser(body));
|
||||
}
|
||||
```
|
||||
|
||||
For additional runtime validation (e.g., database constraints), use Zod in the service layer.
|
||||
|
||||
## Error Handling Examples
|
||||
|
||||
### Not Found
|
||||
|
||||
```typescript
|
||||
@Get('{id}')
|
||||
public async getUser(@Path() id: string): Promise<SuccessResponse<User>> {
|
||||
const user = await userService.findUserById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User with ID ${id} not found`);
|
||||
}
|
||||
return this.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
```typescript
|
||||
@Delete('{id}')
|
||||
@Security('jwt')
|
||||
public async deleteUser(
|
||||
@Path() id: string,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
// Only allow users to delete their own account (or admins)
|
||||
if (ctx.user?.userId !== id && !ctx.user?.roles?.includes('admin')) {
|
||||
throw new ForbiddenError('Cannot delete another user account');
|
||||
}
|
||||
await userService.deleteUser(id, ctx.logger);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### Conflict (Duplicate)
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
try {
|
||||
const user = await userService.createUser(body);
|
||||
return this.created(user);
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
this.setStatus(409);
|
||||
throw error; // Let error handler format the response
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Controllers
|
||||
|
||||
Controllers should be tested via integration tests that verify the full HTTP request/response cycle:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../app';
|
||||
|
||||
describe('UsersController', () => {
|
||||
describe('GET /api/v1/users/:id', () => {
|
||||
it('should return user when found', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users/123')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: expect.objectContaining({
|
||||
user_id: '123',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when user not found', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users/nonexistent')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Migration from Express Routes
|
||||
|
||||
When migrating existing Express routes to tsoa controllers:
|
||||
|
||||
1. Create the controller class extending `BaseController`
|
||||
2. Add route decorators matching existing paths
|
||||
3. Move request handling logic (keep business logic in services)
|
||||
4. Replace `sendSuccess`/`sendError` with `this.success()`/throwing errors
|
||||
5. Update tests to use the new paths
|
||||
6. Remove the old Express route file
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-028: API Response Standards](../../docs/adr/ADR-028-api-response-standards.md)
|
||||
- [ADR-004: Request-Scoped Logging](../../docs/adr/0004-request-scoped-logging.md)
|
||||
- [CODE-PATTERNS.md](../../docs/development/CODE-PATTERNS.md)
|
||||
- [tsoa Documentation](https://tsoa-community.github.io/docs/)
|
||||
862
src/controllers/admin.controller.test.ts
Normal file
862
src/controllers/admin.controller.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
// src/controllers/admin.controller.test.ts
|
||||
// ============================================================================
|
||||
// ADMIN CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AdminController class. These tests verify controller
|
||||
// logic in isolation by mocking database repositories, services, and queues.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
getSuggestedCorrections: vi.fn(),
|
||||
approveCorrection: vi.fn(),
|
||||
rejectCorrection: vi.fn(),
|
||||
updateSuggestedCorrection: vi.fn(),
|
||||
getAllUsers: vi.fn(),
|
||||
updateUserRole: vi.fn(),
|
||||
updateRecipeStatus: vi.fn(),
|
||||
updateRecipeCommentStatus: vi.fn(),
|
||||
getFlyersForReview: vi.fn(),
|
||||
getUnmatchedFlyerItems: vi.fn(),
|
||||
getApplicationStats: vi.fn(),
|
||||
getDailyStatsForLast30Days: vi.fn(),
|
||||
getActivityLog: vi.fn(),
|
||||
},
|
||||
userRepo: {
|
||||
findUserProfileById: vi.fn(),
|
||||
},
|
||||
flyerRepo: {
|
||||
deleteFlyer: vi.fn(),
|
||||
getAllBrands: vi.fn(),
|
||||
},
|
||||
recipeRepo: {
|
||||
deleteRecipe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock services
|
||||
vi.mock('../services/backgroundJobService', () => ({
|
||||
backgroundJobService: {
|
||||
runDailyDealCheck: vi.fn(),
|
||||
triggerAnalyticsReport: vi.fn(),
|
||||
triggerWeeklyAnalyticsReport: vi.fn(),
|
||||
triggerTokenCleanup: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/monitoringService.server', () => ({
|
||||
monitoringService: {
|
||||
getWorkerStatuses: vi.fn(),
|
||||
getQueueStatuses: vi.fn(),
|
||||
retryFailedJob: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
clearGeocodeCache: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/cacheService.server', () => ({
|
||||
cacheService: {
|
||||
invalidateFlyers: vi.fn(),
|
||||
invalidateBrands: vi.fn(),
|
||||
invalidateStats: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/brandService', () => ({
|
||||
brandService: {
|
||||
updateBrandLogo: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/userService', () => ({
|
||||
userService: {
|
||||
deleteUserAsAdmin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/featureFlags.server', () => ({
|
||||
getFeatureFlags: vi.fn(),
|
||||
FeatureFlagName: {},
|
||||
}));
|
||||
|
||||
// Mock queues
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
cleanupQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
analyticsQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
adminTriggerLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
adminUploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Mock file utils
|
||||
vi.mock('../utils/fileUtils', () => ({
|
||||
cleanupUploadedFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock websocket service (dynamic import)
|
||||
vi.mock('../services/websocketService.server', () => ({
|
||||
websocketService: {
|
||||
getConnectionStats: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { cacheService } from '../services/cacheService.server';
|
||||
import { brandService } from '../services/brandService';
|
||||
import { userService } from '../services/userService';
|
||||
import { getFeatureFlags } from '../services/featureFlags.server';
|
||||
import { cleanupQueue, analyticsQueue } from '../services/queueService.server';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access using vi.mocked()
|
||||
const mockedAdminRepo = vi.mocked(db.adminRepo);
|
||||
const mockedUserRepo = vi.mocked(db.userRepo);
|
||||
const mockedFlyerRepo = vi.mocked(db.flyerRepo);
|
||||
const mockedRecipeRepo = vi.mocked(db.recipeRepo);
|
||||
const mockedBackgroundJobService = vi.mocked(backgroundJobService);
|
||||
const mockedMonitoringService = vi.mocked(monitoringService);
|
||||
const mockedGeoCodingService = vi.mocked(geocodingService);
|
||||
const mockedCacheService = vi.mocked(cacheService);
|
||||
const _mockedBrandService = vi.mocked(brandService);
|
||||
const mockedUserService = vi.mocked(userService);
|
||||
const mockedGetFeatureFlags = vi.mocked(getFeatureFlags);
|
||||
const mockedCleanupQueue = vi.mocked(cleanupQueue);
|
||||
const mockedAnalyticsQueue = vi.mocked(analyticsQueue);
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with admin user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
file: undefined,
|
||||
user: createMockAdminProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock admin user profile.
|
||||
*/
|
||||
function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock correction object.
|
||||
*/
|
||||
function createMockCorrection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
suggested_correction_id: 1,
|
||||
flyer_item_id: 100,
|
||||
user_id: 'user-123',
|
||||
correction_type: 'master_item',
|
||||
suggested_value: 'Organic Milk',
|
||||
status: 'pending' as const,
|
||||
flyer_item_name: 'Milk',
|
||||
flyer_item_price_display: '$3.99',
|
||||
user_email: 'user@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AdminController', () => {
|
||||
let controller: AdminController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AdminController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CORRECTIONS MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCorrections()', () => {
|
||||
it('should return pending corrections', async () => {
|
||||
// Arrange
|
||||
const mockCorrections = [
|
||||
createMockCorrection(),
|
||||
createMockCorrection({ suggested_correction_id: 2 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getSuggestedCorrections.mockResolvedValue(mockCorrections);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCorrections(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveCorrection()', () => {
|
||||
it('should approve a correction', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.approveCorrection.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.approveCorrection(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Correction approved successfully.');
|
||||
}
|
||||
expect(mockedAdminRepo.approveCorrection).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectCorrection()', () => {
|
||||
it('should reject a correction', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.rejectCorrection.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.rejectCorrection(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Correction rejected successfully.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCorrection()', () => {
|
||||
it('should update a correction value', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = createMockCorrection({ suggested_value: 'Updated Value' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateSuggestedCorrection.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateCorrection(
|
||||
1,
|
||||
{ suggested_value: 'Updated Value' },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.suggested_value).toBe('Updated Value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// USER MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getUsers()', () => {
|
||||
it('should return paginated user list', async () => {
|
||||
// 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 },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUsers(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.users).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect pagination parameters', async () => {
|
||||
// Arrange
|
||||
const mockResult = { users: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getUsers(request, 50, 20);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getAllUsers).toHaveBeenCalledWith(expect.anything(), 50, 20);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { users: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getUsers(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getAllUsers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserById()', () => {
|
||||
it('should return user profile', async () => {
|
||||
// Arrange
|
||||
const mockProfile = {
|
||||
full_name: 'Test User',
|
||||
role: 'user',
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserRepo.findUserProfileById.mockResolvedValue(mockProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUserById('user-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.user.user_id).toBe('user-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserRole()', () => {
|
||||
it('should update user role', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { role: 'admin', points: 100 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateUserRole.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateUserRole('user-123', { role: 'admin' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.role).toBe('admin');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser()', () => {
|
||||
it('should delete a user', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserService.deleteUserAsAdmin.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteUser('user-to-delete', request);
|
||||
|
||||
// Assert
|
||||
expect(mockedUserService.deleteUserAsAdmin).toHaveBeenCalledWith(
|
||||
'admin-user-id',
|
||||
'user-to-delete',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CONTENT MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('updateRecipeStatus()', () => {
|
||||
it('should update recipe status', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = { recipe_id: 1, status: 'public' };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateRecipeStatus.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateRecipeStatus(1, { status: 'public' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('public');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRecipe()', () => {
|
||||
it('should delete a recipe', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedRecipeRepo.deleteRecipe.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedRecipeRepo.deleteRecipe).toHaveBeenCalledWith(
|
||||
1,
|
||||
'admin-user-id',
|
||||
true, // isAdmin
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlyersForReview()', () => {
|
||||
it('should return flyers needing review', async () => {
|
||||
// Arrange
|
||||
const mockFlyers = [
|
||||
{ flyer_id: 1, status: 'needs_review' },
|
||||
{ flyer_id: 2, status: 'needs_review' },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getFlyersForReview.mockResolvedValue(mockFlyers);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyersForReview(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFlyer()', () => {
|
||||
it('should delete a flyer', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedFlyerRepo.deleteFlyer.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteFlyer(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedFlyerRepo.deleteFlyer).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerFlyerCleanup()', () => {
|
||||
it('should enqueue cleanup job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCleanupQueue.add.mockResolvedValue({} as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerFlyerCleanup(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('File cleanup job');
|
||||
}
|
||||
expect(mockedCleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// STATISTICS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getStats()', () => {
|
||||
it('should return application statistics', async () => {
|
||||
// Arrange
|
||||
const mockStats = {
|
||||
flyerCount: 100,
|
||||
userCount: 50,
|
||||
flyerItemCount: 500,
|
||||
storeCount: 20,
|
||||
pendingCorrectionCount: 5,
|
||||
recipeCount: 30,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyerCount).toBe(100);
|
||||
expect(result.data.userCount).toBe(50);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDailyStats()', () => {
|
||||
it('should return daily statistics', async () => {
|
||||
// Arrange
|
||||
const mockDailyStats = [
|
||||
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
|
||||
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDailyStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE/WORKER MONITORING
|
||||
// ==========================================================================
|
||||
|
||||
describe('getWorkerStatuses()', () => {
|
||||
it('should return worker statuses', async () => {
|
||||
// Arrange
|
||||
const mockStatuses = [
|
||||
{ name: 'flyer-processor', isRunning: true },
|
||||
{ name: 'email-sender', isRunning: true },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getWorkerStatuses.mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const result = await controller.getWorkerStatuses(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].isRunning).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueueStatuses()', () => {
|
||||
it('should return queue statuses', async () => {
|
||||
// Arrange
|
||||
const mockStatuses = [
|
||||
{
|
||||
name: 'flyer-processing',
|
||||
counts: { waiting: 0, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getQueueStatuses.mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const result = await controller.getQueueStatuses(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].counts.completed).toBe(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryJob()', () => {
|
||||
it('should retry a failed job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.retryFailedJob.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.retryJob('flyer-processing', 'job-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('job-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BACKGROUND JOB TRIGGERS
|
||||
// ==========================================================================
|
||||
|
||||
describe('triggerDailyDealCheck()', () => {
|
||||
it('should trigger daily deal check', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerDailyDealCheck(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Daily deal check');
|
||||
}
|
||||
expect(mockedBackgroundJobService.runDailyDealCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerAnalyticsReport()', () => {
|
||||
it('should trigger analytics report', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerAnalyticsReport.mockResolvedValue('job-456');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerAnalyticsReport(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('job-456');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerWeeklyAnalytics()', () => {
|
||||
it('should trigger weekly analytics', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerWeeklyAnalyticsReport.mockResolvedValue('weekly-job-1');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerWeeklyAnalytics(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('weekly-job-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerTokenCleanup()', () => {
|
||||
it('should trigger token cleanup', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerTokenCleanup.mockResolvedValue('cleanup-job-1');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerTokenCleanup(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('cleanup-job-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerFailingJob()', () => {
|
||||
it('should trigger a failing test job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAnalyticsQueue.add.mockResolvedValue({ id: 'fail-job-1' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerFailingJob(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('fail-job-1');
|
||||
}
|
||||
expect(mockedAnalyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
|
||||
reportDate: 'FAIL',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SYSTEM OPERATIONS
|
||||
// ==========================================================================
|
||||
|
||||
describe('clearGeocodeCache()', () => {
|
||||
it('should clear geocode cache', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGeoCodingService.clearGeocodeCache.mockResolvedValue(50);
|
||||
|
||||
// Act
|
||||
const result = await controller.clearGeocodeCache(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('50 keys');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearApplicationCache()', () => {
|
||||
it('should clear all application caches', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCacheService.invalidateFlyers.mockResolvedValue(10);
|
||||
mockedCacheService.invalidateBrands.mockResolvedValue(5);
|
||||
mockedCacheService.invalidateStats.mockResolvedValue(3);
|
||||
|
||||
// Act
|
||||
const result = await controller.clearApplicationCache(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('18 keys');
|
||||
expect(result.data.details?.flyers).toBe(10);
|
||||
expect(result.data.details?.brands).toBe(5);
|
||||
expect(result.data.details?.stats).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// FEATURE FLAGS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFeatureFlags()', () => {
|
||||
it('should return feature flags', async () => {
|
||||
// Arrange
|
||||
const mockFlags = {
|
||||
enableNewUI: true,
|
||||
enableBetaFeatures: false,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGetFeatureFlags.mockReturnValue(mockFlags);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFeatureFlags(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flags.enableNewUI).toBe(true);
|
||||
expect(result.data.flags.enableBetaFeatures).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStats = { flyerCount: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
1419
src/controllers/admin.controller.ts
Normal file
1419
src/controllers/admin.controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
632
src/controllers/ai.controller.test.ts
Normal file
632
src/controllers/ai.controller.test.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
// src/controllers/ai.controller.test.ts
|
||||
// ============================================================================
|
||||
// AI CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AIController class. These tests verify controller
|
||||
// logic in isolation by mocking AI service and monitoring service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
FormField: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock AI service
|
||||
vi.mock('../services/aiService.server', () => ({
|
||||
aiService: {
|
||||
enqueueFlyerProcessing: vi.fn(),
|
||||
processLegacyFlyerUpload: vi.fn(),
|
||||
extractTextFromImageArea: vi.fn(),
|
||||
planTripWithMaps: vi.fn(),
|
||||
},
|
||||
DuplicateFlyerError: class DuplicateFlyerError extends Error {
|
||||
flyerId: number;
|
||||
constructor(message: string, flyerId: number) {
|
||||
super(message);
|
||||
this.flyerId = flyerId;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock monitoring service
|
||||
vi.mock('../services/monitoringService.server', () => ({
|
||||
monitoringService: {
|
||||
getFlyerJobStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock file utils
|
||||
vi.mock('../utils/fileUtils', () => ({
|
||||
cleanupUploadedFile: vi.fn(),
|
||||
cleanupUploadedFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
aiUploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
aiGenerationLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { AIController } from './ai.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
const mockedMonitoringService = monitoringService as Mocked<typeof monitoringService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
headers: {},
|
||||
ip: '127.0.0.1',
|
||||
file: undefined,
|
||||
files: undefined,
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...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 uploaded file.
|
||||
*/
|
||||
function createMockFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
|
||||
return {
|
||||
fieldname: 'flyerFile',
|
||||
originalname: 'test-flyer.jpg',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
destination: '/tmp/uploads',
|
||||
filename: 'abc123.jpg',
|
||||
path: '/tmp/uploads/abc123.jpg',
|
||||
buffer: Buffer.from('mock file content'),
|
||||
stream: {} as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AIController', () => {
|
||||
let controller: AIController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AIController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// FLYER UPLOAD ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('uploadAndProcess()', () => {
|
||||
it('should accept flyer for processing', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.enqueueFlyerProcessing.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadAndProcess(
|
||||
request,
|
||||
'a'.repeat(64), // valid checksum
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('job-123');
|
||||
expect(result.data.message).toContain('Flyer accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid checksum format', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadAndProcess(request, 'invalid')).rejects.toThrow(
|
||||
'Checksum must be a 64-character hexadecimal string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadAndProcess(request, 'a'.repeat(64))).rejects.toThrow(
|
||||
'A flyer file (PDF or image) is required.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate flyer error', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.enqueueFlyerProcessing.mockRejectedValue(
|
||||
new DuplicateFlyerError('Duplicate flyer', 42),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadAndProcess(request, 'a'.repeat(64));
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('CONFLICT');
|
||||
expect(result.error.details).toEqual({ flyerId: 42 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadLegacy()', () => {
|
||||
it('should process legacy upload', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const mockFlyer = { flyer_id: 1, file_name: 'test.jpg' };
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.processLegacyFlyerUpload.mockResolvedValue(mockFlyer as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadLegacy(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyer_id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadLegacy(request)).rejects.toThrow('No flyer file uploaded.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processFlyer()', () => {
|
||||
it('should process flyer data', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const mockFlyer = { flyer_id: 1, file_name: 'test.jpg' };
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.processLegacyFlyerUpload.mockResolvedValue(mockFlyer as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.processFlyer(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('processed');
|
||||
expect(result.data.flyer.flyer_id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.processFlyer(request)).rejects.toThrow(
|
||||
'Flyer image file is required.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// JOB STATUS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getJobStatus()', () => {
|
||||
it('should return job status', async () => {
|
||||
// Arrange
|
||||
const mockStatus = {
|
||||
id: 'job-123',
|
||||
state: 'completed',
|
||||
progress: 100,
|
||||
returnValue: { flyer_id: 1 },
|
||||
failedReason: null,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getFlyerJobStatus.mockResolvedValue(mockStatus);
|
||||
|
||||
// Act
|
||||
const result = await controller.getJobStatus('job-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('job-123');
|
||||
expect(result.data.state).toBe('completed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// IMAGE ANALYSIS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('checkFlyer()', () => {
|
||||
it('should check if image is a flyer', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act
|
||||
const result = await controller.checkFlyer(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.is_flyer).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.checkFlyer(request)).rejects.toThrow('Image file is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAddress()', () => {
|
||||
it('should extract address from image', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act
|
||||
const result = await controller.extractAddress(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.address).toBe('not identified');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLogo()', () => {
|
||||
it('should extract logo from images', async () => {
|
||||
// Arrange
|
||||
const mockFiles = [createMockFile()];
|
||||
const request = createMockRequest({ files: mockFiles });
|
||||
|
||||
// Act
|
||||
const result = await controller.extractLogo(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_logo_base_64).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no files uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ files: [] });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.extractLogo(request)).rejects.toThrow('Image files are required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescanArea()', () => {
|
||||
it('should rescan a specific area', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.extractTextFromImageArea.mockResolvedValue({ text: 'Extracted text' });
|
||||
|
||||
// Act
|
||||
const result = await controller.rescanArea(
|
||||
request,
|
||||
JSON.stringify({ x: 10, y: 10, width: 100, height: 100 }),
|
||||
'item_details',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toBe('Extracted text');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid cropArea JSON', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.rescanArea(request, 'invalid json', 'item_details')).rejects.toThrow(
|
||||
'cropArea must be a valid JSON string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject cropArea with missing properties', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.rescanArea(request, JSON.stringify({ x: 10 }), 'item_details'),
|
||||
).rejects.toThrow('cropArea must contain numeric x, y, width, and height properties.');
|
||||
});
|
||||
|
||||
it('should reject cropArea with zero width', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.rescanArea(
|
||||
request,
|
||||
JSON.stringify({ x: 10, y: 10, width: 0, height: 100 }),
|
||||
'item_details',
|
||||
),
|
||||
).rejects.toThrow('Crop area width must be positive.');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AI INSIGHTS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getQuickInsights()', () => {
|
||||
it('should return quick insights', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getQuickInsights(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('quick insight');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeepDive()', () => {
|
||||
it('should return deep dive analysis', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getDeepDive(request, {
|
||||
items: [{ item: 'Chicken' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('deep dive');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchWeb()', () => {
|
||||
it('should search the web', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.searchWeb(request, {
|
||||
query: 'best grocery deals',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toBeDefined();
|
||||
expect(result.data.sources).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePrices()', () => {
|
||||
it('should compare prices', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.comparePrices(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('price comparison');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTrip()', () => {
|
||||
it('should plan a shopping trip', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
text: 'Here is your trip plan',
|
||||
sources: [{ uri: 'https://maps.google.com', title: 'Google Maps' }],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.planTripWithMaps.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.planTrip(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
store: { name: 'SuperMart' },
|
||||
userLocation: { latitude: 43.65, longitude: -79.38 },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('trip plan');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// STUBBED FUTURE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('generateImage()', () => {
|
||||
it('should return 501 not implemented', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateImage(request, {
|
||||
prompt: 'A grocery store',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NOT_IMPLEMENTED');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSpeech()', () => {
|
||||
it('should return 501 not implemented', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateSpeech(request, {
|
||||
text: 'Hello world',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NOT_IMPLEMENTED');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStatus = {
|
||||
id: 'job-1',
|
||||
state: 'active',
|
||||
progress: 50,
|
||||
returnValue: null,
|
||||
failedReason: null,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getFlyerJobStatus.mockResolvedValue(mockStatus);
|
||||
|
||||
// Act
|
||||
const result = await controller.getJobStatus('job-1', request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for error responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateImage(request, { prompt: 'test' });
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
937
src/controllers/ai.controller.ts
Normal file
937
src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
// src/controllers/ai.controller.ts
|
||||
// ============================================================================
|
||||
// AI CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for AI-powered flyer processing, item extraction, and
|
||||
// various AI-assisted features including OCR, insights generation, and
|
||||
// trip planning.
|
||||
//
|
||||
// Key Features:
|
||||
// - Flyer upload and asynchronous processing via BullMQ
|
||||
// - Image-based text extraction (OCR) and rescan
|
||||
// - AI-generated insights, price comparisons, and trip planning
|
||||
// - Legacy upload endpoints for backward compatibility
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
FormField,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, ControllerErrorCode } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
|
||||
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
|
||||
import type { UserProfile, FlyerItem } from '../types';
|
||||
import type { FlyerDto } from '../dtos/common.dto';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// These Data Transfer Object types define the API contract for AI endpoints.
|
||||
// They are used by tsoa to generate OpenAPI specifications.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Crop area coordinates for targeted image rescanning.
|
||||
*/
|
||||
interface CropArea {
|
||||
/** X coordinate of the top-left corner */
|
||||
x: number;
|
||||
/** Y coordinate of the top-left corner */
|
||||
y: number;
|
||||
/** Width of the crop area in pixels */
|
||||
width: number;
|
||||
/** Height of the crop area in pixels */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of data to extract from a rescan operation.
|
||||
*/
|
||||
type ExtractionType = 'store_name' | 'dates' | 'item_details';
|
||||
|
||||
/**
|
||||
* Flyer item for AI analysis.
|
||||
* At least one of 'item' or 'name' must be provided.
|
||||
*/
|
||||
interface FlyerItemForAnalysis {
|
||||
/** Item name/description (primary identifier) */
|
||||
item?: string;
|
||||
/** Alternative item name field */
|
||||
name?: string;
|
||||
/** Additional properties are allowed */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store information for trip planning.
|
||||
*/
|
||||
interface StoreInfo {
|
||||
/** Store name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User's geographic location for trip planning.
|
||||
*/
|
||||
interface UserLocation {
|
||||
/**
|
||||
* Latitude coordinate.
|
||||
* @minimum -90
|
||||
* @maximum 90
|
||||
*/
|
||||
latitude: number;
|
||||
/**
|
||||
* Longitude coordinate.
|
||||
* @minimum -180
|
||||
* @maximum 180
|
||||
*/
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for quick insights or deep dive analysis.
|
||||
*/
|
||||
interface InsightsRequest {
|
||||
/**
|
||||
* Array of items to analyze.
|
||||
* @minItems 1
|
||||
*/
|
||||
items: FlyerItemForAnalysis[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for price comparison.
|
||||
*/
|
||||
interface ComparePricesRequest {
|
||||
/**
|
||||
* Array of items to compare prices for.
|
||||
* @minItems 1
|
||||
*/
|
||||
items: FlyerItemForAnalysis[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for trip planning.
|
||||
*/
|
||||
interface PlanTripRequest {
|
||||
/** Items to buy on the trip */
|
||||
items: FlyerItemForAnalysis[];
|
||||
/** Target store information */
|
||||
store: StoreInfo;
|
||||
/** User's current location */
|
||||
userLocation: UserLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for image generation (not implemented).
|
||||
*/
|
||||
interface GenerateImageRequest {
|
||||
/**
|
||||
* Prompt for image generation.
|
||||
* @minLength 1
|
||||
*/
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for speech generation (not implemented).
|
||||
*/
|
||||
interface GenerateSpeechRequest {
|
||||
/**
|
||||
* Text to convert to speech.
|
||||
* @minLength 1
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for web search.
|
||||
*/
|
||||
interface SearchWebRequest {
|
||||
/**
|
||||
* Search query.
|
||||
* @minLength 1
|
||||
*/
|
||||
query: string;
|
||||
}
|
||||
|
||||
// RescanAreaRequest is handled via route-level form parsing
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for successful flyer upload.
|
||||
*/
|
||||
interface UploadProcessResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** Background job ID for tracking processing status */
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for duplicate flyer detection.
|
||||
*/
|
||||
interface DuplicateFlyerResponse {
|
||||
/** Existing flyer ID */
|
||||
flyerId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for job status check.
|
||||
*/
|
||||
interface JobStatusResponse {
|
||||
/** Job ID */
|
||||
id: string;
|
||||
/** Current job state */
|
||||
state: string;
|
||||
/** Processing progress (0-100 or object with details) */
|
||||
progress: number | object | string | boolean;
|
||||
/** Return value when job is completed */
|
||||
returnValue: unknown;
|
||||
/** Error reason if job failed */
|
||||
failedReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for flyer check.
|
||||
*/
|
||||
interface FlyerCheckResponse {
|
||||
/** Whether the image is identified as a flyer */
|
||||
is_flyer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for address extraction.
|
||||
*/
|
||||
interface ExtractAddressResponse {
|
||||
/** Extracted address or 'not identified' if not found */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for logo extraction.
|
||||
*/
|
||||
interface ExtractLogoResponse {
|
||||
/** Base64-encoded logo image or null if not found */
|
||||
store_logo_base_64: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for text-based AI features (insights, deep dive, etc.).
|
||||
*/
|
||||
interface TextResponse {
|
||||
/** AI-generated text response */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for web search.
|
||||
*/
|
||||
interface SearchWebResponse {
|
||||
/** AI-generated response */
|
||||
text: string;
|
||||
/** Source references */
|
||||
sources: { uri: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for trip planning.
|
||||
*/
|
||||
interface PlanTripResponse {
|
||||
/** AI-generated trip plan */
|
||||
text: string;
|
||||
/** Map and store sources */
|
||||
sources: { uri: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for rescan area.
|
||||
*/
|
||||
interface RescanAreaResponse {
|
||||
/** Extracted text from the cropped area */
|
||||
text: string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parses cropArea JSON string into a validated CropArea object.
|
||||
* @param cropAreaStr JSON string containing crop area coordinates
|
||||
* @returns Parsed and validated CropArea object
|
||||
* @throws Error if parsing fails or validation fails
|
||||
*/
|
||||
function parseCropArea(cropAreaStr: string): CropArea {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cropAreaStr);
|
||||
} catch {
|
||||
throw new Error('cropArea must be a valid JSON string.');
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('cropArea must be a valid JSON object.');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof obj.x !== 'number' ||
|
||||
typeof obj.y !== 'number' ||
|
||||
typeof obj.width !== 'number' ||
|
||||
typeof obj.height !== 'number'
|
||||
) {
|
||||
throw new Error('cropArea must contain numeric x, y, width, and height properties.');
|
||||
}
|
||||
|
||||
if (obj.width <= 0) {
|
||||
throw new Error('Crop area width must be positive.');
|
||||
}
|
||||
|
||||
if (obj.height <= 0) {
|
||||
throw new Error('Crop area height must be positive.');
|
||||
}
|
||||
|
||||
return {
|
||||
x: obj.x,
|
||||
y: obj.y,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for AI-powered flyer processing and analysis.
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Uploading and processing flyers with AI extraction
|
||||
* - Checking processing job status
|
||||
* - Targeted image rescanning for specific data extraction
|
||||
* - AI-generated insights and recommendations
|
||||
* - Price comparisons and trip planning
|
||||
*
|
||||
* File upload endpoints expect multipart/form-data and are configured
|
||||
* with Express middleware for multer file handling.
|
||||
*/
|
||||
@Route('ai')
|
||||
@Tags('AI')
|
||||
export class AIController extends BaseController {
|
||||
// ==========================================================================
|
||||
// FLYER UPLOAD ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Upload and process a flyer.
|
||||
*
|
||||
* Accepts a single flyer file (PDF or image), validates the checksum to
|
||||
* prevent duplicates, and enqueues it for background AI processing.
|
||||
* Returns immediately with a job ID for tracking.
|
||||
*
|
||||
* The file upload is handled by Express middleware (multer).
|
||||
* The endpoint expects multipart/form-data with:
|
||||
* - flyerFile: The flyer image/PDF file
|
||||
* - checksum: SHA-256 checksum of the file (64 hex characters)
|
||||
* - baseUrl: Optional base URL override
|
||||
*
|
||||
* @summary Upload and process flyer
|
||||
* @param request Express request with uploaded file
|
||||
* @param checksum SHA-256 checksum of the file (64 hex characters)
|
||||
* @param baseUrl Optional base URL for generated image URLs
|
||||
* @returns Job ID for tracking processing status
|
||||
*/
|
||||
@Post('upload-and-process')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(202, 'Flyer accepted for processing')
|
||||
@Response<ErrorResponse>(400, 'Missing file or invalid checksum')
|
||||
@Response<ErrorResponse & { error: { details: DuplicateFlyerResponse } }>(
|
||||
409,
|
||||
'Duplicate flyer detected',
|
||||
)
|
||||
public async uploadAndProcess(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() checksum: string,
|
||||
@FormField() baseUrl?: string,
|
||||
): Promise<SuccessResponseType<UploadProcessResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
// Validate checksum format
|
||||
if (!checksum || checksum.length !== 64 || !/^[a-f0-9]+$/.test(checksum)) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Checksum must be a 64-character hexadecimal string.');
|
||||
}
|
||||
|
||||
// Validate file was uploaded
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('A flyer file (PDF or image) is required.');
|
||||
}
|
||||
|
||||
request.log.debug(
|
||||
{ filename: file.originalname, size: file.size, checksum },
|
||||
'Handling upload-and-process',
|
||||
);
|
||||
|
||||
try {
|
||||
// Handle optional authentication - clear user if no auth header in test/staging
|
||||
let userProfile = request.user as UserProfile | undefined;
|
||||
if (
|
||||
(process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'staging') &&
|
||||
!request.headers['authorization']
|
||||
) {
|
||||
userProfile = undefined;
|
||||
}
|
||||
|
||||
const job = await aiService.enqueueFlyerProcessing(
|
||||
file,
|
||||
checksum,
|
||||
userProfile,
|
||||
request.ip ?? 'unknown',
|
||||
request.log,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
this.setStatus(202);
|
||||
return this.success({
|
||||
message: 'Flyer accepted for processing.',
|
||||
jobId: job.id!,
|
||||
});
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<UploadProcessResponse>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy flyer upload (deprecated).
|
||||
*
|
||||
* Process a flyer upload synchronously. This endpoint is deprecated
|
||||
* and will be removed in a future version. Use /upload-and-process instead.
|
||||
*
|
||||
* @summary Legacy flyer upload (deprecated)
|
||||
* @param request Express request with uploaded file
|
||||
* @returns The processed flyer data
|
||||
* @deprecated Use /upload-and-process instead
|
||||
*/
|
||||
@Post('upload-legacy')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Flyer processed successfully')
|
||||
@Response<ErrorResponse>(400, 'No flyer file uploaded')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(409, 'Duplicate flyer detected')
|
||||
public async uploadLegacy(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('No flyer file uploaded.');
|
||||
}
|
||||
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
try {
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(
|
||||
file,
|
||||
request.body,
|
||||
userProfile,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(newFlyer);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn('Duplicate legacy flyer upload attempt blocked.');
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<FlyerDto>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process flyer data (deprecated).
|
||||
*
|
||||
* Saves processed flyer data to the database. This endpoint is deprecated
|
||||
* and will be removed in a future version. Use /upload-and-process instead.
|
||||
*
|
||||
* @summary Process flyer data (deprecated)
|
||||
* @param request Express request with uploaded file
|
||||
* @returns Success message with flyer data
|
||||
* @deprecated Use /upload-and-process instead
|
||||
*/
|
||||
@Post('flyers/process')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(201, 'Flyer processed and saved successfully')
|
||||
@Response<ErrorResponse>(400, 'Flyer image file is required')
|
||||
@Response<ErrorResponse>(409, 'Duplicate flyer detected')
|
||||
public async processFlyer(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<{ message: string; flyer: FlyerDto }>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Flyer image file is required.');
|
||||
}
|
||||
|
||||
const userProfile = request.user as UserProfile | undefined;
|
||||
|
||||
try {
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(
|
||||
file,
|
||||
request.body,
|
||||
userProfile,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.created({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn('Duplicate flyer upload attempt blocked.');
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<{ message: string; flyer: FlyerDto }>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// JOB STATUS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check job status.
|
||||
*
|
||||
* Checks the status of a background flyer processing job.
|
||||
* Use this endpoint to poll for completion after uploading a flyer.
|
||||
*
|
||||
* @summary Check job status
|
||||
* @param jobId Job ID returned from upload-and-process
|
||||
* @param request Express request for logging
|
||||
* @returns Job status information
|
||||
*/
|
||||
@Get('jobs/{jobId}/status')
|
||||
@SuccessResponse(200, 'Job status retrieved')
|
||||
@Response<ErrorResponse>(404, 'Job not found')
|
||||
public async getJobStatus(
|
||||
@Path() jobId: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<JobStatusResponse>> {
|
||||
const jobStatus = await monitoringService.getFlyerJobStatus(jobId);
|
||||
request.log.debug(`Status check for job ${jobId}: ${jobStatus.state}`);
|
||||
return this.success(jobStatus);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IMAGE ANALYSIS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check if image is a flyer.
|
||||
*
|
||||
* Analyzes an uploaded image to determine if it's a grocery store flyer.
|
||||
*
|
||||
* @summary Check if image is a flyer
|
||||
* @param request Express request with uploaded image
|
||||
* @returns Whether the image is identified as a flyer
|
||||
*/
|
||||
@Post('check-flyer')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Flyer check completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required')
|
||||
public async checkFlyer(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerCheckResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side flyer check for file: ${file.originalname}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ is_flyer: true });
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract address from image.
|
||||
*
|
||||
* Extracts store address information from a flyer image using AI.
|
||||
*
|
||||
* @summary Extract address from image
|
||||
* @param request Express request with uploaded image
|
||||
* @returns Extracted address information
|
||||
*/
|
||||
@Post('extract-address')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Address extraction completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required')
|
||||
public async extractAddress(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExtractAddressResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side address extraction for file: ${file.originalname}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ address: 'not identified' });
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract store logo.
|
||||
*
|
||||
* Extracts the store logo from flyer images using AI.
|
||||
*
|
||||
* @summary Extract store logo
|
||||
* @param request Express request with uploaded images
|
||||
* @returns Extracted logo as base64 string
|
||||
*/
|
||||
@Post('extract-logo')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Logo extraction completed')
|
||||
@Response<ErrorResponse>(400, 'Image files are required')
|
||||
public async extractLogo(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExtractLogoResponse>> {
|
||||
const files = request.files as Express.Multer.File[] | undefined;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image files are required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side logo extraction for ${files.length} image(s).`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ store_logo_base_64: null });
|
||||
} finally {
|
||||
await cleanupUploadedFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescan area of image.
|
||||
*
|
||||
* Performs a targeted AI scan on a specific area of an image.
|
||||
* Useful for re-extracting data from poorly recognized regions.
|
||||
*
|
||||
* @summary Rescan area of image
|
||||
* @param request Express request with uploaded image
|
||||
* @param cropArea JSON string with x, y, width, height coordinates
|
||||
* @param extractionType Type of data to extract (store_name, dates, item_details)
|
||||
* @returns Extracted text from the cropped area
|
||||
*/
|
||||
@Post('rescan-area')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Rescan completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required or invalid cropArea')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async rescanArea(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() cropArea: string,
|
||||
@FormField() extractionType: ExtractionType,
|
||||
): Promise<SuccessResponseType<RescanAreaResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate cropArea
|
||||
const parsedCropArea = parseCropArea(cropArea);
|
||||
|
||||
request.log.debug(
|
||||
{ extractionType, cropArea: parsedCropArea, filename: file.originalname },
|
||||
'Rescan area requested',
|
||||
);
|
||||
|
||||
const result = await aiService.extractTextFromImageArea(
|
||||
file.path,
|
||||
file.mimetype,
|
||||
parsedCropArea,
|
||||
extractionType,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AI INSIGHTS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get quick insights.
|
||||
*
|
||||
* Get AI-generated quick insights about flyer items.
|
||||
* Provides brief recommendations and highlights.
|
||||
*
|
||||
* @summary Get quick insights
|
||||
* @param request Express request for logging
|
||||
* @param body Items to analyze
|
||||
* @returns AI-generated quick insights
|
||||
*/
|
||||
@Post('quick-insights')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Quick insights generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getQuickInsights(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: InsightsRequest,
|
||||
): Promise<SuccessResponseType<TextResponse>> {
|
||||
request.log.info(`Server-side quick insights requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ text: 'This is a server-generated quick insight: buy the cheap stuff!' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deep dive analysis.
|
||||
*
|
||||
* Get detailed AI-generated analysis about flyer items.
|
||||
* Provides comprehensive information including nutritional value,
|
||||
* price history, and recommendations.
|
||||
*
|
||||
* @summary Get deep dive analysis
|
||||
* @param request Express request for logging
|
||||
* @param body Items to analyze
|
||||
* @returns Detailed AI analysis
|
||||
*/
|
||||
@Post('deep-dive')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Deep dive analysis generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getDeepDive(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: InsightsRequest,
|
||||
): Promise<SuccessResponseType<TextResponse>> {
|
||||
request.log.info(`Server-side deep dive requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({
|
||||
text: 'This is a server-generated deep dive analysis. It is very detailed.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search web for information.
|
||||
*
|
||||
* Search the web for product or deal information using AI.
|
||||
*
|
||||
* @summary Search web for information
|
||||
* @param request Express request for logging
|
||||
* @param body Search query
|
||||
* @returns Search results with sources
|
||||
*/
|
||||
@Post('search-web')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Web search completed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async searchWeb(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: SearchWebRequest,
|
||||
): Promise<SuccessResponseType<SearchWebResponse>> {
|
||||
request.log.info(`Server-side web search requested for query: ${body.query}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ text: 'The web says this is good.', sources: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare prices across stores.
|
||||
*
|
||||
* Compare prices for items across different stores using AI.
|
||||
*
|
||||
* @summary Compare prices across stores
|
||||
* @param request Express request for logging
|
||||
* @param body Items to compare
|
||||
* @returns Price comparison results
|
||||
*/
|
||||
@Post('compare-prices')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Price comparison completed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async comparePrices(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ComparePricesRequest,
|
||||
): Promise<SuccessResponseType<SearchWebResponse>> {
|
||||
request.log.info(`Server-side price comparison requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({
|
||||
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
|
||||
sources: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan shopping trip.
|
||||
*
|
||||
* Plan an optimized shopping trip to a store based on items and user location.
|
||||
* Uses Google Maps integration for directions and nearby store suggestions.
|
||||
*
|
||||
* @summary Plan shopping trip
|
||||
* @param request Express request for logging
|
||||
* @param body Trip planning parameters
|
||||
* @returns Trip plan with directions
|
||||
*/
|
||||
@Post('plan-trip')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Trip plan generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Feature disabled')
|
||||
public async planTrip(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: PlanTripRequest,
|
||||
): Promise<SuccessResponseType<PlanTripResponse>> {
|
||||
request.log.debug(
|
||||
{ itemCount: body.items.length, storeName: body.store.name },
|
||||
'Trip planning requested.',
|
||||
);
|
||||
|
||||
try {
|
||||
// Note: planTripWithMaps is currently disabled and throws immediately.
|
||||
// The cast is safe since FlyerItemForAnalysis has the same shape as FlyerItem.
|
||||
const result = await aiService.planTripWithMaps(
|
||||
body.items as unknown as FlyerItem[],
|
||||
body.store,
|
||||
body.userLocation as GeolocationCoordinates,
|
||||
request.log,
|
||||
);
|
||||
return this.success(result);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Error in plan-trip endpoint');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// STUBBED FUTURE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Generate image (not implemented).
|
||||
*
|
||||
* Generate an image from a prompt. Currently not implemented.
|
||||
*
|
||||
* @summary Generate image (not implemented)
|
||||
* @param request Express request for logging
|
||||
* @param body Image generation prompt
|
||||
* @returns 501 Not Implemented
|
||||
*/
|
||||
@Post('generate-image')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(501, 'Not implemented')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Not implemented')
|
||||
public async generateImage(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() _body: GenerateImageRequest,
|
||||
): Promise<ErrorResponse> {
|
||||
request.log.info('Request received for unimplemented endpoint: generate-image');
|
||||
this.setStatus(501);
|
||||
return this.error(
|
||||
ControllerErrorCode.NOT_IMPLEMENTED,
|
||||
'Image generation is not yet implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech (not implemented).
|
||||
*
|
||||
* Generate speech from text. Currently not implemented.
|
||||
*
|
||||
* @summary Generate speech (not implemented)
|
||||
* @param request Express request for logging
|
||||
* @param body Text to convert to speech
|
||||
* @returns 501 Not Implemented
|
||||
*/
|
||||
@Post('generate-speech')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(501, 'Not implemented')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Not implemented')
|
||||
public async generateSpeech(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() _body: GenerateSpeechRequest,
|
||||
): Promise<ErrorResponse> {
|
||||
request.log.info('Request received for unimplemented endpoint: generate-speech');
|
||||
this.setStatus(501);
|
||||
return this.error(
|
||||
ControllerErrorCode.NOT_IMPLEMENTED,
|
||||
'Speech generation is not yet implemented.',
|
||||
);
|
||||
}
|
||||
}
|
||||
486
src/controllers/auth.controller.test.ts
Normal file
486
src/controllers/auth.controller.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
// src/controllers/auth.controller.test.ts
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AuthController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like auth service,
|
||||
// passport, and response handling.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
// Mock all external dependencies before importing the controller module.
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class (required before controller import)
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(_status: number): void {
|
||||
// Mock setStatus
|
||||
}
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock auth service
|
||||
vi.mock('../services/authService', () => ({
|
||||
authService: {
|
||||
registerAndLoginUser: vi.fn(),
|
||||
handleSuccessfulLogin: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
updatePassword: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock passport
|
||||
vi.mock('../config/passport', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock password strength validation
|
||||
vi.mock('../utils/authUtils', () => ({
|
||||
validatePasswordStrength: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
registerLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
forgotPasswordLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
resetPasswordLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
refreshTokenLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
logoutLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { authService } from '../services/authService';
|
||||
import { validatePasswordStrength } from '../utils/authUtils';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAuthService = authService as Mocked<typeof authService>;
|
||||
const mockedValidatePasswordStrength = validatePasswordStrength as ReturnType<typeof vi.fn>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
const mockRes = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as ExpressResponse;
|
||||
|
||||
return {
|
||||
body: {},
|
||||
cookies: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
res: mockRes,
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
avatar_url: null,
|
||||
address_id: null,
|
||||
points: 0,
|
||||
role: 'user' as const,
|
||||
preferences: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
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',
|
||||
},
|
||||
address: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AuthController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// REGISTRATION TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('register()', () => {
|
||||
it('should successfully register a new user', async () => {
|
||||
// Arrange
|
||||
const mockUserProfile = createMockUserProfile();
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
full_name: 'Test User',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({ isValid: true, feedback: '' });
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockUserProfile,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.register(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
full_name: 'Test User',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('User registered successfully!');
|
||||
expect(result.data.userprofile.user.email).toBe('test@example.com');
|
||||
expect(result.data.token).toBe('mock-access-token');
|
||||
}
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'SecurePassword123!',
|
||||
'Test User',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject registration with weak password', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({
|
||||
isValid: false,
|
||||
feedback: 'Password must be at least 8 characters long.',
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.register(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
},
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow('Password must be at least 8 characters long.');
|
||||
|
||||
expect(mockedAuthService.registerAndLoginUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize email input (trim and lowercase)', async () => {
|
||||
// Arrange
|
||||
const mockUserProfile = createMockUserProfile();
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: ' TEST@EXAMPLE.COM ',
|
||||
password: 'SecurePassword123!',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({ isValid: true, feedback: '' });
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockUserProfile,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
await controller.register(
|
||||
{
|
||||
email: ' TEST@EXAMPLE.COM ',
|
||||
password: 'SecurePassword123!',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'SecurePassword123!',
|
||||
undefined,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PASSWORD RESET TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('forgotPassword()', () => {
|
||||
it('should return generic message regardless of email existence', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'test@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
'If an account with that email exists, a password reset link has been sent.',
|
||||
);
|
||||
}
|
||||
expect(mockedAuthService.resetPassword).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return same message when email does not exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'nonexistent@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
'If an account with that email exists, a password reset link has been sent.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include token in test environment', async () => {
|
||||
// Arrange
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'test@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.token).toBe('mock-reset-token');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword()', () => {
|
||||
it('should successfully reset password with valid token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.updatePassword.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
const result = await controller.resetPassword(
|
||||
{ token: 'valid-reset-token', newPassword: 'NewSecurePassword123!' },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Password has been reset successfully.');
|
||||
}
|
||||
expect(mockedAuthService.updatePassword).toHaveBeenCalledWith(
|
||||
'valid-reset-token',
|
||||
'NewSecurePassword123!',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject reset with invalid token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.updatePassword.mockResolvedValue(false);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.resetPassword(
|
||||
{ token: 'invalid-token', newPassword: 'NewSecurePassword123!' },
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow('Invalid or expired password reset token.');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// TOKEN MANAGEMENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('refreshToken()', () => {
|
||||
it('should successfully refresh access token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'valid-refresh-token' },
|
||||
});
|
||||
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.refreshToken(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.token).toBe('new-access-token');
|
||||
}
|
||||
expect(mockedAuthService.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'valid-refresh-token',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when refresh token cookie is missing', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ cookies: {} });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.refreshToken(request)).rejects.toThrow('Refresh token not found.');
|
||||
});
|
||||
|
||||
it('should reject when refresh token is invalid', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'invalid-token' },
|
||||
});
|
||||
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.refreshToken(request)).rejects.toThrow(
|
||||
'Invalid or expired refresh token.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout()', () => {
|
||||
it('should successfully logout and clear refresh token cookie', async () => {
|
||||
// Arrange
|
||||
const mockCookie = vi.fn();
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'valid-refresh-token' },
|
||||
res: { cookie: mockCookie } as unknown as ExpressResponse,
|
||||
});
|
||||
|
||||
mockedAuthService.logout.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Logged out successfully.');
|
||||
}
|
||||
expect(mockCookie).toHaveBeenCalledWith(
|
||||
'refreshToken',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0, httpOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed even without refresh token cookie', async () => {
|
||||
// Arrange
|
||||
const mockCookie = vi.fn();
|
||||
const request = createMockRequest({
|
||||
cookies: {},
|
||||
res: { cookie: mockCookie } as unknown as ExpressResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Logged out successfully.');
|
||||
}
|
||||
expect(mockedAuthService.logout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ cookies: {} });
|
||||
const mockCookie = vi.fn();
|
||||
(request.res as ExpressResponse).cookie = mockCookie;
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
827
src/controllers/auth.controller.ts
Normal file
827
src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
// src/controllers/auth.controller.ts
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER
|
||||
// ============================================================================
|
||||
// Handles user authentication and authorization endpoints including:
|
||||
// - User registration and login
|
||||
// - Password reset flow (forgot-password, reset-password)
|
||||
// - JWT token refresh and logout
|
||||
// - OAuth initiation (Google, GitHub) - Note: callbacks handled via Express middleware
|
||||
//
|
||||
// This controller implements the authentication API following ADR-028 (API Response Standards)
|
||||
// and integrates with the existing passport-based authentication system.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import passport from '../config/passport';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
import { authService } from '../services/authService';
|
||||
import { UniqueConstraintError, ValidationError } from '../services/db/errors.db';
|
||||
import type { UserProfile } from '../types';
|
||||
import { validatePasswordStrength } from '../utils/authUtils';
|
||||
import {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
forgotPasswordLimiter,
|
||||
resetPasswordLimiter,
|
||||
refreshTokenLimiter,
|
||||
logoutLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import type { AddressDto, UserProfileDto } from '../dtos/common.dto';
|
||||
|
||||
/**
|
||||
* User registration request body.
|
||||
*/
|
||||
interface RegisterRequest {
|
||||
/**
|
||||
* User's email address.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password. Must be at least 8 characters with good entropy.
|
||||
* @minLength 8
|
||||
* @example "SecurePassword123!"
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* User's full name (optional).
|
||||
* @example "John Doe"
|
||||
*/
|
||||
full_name?: string;
|
||||
|
||||
/**
|
||||
* URL to user's avatar image (optional).
|
||||
* @format uri
|
||||
*/
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful registration response data.
|
||||
*/
|
||||
interface RegisterResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** The created user's profile */
|
||||
userprofile: UserProfileDto;
|
||||
/** JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User login request body.
|
||||
*/
|
||||
interface LoginRequest {
|
||||
/**
|
||||
* User's email address.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password.
|
||||
* @example "SecurePassword123!"
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* If true, refresh token lasts 30 days instead of session-only.
|
||||
*/
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful login response data.
|
||||
*/
|
||||
interface LoginResponseData {
|
||||
/** The authenticated user's profile */
|
||||
userprofile: UserProfileDto;
|
||||
/** JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password request body.
|
||||
*/
|
||||
interface ForgotPasswordRequest {
|
||||
/**
|
||||
* Email address of the account to reset.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password response data.
|
||||
*/
|
||||
interface ForgotPasswordResponseData {
|
||||
/** Generic success message (same for existing and non-existing emails for security) */
|
||||
message: string;
|
||||
/** Reset token (only included in test environment for testability) */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password request body.
|
||||
*/
|
||||
interface ResetPasswordRequest {
|
||||
/**
|
||||
* Password reset token from email.
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* New password. Must be at least 8 characters with good entropy.
|
||||
* @minLength 8
|
||||
*/
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout response data.
|
||||
*/
|
||||
interface LogoutResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token refresh response data.
|
||||
*/
|
||||
interface RefreshTokenResponseData {
|
||||
/** New JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message response data.
|
||||
*/
|
||||
interface MessageResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM ERROR CLASSES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication error for login failures and related issues.
|
||||
*/
|
||||
class AuthenticationError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, status: number = 401) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTO CONVERSION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Converts a UserProfile to a UserProfileDto.
|
||||
*
|
||||
* This conversion is necessary because UserProfile contains Address which
|
||||
* contains GeoJSONPoint with tuple type coordinates: [number, number].
|
||||
* tsoa cannot serialize tuples, so we flatten to separate lat/lng fields.
|
||||
*
|
||||
* @param userProfile The UserProfile from the service layer
|
||||
* @returns A UserProfileDto safe for tsoa serialization
|
||||
*/
|
||||
function toUserProfileDto(userProfile: UserProfile): UserProfileDto {
|
||||
const addressDto: AddressDto | null = userProfile.address
|
||||
? {
|
||||
address_id: userProfile.address.address_id,
|
||||
address_line_1: userProfile.address.address_line_1,
|
||||
address_line_2: userProfile.address.address_line_2,
|
||||
city: userProfile.address.city,
|
||||
province_state: userProfile.address.province_state,
|
||||
postal_code: userProfile.address.postal_code,
|
||||
country: userProfile.address.country,
|
||||
latitude:
|
||||
userProfile.address.latitude ?? userProfile.address.location?.coordinates[1] ?? null,
|
||||
longitude:
|
||||
userProfile.address.longitude ?? userProfile.address.location?.coordinates[0] ?? null,
|
||||
created_at: userProfile.address.created_at,
|
||||
updated_at: userProfile.address.updated_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
full_name: userProfile.full_name,
|
||||
avatar_url: userProfile.avatar_url,
|
||||
address_id: userProfile.address_id,
|
||||
points: userProfile.points,
|
||||
role: userProfile.role,
|
||||
preferences: userProfile.preferences,
|
||||
created_by: userProfile.created_by,
|
||||
updated_by: userProfile.updated_by,
|
||||
created_at: userProfile.created_at,
|
||||
updated_at: userProfile.updated_at,
|
||||
user: {
|
||||
user_id: userProfile.user.user_id,
|
||||
email: userProfile.user.email,
|
||||
created_at: userProfile.user.created_at,
|
||||
updated_at: userProfile.user.updated_at,
|
||||
},
|
||||
address: addressDto,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication controller handling user registration, login, password reset,
|
||||
* and token management.
|
||||
*
|
||||
* OAuth endpoints (Google, GitHub) use passport middleware for redirect-based
|
||||
* authentication flows and are handled differently than standard JSON endpoints.
|
||||
*/
|
||||
@Route('auth')
|
||||
@Tags('Auth')
|
||||
export class AuthController extends BaseController {
|
||||
// ==========================================================================
|
||||
// REGISTRATION
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Register a new user account.
|
||||
*
|
||||
* Creates a new user with the provided credentials and returns authentication tokens.
|
||||
* The password must be at least 8 characters with good entropy (mix of characters).
|
||||
*
|
||||
* @summary Register a new user
|
||||
* @param requestBody User registration data
|
||||
* @param request Express request object (for logging and cookies)
|
||||
* @returns User profile and JWT token on successful registration
|
||||
*/
|
||||
@Post('register')
|
||||
@Middlewares(registerLimiter)
|
||||
@SuccessResponse(201, 'User registered successfully')
|
||||
@Response<ErrorResponse>(400, 'Validation error (weak password)')
|
||||
@Response<ErrorResponse>(409, 'Email already registered')
|
||||
@Response<ErrorResponse>(429, 'Too many registration attempts')
|
||||
public async register(
|
||||
@Body() requestBody: RegisterRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RegisterResponseData>> {
|
||||
const { email, password, full_name, avatar_url } = this.sanitizeRegisterInput(requestBody);
|
||||
const reqLog = request.log;
|
||||
|
||||
// Validate password strength
|
||||
const strength = validatePasswordStrength(password);
|
||||
if (!strength.isValid) {
|
||||
throw new ValidationError([], strength.feedback);
|
||||
}
|
||||
|
||||
try {
|
||||
const { newUserProfile, accessToken, refreshToken } = await authService.registerAndLoginUser(
|
||||
email,
|
||||
password,
|
||||
full_name,
|
||||
avatar_url,
|
||||
reqLog,
|
||||
);
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
this.setRefreshTokenCookie(request.res!, refreshToken, false);
|
||||
|
||||
this.setStatus(201);
|
||||
return this.success({
|
||||
message: 'User registered successfully!',
|
||||
userprofile: toUserProfileDto(newUserProfile),
|
||||
token: accessToken,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
this.setStatus(409);
|
||||
throw error;
|
||||
}
|
||||
reqLog.error({ error }, `User registration route failed for email: ${email}.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOGIN
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Login with email and password.
|
||||
*
|
||||
* Authenticates user credentials via the local passport strategy and returns JWT tokens.
|
||||
* Failed login attempts are tracked for account lockout protection.
|
||||
*
|
||||
* @summary Login with email and password
|
||||
* @param requestBody Login credentials
|
||||
* @param request Express request object
|
||||
* @returns User profile and JWT token on successful authentication
|
||||
*/
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@SuccessResponse(200, 'Login successful')
|
||||
@Response<ErrorResponse>(401, 'Invalid credentials or account locked')
|
||||
@Response<ErrorResponse>(429, 'Too many login attempts')
|
||||
public async login(
|
||||
@Body() requestBody: LoginRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<LoginResponseData>> {
|
||||
const { email, password, rememberMe } = this.sanitizeLoginInput(requestBody);
|
||||
const reqLog = request.log;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Attach sanitized email to request body for passport
|
||||
request.body.email = email;
|
||||
request.body.password = password;
|
||||
|
||||
passport.authenticate(
|
||||
'local',
|
||||
{ session: false },
|
||||
async (err: Error | null, user: Express.User | false, info: { message: string }) => {
|
||||
reqLog.debug(`[API /login] Received login request for email: ${email}`);
|
||||
|
||||
if (err) {
|
||||
reqLog.error({ err }, '[API /login] Passport reported an error.');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reqLog.warn({ info }, '[API /login] Passport reported NO USER found.');
|
||||
const authError = new AuthenticationError(info?.message || 'Login failed');
|
||||
return reject(authError);
|
||||
}
|
||||
|
||||
reqLog.info(
|
||||
{ userId: (user as UserProfile).user?.user_id },
|
||||
'[API /login] User authenticated.',
|
||||
);
|
||||
|
||||
try {
|
||||
const userProfile = user as UserProfile;
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
|
||||
userProfile,
|
||||
reqLog,
|
||||
);
|
||||
reqLog.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||
|
||||
// Set refresh token cookie
|
||||
this.setRefreshTokenCookie(request.res!, refreshToken, rememberMe || false);
|
||||
|
||||
resolve(
|
||||
this.success({ userprofile: toUserProfileDto(userProfile), token: accessToken }),
|
||||
);
|
||||
} catch (tokenErr) {
|
||||
const email = (user as UserProfile)?.user?.email || request.body.email;
|
||||
reqLog.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
|
||||
reject(tokenErr);
|
||||
}
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PASSWORD RESET FLOW
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Request a password reset.
|
||||
*
|
||||
* Sends a password reset email if the account exists. For security, always returns
|
||||
* the same response whether the email exists or not to prevent email enumeration.
|
||||
*
|
||||
* @summary Request password reset email
|
||||
* @param requestBody Email address for password reset
|
||||
* @param request Express request object
|
||||
* @returns Generic success message (same for existing and non-existing emails)
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@Middlewares(forgotPasswordLimiter)
|
||||
@SuccessResponse(200, 'Request processed')
|
||||
@Response<ErrorResponse>(429, 'Too many password reset requests')
|
||||
public async forgotPassword(
|
||||
@Body() requestBody: ForgotPasswordRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ForgotPasswordResponseData>> {
|
||||
const email = this.sanitizeEmail(requestBody.email);
|
||||
const reqLog = request.log;
|
||||
|
||||
try {
|
||||
const token = await authService.resetPassword(email, reqLog);
|
||||
|
||||
// Response payload - token only included in test environment for testability
|
||||
const responsePayload: ForgotPasswordResponseData = {
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'test' && token) {
|
||||
responsePayload.token = token;
|
||||
}
|
||||
|
||||
return this.success(responsePayload);
|
||||
} catch (error) {
|
||||
reqLog.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token.
|
||||
*
|
||||
* Resets the user's password using a valid reset token from the forgot-password email.
|
||||
* The token is single-use and expires after 1 hour.
|
||||
*
|
||||
* @summary Reset password with token
|
||||
* @param requestBody Reset token and new password
|
||||
* @param request Express request object
|
||||
* @returns Success message on password reset
|
||||
*/
|
||||
@Post('reset-password')
|
||||
@Middlewares(resetPasswordLimiter)
|
||||
@SuccessResponse(200, 'Password reset successful')
|
||||
@Response<ErrorResponse>(400, 'Invalid or expired token, or weak password')
|
||||
@Response<ErrorResponse>(429, 'Too many reset attempts')
|
||||
public async resetPassword(
|
||||
@Body() requestBody: ResetPasswordRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<MessageResponseData>> {
|
||||
const { token, newPassword } = requestBody;
|
||||
const reqLog = request.log;
|
||||
|
||||
try {
|
||||
const resetSuccessful = await authService.updatePassword(token, newPassword, reqLog);
|
||||
|
||||
if (!resetSuccessful) {
|
||||
this.setStatus(400);
|
||||
throw new ValidationError([], 'Invalid or expired password reset token.');
|
||||
}
|
||||
|
||||
return this.success({ message: 'Password has been reset successfully.' });
|
||||
} catch (error) {
|
||||
reqLog.error({ error }, 'An error occurred during password reset.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TOKEN MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Refresh access token.
|
||||
*
|
||||
* Uses the refresh token cookie to issue a new access token.
|
||||
* The refresh token itself is not rotated to allow multiple active sessions.
|
||||
*
|
||||
* @summary Refresh access token
|
||||
* @param request Express request object (contains refresh token cookie)
|
||||
* @returns New JWT access token
|
||||
*/
|
||||
@Post('refresh-token')
|
||||
@Middlewares(refreshTokenLimiter)
|
||||
@SuccessResponse(200, 'New access token issued')
|
||||
@Response<ErrorResponse>(401, 'Refresh token not found')
|
||||
@Response<ErrorResponse>(403, 'Invalid or expired refresh token')
|
||||
@Response<ErrorResponse>(429, 'Too many refresh attempts')
|
||||
public async refreshToken(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RefreshTokenResponseData>> {
|
||||
const refreshToken = request.cookies?.refreshToken;
|
||||
const reqLog = request.log;
|
||||
|
||||
if (!refreshToken) {
|
||||
this.setStatus(401);
|
||||
throw new AuthenticationError('Refresh token not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, reqLog);
|
||||
|
||||
if (!result) {
|
||||
this.setStatus(403);
|
||||
throw new AuthenticationError('Invalid or expired refresh token.', 403);
|
||||
}
|
||||
|
||||
return this.success({ token: result.accessToken });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
reqLog.error({ error }, 'An error occurred during /refresh-token.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user.
|
||||
*
|
||||
* Invalidates the refresh token and clears the cookie.
|
||||
* The access token will remain valid until it expires (15 minutes).
|
||||
*
|
||||
* @summary Logout user
|
||||
* @param request Express request object
|
||||
* @returns Success message
|
||||
*/
|
||||
@Post('logout')
|
||||
@Middlewares(logoutLimiter)
|
||||
@SuccessResponse(200, 'Logged out successfully')
|
||||
@Response<ErrorResponse>(429, 'Too many logout attempts')
|
||||
public async logout(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<LogoutResponseData>> {
|
||||
const refreshToken = request.cookies?.refreshToken;
|
||||
const reqLog = request.log;
|
||||
|
||||
if (refreshToken) {
|
||||
// Invalidate the token in the database (fire and forget)
|
||||
authService.logout(refreshToken, reqLog).catch((err: Error) => {
|
||||
reqLog.error({ error: err }, 'Logout token invalidation failed in background.');
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the refresh token cookie
|
||||
this.clearRefreshTokenCookie(request.res!);
|
||||
|
||||
return this.success({ message: 'Logged out successfully.' });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// OAUTH INITIATION ENDPOINTS
|
||||
// ==========================================================================
|
||||
// Note: OAuth callback endpoints are handled by Express middleware due to
|
||||
// their redirect-based nature. These initiation endpoints redirect to the
|
||||
// OAuth provider and are documented here for completeness.
|
||||
//
|
||||
// The actual callbacks (/auth/google/callback, /auth/github/callback) remain
|
||||
// in the Express routes file (auth.routes.ts) because tsoa controllers are
|
||||
// designed for JSON APIs, not redirect-based OAuth flows.
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Initiate Google OAuth login.
|
||||
*
|
||||
* Redirects to Google for authentication. After successful authentication,
|
||||
* Google redirects back to /auth/google/callback with an authorization code.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Initiate Google OAuth
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('google')
|
||||
@Response(302, 'Redirects to Google OAuth consent screen')
|
||||
public async googleAuth(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate('google', { session: false })(request, request.res!, (err: unknown) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth callback.
|
||||
*
|
||||
* Handles the callback from Google after user authentication.
|
||||
* On success, redirects to the frontend with an access token in the query string.
|
||||
* On failure, redirects to the frontend with an error parameter.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Google OAuth callback
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('google/callback')
|
||||
@Response(302, 'Redirects to frontend with token or error')
|
||||
public async googleAuthCallback(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate(
|
||||
'google',
|
||||
{ session: false, failureRedirect: '/?error=google_auth_failed' },
|
||||
async (err: Error | null, user: Express.User | false) => {
|
||||
if (err) {
|
||||
request.log.error({ error: err }, 'Google OAuth authentication error');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
await this.handleOAuthCallback(
|
||||
'google',
|
||||
user as UserProfile | false,
|
||||
request,
|
||||
request.res!,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate GitHub OAuth login.
|
||||
*
|
||||
* Redirects to GitHub for authentication. After successful authentication,
|
||||
* GitHub redirects back to /auth/github/callback with an authorization code.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Initiate GitHub OAuth
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('github')
|
||||
@Response(302, 'Redirects to GitHub OAuth consent screen')
|
||||
public async githubAuth(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate('github', { session: false })(request, request.res!, (err: unknown) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth callback.
|
||||
*
|
||||
* Handles the callback from GitHub after user authentication.
|
||||
* On success, redirects to the frontend with an access token in the query string.
|
||||
* On failure, redirects to the frontend with an error parameter.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary GitHub OAuth callback
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('github/callback')
|
||||
@Response(302, 'Redirects to frontend with token or error')
|
||||
public async githubAuthCallback(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate(
|
||||
'github',
|
||||
{ session: false, failureRedirect: '/?error=github_auth_failed' },
|
||||
async (err: Error | null, user: Express.User | false) => {
|
||||
if (err) {
|
||||
request.log.error({ error: err }, 'GitHub OAuth authentication error');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
await this.handleOAuthCallback(
|
||||
'github',
|
||||
user as UserProfile | false,
|
||||
request,
|
||||
request.res!,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Sanitizes and normalizes email input.
|
||||
* Trims whitespace and converts to lowercase.
|
||||
*/
|
||||
private sanitizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes registration input by trimming and normalizing values.
|
||||
*/
|
||||
private sanitizeRegisterInput(input: RegisterRequest): RegisterRequest {
|
||||
return {
|
||||
email: this.sanitizeEmail(input.email),
|
||||
password: input.password.trim(),
|
||||
full_name: input.full_name?.trim() || undefined,
|
||||
avatar_url: input.avatar_url?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes login input by trimming and normalizing values.
|
||||
*/
|
||||
private sanitizeLoginInput(input: LoginRequest): LoginRequest {
|
||||
return {
|
||||
email: this.sanitizeEmail(input.email),
|
||||
password: input.password,
|
||||
rememberMe: input.rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token as an httpOnly cookie.
|
||||
*
|
||||
* @param res Express response object
|
||||
* @param refreshToken The refresh token to set
|
||||
* @param rememberMe If true, cookie persists for 30 days; otherwise 7 days
|
||||
*/
|
||||
private setRefreshTokenCookie(
|
||||
res: ExpressResponse,
|
||||
refreshToken: string,
|
||||
rememberMe: boolean,
|
||||
): void {
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the refresh token cookie by setting it to expire immediately.
|
||||
*
|
||||
* @param res Express response object
|
||||
*/
|
||||
private clearRefreshTokenCookie(res: ExpressResponse): void {
|
||||
res.cookie('refreshToken', '', {
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles OAuth callback by generating tokens and redirecting to frontend.
|
||||
*
|
||||
* @param provider OAuth provider name ('google' or 'github')
|
||||
* @param user Authenticated user profile or false if authentication failed
|
||||
* @param request Express request object
|
||||
* @param res Express response object
|
||||
*/
|
||||
private async handleOAuthCallback(
|
||||
provider: 'google' | 'github',
|
||||
user: UserProfile | false,
|
||||
request: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<void> {
|
||||
const reqLog = request.log;
|
||||
|
||||
if (!user || !user.user) {
|
||||
reqLog.error('OAuth callback received but no user profile found');
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(user, reqLog);
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
|
||||
// Redirect to frontend with provider-specific token parameter
|
||||
const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken';
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`);
|
||||
} catch (err) {
|
||||
reqLog.error({ error: err }, `Failed to complete ${provider} OAuth login`);
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
344
src/controllers/base.controller.ts
Normal file
344
src/controllers/base.controller.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// src/controllers/base.controller.ts
|
||||
// ============================================================================
|
||||
// BASE CONTROLLER FOR TSOA
|
||||
// ============================================================================
|
||||
// Provides a standardized base class for all tsoa controllers, ensuring
|
||||
// consistent response formatting, error handling, and access to common
|
||||
// utilities across the API.
|
||||
//
|
||||
// All controller methods should use the helper methods provided here to
|
||||
// construct responses, ensuring compliance with ADR-028 (API Response Format).
|
||||
// ============================================================================
|
||||
|
||||
import { Controller } from 'tsoa';
|
||||
import type {
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
PaginationInput,
|
||||
PaginationMeta,
|
||||
ResponseMeta,
|
||||
ControllerErrorCodeType,
|
||||
} from './types';
|
||||
import { ControllerErrorCode } from './types';
|
||||
|
||||
/**
|
||||
* Base controller class providing standardized response helpers and error handling.
|
||||
*
|
||||
* All tsoa controllers should extend this class to ensure consistent API
|
||||
* response formatting per ADR-028.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Route, Get, Tags } from 'tsoa';
|
||||
* import { BaseController } from './base.controller';
|
||||
* import type { SuccessResponse } from './types';
|
||||
*
|
||||
* @Route('users')
|
||||
* @Tags('Users')
|
||||
* export class UsersController extends BaseController {
|
||||
* @Get('{id}')
|
||||
* public async getUser(id: string): Promise<SuccessResponse<User>> {
|
||||
* const user = await userService.getUserById(id);
|
||||
* return this.success(user);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseController extends Controller {
|
||||
// ==========================================================================
|
||||
// SUCCESS RESPONSE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Creates a standard success response envelope.
|
||||
*
|
||||
* @param data - The response payload
|
||||
* @param statusCode - HTTP status code (default: 200)
|
||||
* @param meta - Optional metadata (requestId, timestamp)
|
||||
* @returns A SuccessResponse object matching ADR-028 format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple success response
|
||||
* return this.success({ id: 1, name: 'Item' });
|
||||
*
|
||||
* // Success with 201 Created
|
||||
* this.setStatus(201);
|
||||
* return this.success(newUser);
|
||||
*
|
||||
* // Success with metadata
|
||||
* return this.success(data, { requestId: 'abc-123' });
|
||||
* ```
|
||||
*/
|
||||
protected success<T>(data: T, meta?: Omit<ResponseMeta, 'pagination'>): SuccessResponse<T> {
|
||||
const response: SuccessResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a paginated success response with pagination metadata.
|
||||
*
|
||||
* @param data - Array of items for the current page
|
||||
* @param pagination - Pagination input (page, limit, total)
|
||||
* @param meta - Optional additional metadata
|
||||
* @returns A PaginatedResponse object with calculated pagination info
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { users, total } = await userService.listUsers({ page, limit });
|
||||
* return this.paginated(users, { page, limit, total });
|
||||
* ```
|
||||
*/
|
||||
protected paginated<T>(
|
||||
data: T[],
|
||||
pagination: PaginationInput,
|
||||
meta?: Omit<ResponseMeta, 'pagination'>,
|
||||
): PaginatedResponse<T> {
|
||||
const paginationMeta = this.calculatePagination(pagination);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
...meta,
|
||||
pagination: paginationMeta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a success response with just a message.
|
||||
* Useful for operations that complete successfully but don't return data.
|
||||
*
|
||||
* @param message - Success message
|
||||
* @returns A SuccessResponse with a message object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // After deleting a resource
|
||||
* return this.message('User deleted successfully');
|
||||
*
|
||||
* // After an action that doesn't return data
|
||||
* return this.message('Password updated successfully');
|
||||
* ```
|
||||
*/
|
||||
protected message(message: string): SuccessResponse<{ message: string }> {
|
||||
return this.success({ message });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ERROR RESPONSE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Creates a standard error response envelope.
|
||||
*
|
||||
* Note: For most error cases, you should throw an appropriate error class
|
||||
* (NotFoundError, ValidationError, etc.) and let the global error handler
|
||||
* format the response. Use this method only when you need fine-grained
|
||||
* control over the error response format.
|
||||
*
|
||||
* @param code - Machine-readable error code
|
||||
* @param message - Human-readable error message
|
||||
* @param details - Optional error details (validation errors, etc.)
|
||||
* @param meta - Optional metadata (requestId for error tracking)
|
||||
* @returns An ErrorResponse object matching ADR-028 format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Manual error response (prefer throwing errors instead)
|
||||
* this.setStatus(400);
|
||||
* return this.error(
|
||||
* ControllerErrorCode.BAD_REQUEST,
|
||||
* 'Invalid operation',
|
||||
* { reason: 'Cannot delete last admin user' }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
protected error(
|
||||
code: ControllerErrorCodeType | string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>,
|
||||
): ErrorResponse {
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
if (details !== undefined) {
|
||||
response.error.details = details;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PAGINATION HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Calculates pagination metadata from input parameters.
|
||||
*
|
||||
* @param input - Pagination input (page, limit, total)
|
||||
* @returns Calculated pagination metadata
|
||||
*/
|
||||
protected calculatePagination(input: PaginationInput): PaginationMeta {
|
||||
const { page, limit, total } = input;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes pagination parameters with defaults and bounds.
|
||||
*
|
||||
* @param page - Requested page number (defaults to 1)
|
||||
* @param limit - Requested page size (defaults to 20, max 100)
|
||||
* @returns Normalized page and limit values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* public async listUsers(
|
||||
* @Query() page?: number,
|
||||
* @Query() limit?: number,
|
||||
* ): Promise<PaginatedResponse<User>> {
|
||||
* const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
* // p and l are now safe to use with guaranteed bounds
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected normalizePagination(page?: number, limit?: number): { page: number; limit: number } {
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
return {
|
||||
page: Math.max(DEFAULT_PAGE, Math.floor(page ?? DEFAULT_PAGE)),
|
||||
limit: Math.min(MAX_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT))),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HTTP STATUS CODE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Sets HTTP 201 Created status and returns success response.
|
||||
* Use for POST endpoints that create new resources.
|
||||
*
|
||||
* @param data - The created resource
|
||||
* @param meta - Optional metadata
|
||||
* @returns SuccessResponse with 201 status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Post()
|
||||
* public async createUser(body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
* const user = await userService.createUser(body);
|
||||
* return this.created(user);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected created<T>(data: T, meta?: Omit<ResponseMeta, 'pagination'>): SuccessResponse<T> {
|
||||
this.setStatus(201);
|
||||
return this.success(data, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets HTTP 204 No Content status.
|
||||
* Use for DELETE endpoints or operations that succeed without returning data.
|
||||
*
|
||||
* Note: tsoa requires a return type, so this returns undefined.
|
||||
* The actual HTTP response will have no body.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Delete('{id}')
|
||||
* public async deleteUser(id: string): Promise<void> {
|
||||
* await userService.deleteUser(id);
|
||||
* return this.noContent();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected noContent(): void {
|
||||
this.setStatus(204);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ERROR CODE CONSTANTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Standard error codes for use in error responses.
|
||||
* Exposed as a protected property for use in derived controllers.
|
||||
*/
|
||||
protected readonly ErrorCode = ControllerErrorCode;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTROLLER ERROR CLASSES
|
||||
// ============================================================================
|
||||
// Error classes that can be thrown from controllers and will be handled
|
||||
// by the global error handler to produce appropriate HTTP responses.
|
||||
// These re-export the repository errors for convenience.
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ForbiddenError,
|
||||
UniqueConstraintError,
|
||||
RepositoryError,
|
||||
} from '../services/db/errors.db';
|
||||
|
||||
// ============================================================================
|
||||
// RE-EXPORTS
|
||||
// ============================================================================
|
||||
// Re-export types for convenient imports in controller files.
|
||||
// ============================================================================
|
||||
|
||||
export { ControllerErrorCode } from './types';
|
||||
export type {
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
PaginationInput,
|
||||
PaginationMeta,
|
||||
PaginationParams,
|
||||
ResponseMeta,
|
||||
RequestContext,
|
||||
AuthenticatedUser,
|
||||
MessageResponse,
|
||||
HealthResponse,
|
||||
DetailedHealthResponse,
|
||||
ServiceHealth,
|
||||
ValidationIssue,
|
||||
ValidationErrorResponse,
|
||||
ControllerErrorCodeType,
|
||||
ApiResponse,
|
||||
} from './types';
|
||||
467
src/controllers/budget.controller.test.ts
Normal file
467
src/controllers/budget.controller.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
// src/controllers/budget.controller.test.ts
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the BudgetController class. These tests verify controller
|
||||
// logic in isolation by mocking the budget repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock budget repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
budgetRepo: {
|
||||
getBudgetsForUser: vi.fn(),
|
||||
createBudget: vi.fn(),
|
||||
updateBudget: vi.fn(),
|
||||
deleteBudget: vi.fn(),
|
||||
getSpendingByCategory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { budgetRepo } from '../services/db/index.db';
|
||||
import { BudgetController } from './budget.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedBudgetRepo = budgetRepo as Mocked<typeof budgetRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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 budget record.
|
||||
*/
|
||||
function createMockBudget(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
budget_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock spending by category record.
|
||||
*/
|
||||
function createMockSpendingByCategory(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
total_cents: 2500,
|
||||
item_count: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('BudgetController', () => {
|
||||
let controller: BudgetController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new BudgetController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST BUDGETS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getBudgets()', () => {
|
||||
it('should return all budgets for the user', async () => {
|
||||
// Arrange
|
||||
const mockBudgets = [
|
||||
createMockBudget(),
|
||||
createMockBudget({ budget_id: 2, name: 'Weekly Snacks', period: 'weekly' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue(mockBudgets);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe('Monthly Groceries');
|
||||
}
|
||||
expect(mockedBudgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no budgets', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CREATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('createBudget()', () => {
|
||||
it('should create a new budget', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Monthly Groceries');
|
||||
expect(result.data.amount_cents).toBe(50000);
|
||||
}
|
||||
expect(mockedBudgetRepo.createBudget).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.objectContaining({
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a weekly budget', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget({ period: 'weekly', amount_cents: 10000 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Weekly Snacks',
|
||||
amount_cents: 10000,
|
||||
period: 'weekly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.period).toBe('weekly');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// UPDATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('updateBudget()', () => {
|
||||
it('should update an existing budget', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({ amount_cents: 60000 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
amount_cents: 60000,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.amount_cents).toBe(60000);
|
||||
}
|
||||
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.objectContaining({ amount_cents: 60000 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update budget name', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({ name: 'Updated Budget Name' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
name: 'Updated Budget Name',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Updated Budget Name');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateBudget(1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple fields at once', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
});
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.objectContaining({
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DELETE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('deleteBudget()', () => {
|
||||
it('should delete a budget', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteBudget(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedBudgetRepo.deleteBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SPENDING ANALYSIS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getSpendingAnalysis()', () => {
|
||||
it('should return spending breakdown by category', async () => {
|
||||
// Arrange
|
||||
const mockSpendingData = [
|
||||
createMockSpendingByCategory(),
|
||||
createMockSpendingByCategory({
|
||||
category_id: 2,
|
||||
category_name: 'Produce',
|
||||
total_cents: 3500,
|
||||
}),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue(mockSpendingData);
|
||||
|
||||
// Act
|
||||
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].category_name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedBudgetRepo.getSpendingByCategory).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no spending data exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Test',
|
||||
amount_cents: 1000,
|
||||
period: 'weekly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteBudget(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/controllers/budget.controller.ts
Normal file
233
src/controllers/budget.controller.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// src/controllers/budget.controller.ts
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing user budgets, including CRUD operations
|
||||
// and spending analysis. All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { budgetRepo } from '../services/db/index.db';
|
||||
import type { Budget, SpendingByCategory, UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for creating a new budget.
|
||||
*/
|
||||
interface CreateBudgetRequest {
|
||||
/** Budget name */
|
||||
name: string;
|
||||
/** Budget amount in cents (must be positive) */
|
||||
amount_cents: number;
|
||||
/** Budget period - weekly or monthly */
|
||||
period: 'weekly' | 'monthly';
|
||||
/** Budget start date in YYYY-MM-DD format */
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating a budget.
|
||||
* All fields are optional, but at least one must be provided.
|
||||
*/
|
||||
interface UpdateBudgetRequest {
|
||||
/** Budget name */
|
||||
name?: string;
|
||||
/** Budget amount in cents (must be positive) */
|
||||
amount_cents?: number;
|
||||
/** Budget period - weekly or monthly */
|
||||
period?: 'weekly' | 'monthly';
|
||||
/** Budget start date in YYYY-MM-DD format */
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing user budgets.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own budgets - the user ID is extracted from the JWT token.
|
||||
*/
|
||||
@Route('budgets')
|
||||
@Tags('Budgets')
|
||||
@Security('bearerAuth')
|
||||
export class BudgetController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST BUDGETS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all budgets for the authenticated user.
|
||||
*
|
||||
* Returns a list of all budgets owned by the authenticated user,
|
||||
* ordered by start date descending (newest first).
|
||||
*
|
||||
* @summary Get all budgets
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of user budgets
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of user budgets')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getBudgets(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Budget[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const budgets = await budgetRepo.getBudgetsForUser(userProfile.user.user_id, request.log);
|
||||
return this.success(budgets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CREATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a new budget for the authenticated user.
|
||||
*
|
||||
* Creates a budget with the specified name, amount, period, and start date.
|
||||
* The budget is automatically associated with the authenticated user.
|
||||
*
|
||||
* @summary Create budget
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Budget creation data
|
||||
* @returns The newly created budget
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Budget created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async createBudget(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: CreateBudgetRequest,
|
||||
): Promise<SuccessResponseType<Budget>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, request.log);
|
||||
return this.created(newBudget);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// UPDATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Update an existing budget.
|
||||
*
|
||||
* Updates the specified budget with the provided fields. At least one
|
||||
* field must be provided. The user must own the budget to update it.
|
||||
*
|
||||
* @summary Update budget
|
||||
* @param id Budget ID
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated budget
|
||||
*/
|
||||
@Put('{id}')
|
||||
@SuccessResponse(200, 'Budget updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Budget not found')
|
||||
public async updateBudget(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateBudgetRequest,
|
||||
): Promise<SuccessResponseType<Budget>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
const updatedBudget = await budgetRepo.updateBudget(
|
||||
id,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
return this.success(updatedBudget);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DELETE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Delete a budget.
|
||||
*
|
||||
* Permanently deletes the specified budget. The user must own
|
||||
* the budget to delete it.
|
||||
*
|
||||
* @summary Delete budget
|
||||
* @param id Budget ID
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{id}')
|
||||
@SuccessResponse(204, 'Budget deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Budget not found')
|
||||
public async deleteBudget(@Path() id: number, @Request() request: ExpressRequest): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await budgetRepo.deleteBudget(id, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SPENDING ANALYSIS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get spending analysis by category.
|
||||
*
|
||||
* Returns a breakdown of spending by category for the specified date range.
|
||||
* This helps users understand their spending patterns relative to their budgets.
|
||||
*
|
||||
* @summary Get spending analysis
|
||||
* @param startDate Start date in YYYY-MM-DD format
|
||||
* @param endDate End date in YYYY-MM-DD format
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Spending breakdown by category
|
||||
*/
|
||||
@Get('spending-analysis')
|
||||
@SuccessResponse(200, 'Spending breakdown by category')
|
||||
@Response<ErrorResponse>(400, 'Invalid date format')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getSpendingAnalysis(
|
||||
@Query() startDate: string,
|
||||
@Query() endDate: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<SpendingByCategory[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const spendingData = await budgetRepo.getSpendingByCategory(
|
||||
userProfile.user.user_id,
|
||||
startDate,
|
||||
endDate,
|
||||
request.log,
|
||||
);
|
||||
return this.success(spendingData);
|
||||
}
|
||||
}
|
||||
333
src/controllers/category.controller.test.ts
Normal file
333
src/controllers/category.controller.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
// src/controllers/category.controller.test.ts
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the CategoryController class. These tests verify controller
|
||||
// logic in isolation by mocking the category database service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock category database service
|
||||
vi.mock('../services/db/category.db', () => ({
|
||||
CategoryDbService: {
|
||||
getAllCategories: vi.fn(),
|
||||
getCategoryByName: vi.fn(),
|
||||
getCategoryById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { CategoryDbService } from '../services/db/category.db';
|
||||
import { CategoryController } from './category.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedCategoryDbService = CategoryDbService as Mocked<typeof CategoryDbService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock category record.
|
||||
*/
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('CategoryController', () => {
|
||||
let controller: CategoryController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new CategoryController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST CATEGORIES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAllCategories()', () => {
|
||||
it('should return all categories', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [
|
||||
createMockCategory(),
|
||||
createMockCategory({ category_id: 2, name: 'Produce' }),
|
||||
createMockCategory({ category_id: 3, name: 'Meat & Seafood' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedCategoryDbService.getAllCategories).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no categories exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP BY NAME
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCategoryByName()', () => {
|
||||
it('should return category when found by name', async () => {
|
||||
// Arrange
|
||||
const mockCategory = createMockCategory();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('Dairy & Eggs', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Dairy & Eggs');
|
||||
expect(result.data.category_id).toBe(1);
|
||||
}
|
||||
expect(mockedCategoryDbService.getCategoryByName).toHaveBeenCalledWith(
|
||||
'Dairy & Eggs',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when category not found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryByName.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.getCategoryByName('Nonexistent Category', request)).rejects.toThrow(
|
||||
"Category 'Nonexistent Category' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when name is empty', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when name is whitespace only', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName(' ', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GET BY ID
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCategoryById()', () => {
|
||||
it('should return category when found by ID', async () => {
|
||||
// Arrange
|
||||
const mockCategory = createMockCategory();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryById.mockResolvedValue(mockCategory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.category_id).toBe(1);
|
||||
expect(result.data.name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedCategoryDbService.getCategoryById).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when category not found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryById.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.getCategoryById(999, request)).rejects.toThrow(
|
||||
'Category with ID 999 not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (zero)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(0, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (negative)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(-1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (NaN)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(NaN, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [createMockCategory()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [createMockCategory()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for validation errors', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('', request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/controllers/category.controller.ts
Normal file
137
src/controllers/category.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/controllers/category.controller.ts
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving grocery categories. Categories are
|
||||
// predefined (e.g., "Dairy & Eggs", "Fruits & Vegetables") and are used
|
||||
// to organize items throughout the application.
|
||||
//
|
||||
// All endpoints are public (no authentication required).
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Path, Query, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, NotFoundError } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { CategoryDbService, type Category } from '../services/db/category.db';
|
||||
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving grocery categories.
|
||||
*
|
||||
* Categories are system-defined and cannot be modified by users.
|
||||
* All endpoints are public and do not require authentication.
|
||||
*/
|
||||
@Route('categories')
|
||||
@Tags('Categories')
|
||||
export class CategoryController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST CATEGORIES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* List all available grocery categories.
|
||||
*
|
||||
* Returns all predefined grocery categories ordered alphabetically by name.
|
||||
* Use this endpoint to populate category dropdowns in the UI.
|
||||
*
|
||||
* @summary List all available grocery categories
|
||||
* @param request Express request for logging
|
||||
* @returns List of categories ordered alphabetically by name
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of categories ordered alphabetically by name')
|
||||
@Response<ErrorResponse>(500, 'Server error')
|
||||
public async getAllCategories(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category[]>> {
|
||||
const categories = await CategoryDbService.getAllCategories(request.log);
|
||||
return this.success(categories);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP BY NAME
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Lookup category by name.
|
||||
*
|
||||
* Find a category by its name (case-insensitive). This endpoint is provided
|
||||
* for migration support to help clients transition from using category names
|
||||
* to category IDs.
|
||||
*
|
||||
* @summary Lookup category by name
|
||||
* @param name The category name to search for (case-insensitive)
|
||||
* @param request Express request for logging
|
||||
* @returns Category found
|
||||
*/
|
||||
@Get('lookup')
|
||||
@SuccessResponse(200, 'Category found')
|
||||
@Response<ErrorResponse>(400, 'Missing or invalid query parameter')
|
||||
@Response<ErrorResponse>(404, 'Category not found')
|
||||
public async getCategoryByName(
|
||||
@Query() name: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category>> {
|
||||
// Validate name parameter
|
||||
if (!name || name.trim() === '') {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.BAD_REQUEST,
|
||||
'Query parameter "name" is required and must be a non-empty string',
|
||||
) as unknown as SuccessResponseType<Category>;
|
||||
}
|
||||
|
||||
const category = await CategoryDbService.getCategoryByName(name, request.log);
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundError(`Category '${name}' not found`);
|
||||
}
|
||||
|
||||
return this.success(category);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GET BY ID
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get a specific category by ID.
|
||||
*
|
||||
* Retrieve detailed information about a single category.
|
||||
*
|
||||
* @summary Get a specific category by ID
|
||||
* @param id The category ID
|
||||
* @param request Express request for logging
|
||||
* @returns Category details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Category details')
|
||||
@Response<ErrorResponse>(400, 'Invalid category ID')
|
||||
@Response<ErrorResponse>(404, 'Category not found')
|
||||
public async getCategoryById(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category>> {
|
||||
// Validate ID
|
||||
if (isNaN(id) || id <= 0) {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.BAD_REQUEST,
|
||||
'Invalid category ID. Must be a positive integer.',
|
||||
) as unknown as SuccessResponseType<Category>;
|
||||
}
|
||||
|
||||
const category = await CategoryDbService.getCategoryById(id, request.log);
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundError(`Category with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return this.success(category);
|
||||
}
|
||||
}
|
||||
254
src/controllers/deals.controller.test.ts
Normal file
254
src/controllers/deals.controller.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// src/controllers/deals.controller.test.ts
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the DealsController class. These tests verify controller
|
||||
// logic in isolation by mocking the deals repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock deals repository
|
||||
vi.mock('../services/db/deals.db', () => ({
|
||||
dealsRepo: {
|
||||
findBestPricesForWatchedItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import { DealsController } from './deals.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDealsRepo = dealsRepo as Mocked<typeof dealsRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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 watched item deal.
|
||||
*/
|
||||
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,
|
||||
flyer_id: 10,
|
||||
flyer_valid_from: '2024-01-15',
|
||||
flyer_valid_to: '2024-01-21',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('DealsController', () => {
|
||||
let controller: DealsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new DealsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BEST WATCHED PRICES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getBestWatchedPrices()', () => {
|
||||
it('should return best prices for watched items', async () => {
|
||||
// Arrange
|
||||
const mockDeals = [
|
||||
createMockWatchedItemDeal(),
|
||||
createMockWatchedItemDeal({
|
||||
watched_item_id: 2,
|
||||
item_name: 'Bread',
|
||||
current_price_cents: 250,
|
||||
}),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue(mockDeals);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
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(mockedDealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no watched items', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when no active deals exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue(mockDeals);
|
||||
|
||||
// Act
|
||||
await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ dealCount: 1 },
|
||||
'Successfully fetched best watched item deals.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(mockedDealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
'custom-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/controllers/deals.controller.ts
Normal file
62
src/controllers/deals.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/controllers/deals.controller.ts
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving deal information, specifically the
|
||||
// best prices for items that the user is watching.
|
||||
//
|
||||
// All endpoints require authentication.
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Security, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import type { WatchedItemDeal, UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving deal information.
|
||||
*
|
||||
* All endpoints require JWT authentication. The user ID is extracted
|
||||
* from the JWT token to retrieve user-specific deal information.
|
||||
*/
|
||||
@Route('deals')
|
||||
@Tags('Deals')
|
||||
@Security('bearerAuth')
|
||||
export class DealsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// BEST WATCHED PRICES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get best prices for watched items.
|
||||
*
|
||||
* Fetches the best current sale price for each of the authenticated user's
|
||||
* watched items. Only considers currently active flyers (valid_to >= today).
|
||||
* In case of price ties, the deal that is valid for the longest time is preferred.
|
||||
*
|
||||
* @summary Get best prices for watched items
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of best prices for watched items
|
||||
*/
|
||||
@Get('best-watched-prices')
|
||||
@SuccessResponse(200, 'List of best prices for watched items')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getBestWatchedPrices(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<WatchedItemDeal[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const deals = await dealsRepo.findBestPricesForWatchedItems(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
request.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
|
||||
return this.success(deals);
|
||||
}
|
||||
}
|
||||
590
src/controllers/flyer.controller.test.ts
Normal file
590
src/controllers/flyer.controller.test.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
// src/controllers/flyer.controller.test.ts
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the FlyerController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like database repositories.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
flyerRepo: {
|
||||
getFlyers: vi.fn(),
|
||||
getFlyerById: vi.fn(),
|
||||
getFlyerItems: vi.fn(),
|
||||
getFlyerItemsForFlyers: vi.fn(),
|
||||
countFlyerItemsForFlyers: vi.fn(),
|
||||
trackFlyerItemInteraction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
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>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...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
|
||||
// ============================================================================
|
||||
|
||||
describe('FlyerController', () => {
|
||||
let controller: FlyerController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new FlyerController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFlyers()', () => {
|
||||
it('should return flyers with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockFlyers = [createMockFlyer(), createMockFlyer({ flyer_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue(mockFlyers);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyers(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
20, // default limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect custom pagination parameters', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 50, 10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 50, 10);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
});
|
||||
|
||||
it('should floor limit to minimum of 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, -5);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 1, 0);
|
||||
});
|
||||
|
||||
it('should normalize offset to 0 if negative', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 20, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 20, 0);
|
||||
});
|
||||
|
||||
it('should floor decimal pagination values', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 15.9, 5.7);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 15, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFlyerById()', () => {
|
||||
it('should return flyer by ID successfully', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
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(mockedDb.flyerRepo.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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith({ flyerId: 1 }, 'Retrieved flyer by ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlyerItems()', () => {
|
||||
it('should return flyer items successfully', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockFlyerItem(),
|
||||
createMockFlyerItem({ flyer_item_id: 2, item: 'Another Product' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item).toBe('Test Product');
|
||||
}
|
||||
expect(mockedDb.flyerRepo.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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerId: 1, itemCount: 1 },
|
||||
'Retrieved flyer items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for flyer with no items', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(999, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BATCH ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('batchFetchItems()', () => {
|
||||
it('should return items for multiple flyers', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockFlyerItem({ flyer_id: 1 }),
|
||||
createMockFlyerItem({ flyer_id: 2, flyer_item_id: 2 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [1, 2, 3] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.batchFetchItems({ flyerIds: [1, 2] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerCount: 2, itemCount: 1 },
|
||||
'Batch fetched flyer items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no items found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [999, 1000] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchCountItems()', () => {
|
||||
it('should return total item count for multiple flyers', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(25);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [1, 2, 3] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(25);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.countFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log count details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(10);
|
||||
|
||||
// Act
|
||||
await controller.batchCountItems({ flyerIds: [1] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerCount: 1, totalItems: 10 },
|
||||
'Batch counted items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 for empty flyer list', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(0);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// TRACKING ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('trackItemInteraction()', () => {
|
||||
it('should accept view tracking (fire-and-forget)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept click tracking', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'click' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
// Make tracking fail
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
|
||||
// Assert - should still return success (fire-and-forget)
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
|
||||
// Wait for async error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Error should be logged
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.any(Error),
|
||||
itemId: 1,
|
||||
interactionType: 'view',
|
||||
}),
|
||||
'Flyer item interaction tracking failed (fire-and-forget)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call tracking with correct parameters', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.trackItemInteraction(42, { type: 'click' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
|
||||
42,
|
||||
'click',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
282
src/controllers/flyer.controller.ts
Normal file
282
src/controllers/flyer.controller.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// src/controllers/flyer.controller.ts
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing flyers and flyer items.
|
||||
// Implements endpoints for:
|
||||
// - Listing flyers with pagination
|
||||
// - Getting individual flyer details
|
||||
// - Getting items for a flyer
|
||||
// - Batch fetching items for multiple flyers
|
||||
// - Batch counting items for multiple flyers
|
||||
// - Tracking item interactions (fire-and-forget)
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import type { FlyerDto, FlyerItemDto } from '../dtos/common.dto';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// Types for request bodies and custom response shapes that will appear in
|
||||
// the OpenAPI specification.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for batch fetching flyer items.
|
||||
*/
|
||||
interface BatchFetchRequest {
|
||||
/**
|
||||
* Array of flyer IDs to fetch items for.
|
||||
* @minItems 1
|
||||
* @example [1, 2, 3]
|
||||
*/
|
||||
flyerIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for batch counting flyer items.
|
||||
*/
|
||||
interface BatchCountRequest {
|
||||
/**
|
||||
* Array of flyer IDs to count items for.
|
||||
* @example [1, 2, 3]
|
||||
*/
|
||||
flyerIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for tracking item interactions.
|
||||
*/
|
||||
interface TrackInteractionRequest {
|
||||
/**
|
||||
* Type of interaction to track.
|
||||
* @example "view"
|
||||
*/
|
||||
type: 'view' | 'click';
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for batch item count.
|
||||
*/
|
||||
interface BatchCountResponse {
|
||||
/**
|
||||
* Total number of items across all requested flyers.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for tracking confirmation.
|
||||
*/
|
||||
interface TrackingResponse {
|
||||
/**
|
||||
* Confirmation message.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for flyer management endpoints.
|
||||
*
|
||||
* Provides read-only access to flyers and flyer items for all users,
|
||||
* with analytics tracking capabilities.
|
||||
*/
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyerController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all flyers.
|
||||
*
|
||||
* Returns a paginated list of all flyers, ordered by creation date (newest first).
|
||||
* Includes store information and location data for each flyer.
|
||||
*
|
||||
* @summary List all flyers
|
||||
* @param limit Maximum number of flyers to return (default: 20)
|
||||
* @param offset Number of flyers to skip for pagination (default: 0)
|
||||
* @returns Array of flyer objects with store information
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of flyers retrieved successfully')
|
||||
public async getFlyers(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<FlyerDto[]>> {
|
||||
// Apply defaults and bounds for pagination
|
||||
// Note: Using offset-based pagination to match existing API behavior
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 20)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, normalizedLimit, normalizedOffset);
|
||||
// The Flyer type from the repository is structurally compatible with FlyerDto
|
||||
// (FlyerDto just omits the GeoJSONPoint type that tsoa can't handle)
|
||||
return this.success(flyers as unknown as FlyerDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get flyer by ID.
|
||||
*
|
||||
* Returns a single flyer with its full details, including store information
|
||||
* and all associated store locations.
|
||||
*
|
||||
* @summary Get a single flyer
|
||||
* @param id The unique identifier of the flyer
|
||||
* @returns The flyer object with full details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Flyer retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Flyer not found')
|
||||
public async getFlyerById(
|
||||
@Path() id: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
// getFlyerById throws NotFoundError if flyer doesn't exist
|
||||
// The global error handler converts this to a 404 response
|
||||
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||
req.log.debug({ flyerId: id }, 'Retrieved flyer by ID');
|
||||
return this.success(flyer as unknown as FlyerDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flyer items.
|
||||
*
|
||||
* Returns all items (deals) associated with a specific flyer.
|
||||
* Items are ordered by their position in the flyer.
|
||||
*
|
||||
* @summary Get items for a flyer
|
||||
* @param id The unique identifier of the flyer
|
||||
* @returns Array of flyer items with pricing and category information
|
||||
*/
|
||||
@Get('{id}/items')
|
||||
@SuccessResponse(200, 'Flyer items retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Flyer not found')
|
||||
public async getFlyerItems(
|
||||
@Path() id: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerItemDto[]>> {
|
||||
const items = await db.flyerRepo.getFlyerItems(id, req.log);
|
||||
req.log.debug({ flyerId: id, itemCount: items.length }, 'Retrieved flyer items');
|
||||
return this.success(items as unknown as FlyerItemDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// BATCH ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Batch fetch flyer items.
|
||||
*
|
||||
* Returns all items for multiple flyers in a single request.
|
||||
* This is more efficient than making separate requests for each flyer.
|
||||
* Items are ordered by flyer ID, then by item position within each flyer.
|
||||
*
|
||||
* @summary Batch fetch items for multiple flyers
|
||||
* @param body Request body containing array of flyer IDs
|
||||
* @returns Array of all flyer items for the requested flyers
|
||||
*/
|
||||
@Post('items/batch-fetch')
|
||||
@SuccessResponse(200, 'Batch items retrieved successfully')
|
||||
public async batchFetchItems(
|
||||
@Body() body: BatchFetchRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerItemDto[]>> {
|
||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
req.log.debug(
|
||||
{ flyerCount: body.flyerIds.length, itemCount: items.length },
|
||||
'Batch fetched flyer items',
|
||||
);
|
||||
return this.success(items as unknown as FlyerItemDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch count flyer items.
|
||||
*
|
||||
* Returns the total item count for multiple flyers.
|
||||
* Useful for displaying item counts without fetching all item data.
|
||||
*
|
||||
* @summary Batch count items for multiple flyers
|
||||
* @param body Request body containing array of flyer IDs
|
||||
* @returns Object with total count of items across all requested flyers
|
||||
*/
|
||||
@Post('items/batch-count')
|
||||
@SuccessResponse(200, 'Batch count retrieved successfully')
|
||||
public async batchCountItems(
|
||||
@Body() body: BatchCountRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<BatchCountResponse>> {
|
||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
req.log.debug({ flyerCount: body.flyerIds.length, totalItems: count }, 'Batch counted items');
|
||||
return this.success({ count });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TRACKING ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Track item interaction.
|
||||
*
|
||||
* Records a view or click interaction with a flyer item for analytics purposes.
|
||||
* This endpoint uses a fire-and-forget pattern: it returns immediately with a
|
||||
* 202 Accepted response while the tracking is processed asynchronously.
|
||||
*
|
||||
* This design ensures that tracking does not slow down the user experience,
|
||||
* and any tracking failures are logged but do not affect the client.
|
||||
*
|
||||
* @summary Track a flyer item interaction
|
||||
* @param itemId The unique identifier of the flyer item
|
||||
* @param body The interaction type (view or click)
|
||||
* @returns Confirmation that tracking was accepted
|
||||
*/
|
||||
@Post('items/{itemId}/track')
|
||||
@SuccessResponse(202, 'Tracking accepted')
|
||||
public async trackItemInteraction(
|
||||
@Path() itemId: number,
|
||||
@Body() body: TrackInteractionRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<TrackingResponse>> {
|
||||
// Fire-and-forget: start the tracking operation but don't await it.
|
||||
// We explicitly handle errors in the .catch() to prevent unhandled rejections
|
||||
// and to log any failures without affecting the client response.
|
||||
db.flyerRepo.trackFlyerItemInteraction(itemId, body.type, req.log).catch((error) => {
|
||||
// Log the error but don't propagate it - this is intentional
|
||||
// as tracking failures should not impact user experience
|
||||
req.log.error(
|
||||
{ error, itemId, interactionType: body.type },
|
||||
'Flyer item interaction tracking failed (fire-and-forget)',
|
||||
);
|
||||
});
|
||||
|
||||
// Return immediately with 202 Accepted
|
||||
this.setStatus(202);
|
||||
return this.success({ message: 'Tracking accepted' });
|
||||
}
|
||||
}
|
||||
457
src/controllers/gamification.controller.test.ts
Normal file
457
src/controllers/gamification.controller.test.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
// src/controllers/gamification.controller.test.ts
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the GamificationController class. These tests verify controller
|
||||
// logic in isolation by mocking the gamification service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock gamification service
|
||||
vi.mock('../services/gamificationService', () => ({
|
||||
gamificationService: {
|
||||
getAllAchievements: vi.fn(),
|
||||
getLeaderboard: vi.fn(),
|
||||
getUserAchievements: vi.fn(),
|
||||
awardAchievement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
userReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
adminTriggerLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { gamificationService } from '../services/gamificationService';
|
||||
import { GamificationController } from './gamification.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedGamificationService = gamificationService as Mocked<typeof gamificationService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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 admin user profile for testing.
|
||||
*/
|
||||
function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock achievement.
|
||||
*/
|
||||
function createMockAchievement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
achievement_id: 1,
|
||||
name: 'First-Upload',
|
||||
description: 'Upload your first flyer',
|
||||
points: 10,
|
||||
icon: 'upload',
|
||||
category: 'contribution',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user achievement.
|
||||
*/
|
||||
function createMockUserAchievement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_achievement_id: 1,
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock leaderboard user.
|
||||
*/
|
||||
function createMockLeaderboardUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_id: 'user-1',
|
||||
display_name: 'Top User',
|
||||
total_points: 150,
|
||||
achievement_count: 8,
|
||||
rank: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('GamificationController', () => {
|
||||
let controller: GamificationController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new GamificationController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAllAchievements()', () => {
|
||||
it('should return all achievements', async () => {
|
||||
// Arrange
|
||||
const mockAchievements = [
|
||||
createMockAchievement(),
|
||||
createMockAchievement({ achievement_id: 2, name: 'Deal-Hunter', points: 25 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue(mockAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe('First-Upload');
|
||||
}
|
||||
expect(mockedGamificationService.getAllAchievements).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no achievements exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockAchievements = [createMockAchievement()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue(mockAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeaderboard()', () => {
|
||||
it('should return leaderboard with default limit', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [
|
||||
createMockLeaderboardUser(),
|
||||
createMockLeaderboardUser({ user_id: 'user-2', rank: 2, total_points: 120 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
const result = await controller.getLeaderboard(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].rank).toBe(1);
|
||||
}
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(
|
||||
10, // default limit
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom limit', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 25);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(25, expect.anything());
|
||||
});
|
||||
|
||||
it('should cap limit at 50', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(50, expect.anything());
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
const result = await controller.getLeaderboard(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED USER ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMyAchievements()', () => {
|
||||
it('should return user achievements', async () => {
|
||||
// Arrange
|
||||
const mockUserAchievements = [
|
||||
createMockUserAchievement(),
|
||||
createMockUserAchievement({ user_achievement_id: 2, achievement_name: 'Deal-Hunter' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue(mockUserAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].achievement_name).toBe('First-Upload');
|
||||
}
|
||||
expect(mockedGamificationService.getUserAchievements).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no achievements', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getUserAchievements).toHaveBeenCalledWith(
|
||||
'custom-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('awardAchievement()', () => {
|
||||
it('should award achievement to user (admin)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedGamificationService.awardAchievement.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.awardAchievement(request, {
|
||||
userId: 'target-user-id',
|
||||
achievementName: 'First-Upload',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
"Successfully awarded 'First-Upload' to user target-user-id.",
|
||||
);
|
||||
}
|
||||
expect(mockedGamificationService.awardAchievement).toHaveBeenCalledWith(
|
||||
'target-user-id',
|
||||
'First-Upload',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include achievement name in success message', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedGamificationService.awardAchievement.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.awardAchievement(request, {
|
||||
userId: 'user-123',
|
||||
achievementName: 'Deal-Hunter',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Deal-Hunter');
|
||||
expect(result.data.message).toContain('user-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/controllers/gamification.controller.ts
Normal file
190
src/controllers/gamification.controller.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// src/controllers/gamification.controller.ts
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for the achievement and leaderboard system.
|
||||
// Includes public endpoints for viewing achievements and leaderboard,
|
||||
// authenticated endpoint for user's achievements, and admin endpoint
|
||||
// for manually awarding achievements.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { gamificationService } from '../services/gamificationService';
|
||||
import type { UserProfile, Achievement, UserAchievement, LeaderboardUser } from '../types';
|
||||
import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for awarding an achievement (admin only).
|
||||
*/
|
||||
interface AwardAchievementRequest {
|
||||
/**
|
||||
* User ID to award the achievement to.
|
||||
* @format uuid
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* Name of the achievement to award.
|
||||
* @example "First-Upload"
|
||||
*/
|
||||
achievementName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for successful achievement award.
|
||||
*/
|
||||
interface AwardAchievementResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for achievement and leaderboard system.
|
||||
*
|
||||
* Public endpoints:
|
||||
* - GET /achievements - List all available achievements
|
||||
* - GET /achievements/leaderboard - View top users by points
|
||||
*
|
||||
* Authenticated endpoints:
|
||||
* - GET /achievements/me - View user's earned achievements
|
||||
*
|
||||
* Admin endpoints:
|
||||
* - POST /achievements/award - Manually award an achievement
|
||||
*/
|
||||
@Route('achievements')
|
||||
@Tags('Achievements')
|
||||
export class GamificationController extends BaseController {
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all achievements.
|
||||
*
|
||||
* Returns the master list of all available achievements in the system.
|
||||
* This is a public endpoint.
|
||||
*
|
||||
* @summary Get all achievements
|
||||
* @param request Express request for logging
|
||||
* @returns List of all available achievements
|
||||
*/
|
||||
@Get()
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all achievements')
|
||||
public async getAllAchievements(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Achievement[]>> {
|
||||
const achievements = await gamificationService.getAllAchievements(request.log);
|
||||
return this.success(achievements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaderboard.
|
||||
*
|
||||
* Returns the top users ranked by total points earned from achievements.
|
||||
* This is a public endpoint.
|
||||
*
|
||||
* @summary Get leaderboard
|
||||
* @param request Express request for logging
|
||||
* @param limit Maximum number of users to return (1-50, default: 10)
|
||||
* @returns Leaderboard entries with user points
|
||||
*/
|
||||
@Get('leaderboard')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'Leaderboard entries')
|
||||
public async getLeaderboard(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
): Promise<SuccessResponseType<LeaderboardUser[]>> {
|
||||
// Normalize limit: default 10, min 1, max 50
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
|
||||
const leaderboard = await gamificationService.getLeaderboard(normalizedLimit, request.log);
|
||||
return this.success(leaderboard);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED USER ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get my achievements.
|
||||
*
|
||||
* Returns all achievements earned by the authenticated user.
|
||||
*
|
||||
* @summary Get my achievements
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of user's earned achievements
|
||||
*/
|
||||
@Get('me')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(userReadLimiter)
|
||||
@SuccessResponse(200, "List of user's earned achievements")
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - JWT token missing or invalid')
|
||||
public async getMyAchievements(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserAchievement[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const userAchievements = await gamificationService.getUserAchievements(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(userAchievements);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Award achievement to user (Admin only).
|
||||
*
|
||||
* Manually award an achievement to a specific user. Requires admin role.
|
||||
*
|
||||
* @summary Award achievement to user (Admin only)
|
||||
* @param request Express request with authenticated admin user
|
||||
* @param body User ID and achievement name
|
||||
* @returns Success message
|
||||
*/
|
||||
@Post('award')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@Middlewares(adminTriggerLimiter)
|
||||
@SuccessResponse(200, 'Achievement awarded successfully')
|
||||
@Response<ErrorResponse>(400, 'Invalid achievement name')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - JWT token missing or invalid')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - User is not an admin')
|
||||
@Response<ErrorResponse>(404, 'User or achievement not found')
|
||||
public async awardAchievement(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: AwardAchievementRequest,
|
||||
): Promise<SuccessResponseType<AwardAchievementResponse>> {
|
||||
await gamificationService.awardAchievement(body.userId, body.achievementName, request.log);
|
||||
return this.success({
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
769
src/controllers/health.controller.test.ts
Normal file
769
src/controllers/health.controller.test.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
// src/controllers/health.controller.test.ts
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the HealthController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like database, Redis,
|
||||
// and file system access.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
// Mock all external dependencies before importing the controller module.
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class (required before controller import)
|
||||
// tsoa is used at compile-time for code generation but needs to be mocked for Vitest
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(_status: number): void {
|
||||
// Mock setStatus
|
||||
}
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database connection module
|
||||
vi.mock('../services/db/connection.db', () => ({
|
||||
checkTablesExist: vi.fn(),
|
||||
getPoolStatus: vi.fn(),
|
||||
getPool: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock file system module
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
access: vi.fn(),
|
||||
constants: { W_OK: 1 },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Redis connection from queue service
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
connection: {
|
||||
ping: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Use vi.hoisted to create mock queue objects available during vi.mock hoisting
|
||||
const { mockQueuesModule } = vi.hoisted(() => {
|
||||
const createMockQueue = () => ({
|
||||
getJobCounts: vi.fn().mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
mockQueuesModule: {
|
||||
flyerQueue: createMockQueue(),
|
||||
emailQueue: createMockQueue(),
|
||||
analyticsQueue: createMockQueue(),
|
||||
weeklyAnalyticsQueue: createMockQueue(),
|
||||
cleanupQueue: createMockQueue(),
|
||||
tokenCleanupQueue: createMockQueue(),
|
||||
receiptQueue: createMockQueue(),
|
||||
expiryAlertQueue: createMockQueue(),
|
||||
barcodeQueue: createMockQueue(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the queues.server module
|
||||
vi.mock('../services/queues.server', () => mockQueuesModule);
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as dbConnection from '../services/db/connection.db';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
import fs from 'node:fs/promises';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDbConnection = dbConnection as Mocked<typeof dbConnection>;
|
||||
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection> & {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const mockedFs = fs as Mocked<typeof fs>;
|
||||
|
||||
// Cast queues module for test assertions
|
||||
const mockedQueues = mockQueuesModule as {
|
||||
flyerQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
emailQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
analyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
weeklyAnalyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
cleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
tokenCleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
receiptQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
expiryAlertQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
barcodeQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new HealthController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASIC HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
describe('ping()', () => {
|
||||
it('should return a pong response', async () => {
|
||||
const result = await controller.ping();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ message: 'pong' });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// KUBERNETES PROBES (ADR-020)
|
||||
// ==========================================================================
|
||||
|
||||
describe('live()', () => {
|
||||
it('should return ok status with timestamp', async () => {
|
||||
const result = await controller.live();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.status).toBe('ok');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
expect(() => new Date(result.data.timestamp)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ready()', () => {
|
||||
it('should return healthy status when all services are healthy', async () => {
|
||||
// Arrange: Mock all services as healthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.services.database.status).toBe('healthy');
|
||||
expect(result.data.services.redis.status).toBe('healthy');
|
||||
expect(result.data.services.storage.status).toBe('healthy');
|
||||
expect(result.data.uptime).toBeDefined();
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return degraded status when database pool has high waiting count', async () => {
|
||||
// Arrange: Mock database as degraded (waitingCount > 3)
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 2,
|
||||
waitingCount: 5,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('degraded');
|
||||
expect(result.data.services.database.status).toBe('degraded');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when database is unavailable', async () => {
|
||||
// Arrange: Mock database as unhealthy
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('Connection failed')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Service unhealthy');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
services: { database: { status: string; message: string } };
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.services.database.status).toBe('unhealthy');
|
||||
expect(details.services.database.message).toBe('Connection failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when Redis is unavailable', async () => {
|
||||
// Arrange: Mock Redis as unhealthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockRejectedValue(new Error('Redis connection refused'));
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
services: { redis: { status: string; message: string } };
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.services.redis.status).toBe('unhealthy');
|
||||
expect(details.services.redis.message).toBe('Redis connection refused');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy when Redis returns unexpected ping response', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('UNEXPECTED');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
services: { redis: { status: string; message: string } };
|
||||
};
|
||||
expect(details.services.redis.status).toBe('unhealthy');
|
||||
expect(details.services.redis.message).toContain('Unexpected ping response');
|
||||
}
|
||||
});
|
||||
|
||||
it('should still return healthy when storage is unhealthy but critical services are healthy', async () => {
|
||||
// Arrange: Storage unhealthy, but db and redis healthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert: Storage is not critical, so should still be healthy/200
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.services.storage.status).toBe('unhealthy');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle database error with non-Error object', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockRejectedValue('String error') };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as { services: { database: { message: string } } };
|
||||
expect(details.services.database.message).toBe('Database connection failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup()', () => {
|
||||
it('should return started status when database is healthy', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('started');
|
||||
expect(result.data.database.status).toBe('healthy');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when database is unhealthy during startup', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('Database not ready')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Waiting for database connection');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
database: { status: string; message: string };
|
||||
};
|
||||
expect(details.status).toBe('starting');
|
||||
expect(details.database.status).toBe('unhealthy');
|
||||
expect(details.database.message).toBe('Database not ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return started with degraded database when pool has high waiting count', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 2,
|
||||
waitingCount: 5,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert: Degraded is not unhealthy, so startup should succeed
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('started');
|
||||
expect(result.data.database.status).toBe('degraded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INDIVIDUAL SERVICE HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
describe('dbSchema()', () => {
|
||||
it('should return success when all tables exist', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('All required database tables exist.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when tables are missing', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table_1', 'missing_table_2']);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage()', () => {
|
||||
it('should return success when storage is accessible', async () => {
|
||||
// Arrange
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.storage();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('is accessible and writable');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when storage is not accessible', async () => {
|
||||
// Arrange
|
||||
mockedFs.access.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
// Act
|
||||
const result = await controller.storage();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Storage check failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dbPool()', () => {
|
||||
it('should return success for a healthy pool status', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.dbPool();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting');
|
||||
expect(result.data.totalCount).toBe(10);
|
||||
expect(result.data.idleCount).toBe(8);
|
||||
expect(result.data.waitingCount).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error for an unhealthy pool status', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 20,
|
||||
idleCount: 5,
|
||||
waitingCount: 15,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.dbPool();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Pool may be under stress');
|
||||
expect(result.error.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('time()', () => {
|
||||
it('should return current server time, year, and week', async () => {
|
||||
// Arrange
|
||||
const fakeDate = new Date('2024-03-15T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeDate);
|
||||
|
||||
// Act
|
||||
const result = await controller.time();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.currentTime).toBe('2024-03-15T10:30:00.000Z');
|
||||
expect(result.data.year).toBe(2024);
|
||||
expect(result.data.week).toBe(11);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('redis()', () => {
|
||||
it('should return success when Redis ping is successful', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Redis connection is healthy.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when Redis ping fails', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockRejectedValue(new Error('Connection timed out'));
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Connection timed out');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when Redis returns unexpected response', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockResolvedValue('OK');
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Unexpected Redis ping response: OK');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// ==========================================================================
|
||||
|
||||
describe('queues()', () => {
|
||||
// Helper function to set all queue mocks
|
||||
const setAllQueueMocks = (jobCounts: {
|
||||
waiting: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}) => {
|
||||
mockedQueues.flyerQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.emailQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
};
|
||||
|
||||
it('should return healthy status when all queues and workers are healthy', async () => {
|
||||
// Arrange
|
||||
setAllQueueMocks({ waiting: 5, active: 2, failed: 1, delayed: 0 });
|
||||
|
||||
// Mock Redis heartbeat responses (all healthy)
|
||||
const recentTimestamp = new Date(Date.now() - 10000).toISOString();
|
||||
const heartbeatValue = JSON.stringify({
|
||||
timestamp: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
mockedRedisConnection.get.mockResolvedValue(heartbeatValue);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.queues['flyer-processing']).toEqual({
|
||||
waiting: 5,
|
||||
active: 2,
|
||||
failed: 1,
|
||||
delayed: 0,
|
||||
});
|
||||
expect(result.data.workers['flyer-processing']).toEqual({
|
||||
alive: true,
|
||||
lastSeen: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when a queue is unavailable', async () => {
|
||||
// Arrange: flyerQueue fails, others succeed
|
||||
mockedQueues.flyerQueue.getJobCounts.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
mockedQueues.emailQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
|
||||
mockedRedisConnection.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('One or more queues or workers unavailable');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
queues: Record<string, { error?: string }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.queues['flyer-processing']).toEqual({ error: 'Redis connection lost' });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when a worker heartbeat is stale', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
|
||||
// Stale heartbeat (> 60s ago)
|
||||
const staleTimestamp = new Date(Date.now() - 120000).toISOString();
|
||||
const staleHeartbeat = JSON.stringify({
|
||||
timestamp: staleTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
mockedRedisConnection.get.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? staleHeartbeat : null);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
workers: Record<string, { alive: boolean }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when worker heartbeat is missing', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
mockedRedisConnection.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
workers: Record<string, { alive: boolean }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Redis connection errors gracefully for heartbeat checks', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
mockedRedisConnection.get.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert: Heartbeat fetch errors are treated as non-critical
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.workers['flyer-processing']).toEqual({
|
||||
alive: false,
|
||||
error: 'Redis connection lost',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
const result = await controller.ping();
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for consistent error format', async () => {
|
||||
// Arrange: Make database check fail
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table']);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error');
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set HTTP status codes via setStatus', async () => {
|
||||
// Arrange: Make startup probe fail
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('No database')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert: The controller called setStatus(503) internally
|
||||
// We can verify this by checking the result structure is an error
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
673
src/controllers/health.controller.ts
Normal file
673
src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
// src/controllers/health.controller.ts
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides health check endpoints for monitoring the application state,
|
||||
// implementing ADR-020: Health Checks and Liveness/Readiness Probes.
|
||||
//
|
||||
// This controller exposes endpoints for:
|
||||
// - Liveness probe (/live) - Is the server process running?
|
||||
// - Readiness probe (/ready) - Is the server ready to accept traffic?
|
||||
// - Startup probe (/startup) - Has the server completed initialization?
|
||||
// - Individual service health checks (db, redis, storage, queues)
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, SuccessResponse, Response } from 'tsoa';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse, ServiceHealth } from './types';
|
||||
import { getPoolStatus, getPool, checkTablesExist } from '../services/db/connection.db';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
import {
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
cleanupQueue,
|
||||
tokenCleanupQueue,
|
||||
receiptQueue,
|
||||
expiryAlertQueue,
|
||||
barcodeQueue,
|
||||
} from '../services/queues.server';
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// Types for health check responses that will appear in the OpenAPI spec.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple ping response.
|
||||
*/
|
||||
interface PingResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness probe response.
|
||||
*/
|
||||
interface LivenessResponse {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe response with service status.
|
||||
*/
|
||||
interface ReadinessResponse {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
services: {
|
||||
database: ServiceHealth;
|
||||
redis: ServiceHealth;
|
||||
storage: ServiceHealth;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup probe response.
|
||||
*/
|
||||
interface StartupResponse {
|
||||
status: 'started' | 'starting';
|
||||
timestamp: string;
|
||||
database: ServiceHealth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database schema check response.
|
||||
*/
|
||||
interface DbSchemaResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage check response.
|
||||
*/
|
||||
interface StorageResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database pool status response.
|
||||
*/
|
||||
interface DbPoolResponse {
|
||||
message: string;
|
||||
totalCount: number;
|
||||
idleCount: number;
|
||||
waitingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server time response.
|
||||
*/
|
||||
interface TimeResponse {
|
||||
currentTime: string;
|
||||
year: number;
|
||||
week: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis health check response.
|
||||
*/
|
||||
interface RedisHealthResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue job counts.
|
||||
*/
|
||||
interface QueueJobCounts {
|
||||
waiting: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker heartbeat status.
|
||||
*/
|
||||
interface WorkerHeartbeat {
|
||||
alive: boolean;
|
||||
lastSeen?: string;
|
||||
pid?: number;
|
||||
host?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue health response with metrics and worker heartbeats.
|
||||
*/
|
||||
interface QueuesHealthResponse {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
timestamp: string;
|
||||
queues: Record<string, QueueJobCounts | { error: string }>;
|
||||
workers: Record<string, WorkerHeartbeat>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
// Reusable functions for checking service health.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks database connectivity with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with database status and latency
|
||||
*/
|
||||
async function checkDatabase(): Promise<ServiceHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query('SELECT 1');
|
||||
const latency = Date.now() - start;
|
||||
const poolStatus = getPoolStatus();
|
||||
|
||||
// Consider degraded if waiting connections > 3
|
||||
const status = poolStatus.waitingCount > 3 ? 'degraded' : 'healthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
latency,
|
||||
details: {
|
||||
totalConnections: poolStatus.totalCount,
|
||||
idleConnections: poolStatus.idleCount,
|
||||
waitingConnections: poolStatus.waitingCount,
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: error instanceof Error ? error.message : 'Database connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Redis connectivity with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with Redis status and latency
|
||||
*/
|
||||
async function checkRedis(): Promise<ServiceHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (reply === 'PONG') {
|
||||
return { status: 'healthy', latency };
|
||||
}
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency,
|
||||
message: `Unexpected ping response: ${reply}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: error instanceof Error ? error.message : 'Redis connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks storage accessibility with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with storage status and latency
|
||||
*/
|
||||
async function checkStorage(): Promise<ServiceHealth> {
|
||||
const storagePath =
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
const start = Date.now();
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK);
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency: Date.now() - start,
|
||||
details: { path: storagePath },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: `Storage not accessible: ${storagePath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Health check controller for monitoring application state.
|
||||
*
|
||||
* Provides endpoints for Kubernetes liveness/readiness/startup probes
|
||||
* and individual service health checks per ADR-020.
|
||||
*/
|
||||
@Route('health')
|
||||
@Tags('Health')
|
||||
export class HealthController extends BaseController {
|
||||
// ==========================================================================
|
||||
// BASIC HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Simple ping endpoint.
|
||||
*
|
||||
* Returns a pong response to verify server is responsive.
|
||||
* Use this for basic connectivity checks.
|
||||
*
|
||||
* @summary Simple ping endpoint
|
||||
* @returns A pong response confirming the server is alive
|
||||
*/
|
||||
@Get('ping')
|
||||
@SuccessResponse(200, 'Server is responsive')
|
||||
public async ping(): Promise<SuccessResponseType<PingResponse>> {
|
||||
return this.success({ message: 'pong' });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// KUBERNETES PROBES (ADR-020)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Liveness probe.
|
||||
*
|
||||
* Returns 200 OK if the server process is running.
|
||||
* If this fails, the orchestrator should restart the container.
|
||||
* This endpoint is intentionally simple and has no external dependencies.
|
||||
*
|
||||
* @summary Liveness probe
|
||||
* @returns Status indicating the server process is alive
|
||||
*/
|
||||
@Get('live')
|
||||
@SuccessResponse(200, 'Server process is alive')
|
||||
public async live(): Promise<SuccessResponseType<LivenessResponse>> {
|
||||
return this.success({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe.
|
||||
*
|
||||
* Returns 200 OK if the server is ready to accept traffic.
|
||||
* Checks all critical dependencies (database, Redis, storage).
|
||||
* If this fails, the orchestrator should remove the container from the load balancer.
|
||||
*
|
||||
* @summary Readiness probe
|
||||
* @returns Service health status for all critical dependencies
|
||||
*/
|
||||
@Get('ready')
|
||||
@SuccessResponse(200, 'Server is ready to accept traffic')
|
||||
@Response<ErrorResponse>(503, 'Service is unhealthy and should not receive traffic')
|
||||
public async ready(): Promise<SuccessResponseType<ReadinessResponse> | ErrorResponse> {
|
||||
// Check all services in parallel for speed
|
||||
const [database, redis, storage] = await Promise.all([
|
||||
checkDatabase(),
|
||||
checkRedis(),
|
||||
checkStorage(),
|
||||
]);
|
||||
|
||||
// Determine overall status
|
||||
// - 'healthy' if all critical services (db, redis) are healthy
|
||||
// - 'degraded' if any service is degraded but none unhealthy
|
||||
// - 'unhealthy' if any critical service is unhealthy
|
||||
const criticalServices = [database, redis];
|
||||
const allServices = [database, redis, storage];
|
||||
|
||||
let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
|
||||
if (criticalServices.some((s) => s.status === 'unhealthy')) {
|
||||
overallStatus = 'unhealthy';
|
||||
} else if (allServices.some((s) => s.status === 'degraded')) {
|
||||
overallStatus = 'degraded';
|
||||
}
|
||||
|
||||
const response: ReadinessResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
services: {
|
||||
database,
|
||||
redis,
|
||||
storage,
|
||||
},
|
||||
};
|
||||
|
||||
// Return appropriate HTTP status code
|
||||
// 200 = healthy or degraded (can still handle traffic)
|
||||
// 503 = unhealthy (should not receive traffic)
|
||||
if (overallStatus === 'unhealthy') {
|
||||
this.setStatus(503);
|
||||
return this.error(this.ErrorCode.SERVICE_UNAVAILABLE, 'Service unhealthy', response);
|
||||
}
|
||||
return this.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup probe.
|
||||
*
|
||||
* Similar to readiness but used during container startup.
|
||||
* The orchestrator will not send liveness/readiness probes until this succeeds.
|
||||
* This allows for longer initialization times without triggering restarts.
|
||||
*
|
||||
* @summary Startup probe for container orchestration
|
||||
* @returns Startup status with database health
|
||||
*/
|
||||
@Get('startup')
|
||||
@SuccessResponse(200, 'Server has started successfully')
|
||||
@Response<ErrorResponse>(503, 'Server is still starting')
|
||||
public async startup(): Promise<SuccessResponseType<StartupResponse> | ErrorResponse> {
|
||||
// For startup, we only check database connectivity
|
||||
// Redis and storage can be checked later in readiness
|
||||
const database = await checkDatabase();
|
||||
|
||||
if (database.status === 'unhealthy') {
|
||||
this.setStatus(503);
|
||||
return this.error(this.ErrorCode.SERVICE_UNAVAILABLE, 'Waiting for database connection', {
|
||||
status: 'starting',
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
return this.success({
|
||||
status: 'started',
|
||||
timestamp: new Date().toISOString(),
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// INDIVIDUAL SERVICE HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Database schema check.
|
||||
*
|
||||
* Checks if all essential database tables exist.
|
||||
* This is a critical check to ensure the database schema is correctly set up.
|
||||
*
|
||||
* @summary Check database schema
|
||||
* @returns Message confirming all required tables exist
|
||||
*/
|
||||
@Get('db-schema')
|
||||
@SuccessResponse(200, 'All required database tables exist')
|
||||
@Response<ErrorResponse>(500, 'Database schema check failed')
|
||||
public async dbSchema(): Promise<SuccessResponseType<DbSchemaResponse> | ErrorResponse> {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.success({ message: 'All required database tables exist.' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage health check.
|
||||
*
|
||||
* Verifies that the application's file storage path is accessible and writable.
|
||||
* This is important for features like file uploads.
|
||||
*
|
||||
* @summary Check storage accessibility
|
||||
* @returns Message confirming storage is accessible
|
||||
*/
|
||||
@Get('storage')
|
||||
@SuccessResponse(200, 'Storage is accessible and writable')
|
||||
@Response<ErrorResponse>(500, 'Storage check failed')
|
||||
public async storage(): Promise<SuccessResponseType<StorageResponse> | ErrorResponse> {
|
||||
const storagePath =
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK);
|
||||
return this.success({
|
||||
message: `Storage directory '${storagePath}' is accessible and writable.`,
|
||||
});
|
||||
} catch {
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database pool status check.
|
||||
*
|
||||
* Checks the status of the database connection pool.
|
||||
* This helps diagnose issues related to database connection saturation.
|
||||
*
|
||||
* @summary Check database connection pool status
|
||||
* @returns Pool status with connection counts
|
||||
*/
|
||||
@Get('db-pool')
|
||||
@SuccessResponse(200, 'Database pool is healthy')
|
||||
@Response<ErrorResponse>(500, 'Database pool may be under stress')
|
||||
public async dbPool(): Promise<SuccessResponseType<DbPoolResponse> | ErrorResponse> {
|
||||
const status = getPoolStatus();
|
||||
const isHealthy = status.waitingCount < 5;
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
|
||||
if (isHealthy) {
|
||||
return this.success({
|
||||
message,
|
||||
totalCount: status.totalCount,
|
||||
idleCount: status.idleCount,
|
||||
waitingCount: status.waitingCount,
|
||||
});
|
||||
}
|
||||
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Pool may be under stress. ${message}`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server time check.
|
||||
*
|
||||
* Returns the server's current time, year, and week number.
|
||||
* Useful for verifying time synchronization and for features dependent on week numbers.
|
||||
*
|
||||
* @summary Get server time and week number
|
||||
* @returns Current server time with year and week number
|
||||
*/
|
||||
@Get('time')
|
||||
@SuccessResponse(200, 'Server time retrieved')
|
||||
public async time(): Promise<SuccessResponseType<TimeResponse>> {
|
||||
const now = new Date();
|
||||
const { year, week } = getSimpleWeekAndYear(now);
|
||||
return this.success({
|
||||
currentTime: now.toISOString(),
|
||||
year,
|
||||
week,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis health check.
|
||||
*
|
||||
* Checks the health of the Redis connection.
|
||||
*
|
||||
* @summary Check Redis connectivity
|
||||
* @returns Message confirming Redis is healthy
|
||||
*/
|
||||
@Get('redis')
|
||||
@SuccessResponse(200, 'Redis connection is healthy')
|
||||
@Response<ErrorResponse>(500, 'Redis health check failed')
|
||||
public async redis(): Promise<SuccessResponseType<RedisHealthResponse> | ErrorResponse> {
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
if (reply === 'PONG') {
|
||||
return this.success({ message: 'Redis connection is healthy.' });
|
||||
}
|
||||
throw new Error(`Unexpected Redis ping response: ${reply}`);
|
||||
} catch (error) {
|
||||
this.setStatus(500);
|
||||
const message = error instanceof Error ? error.message : 'Redis health check failed';
|
||||
return this.error(this.ErrorCode.INTERNAL_ERROR, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Queue health and metrics with worker heartbeats.
|
||||
*
|
||||
* Returns job counts for all BullMQ queues and worker heartbeat status.
|
||||
* Use this endpoint to monitor queue depths and detect stuck/frozen workers.
|
||||
* Implements ADR-053: Worker Health Checks and Stalled Job Monitoring.
|
||||
*
|
||||
* @summary Queue health and metrics
|
||||
* @returns Queue metrics and worker heartbeat status
|
||||
*/
|
||||
@Get('queues')
|
||||
@SuccessResponse(200, 'Queue metrics retrieved successfully')
|
||||
@Response<ErrorResponse>(503, 'One or more queues or workers unavailable')
|
||||
public async queues(): Promise<SuccessResponseType<QueuesHealthResponse> | ErrorResponse> {
|
||||
// Define all queues to monitor
|
||||
const queues = [
|
||||
{ name: 'flyer-processing', queue: flyerQueue },
|
||||
{ name: 'email-sending', queue: emailQueue },
|
||||
{ name: 'analytics-reporting', queue: analyticsQueue },
|
||||
{ name: 'weekly-analytics-reporting', queue: weeklyAnalyticsQueue },
|
||||
{ name: 'file-cleanup', queue: cleanupQueue },
|
||||
{ name: 'token-cleanup', queue: tokenCleanupQueue },
|
||||
{ name: 'receipt-processing', queue: receiptQueue },
|
||||
{ name: 'expiry-alerts', queue: expiryAlertQueue },
|
||||
{ name: 'barcode-detection', queue: barcodeQueue },
|
||||
];
|
||||
|
||||
// Fetch job counts for all queues in parallel
|
||||
const queueMetrics = await Promise.all(
|
||||
queues.map(async ({ name, queue }) => {
|
||||
try {
|
||||
const counts = await queue.getJobCounts();
|
||||
return {
|
||||
name,
|
||||
counts: {
|
||||
waiting: counts.waiting || 0,
|
||||
active: counts.active || 0,
|
||||
failed: counts.failed || 0,
|
||||
delayed: counts.delayed || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If individual queue fails, return error state
|
||||
return {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch worker heartbeats in parallel
|
||||
const workerNames = queues.map((q) => q.name);
|
||||
const workerHeartbeats = await Promise.all(
|
||||
workerNames.map(async (name) => {
|
||||
try {
|
||||
const key = `worker:heartbeat:${name}`;
|
||||
const value = await redisConnection.get(key);
|
||||
|
||||
if (!value) {
|
||||
return { name, alive: false };
|
||||
}
|
||||
|
||||
const heartbeat = JSON.parse(value) as {
|
||||
timestamp: string;
|
||||
pid: number;
|
||||
host: string;
|
||||
};
|
||||
const lastSeenMs = new Date(heartbeat.timestamp).getTime();
|
||||
const nowMs = Date.now();
|
||||
const ageSeconds = (nowMs - lastSeenMs) / 1000;
|
||||
|
||||
// Consider alive if last heartbeat < 60 seconds ago
|
||||
const alive = ageSeconds < 60;
|
||||
|
||||
return {
|
||||
name,
|
||||
alive,
|
||||
lastSeen: heartbeat.timestamp,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
} catch (error) {
|
||||
// If heartbeat check fails, mark as unknown
|
||||
return {
|
||||
name,
|
||||
alive: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Build response objects
|
||||
const queuesData: Record<string, QueueJobCounts | { error: string }> = {};
|
||||
const workersData: Record<string, WorkerHeartbeat> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
for (const metric of queueMetrics) {
|
||||
if ('error' in metric && metric.error) {
|
||||
queuesData[metric.name] = { error: metric.error };
|
||||
hasErrors = true;
|
||||
} else if ('counts' in metric && metric.counts) {
|
||||
queuesData[metric.name] = metric.counts;
|
||||
}
|
||||
}
|
||||
|
||||
for (const heartbeat of workerHeartbeats) {
|
||||
if ('error' in heartbeat && heartbeat.error) {
|
||||
workersData[heartbeat.name] = { alive: false, error: heartbeat.error };
|
||||
} else if (!heartbeat.alive) {
|
||||
workersData[heartbeat.name] = { alive: false };
|
||||
hasErrors = true;
|
||||
} else {
|
||||
workersData[heartbeat.name] = {
|
||||
alive: heartbeat.alive,
|
||||
lastSeen: heartbeat.lastSeen,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response: QueuesHealthResponse = {
|
||||
status: hasErrors ? 'unhealthy' : 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
queues: queuesData,
|
||||
workers: workersData,
|
||||
};
|
||||
|
||||
if (hasErrors) {
|
||||
this.setStatus(503);
|
||||
return this.error(
|
||||
this.ErrorCode.SERVICE_UNAVAILABLE,
|
||||
'One or more queues or workers unavailable',
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
return this.success(response);
|
||||
}
|
||||
}
|
||||
616
src/controllers/inventory.controller.test.ts
Normal file
616
src/controllers/inventory.controller.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
// src/controllers/inventory.controller.test.ts
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the InventoryController class. These tests verify controller
|
||||
// logic in isolation by mocking the expiry service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock expiry service
|
||||
vi.mock('../services/expiryService.server', () => ({
|
||||
getInventory: vi.fn(),
|
||||
addInventoryItem: vi.fn(),
|
||||
getExpiringItemsGrouped: vi.fn(),
|
||||
getExpiringItems: vi.fn(),
|
||||
getExpiredItems: vi.fn(),
|
||||
getAlertSettings: vi.fn(),
|
||||
updateAlertSettings: vi.fn(),
|
||||
getRecipeSuggestionsForExpiringItems: vi.fn(),
|
||||
getInventoryItemById: vi.fn(),
|
||||
updateInventoryItem: vi.fn(),
|
||||
deleteInventoryItem: vi.fn(),
|
||||
markItemConsumed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedExpiryService = expiryService as Mocked<typeof expiryService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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.
|
||||
*/
|
||||
function createMockInventoryItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('InventoryController', () => {
|
||||
let controller: InventoryController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new InventoryController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getInventory()', () => {
|
||||
it('should return inventory items with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockInventoryItem()],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventory(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'test-user-id',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support filtering by location', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(request, 50, 0, 'refrigerator');
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ location: 'refrigerator' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support search parameter', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(
|
||||
request,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'milk',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: 'milk' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addInventoryItem()', () => {
|
||||
it('should add an inventory item', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item_name).toBe('Milk');
|
||||
}
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ userId: 'test-user-id', itemName: 'Milk' },
|
||||
'Adding item to inventory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// EXPIRING ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getExpiringSummary()', () => {
|
||||
it('should return expiring items grouped by urgency', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
expired: [],
|
||||
expiring_today: [],
|
||||
expiring_this_week: [createMockInventoryItem()],
|
||||
expiring_this_month: [],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItemsGrouped.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiringSummary(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedExpiryService.getExpiringItemsGrouped).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiringItems()', () => {
|
||||
it('should return expiring items with default 7 days', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockInventoryItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiringItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
7,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap days at 90', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getExpiringItems(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
90,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor days at 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getExpiringItems(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiredItems()', () => {
|
||||
it('should return expired items', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockInventoryItem({ expiry_date: '2023-12-01' })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiredItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiredItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ALERT SETTINGS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAlertSettings()', () => {
|
||||
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 },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getAlertSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAlertSettings(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAlertSettings()', () => {
|
||||
it('should update alert settings', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { alert_method: 'email', days_before_expiry: 5, is_enabled: true };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.updateAlertSettings.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateAlertSettings('email', request, {
|
||||
days_before_expiry: 5,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.days_before_expiry).toBe(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECIPE SUGGESTIONS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipeSuggestions()', () => {
|
||||
it('should return recipe suggestions for expiring items', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
recipes: [{ recipe_id: 1, name: 'Test Recipe' }],
|
||||
total: 1,
|
||||
considered_items: [createMockInventoryItem()],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getRecipeSuggestionsForExpiringItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeSuggestions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipes).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should normalize pagination parameters', async () => {
|
||||
// Arrange
|
||||
const mockResult = { recipes: [], total: 0, considered_items: [] };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getRecipeSuggestionsForExpiringItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getRecipeSuggestions(request, 100, 100, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getRecipeSuggestionsForExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
90, // days capped at 90
|
||||
expect.anything(),
|
||||
{ limit: 50, offset: 100 }, // limit capped at 50
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM BY ID ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getInventoryItemById()', () => {
|
||||
it('should return an inventory item by ID', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventoryItemById.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventoryItemById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.inventory_id).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInventoryItem()', () => {
|
||||
it('should update an inventory item', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem({ quantity: 2 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.updateInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateInventoryItem(1, request, { quantity: 2 });
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.quantity).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateInventoryItem(1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteInventoryItem()', () => {
|
||||
it('should delete an inventory item', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.deleteInventoryItem.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteInventoryItem(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExpiryService.deleteInventoryItem).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markItemConsumed()', () => {
|
||||
it('should mark an item as consumed', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.markItemConsumed.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.markItemConsumed(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExpiryService.markItemConsumed).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventoryItemById.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventoryItemById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Test',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.deleteInventoryItem.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteInventoryItem(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
535
src/controllers/inventory.controller.ts
Normal file
535
src/controllers/inventory.controller.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/controllers/inventory.controller.ts
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing pantry inventory, expiry tracking, and alerts.
|
||||
// All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type {
|
||||
UserInventoryItem,
|
||||
StorageLocation,
|
||||
InventorySource,
|
||||
ExpiryAlertSettings,
|
||||
ExpiringItemsResponse,
|
||||
AlertMethod,
|
||||
} from '../types/expiry';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for adding an inventory item.
|
||||
*/
|
||||
interface AddInventoryItemRequest {
|
||||
/** Link to products table */
|
||||
product_id?: number;
|
||||
/** Link to master grocery items */
|
||||
master_item_id?: number;
|
||||
/**
|
||||
* Item name (required)
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
item_name: string;
|
||||
/** Quantity of item (default: 1) */
|
||||
quantity?: number;
|
||||
/**
|
||||
* Unit of measurement
|
||||
* @maxLength 50
|
||||
*/
|
||||
unit?: string;
|
||||
/** When the item was purchased (YYYY-MM-DD format) */
|
||||
purchase_date?: string;
|
||||
/** Expected expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** How the item is being added */
|
||||
source: InventorySource;
|
||||
/** Where the item will be stored */
|
||||
location?: StorageLocation;
|
||||
/**
|
||||
* User notes
|
||||
* @maxLength 500
|
||||
*/
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating an inventory item.
|
||||
* At least one field must be provided.
|
||||
*/
|
||||
interface UpdateInventoryItemRequest {
|
||||
/** Updated quantity */
|
||||
quantity?: number;
|
||||
/**
|
||||
* Updated unit
|
||||
* @maxLength 50
|
||||
*/
|
||||
unit?: string;
|
||||
/** Updated expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** Updated storage location */
|
||||
location?: StorageLocation;
|
||||
/**
|
||||
* Updated notes
|
||||
* @maxLength 500
|
||||
*/
|
||||
notes?: string;
|
||||
/** Mark as consumed */
|
||||
is_consumed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating alert settings.
|
||||
*/
|
||||
interface UpdateAlertSettingsRequest {
|
||||
/**
|
||||
* Days before expiry to send alert
|
||||
* @minimum 1
|
||||
* @maximum 30
|
||||
*/
|
||||
days_before_expiry?: number;
|
||||
/** Whether this alert type is enabled */
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for expiring items list.
|
||||
*/
|
||||
interface ExpiringItemsListResponse {
|
||||
/** Array of expiring items */
|
||||
items: UserInventoryItem[];
|
||||
/** Total count of items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for recipe suggestions.
|
||||
*/
|
||||
interface RecipeSuggestionsResponse {
|
||||
/** Recipes that use expiring items */
|
||||
recipes: unknown[];
|
||||
/** Total count for pagination */
|
||||
total: number;
|
||||
/** Items considered for matching */
|
||||
considered_items: UserInventoryItem[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing pantry inventory and expiry tracking.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own inventory - the user ID is extracted from the JWT token.
|
||||
*/
|
||||
@Route('inventory')
|
||||
@Tags('Inventory')
|
||||
@Security('bearerAuth')
|
||||
export class InventoryController extends BaseController {
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get inventory items.
|
||||
*
|
||||
* Retrieves the user's pantry inventory with optional filtering and pagination.
|
||||
*
|
||||
* @summary Get inventory items
|
||||
* @param request Express request with authenticated user
|
||||
* @param limit Maximum number of items to return (default: 50, max: 100)
|
||||
* @param offset Number of items to skip for pagination (default: 0)
|
||||
* @param location Filter by storage location
|
||||
* @param is_consumed Filter by consumed status
|
||||
* @param expiring_within_days Filter items expiring within N days
|
||||
* @param category_id Filter by category ID
|
||||
* @param search Search by item name
|
||||
* @param sort_by Sort field
|
||||
* @param sort_order Sort direction
|
||||
* @returns List of inventory items
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Inventory items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getInventory(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
@Query() location?: StorageLocation,
|
||||
@Query() is_consumed?: boolean,
|
||||
@Query() expiring_within_days?: number,
|
||||
@Query() category_id?: number,
|
||||
@Query() search?: string,
|
||||
@Query() sort_by?: 'expiry_date' | 'purchase_date' | 'item_name' | 'created_at',
|
||||
@Query() sort_order?: 'asc' | 'desc',
|
||||
): Promise<SuccessResponseType<unknown>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize pagination parameters
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await expiryService.getInventory(
|
||||
{
|
||||
user_id: userProfile.user.user_id,
|
||||
location,
|
||||
is_consumed,
|
||||
expiring_within_days,
|
||||
category_id,
|
||||
search,
|
||||
limit: normalizedLimit,
|
||||
offset: normalizedOffset,
|
||||
sort_by,
|
||||
sort_order,
|
||||
},
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add inventory item.
|
||||
*
|
||||
* Add a new item to the user's pantry inventory.
|
||||
*
|
||||
* @summary Add inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Item data
|
||||
* @returns The created inventory item
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Item added to inventory')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async addInventoryItem(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: AddInventoryItemRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
request.log.info(
|
||||
{ userId: userProfile.user.user_id, itemName: body.item_name },
|
||||
'Adding item to inventory',
|
||||
);
|
||||
|
||||
const item = await expiryService.addInventoryItem(userProfile.user.user_id, body, request.log);
|
||||
return this.created(item);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EXPIRING ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get expiring items summary.
|
||||
*
|
||||
* Get items grouped by expiry urgency (today, this week, this month, expired).
|
||||
*
|
||||
* @summary Get expiring items summary
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Expiring items grouped by urgency with counts
|
||||
*/
|
||||
@Get('expiring/summary')
|
||||
@SuccessResponse(200, 'Expiring items grouped by urgency')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiringSummary(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiringItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const result = await expiryService.getExpiringItemsGrouped(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiring items.
|
||||
*
|
||||
* Get items expiring within a specified number of days.
|
||||
*
|
||||
* @summary Get expiring items
|
||||
* @param request Express request with authenticated user
|
||||
* @param days Number of days to look ahead (1-90, default: 7)
|
||||
* @returns List of expiring items
|
||||
*/
|
||||
@Get('expiring')
|
||||
@SuccessResponse(200, 'Expiring items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiringItems(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() days?: number,
|
||||
): Promise<SuccessResponseType<ExpiringItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize days parameter: default 7, min 1, max 90
|
||||
const normalizedDays = Math.min(90, Math.max(1, Math.floor(days ?? 7)));
|
||||
|
||||
const items = await expiryService.getExpiringItems(
|
||||
userProfile.user.user_id,
|
||||
normalizedDays,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired items.
|
||||
*
|
||||
* Get all items that have already expired.
|
||||
*
|
||||
* @summary Get expired items
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of expired items
|
||||
*/
|
||||
@Get('expired')
|
||||
@SuccessResponse(200, 'Expired items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiredItems(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiringItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const items = await expiryService.getExpiredItems(userProfile.user.user_id, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ALERT SETTINGS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get alert settings.
|
||||
*
|
||||
* Get the user's expiry alert settings for all notification methods.
|
||||
*
|
||||
* @summary Get alert settings
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Alert settings for all methods
|
||||
*/
|
||||
@Get('alerts')
|
||||
@SuccessResponse(200, 'Alert settings retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getAlertSettings(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiryAlertSettings[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const settings = await expiryService.getAlertSettings(userProfile.user.user_id, request.log);
|
||||
return this.success(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*
|
||||
* Update alert settings for a specific notification method.
|
||||
*
|
||||
* @summary Update alert settings
|
||||
* @param alertMethod The notification method to update (email, push, in_app)
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Settings to update
|
||||
* @returns Updated alert settings
|
||||
*/
|
||||
@Put('alerts/{alertMethod}')
|
||||
@SuccessResponse(200, 'Alert settings updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async updateAlertSettings(
|
||||
@Path() alertMethod: AlertMethod,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateAlertSettingsRequest,
|
||||
): Promise<SuccessResponseType<ExpiryAlertSettings>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const settings = await expiryService.updateAlertSettings(
|
||||
userProfile.user.user_id,
|
||||
alertMethod,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(settings);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// RECIPE SUGGESTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipe suggestions for expiring items.
|
||||
*
|
||||
* Get recipes that use items expiring soon to reduce food waste.
|
||||
*
|
||||
* @summary Get recipe suggestions for expiring items
|
||||
* @param request Express request with authenticated user
|
||||
* @param days Consider items expiring within this many days (1-90, default: 7)
|
||||
* @param limit Maximum number of recipes to return (1-50, default: 10)
|
||||
* @param offset Number of recipes to skip for pagination (default: 0)
|
||||
* @returns Recipe suggestions with matching expiring items
|
||||
*/
|
||||
@Get('recipes/suggestions')
|
||||
@SuccessResponse(200, 'Recipe suggestions retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getRecipeSuggestions(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() days?: number,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<RecipeSuggestionsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize parameters
|
||||
const normalizedDays = Math.min(90, Math.max(1, Math.floor(days ?? 7)));
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await expiryService.getRecipeSuggestionsForExpiringItems(
|
||||
userProfile.user.user_id,
|
||||
normalizedDays,
|
||||
request.log,
|
||||
{ limit: normalizedLimit, offset: normalizedOffset },
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM BY ID ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get inventory item by ID.
|
||||
*
|
||||
* Retrieve a specific inventory item.
|
||||
*
|
||||
* @summary Get inventory item by ID
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @returns The inventory item
|
||||
*/
|
||||
@Get('{inventoryId}')
|
||||
@SuccessResponse(200, 'Inventory item retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async getInventoryItemById(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const item = await expiryService.getInventoryItemById(
|
||||
inventoryId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inventory item.
|
||||
*
|
||||
* Update an existing inventory item. At least one field must be provided.
|
||||
*
|
||||
* @summary Update inventory item
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated inventory item
|
||||
*/
|
||||
@Put('{inventoryId}')
|
||||
@SuccessResponse(200, 'Item updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async updateInventoryItem(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateInventoryItemRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
const item = await expiryService.updateInventoryItem(
|
||||
inventoryId,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete inventory item.
|
||||
*
|
||||
* Remove an item from the user's inventory.
|
||||
*
|
||||
* @summary Delete inventory item
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{inventoryId}')
|
||||
@SuccessResponse(204, 'Item deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async deleteInventoryItem(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await expiryService.deleteInventoryItem(inventoryId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark item as consumed.
|
||||
*
|
||||
* Mark an inventory item as consumed.
|
||||
*
|
||||
* @summary Mark item as consumed
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Post('{inventoryId}/consume')
|
||||
@SuccessResponse(204, 'Item marked as consumed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async markItemConsumed(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await expiryService.markItemConsumed(inventoryId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
476
src/controllers/personalization.controller.test.ts
Normal file
476
src/controllers/personalization.controller.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
// src/controllers/personalization.controller.test.ts
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the PersonalizationController class. These tests verify
|
||||
// controller logic in isolation by mocking the personalization repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock personalization repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
personalizationRepo: {
|
||||
getAllMasterItems: vi.fn(),
|
||||
getDietaryRestrictions: vi.fn(),
|
||||
getAppliances: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { PersonalizationController } from './personalization.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedPersonalizationRepo = db.personalizationRepo as Mocked<typeof db.personalizationRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
res: {
|
||||
set: vi.fn(),
|
||||
},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock master grocery item.
|
||||
*/
|
||||
function createMockMasterItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
name: 'Milk 2%',
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
typical_shelf_life_days: 14,
|
||||
storage_recommendation: 'refrigerator',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock dietary restriction.
|
||||
*/
|
||||
function createMockDietaryRestriction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
restriction_id: 1,
|
||||
name: 'Vegetarian',
|
||||
description: 'No meat or fish',
|
||||
icon: 'leaf',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock appliance.
|
||||
*/
|
||||
function createMockAppliance(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
appliance_id: 1,
|
||||
name: 'Air Fryer',
|
||||
icon: 'air-fryer',
|
||||
category: 'cooking',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('PersonalizationController', () => {
|
||||
let controller: PersonalizationController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new PersonalizationController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// MASTER ITEMS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMasterItems()', () => {
|
||||
it('should return master items without pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockMasterItem(), createMockMasterItem({ master_item_id: 2, name: 'Bread' })],
|
||||
total: 2,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
undefined, // no limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should support pagination with limit and offset', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockMasterItem()],
|
||||
total: 100,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 50, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
50,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 500', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 1000, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
500,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 0, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor offset at 0', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 50, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
50,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
const mockResult = { items: [], total: 0 };
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
|
||||
it('should log request details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
const mockResult = { items: [], total: 0 };
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 100, 50);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
}),
|
||||
'Fetching master items list from database...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DIETARY RESTRICTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getDietaryRestrictions()', () => {
|
||||
it('should return dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const mockRestrictions = [
|
||||
createMockDietaryRestriction(),
|
||||
createMockDietaryRestriction({ restriction_id: 2, name: 'Vegan' }),
|
||||
createMockDietaryRestriction({ restriction_id: 3, name: 'Gluten-Free' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue(mockRestrictions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Vegetarian');
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getDietaryRestrictions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no restrictions exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// APPLIANCES ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAppliances()', () => {
|
||||
it('should return appliances', async () => {
|
||||
// Arrange
|
||||
const mockAppliances = [
|
||||
createMockAppliance(),
|
||||
createMockAppliance({ appliance_id: 2, name: 'Instant Pot' }),
|
||||
createMockAppliance({ appliance_id: 3, name: 'Stand Mixer' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue(mockAppliances);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Air Fryer');
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getAppliances).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no appliances exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication for master items', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [createMockMasterItem()], total: 1 };
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should work without user authentication for dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should work without user authentication for appliances', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/controllers/personalization.controller.ts
Normal file
150
src/controllers/personalization.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// src/controllers/personalization.controller.ts
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for personalization data including master grocery items,
|
||||
// dietary restrictions, and kitchen appliances. These are public endpoints
|
||||
// used by the frontend for dropdown/selection components.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Query, Request, SuccessResponse, Middlewares } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import type { MasterGroceryItem, DietaryRestriction, Appliance } from '../types';
|
||||
import { publicReadLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for paginated master items list.
|
||||
*/
|
||||
interface MasterItemsResponse {
|
||||
/** Array of master grocery items */
|
||||
items: MasterGroceryItem[];
|
||||
/** Total count of all items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for personalization reference data.
|
||||
*
|
||||
* All endpoints are public and do not require authentication.
|
||||
* Data is used for dropdown/selection components in the UI.
|
||||
*
|
||||
* Responses are cached for 1 hour (Cache-Control header) as this
|
||||
* reference data changes infrequently.
|
||||
*/
|
||||
@Route('personalization')
|
||||
@Tags('Personalization')
|
||||
export class PersonalizationController extends BaseController {
|
||||
// ==========================================================================
|
||||
// MASTER ITEMS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get master items list.
|
||||
*
|
||||
* Get the master list of all grocery items with optional pagination.
|
||||
* Response is cached for 1 hour as this data changes infrequently.
|
||||
*
|
||||
* @summary Get master items list
|
||||
* @param request Express request for logging and response headers
|
||||
* @param limit Maximum number of items to return (max: 500). If omitted, returns all items.
|
||||
* @param offset Number of items to skip for pagination (default: 0)
|
||||
* @returns Paginated list of master grocery items with total count
|
||||
*/
|
||||
@Get('master-items')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of master grocery items with total count')
|
||||
public async getMasterItems(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<MasterItemsResponse>> {
|
||||
// Normalize parameters
|
||||
const normalizedLimit =
|
||||
limit !== undefined ? Math.min(500, Math.max(1, Math.floor(limit))) : undefined;
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
// Log database call for tracking
|
||||
request.log.info(
|
||||
{ limit: normalizedLimit, offset: normalizedOffset },
|
||||
'Fetching master items list from database...',
|
||||
);
|
||||
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const result = await db.personalizationRepo.getAllMasterItems(
|
||||
request.log,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DIETARY RESTRICTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get dietary restrictions.
|
||||
*
|
||||
* Get the master list of all available dietary restrictions.
|
||||
* Response is cached for 1 hour.
|
||||
*
|
||||
* @summary Get dietary restrictions
|
||||
* @param request Express request for logging and response headers
|
||||
* @returns List of all dietary restrictions
|
||||
*/
|
||||
@Get('dietary-restrictions')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all dietary restrictions')
|
||||
public async getDietaryRestrictions(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<DietaryRestriction[]>> {
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const restrictions = await db.personalizationRepo.getDietaryRestrictions(request.log);
|
||||
return this.success(restrictions);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// APPLIANCES ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get kitchen appliances.
|
||||
*
|
||||
* Get the master list of all available kitchen appliances.
|
||||
* Response is cached for 1 hour.
|
||||
*
|
||||
* @summary Get kitchen appliances
|
||||
* @param request Express request for logging and response headers
|
||||
* @returns List of all kitchen appliances
|
||||
*/
|
||||
@Get('appliances')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all kitchen appliances')
|
||||
public async getAppliances(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Appliance[]>> {
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const appliances = await db.personalizationRepo.getAppliances(request.log);
|
||||
return this.success(appliances);
|
||||
}
|
||||
}
|
||||
43
src/controllers/placeholder.controller.ts
Normal file
43
src/controllers/placeholder.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Placeholder controller for tsoa configuration verification.
|
||||
*
|
||||
* This minimal controller exists only to verify that tsoa is correctly configured.
|
||||
* It should be removed once actual controllers are implemented.
|
||||
*
|
||||
* @see ADR-055 for the OpenAPI specification migration plan
|
||||
*/
|
||||
import { Controller, Get, Route, Tags } from 'tsoa';
|
||||
|
||||
/**
|
||||
* Placeholder response type for configuration verification.
|
||||
*/
|
||||
interface PlaceholderResponse {
|
||||
message: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder controller for verifying tsoa configuration.
|
||||
*
|
||||
* This controller is temporary and should be removed once actual
|
||||
* API controllers are implemented using tsoa decorators.
|
||||
*/
|
||||
@Route('_tsoa')
|
||||
@Tags('Internal')
|
||||
export class PlaceholderController extends Controller {
|
||||
/**
|
||||
* Verify tsoa configuration is working.
|
||||
*
|
||||
* This endpoint exists only for configuration verification and
|
||||
* should be removed in production.
|
||||
*
|
||||
* @returns A simple message confirming tsoa is configured
|
||||
*/
|
||||
@Get('verify')
|
||||
public async verify(): Promise<PlaceholderResponse> {
|
||||
return {
|
||||
message: 'tsoa is correctly configured',
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
393
src/controllers/price.controller.test.ts
Normal file
393
src/controllers/price.controller.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// src/controllers/price.controller.test.ts
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the PriceController class. These tests verify controller
|
||||
// logic in isolation by mocking the price repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock price repository
|
||||
vi.mock('../services/db/price.db', () => ({
|
||||
priceRepo: {
|
||||
getPriceHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
import { PriceController } from './price.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedPriceRepo = priceRepo as Mocked<typeof priceRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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 price history data point.
|
||||
*/
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('PriceController', () => {
|
||||
let controller: PriceController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new PriceController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GET PRICE HISTORY
|
||||
// ==========================================================================
|
||||
|
||||
describe('getPriceHistory()', () => {
|
||||
it('should return price history for specified items', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08', price_cents: 399 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2, price_cents: 450 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].price_cents).toBe(350);
|
||||
}
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2],
|
||||
expect.anything(),
|
||||
1000, // default limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default limit and offset when not provided', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith([1], expect.anything(), 1000, 0);
|
||||
});
|
||||
|
||||
it('should use custom limit and offset', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
limit: 500,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
500,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when masterItemIds is empty', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when masterItemIds is not an array', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: null as unknown as number[],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalize limit to at least 1', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
1, // floored to 1
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize offset to at least 0', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
offset: -10,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
1000,
|
||||
0, // floored to 0
|
||||
);
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2, 3],
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
itemCount: 3,
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
}),
|
||||
'[API /price-history] Received request for historical price data.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no price history exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle single item request', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith([1], expect.anything(), 1000, 0);
|
||||
});
|
||||
|
||||
it('should handle multiple items request', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData({ master_item_id: 1 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2 }),
|
||||
createMockPriceHistoryData({ master_item_id: 3 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2, 3, 4, 5],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2, 3, 4, 5],
|
||||
expect.anything(),
|
||||
1000,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for validation errors', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/controllers/price.controller.ts
Normal file
113
src/controllers/price.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// src/controllers/price.controller.ts
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving historical price data for grocery items.
|
||||
// Used for price trend analysis and charting.
|
||||
//
|
||||
// All endpoints require authentication.
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Post, Route, Tags, Security, Body, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
import type { PriceHistoryData } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for fetching price history.
|
||||
*/
|
||||
interface PriceHistoryRequest {
|
||||
/**
|
||||
* Array of master item IDs to get price history for.
|
||||
* Must be a non-empty array of positive integers.
|
||||
*/
|
||||
masterItemIds: number[];
|
||||
/**
|
||||
* Maximum number of price points to return.
|
||||
* @default 1000
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Number of price points to skip.
|
||||
* @default 0
|
||||
*/
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving price history data.
|
||||
*
|
||||
* All endpoints require JWT authentication. Price history is fetched
|
||||
* for specified master grocery items, useful for trend analysis and charting.
|
||||
*/
|
||||
@Route('price-history')
|
||||
@Tags('Price')
|
||||
@Security('bearerAuth')
|
||||
export class PriceController extends BaseController {
|
||||
// ==========================================================================
|
||||
// GET PRICE HISTORY
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get price history for specified items.
|
||||
*
|
||||
* Fetches historical price data for a given list of master item IDs.
|
||||
* Returns the price in cents and the start date of each flyer where
|
||||
* the item appeared, ordered by master_item_id and date ascending.
|
||||
*
|
||||
* Use POST instead of GET because the list of item IDs can be large
|
||||
* and would exceed URL length limits as query parameters.
|
||||
*
|
||||
* @summary Get price history
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Request body with master item IDs and optional pagination
|
||||
* @returns Historical price data for specified items
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(200, 'Historical price data for specified items')
|
||||
@Response<ErrorResponse>(400, 'Validation error - masterItemIds must be a non-empty array')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getPriceHistory(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: PriceHistoryRequest,
|
||||
): Promise<SuccessResponseType<PriceHistoryData[]>> {
|
||||
const { masterItemIds, limit = 1000, offset = 0 } = body;
|
||||
|
||||
// Validate masterItemIds
|
||||
if (!Array.isArray(masterItemIds) || masterItemIds.length === 0) {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.VALIDATION_ERROR,
|
||||
'masterItemIds must be a non-empty array of positive integers.',
|
||||
) as unknown as SuccessResponseType<PriceHistoryData[]>;
|
||||
}
|
||||
|
||||
// Normalize limit and offset
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
||||
|
||||
request.log.info(
|
||||
{ itemCount: masterItemIds.length, limit: normalizedLimit, offset: normalizedOffset },
|
||||
'[API /price-history] Received request for historical price data.',
|
||||
);
|
||||
|
||||
const priceHistory = await priceRepo.getPriceHistory(
|
||||
masterItemIds,
|
||||
request.log,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
);
|
||||
return this.success(priceHistory);
|
||||
}
|
||||
}
|
||||
531
src/controllers/reactions.controller.test.ts
Normal file
531
src/controllers/reactions.controller.test.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
// src/controllers/reactions.controller.test.ts
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the ReactionsController class. These tests verify controller
|
||||
// logic in isolation by mocking the reaction repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock reaction repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
reactionRepo: {
|
||||
getReactions: vi.fn(),
|
||||
getReactionSummary: vi.fn(),
|
||||
toggleReaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
reactionToggleLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { reactionRepo } from '../services/db/index.db';
|
||||
import { ReactionsController } from './reactions.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedReactionRepo = reactionRepo as Mocked<typeof reactionRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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 user reaction.
|
||||
*/
|
||||
function createMockReaction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reaction_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock reaction summary entry.
|
||||
*/
|
||||
function createMockReactionSummary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reaction_type: 'like',
|
||||
count: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('ReactionsController', () => {
|
||||
let controller: ReactionsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new ReactionsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReactions()', () => {
|
||||
it('should return reactions without filters', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [
|
||||
createMockReaction(),
|
||||
createMockReaction({ reaction_id: 2, reaction_type: 'love' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: undefined, entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, 'user-123');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: 'user-123', entityType: undefined, entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by entityType', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, undefined, 'recipe');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: 'recipe', entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by entityId', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, undefined, undefined, '123');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: undefined, entityId: '123' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support multiple filters', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, 'user-123', 'recipe', '456');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: 'user-123', entityType: 'recipe', entityId: '456' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no reactions exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReactionSummary()', () => {
|
||||
it('should return reaction summary for an entity', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [
|
||||
createMockReactionSummary(),
|
||||
createMockReactionSummary({ reaction_type: 'love', count: 5 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].reaction_type).toBe('like');
|
||||
expect(result.data[0].count).toBe(10);
|
||||
}
|
||||
expect(mockedReactionRepo.getReactionSummary).toHaveBeenCalledWith(
|
||||
'recipe',
|
||||
'123',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no reactions exist for entity', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '999');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with different entity types', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [createMockReactionSummary()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
await controller.getReactionSummary(request, 'comment', '456');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactionSummary).toHaveBeenCalledWith(
|
||||
'comment',
|
||||
'456',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [createMockReactionSummary()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('toggleReaction()', () => {
|
||||
it('should add reaction when it does not exist', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction added.');
|
||||
expect((result.data as { reaction: typeof mockReaction }).reaction).toEqual(mockReaction);
|
||||
}
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
{
|
||||
user_id: 'test-user-id',
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove reaction when it already exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction removed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: 'custom-user-id' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support different reaction types', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction({ reaction_type: 'love' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'love',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reaction_type: 'love' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support different entity types', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction({ entity_type: 'comment' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
await controller.toggleReaction(request, {
|
||||
entity_type: 'comment',
|
||||
entity_id: '456',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entity_type: 'comment', entity_id: '456' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should set 201 status when reaction is added', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction added.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set 200 status when reaction is removed', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction removed.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
204
src/controllers/reactions.controller.ts
Normal file
204
src/controllers/reactions.controller.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/controllers/reactions.controller.ts
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for user reactions on content (recipes, comments, etc.).
|
||||
// Includes public endpoints for viewing reactions and authenticated endpoint
|
||||
// for toggling reactions.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { reactionRepo } from '../services/db/index.db';
|
||||
import type { UserProfile, UserReaction } from '../types';
|
||||
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for toggling a reaction.
|
||||
*/
|
||||
interface ToggleReactionRequest {
|
||||
/**
|
||||
* Entity type (e.g., 'recipe', 'comment')
|
||||
* @minLength 1
|
||||
*/
|
||||
entity_type: string;
|
||||
/**
|
||||
* Entity ID
|
||||
* @minLength 1
|
||||
*/
|
||||
entity_id: string;
|
||||
/**
|
||||
* Type of reaction (e.g., 'like', 'love')
|
||||
* @minLength 1
|
||||
*/
|
||||
reaction_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for toggling a reaction - when added.
|
||||
*/
|
||||
interface ReactionAddedResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** The created reaction */
|
||||
reaction: UserReaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for toggling a reaction - when removed.
|
||||
*/
|
||||
interface ReactionRemovedResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction summary entry showing count by type.
|
||||
*/
|
||||
interface ReactionSummaryEntry {
|
||||
/** Reaction type */
|
||||
reaction_type: string;
|
||||
/** Count of this reaction type */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for user reactions on content.
|
||||
*
|
||||
* Public endpoints:
|
||||
* - GET /reactions - Get reactions with optional filters
|
||||
* - GET /reactions/summary - Get reaction summary for an entity
|
||||
*
|
||||
* Authenticated endpoints:
|
||||
* - POST /reactions/toggle - Toggle (add/remove) a reaction
|
||||
*/
|
||||
@Route('reactions')
|
||||
@Tags('Reactions')
|
||||
export class ReactionsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get reactions.
|
||||
*
|
||||
* Fetches user reactions based on query filters. Supports filtering by
|
||||
* userId, entityType, and entityId. All filters are optional.
|
||||
*
|
||||
* @summary Get reactions
|
||||
* @param request Express request for logging
|
||||
* @param userId Filter by user ID (UUID format)
|
||||
* @param entityType Filter by entity type (e.g., 'recipe', 'comment')
|
||||
* @param entityId Filter by entity ID
|
||||
* @returns List of reactions matching filters
|
||||
*/
|
||||
@Get()
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of reactions matching filters')
|
||||
public async getReactions(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() userId?: string,
|
||||
@Query() entityType?: string,
|
||||
@Query() entityId?: string,
|
||||
): Promise<SuccessResponseType<UserReaction[]>> {
|
||||
const reactions = await reactionRepo.getReactions(
|
||||
{ userId, entityType, entityId },
|
||||
request.log,
|
||||
);
|
||||
return this.success(reactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reaction summary.
|
||||
*
|
||||
* Fetches a summary of reactions for a specific entity, showing
|
||||
* the count of each reaction type.
|
||||
*
|
||||
* @summary Get reaction summary
|
||||
* @param request Express request for logging
|
||||
* @param entityType Entity type (e.g., 'recipe', 'comment') - required
|
||||
* @param entityId Entity ID - required
|
||||
* @returns Reaction summary with counts by type
|
||||
*/
|
||||
@Get('summary')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'Reaction summary with counts by type')
|
||||
@Response<ErrorResponse>(400, 'Missing required query parameters')
|
||||
public async getReactionSummary(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() entityType: string,
|
||||
@Query() entityId: string,
|
||||
): Promise<SuccessResponseType<ReactionSummaryEntry[]>> {
|
||||
const summary = await reactionRepo.getReactionSummary(entityType, entityId, request.log);
|
||||
return this.success(summary);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Toggle reaction.
|
||||
*
|
||||
* Toggles a user's reaction to an entity. If the reaction exists,
|
||||
* it's removed; otherwise, it's added.
|
||||
*
|
||||
* @summary Toggle reaction
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Reaction details
|
||||
* @returns Reaction added (201) or removed (200) confirmation
|
||||
*/
|
||||
@Post('toggle')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(reactionToggleLimiter)
|
||||
@SuccessResponse(200, 'Reaction removed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async toggleReaction(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ToggleReactionRequest,
|
||||
): Promise<SuccessResponseType<ReactionAddedResponse | ReactionRemovedResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const reactionData = {
|
||||
user_id: userProfile.user.user_id,
|
||||
entity_type: body.entity_type,
|
||||
entity_id: body.entity_id,
|
||||
reaction_type: body.reaction_type,
|
||||
};
|
||||
|
||||
const result = await reactionRepo.toggleReaction(reactionData, request.log);
|
||||
|
||||
if (result) {
|
||||
// Reaction was added
|
||||
this.setStatus(201);
|
||||
return this.success({ message: 'Reaction added.', reaction: result });
|
||||
} else {
|
||||
// Reaction was removed
|
||||
return this.success({ message: 'Reaction removed.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
659
src/controllers/receipt.controller.test.ts
Normal file
659
src/controllers/receipt.controller.test.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
// src/controllers/receipt.controller.test.ts
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the ReceiptController class. These tests verify controller
|
||||
// logic in isolation by mocking the receipt service and queue.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
FormField: () => () => {},
|
||||
UploadedFile: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock receipt service
|
||||
vi.mock('../services/receiptService.server', () => ({
|
||||
getReceipts: vi.fn(),
|
||||
createReceipt: vi.fn(),
|
||||
getReceiptById: vi.fn(),
|
||||
deleteReceipt: vi.fn(),
|
||||
getReceiptItems: vi.fn(),
|
||||
getUnaddedItems: vi.fn(),
|
||||
updateReceiptItem: vi.fn(),
|
||||
getProcessingLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock expiry service
|
||||
vi.mock('../services/expiryService.server', () => ({
|
||||
addItemsFromReceipt: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock receipt queue
|
||||
vi.mock('../services/queues.server', () => ({
|
||||
receiptQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as receiptService from '../services/receiptService.server';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { receiptQueue } from '../services/queues.server';
|
||||
import { ReceiptController } from './receipt.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedReceiptService = receiptService as Mocked<typeof receiptService>;
|
||||
const mockedExpiryService = expiryService as Mocked<typeof expiryService>;
|
||||
const mockedReceiptQueue = receiptQueue as Mocked<typeof receiptQueue>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
file: undefined,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({ request_id: 'test-request-id' }),
|
||||
},
|
||||
...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.
|
||||
*/
|
||||
function createMockReceipt(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock receipt item.
|
||||
*/
|
||||
function createMockReceiptItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
receipt_item_id: 1,
|
||||
receipt_id: 1,
|
||||
raw_text: 'Milk 2%',
|
||||
quantity: 1,
|
||||
price_cents: 350,
|
||||
unit_price_cents: 350,
|
||||
status: 'matched' as const,
|
||||
master_item_id: 100,
|
||||
product_id: 200,
|
||||
match_confidence: 0.95,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock processing log record.
|
||||
*/
|
||||
function createMockProcessingLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
log_id: 1,
|
||||
receipt_id: 1,
|
||||
status: 'processing',
|
||||
message: 'Started processing receipt',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('ReceiptController', () => {
|
||||
let controller: ReceiptController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new ReceiptController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT MANAGEMENT ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReceipts()', () => {
|
||||
it('should return receipts with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
receipts: [createMockReceipt()],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceipts(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipts).toHaveLength(1);
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'test-user-id',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support filtering by status', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(request, 50, 0, 'processed');
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'processed' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support date range filtering', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(
|
||||
request,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from_date: '2024-01-01',
|
||||
to_date: '2024-01-31',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReceipt()', () => {
|
||||
it('should upload a receipt and queue for processing', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt({ status: 'pending' });
|
||||
const mockFile = {
|
||||
filename: 'receipt-123.jpg',
|
||||
path: '/uploads/receipt-123.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadReceipt(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipt_id).toBe(1);
|
||||
expect(result.data.job_id).toBe('job-123');
|
||||
}
|
||||
expect(mockedReceiptService.createReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'/uploads/receipt-123.jpg',
|
||||
expect.anything(),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(mockedReceiptQueue.add).toHaveBeenCalledWith(
|
||||
'process-receipt',
|
||||
expect.objectContaining({
|
||||
receiptId: 1,
|
||||
userId: 'test-user-id',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when no file is uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadReceipt(request)).rejects.toThrow('Receipt image is required.');
|
||||
});
|
||||
|
||||
it('should support optional store location and transaction date', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockFile = {
|
||||
filename: 'receipt-123.jpg',
|
||||
path: '/uploads/receipt-123.jpg',
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
await controller.uploadReceipt(request, 5, '2024-01-15');
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.createReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'/uploads/receipt-123.jpg',
|
||||
expect.anything(),
|
||||
{
|
||||
storeLocationId: 5,
|
||||
transactionDate: '2024-01-15',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReceiptById()', () => {
|
||||
it('should return a receipt with its items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getReceiptItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceiptById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipt.receipt_id).toBe(1);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteReceipt()', () => {
|
||||
it('should delete a receipt', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.deleteReceipt.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedReceiptService.deleteReceipt).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reprocessReceipt()', () => {
|
||||
it('should queue a receipt for reprocessing', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt({ status: 'failed' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-456' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.reprocessReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Receipt queued for reprocessing');
|
||||
expect(result.data.receipt_id).toBe(1);
|
||||
expect(result.data.job_id).toBe('job-456');
|
||||
}
|
||||
expect(mockedReceiptQueue.add).toHaveBeenCalledWith(
|
||||
'process-receipt',
|
||||
expect.objectContaining({
|
||||
receiptId: 1,
|
||||
imagePath: '/uploads/receipt-123.jpg',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReceiptItems()', () => {
|
||||
it('should return receipt items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem(), createMockReceiptItem({ receipt_item_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getReceiptItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceiptItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnaddedItems()', () => {
|
||||
it('should return unadded receipt items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem({ status: 'unmatched' })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getUnaddedItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnaddedItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReceiptItem()', () => {
|
||||
it('should update a receipt item', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockUpdatedItem = createMockReceiptItem({ status: 'confirmed', match_confidence: 1.0 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.updateReceiptItem.mockResolvedValue(mockUpdatedItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateReceiptItem(1, 1, request, {
|
||||
status: 'confirmed',
|
||||
match_confidence: 1.0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('confirmed');
|
||||
expect(result.data.match_confidence).toBe(1.0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateReceiptItem(1, 1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmItems()', () => {
|
||||
it('should confirm items and add to inventory', async () => {
|
||||
// Arrange
|
||||
const mockAddedItems = [
|
||||
{ inventory_id: 1, item_name: 'Milk' },
|
||||
{ inventory_id: 2, item_name: 'Bread' },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addItemsFromReceipt.mockResolvedValue(mockAddedItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.confirmItems(1, request, {
|
||||
items: [
|
||||
{ receipt_item_id: 1, include: true },
|
||||
{ receipt_item_id: 2, include: true },
|
||||
],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.added_items).toHaveLength(2);
|
||||
expect(result.data.count).toBe(2);
|
||||
}
|
||||
expect(mockedExpiryService.addItemsFromReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.arrayContaining([expect.objectContaining({ receipt_item_id: 1, include: true })]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addItemsFromReceipt.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.confirmItems(1, request, {
|
||||
items: [{ receipt_item_id: 1, include: true }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
receiptId: 1,
|
||||
itemCount: 1,
|
||||
}),
|
||||
'Confirming receipt items for inventory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PROCESSING LOGS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getProcessingLogs()', () => {
|
||||
it('should return processing logs', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockLogs = [
|
||||
createMockProcessingLog({ status: 'started' }),
|
||||
createMockProcessingLog({ log_id: 2, status: 'completed' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getProcessingLogs.mockResolvedValue(mockLogs);
|
||||
|
||||
// Act
|
||||
const result = await controller.getProcessingLogs(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.logs).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceipts(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockFile = {
|
||||
filename: 'receipt.jpg',
|
||||
path: '/uploads/receipt.jpg',
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-1' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadReceipt(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.deleteReceipt.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
578
src/controllers/receipt.controller.ts
Normal file
578
src/controllers/receipt.controller.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
// src/controllers/receipt.controller.ts
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for uploading, processing, and managing scanned receipts.
|
||||
// All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
FormField,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as receiptService from '../services/receiptService.server';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { receiptQueue } from '../services/queues.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type {
|
||||
ReceiptScan,
|
||||
ReceiptItem,
|
||||
ReceiptStatus,
|
||||
ReceiptItemStatus,
|
||||
ReceiptProcessingLogRecord,
|
||||
StorageLocation,
|
||||
} from '../types/expiry';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for a receipt with its items.
|
||||
*/
|
||||
interface ReceiptWithItemsResponse {
|
||||
/** The receipt record */
|
||||
receipt: ReceiptScan;
|
||||
/** Extracted items from the receipt */
|
||||
items: ReceiptItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for receipt items list.
|
||||
*/
|
||||
interface ReceiptItemsListResponse {
|
||||
/** Array of receipt items */
|
||||
items: ReceiptItem[];
|
||||
/** Total count of items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for receipt upload.
|
||||
*/
|
||||
interface ReceiptUploadResponse extends ReceiptScan {
|
||||
/** Background job ID for tracking processing */
|
||||
job_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for reprocessing a receipt.
|
||||
*/
|
||||
interface ReprocessResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** Receipt ID */
|
||||
receipt_id: number;
|
||||
/** Background job ID for tracking */
|
||||
job_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for processing logs.
|
||||
*/
|
||||
interface ProcessingLogsResponse {
|
||||
/** Array of log records */
|
||||
logs: ReceiptProcessingLogRecord[];
|
||||
/** Total count of logs */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for updating a receipt item.
|
||||
*/
|
||||
interface UpdateReceiptItemRequest {
|
||||
/** Matching status */
|
||||
status?: ReceiptItemStatus;
|
||||
/** Matched master item ID (null to unlink) */
|
||||
master_item_id?: number | null;
|
||||
/** Matched product ID (null to unlink) */
|
||||
product_id?: number | null;
|
||||
/**
|
||||
* Match confidence score
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
*/
|
||||
match_confidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item confirmation for adding to inventory.
|
||||
*/
|
||||
interface ConfirmItemEntry {
|
||||
/** Receipt item ID */
|
||||
receipt_item_id: number;
|
||||
/**
|
||||
* Override item name
|
||||
* @maxLength 255
|
||||
*/
|
||||
item_name?: string;
|
||||
/** Override quantity */
|
||||
quantity?: number;
|
||||
/** Storage location */
|
||||
location?: StorageLocation;
|
||||
/** Expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** Whether to add this item (false = skip) */
|
||||
include: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for confirming items to add to inventory.
|
||||
*/
|
||||
interface ConfirmItemsRequest {
|
||||
/** Array of items to confirm */
|
||||
items: ConfirmItemEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for confirmed items.
|
||||
*/
|
||||
interface ConfirmItemsResponse {
|
||||
/** Items added to inventory */
|
||||
added_items: unknown[];
|
||||
/** Count of items added */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing receipt scanning and processing.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own receipts - the user ID is extracted from the JWT token.
|
||||
*
|
||||
* Note: File upload functionality uses Express middleware that is
|
||||
* configured separately from tsoa. The POST /receipts endpoint
|
||||
* expects multipart/form-data with a 'receipt' file field.
|
||||
*/
|
||||
@Route('receipts')
|
||||
@Tags('Receipts')
|
||||
@Security('bearerAuth')
|
||||
export class ReceiptController extends BaseController {
|
||||
// ==========================================================================
|
||||
// RECEIPT MANAGEMENT ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get user's receipts.
|
||||
*
|
||||
* Retrieve the user's scanned receipts with optional filtering and pagination.
|
||||
*
|
||||
* @summary Get user's receipts
|
||||
* @param request Express request with authenticated user
|
||||
* @param limit Maximum number of receipts to return (default: 50, max: 100)
|
||||
* @param offset Number of receipts to skip for pagination (default: 0)
|
||||
* @param status Filter by processing status
|
||||
* @param store_location_id Filter by store location ID
|
||||
* @param from_date Filter by transaction date (start, YYYY-MM-DD format)
|
||||
* @param to_date Filter by transaction date (end, YYYY-MM-DD format)
|
||||
* @returns List of receipts with total count
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Receipts retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getReceipts(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
@Query() status?: ReceiptStatus,
|
||||
@Query() store_location_id?: number,
|
||||
@Query() from_date?: string,
|
||||
@Query() to_date?: string,
|
||||
): Promise<SuccessResponseType<{ receipts: ReceiptScan[]; total: number }>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize pagination parameters
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await receiptService.getReceipts(
|
||||
{
|
||||
user_id: userProfile.user.user_id,
|
||||
status,
|
||||
store_location_id,
|
||||
from_date,
|
||||
to_date,
|
||||
limit: normalizedLimit,
|
||||
offset: normalizedOffset,
|
||||
},
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a receipt.
|
||||
*
|
||||
* Upload a receipt image for processing and item extraction.
|
||||
* The receipt will be queued for background processing.
|
||||
*
|
||||
* Note: This endpoint is handled by Express middleware for file uploads.
|
||||
* See receipt.routes.ts for the actual implementation with multer.
|
||||
*
|
||||
* @summary Upload a receipt
|
||||
* @param request Express request with authenticated user and uploaded file
|
||||
* @param store_location_id Store location ID if known
|
||||
* @param transaction_date Transaction date if known (YYYY-MM-DD format)
|
||||
* @returns Created receipt record with job ID
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Receipt uploaded and queued for processing')
|
||||
@Response<ErrorResponse>(400, 'Validation error - no file uploaded')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async uploadReceipt(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() store_location_id?: number,
|
||||
@FormField() transaction_date?: string,
|
||||
): Promise<SuccessResponseType<ReceiptUploadResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
// Validate file was uploaded (middleware should handle this, but double-check)
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Receipt image is required.');
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
{ userId: userProfile.user.user_id, filename: file.filename },
|
||||
'Uploading receipt',
|
||||
);
|
||||
|
||||
// Create receipt record with the actual file path
|
||||
const receipt = await receiptService.createReceipt(
|
||||
userProfile.user.user_id,
|
||||
file.path,
|
||||
request.log,
|
||||
{
|
||||
storeLocationId: store_location_id,
|
||||
transactionDate: transaction_date,
|
||||
},
|
||||
);
|
||||
|
||||
// Queue the receipt for processing via BullMQ
|
||||
const bindings = request.log.bindings?.() || {};
|
||||
const job = await receiptQueue.add(
|
||||
'process-receipt',
|
||||
{
|
||||
receiptId: receipt.receipt_id,
|
||||
userId: userProfile.user.user_id,
|
||||
imagePath: file.path,
|
||||
meta: {
|
||||
requestId: bindings.request_id as string | undefined,
|
||||
userId: userProfile.user.user_id,
|
||||
origin: 'api',
|
||||
},
|
||||
},
|
||||
{
|
||||
jobId: `receipt-${receipt.receipt_id}`,
|
||||
},
|
||||
);
|
||||
|
||||
request.log.info(
|
||||
{ receiptId: receipt.receipt_id, jobId: job.id },
|
||||
'Receipt queued for processing',
|
||||
);
|
||||
|
||||
return this.created({ ...receipt, job_id: job.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get receipt by ID.
|
||||
*
|
||||
* Retrieve a specific receipt with its extracted items.
|
||||
*
|
||||
* @summary Get receipt by ID
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Receipt with its items
|
||||
*/
|
||||
@Get('{receiptId}')
|
||||
@SuccessResponse(200, 'Receipt retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getReceiptById(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptWithItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const receipt = await receiptService.getReceiptById(
|
||||
receiptId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
|
||||
// Also get the items
|
||||
const items = await receiptService.getReceiptItems(receiptId, request.log);
|
||||
|
||||
return this.success({ receipt, items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete receipt.
|
||||
*
|
||||
* Delete a receipt and all associated data.
|
||||
*
|
||||
* @summary Delete receipt
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{receiptId}')
|
||||
@SuccessResponse(204, 'Receipt deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async deleteReceipt(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await receiptService.deleteReceipt(receiptId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocess receipt.
|
||||
*
|
||||
* Queue a failed receipt for reprocessing.
|
||||
*
|
||||
* @summary Reprocess receipt
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Confirmation with job ID
|
||||
*/
|
||||
@Post('{receiptId}/reprocess')
|
||||
@SuccessResponse(200, 'Receipt queued for reprocessing')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async reprocessReceipt(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReprocessResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify the receipt exists and belongs to user
|
||||
const receipt = await receiptService.getReceiptById(
|
||||
receiptId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
|
||||
// Queue for reprocessing via BullMQ
|
||||
const bindings = request.log.bindings?.() || {};
|
||||
const job = await receiptQueue.add(
|
||||
'process-receipt',
|
||||
{
|
||||
receiptId: receipt.receipt_id,
|
||||
userId: userProfile.user.user_id,
|
||||
imagePath: receipt.receipt_image_url, // Use stored image path
|
||||
meta: {
|
||||
requestId: bindings.request_id as string | undefined,
|
||||
userId: userProfile.user.user_id,
|
||||
origin: 'api-reprocess',
|
||||
},
|
||||
},
|
||||
{
|
||||
jobId: `receipt-${receipt.receipt_id}-reprocess-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
|
||||
request.log.info({ receiptId, jobId: job.id }, 'Receipt queued for reprocessing');
|
||||
|
||||
return this.success({
|
||||
message: 'Receipt queued for reprocessing',
|
||||
receipt_id: receipt.receipt_id,
|
||||
job_id: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get receipt items.
|
||||
*
|
||||
* Get all extracted items from a receipt.
|
||||
*
|
||||
* @summary Get receipt items
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of receipt items
|
||||
*/
|
||||
@Get('{receiptId}/items')
|
||||
@SuccessResponse(200, 'Receipt items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getReceiptItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const items = await receiptService.getReceiptItems(receiptId, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unadded items.
|
||||
*
|
||||
* Get receipt items that haven't been added to inventory yet.
|
||||
*
|
||||
* @summary Get unadded items
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of unadded receipt items
|
||||
*/
|
||||
@Get('{receiptId}/items/unadded')
|
||||
@SuccessResponse(200, 'Unadded items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getUnaddedItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const items = await receiptService.getUnaddedItems(receiptId, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update receipt item.
|
||||
*
|
||||
* Update a receipt item's matching status or linked product.
|
||||
*
|
||||
* @summary Update receipt item
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param itemId The unique identifier of the receipt item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated receipt item
|
||||
*/
|
||||
@Put('{receiptId}/items/{itemId}')
|
||||
@SuccessResponse(200, 'Item updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt or item not found')
|
||||
public async updateReceiptItem(
|
||||
@Path() receiptId: number,
|
||||
@Path() itemId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateReceiptItemRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const item = await receiptService.updateReceiptItem(itemId, body, request.log);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm items for inventory.
|
||||
*
|
||||
* Confirm selected receipt items to add to user's inventory.
|
||||
*
|
||||
* @summary Confirm items for inventory
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Items to confirm
|
||||
* @returns Added items with count
|
||||
*/
|
||||
@Post('{receiptId}/confirm')
|
||||
@SuccessResponse(200, 'Items added to inventory')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async confirmItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ConfirmItemsRequest,
|
||||
): Promise<SuccessResponseType<ConfirmItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId: userProfile.user.user_id,
|
||||
receiptId,
|
||||
itemCount: body.items.length,
|
||||
},
|
||||
'Confirming receipt items for inventory',
|
||||
);
|
||||
|
||||
const addedItems = await expiryService.addItemsFromReceipt(
|
||||
userProfile.user.user_id,
|
||||
receiptId,
|
||||
body.items,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success({ added_items: addedItems, count: addedItems.length });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PROCESSING LOGS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get processing logs.
|
||||
*
|
||||
* Get the processing log history for a receipt.
|
||||
*
|
||||
* @summary Get processing logs
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Processing log history
|
||||
*/
|
||||
@Get('{receiptId}/logs')
|
||||
@SuccessResponse(200, 'Processing logs retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getProcessingLogs(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ProcessingLogsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const logs = await receiptService.getProcessingLogs(receiptId, request.log);
|
||||
return this.success({ logs, total: logs.length });
|
||||
}
|
||||
}
|
||||
691
src/controllers/recipe.controller.test.ts
Normal file
691
src/controllers/recipe.controller.test.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
// src/controllers/recipe.controller.test.ts
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the RecipeController class. These tests verify controller
|
||||
// logic in isolation by mocking database repositories and AI service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
recipeRepo: {
|
||||
getRecipesBySalePercentage: vi.fn(),
|
||||
getRecipesByMinSaleIngredients: vi.fn(),
|
||||
findRecipesByIngredientAndTag: vi.fn(),
|
||||
getRecipeById: vi.fn(),
|
||||
getRecipeComments: vi.fn(),
|
||||
addRecipeComment: vi.fn(),
|
||||
forkRecipe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock AI service
|
||||
vi.mock('../services/aiService.server', () => ({
|
||||
aiService: {
|
||||
generateRecipeSuggestion: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { aiService } from '../services/aiService.server';
|
||||
import { RecipeController } from './recipe.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
...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.
|
||||
*/
|
||||
function createMockComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('RecipeController', () => {
|
||||
let controller: RecipeController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new RecipeController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DISCOVERY ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipesBySalePercentage()', () => {
|
||||
it('should return recipes with default 50% minimum', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe(), createMockRecipe({ recipe_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySalePercentage(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
50,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect custom percentage parameter', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 75);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
75,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap percentage at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 150);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor percentage at 0', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipesBySaleIngredients()', () => {
|
||||
it('should return recipes with default 3 minimum ingredients', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySaleIngredients(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor minimum ingredients at 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor decimal values', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 5.9);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRecipesByIngredientAndTag()', () => {
|
||||
it('should find recipes by ingredient and tag', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.findRecipesByIngredientAndTag(request, 'chicken', 'dinner');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.findRecipesByIngredientAndTag).toHaveBeenCalledWith(
|
||||
'chicken',
|
||||
'dinner',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log search parameters', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.findRecipesByIngredientAndTag(request, 'beef', 'quick');
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ ingredient: 'beef', tag: 'quick' },
|
||||
'Finding recipes by ingredient and tag',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipeById()', () => {
|
||||
it('should return recipe by ID', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipe_id).toBe(1);
|
||||
expect(result.data.name).toBe('Test Recipe');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipeById).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should include ingredients and tags', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.ingredients).toHaveLength(1);
|
||||
expect(result.data.tags).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipeComments()', () => {
|
||||
it('should return recipe comments', async () => {
|
||||
// Arrange
|
||||
const mockComments = [
|
||||
createMockComment(),
|
||||
createMockComment({ recipe_comment_id: 2, content: 'Another comment' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue(mockComments);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].content).toBe('Great recipe!');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no comments', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('suggestRecipe()', () => {
|
||||
it('should return AI-generated recipe suggestion', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(
|
||||
'Here is a delicious recipe using chicken and rice...',
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe(
|
||||
{ ingredients: ['chicken', 'rice', 'vegetables'] },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.suggestion).toContain('chicken');
|
||||
}
|
||||
expect(mockedAiService.generateRecipeSuggestion).toHaveBeenCalledWith(
|
||||
['chicken', 'rice', 'vegetables'],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when AI service fails', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
it('should log suggestion generation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue('Recipe suggestion');
|
||||
|
||||
// Act
|
||||
await controller.suggestRecipe({ ingredients: ['chicken', 'rice'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ userId: 'test-user-id', ingredientCount: 2 },
|
||||
'Generating recipe suggestion',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addComment()', () => {
|
||||
it('should add comment to recipe', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Great recipe!' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.content).toBe('Great recipe!');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
'Great recipe!',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support nested replies', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment({ parent_comment_id: 5 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(
|
||||
1,
|
||||
{ content: 'Reply to comment', parentCommentId: 5 },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.parent_comment_id).toBe(5);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
'Reply to comment',
|
||||
expect.anything(),
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
await controller.addComment(1, { content: 'Test' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ recipeId: 1, userId: 'test-user-id', hasParent: false },
|
||||
'Adding comment to recipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forkRecipe()', () => {
|
||||
it('should fork a recipe', async () => {
|
||||
// Arrange
|
||||
const mockForkedRecipe = createMockRecipe({
|
||||
recipe_id: 10,
|
||||
user_id: 'test-user-id',
|
||||
forked_from: 1,
|
||||
status: 'private',
|
||||
});
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.forkRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipe_id).toBe(10);
|
||||
expect(result.data.forked_from).toBe(1);
|
||||
expect(result.data.user_id).toBe('test-user-id');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.forkRecipe).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
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 request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
await controller.forkRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ recipeId: 1, userId: 'test-user-id' },
|
||||
'Forking recipe',
|
||||
);
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ originalRecipeId: 1, forkedRecipeId: 10 },
|
||||
'Recipe forked successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Test' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use error helper for error responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
441
src/controllers/recipe.controller.ts
Normal file
441
src/controllers/recipe.controller.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
// src/controllers/recipe.controller.ts
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing recipes and recipe interactions.
|
||||
// Implements endpoints for:
|
||||
// - Getting recipes by sale percentage
|
||||
// - Getting recipes by minimum sale ingredients
|
||||
// - Finding recipes by ingredient and tag
|
||||
// - Getting a single recipe by ID
|
||||
// - Getting recipe comments
|
||||
// - AI-powered recipe suggestions (authenticated)
|
||||
// - Adding comments to recipes (authenticated)
|
||||
// - Forking recipes (authenticated)
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, ControllerErrorCode } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { aiService } from '../services/aiService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// Data Transfer Objects that are tsoa-compatible for API documentation.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recipe ingredient data.
|
||||
*/
|
||||
interface RecipeIngredientDto {
|
||||
/** Recipe ingredient ID */
|
||||
recipe_ingredient_id: number;
|
||||
/** Master grocery item name */
|
||||
master_item_name: string | null;
|
||||
/** Quantity required */
|
||||
quantity: string | null;
|
||||
/** Unit of measurement */
|
||||
unit: string | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe tag data.
|
||||
*/
|
||||
interface RecipeTagDto {
|
||||
/** Tag ID */
|
||||
tag_id: number;
|
||||
/** Tag name */
|
||||
name: string;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe data transfer object for API responses.
|
||||
*/
|
||||
interface RecipeDto {
|
||||
/** Unique recipe identifier */
|
||||
readonly recipe_id: number;
|
||||
/** User ID who created the recipe */
|
||||
readonly user_id: string;
|
||||
/** Recipe name */
|
||||
name: string;
|
||||
/** Recipe description */
|
||||
description: string | null;
|
||||
/** Cooking instructions */
|
||||
instructions: string | null;
|
||||
/** Preparation time in minutes */
|
||||
prep_time_minutes: number | null;
|
||||
/** Cooking time in minutes */
|
||||
cook_time_minutes: number | null;
|
||||
/** Number of servings */
|
||||
servings: number | null;
|
||||
/** URL to recipe photo */
|
||||
photo_url: string | null;
|
||||
/** Recipe status (public, private, archived) */
|
||||
status: 'public' | 'private' | 'archived';
|
||||
/** ID of the original recipe if this is a fork */
|
||||
forked_from: number | null;
|
||||
/** Recipe ingredients */
|
||||
ingredients?: RecipeIngredientDto[];
|
||||
/** Recipe tags */
|
||||
tags?: RecipeTagDto[];
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe comment data transfer object.
|
||||
*/
|
||||
interface RecipeCommentDto {
|
||||
/** Unique comment identifier */
|
||||
readonly recipe_comment_id: number;
|
||||
/** Recipe ID this comment belongs to */
|
||||
readonly recipe_id: number;
|
||||
/** User ID who posted the comment */
|
||||
readonly user_id: string;
|
||||
/** Comment content */
|
||||
content: string;
|
||||
/** Parent comment ID for threaded replies */
|
||||
parent_comment_id: number | null;
|
||||
/** User's full name */
|
||||
user_full_name: string | null;
|
||||
/** User's avatar URL */
|
||||
user_avatar_url: string | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for AI recipe suggestion.
|
||||
*/
|
||||
interface SuggestRecipeRequest {
|
||||
/**
|
||||
* List of ingredients to use for the suggestion.
|
||||
* @minItems 1
|
||||
* @example ["chicken", "rice", "broccoli"]
|
||||
*/
|
||||
ingredients: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for AI recipe suggestion.
|
||||
*/
|
||||
interface SuggestRecipeResponseData {
|
||||
/** The AI-generated recipe suggestion */
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for adding a comment to a recipe.
|
||||
*/
|
||||
interface AddCommentRequest {
|
||||
/**
|
||||
* The comment content.
|
||||
* @minLength 1
|
||||
* @example "This recipe is delicious! I added some garlic for extra flavor."
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Parent comment ID for threaded replies (optional).
|
||||
*/
|
||||
parentCommentId?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for recipe endpoints.
|
||||
*
|
||||
* Provides read access for browsing recipes and write access for
|
||||
* authenticated users to interact with recipes (comments, forks, AI suggestions).
|
||||
*/
|
||||
@Route('recipes')
|
||||
@Tags('Recipes')
|
||||
export class RecipeController extends BaseController {
|
||||
// ==========================================================================
|
||||
// DISCOVERY ENDPOINTS (PUBLIC)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipes by sale percentage.
|
||||
*
|
||||
* Returns recipes where at least the specified percentage of ingredients
|
||||
* are currently on sale. Useful for finding budget-friendly meals.
|
||||
*
|
||||
* @summary Get recipes by sale percentage
|
||||
* @param minPercentage Minimum percentage of ingredients on sale (0-100, default: 50)
|
||||
* @returns Array of recipes matching the criteria
|
||||
*/
|
||||
@Get('by-sale-percentage')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
public async getRecipesBySalePercentage(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() minPercentage?: number,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
// Apply defaults and bounds
|
||||
const normalizedPercentage = Math.min(100, Math.max(0, minPercentage ?? 50));
|
||||
|
||||
req.log.debug({ minPercentage: normalizedPercentage }, 'Fetching recipes by sale percentage');
|
||||
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(normalizedPercentage, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipes by sale ingredients count.
|
||||
*
|
||||
* Returns recipes with at least the specified number of ingredients
|
||||
* currently on sale. Helps find recipes that maximize current deals.
|
||||
*
|
||||
* @summary Get recipes by minimum sale ingredients
|
||||
* @param minIngredients Minimum number of sale ingredients required (default: 3)
|
||||
* @returns Array of recipes matching the criteria
|
||||
*/
|
||||
@Get('by-sale-ingredients')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
public async getRecipesBySaleIngredients(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() minIngredients?: number,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
// Apply defaults and bounds
|
||||
const normalizedCount = Math.max(1, Math.floor(minIngredients ?? 3));
|
||||
|
||||
req.log.debug({ minIngredients: normalizedCount }, 'Fetching recipes by sale ingredients');
|
||||
|
||||
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(normalizedCount, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find recipes by ingredient and tag.
|
||||
*
|
||||
* Returns recipes that contain a specific ingredient and have a specific tag.
|
||||
* Both parameters are required for filtering.
|
||||
*
|
||||
* @summary Find recipes by ingredient and tag
|
||||
* @param ingredient Ingredient name to search for
|
||||
* @param tag Tag to filter by
|
||||
* @returns Array of matching recipes
|
||||
*/
|
||||
@Get('by-ingredient-and-tag')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
@Response<ErrorResponse>(400, 'Missing required query parameters')
|
||||
public async findRecipesByIngredientAndTag(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() ingredient: string,
|
||||
@Query() tag: string,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
req.log.debug({ ingredient, tag }, 'Finding recipes by ingredient and tag');
|
||||
|
||||
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient, tag, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipe by ID.
|
||||
*
|
||||
* Returns a single recipe with its ingredients and tags.
|
||||
*
|
||||
* @summary Get a single recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @returns The recipe object with ingredients and tags
|
||||
*/
|
||||
@Get('{recipeId}')
|
||||
@SuccessResponse(200, 'Recipe retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found')
|
||||
public async getRecipeById(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeDto>> {
|
||||
req.log.debug({ recipeId }, 'Fetching recipe by ID');
|
||||
|
||||
const recipe = await db.recipeRepo.getRecipeById(recipeId, req.log);
|
||||
|
||||
return this.success(recipe as unknown as RecipeDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe comments.
|
||||
*
|
||||
* Returns all comments for a specific recipe, ordered by creation date.
|
||||
* Includes user information (name, avatar) for each comment.
|
||||
*
|
||||
* @summary Get comments for a recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @returns Array of comments for the recipe
|
||||
*/
|
||||
@Get('{recipeId}/comments')
|
||||
@SuccessResponse(200, 'Comments retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found')
|
||||
public async getRecipeComments(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeCommentDto[]>> {
|
||||
req.log.debug({ recipeId }, 'Fetching recipe comments');
|
||||
|
||||
const comments = await db.recipeRepo.getRecipeComments(recipeId, req.log);
|
||||
|
||||
return this.success(comments as unknown as RecipeCommentDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get AI recipe suggestion.
|
||||
*
|
||||
* Uses AI to generate a recipe suggestion based on provided ingredients.
|
||||
* Requires authentication due to API usage costs.
|
||||
*
|
||||
* @summary Get AI-powered recipe suggestion
|
||||
* @param body List of ingredients to use
|
||||
* @returns AI-generated recipe suggestion
|
||||
*/
|
||||
@Post('suggest')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(200, 'Suggestion generated successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(503, 'AI service unavailable')
|
||||
public async suggestRecipe(
|
||||
@Body() body: SuggestRecipeRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<SuggestRecipeResponseData>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
req.log.info(
|
||||
{ userId: userProfile.user.user_id, ingredientCount: body.ingredients.length },
|
||||
'Generating recipe suggestion',
|
||||
);
|
||||
|
||||
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
|
||||
|
||||
if (!suggestion) {
|
||||
this.setStatus(503);
|
||||
return this.error(
|
||||
ControllerErrorCode.SERVICE_UNAVAILABLE,
|
||||
'AI service is currently unavailable or failed to generate a suggestion.',
|
||||
) as unknown as SuccessResponseType<SuggestRecipeResponseData>;
|
||||
}
|
||||
|
||||
req.log.info({ userId: userProfile.user.user_id }, 'Recipe suggestion generated successfully');
|
||||
|
||||
return this.success({ suggestion });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to recipe.
|
||||
*
|
||||
* Adds a comment to a recipe. Supports nested replies via parentCommentId.
|
||||
*
|
||||
* @summary Add a comment to a recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @param body Comment content and optional parent comment ID
|
||||
* @returns The created comment
|
||||
*/
|
||||
@Post('{recipeId}/comments')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Comment added successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(404, 'Recipe or parent comment not found')
|
||||
public async addComment(
|
||||
@Path() recipeId: number,
|
||||
@Body() body: AddCommentRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeCommentDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info(
|
||||
{ recipeId, userId, hasParent: !!body.parentCommentId },
|
||||
'Adding comment to recipe',
|
||||
);
|
||||
|
||||
const comment = await db.recipeRepo.addRecipeComment(
|
||||
recipeId,
|
||||
userId,
|
||||
body.content,
|
||||
req.log,
|
||||
body.parentCommentId,
|
||||
);
|
||||
|
||||
req.log.info({ recipeId, commentId: comment.recipe_comment_id }, 'Comment added successfully');
|
||||
|
||||
return this.created(comment as unknown as RecipeCommentDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a recipe.
|
||||
*
|
||||
* Creates a personal, editable copy of a public recipe.
|
||||
* The forked recipe can be modified without affecting the original.
|
||||
*
|
||||
* @summary Fork a recipe
|
||||
* @param recipeId The unique identifier of the recipe to fork
|
||||
* @returns The newly created forked recipe
|
||||
*/
|
||||
@Post('{recipeId}/fork')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Recipe forked successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found or not public')
|
||||
public async forkRecipe(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info({ recipeId, userId }, 'Forking recipe');
|
||||
|
||||
const forkedRecipe = await db.recipeRepo.forkRecipe(userId, recipeId, req.log);
|
||||
|
||||
req.log.info(
|
||||
{ originalRecipeId: recipeId, forkedRecipeId: forkedRecipe.recipe_id },
|
||||
'Recipe forked successfully',
|
||||
);
|
||||
|
||||
return this.created(forkedRecipe as unknown as RecipeDto);
|
||||
}
|
||||
}
|
||||
336
src/controllers/stats.controller.test.ts
Normal file
336
src/controllers/stats.controller.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// src/controllers/stats.controller.test.ts
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the StatsController class. These tests verify controller
|
||||
// logic in isolation by mocking the admin repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock admin repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
getMostFrequentSaleItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
import { StatsController } from './stats.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAdminRepo = adminRepo as Mocked<typeof adminRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock most frequent sale item.
|
||||
*/
|
||||
function createMockSaleItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
item_name: 'Milk 2%',
|
||||
category_name: 'Dairy & Eggs',
|
||||
sale_count: 15,
|
||||
avg_discount_percent: 25.5,
|
||||
lowest_price_cents: 299,
|
||||
highest_price_cents: 450,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('StatsController', () => {
|
||||
let controller: StatsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new StatsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// MOST FREQUENT SALES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMostFrequentSales()', () => {
|
||||
it('should return most frequent sale items with default parameters', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockSaleItem(),
|
||||
createMockSaleItem({ master_item_id: 2, item_name: 'Bread', sale_count: 12 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item_name).toBe('Milk 2%');
|
||||
expect(result.data[0].sale_count).toBe(15);
|
||||
}
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30, // default days
|
||||
10, // default limit
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom days parameter', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(60, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
60,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom limit parameter', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 25, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
25,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap days at 365', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(500, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
365,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor days at 1', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(0, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
1,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 50', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 100, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
50,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 0, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle negative values', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(-10, -5, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
1, // floored to 1
|
||||
1, // floored to 1
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no sale items exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle decimal values by flooring them', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(45.7, 15.3, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
45, // floored
|
||||
15, // floored
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/controllers/stats.controller.ts
Normal file
67
src/controllers/stats.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/controllers/stats.controller.ts
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides public endpoints for statistical analysis of sale data.
|
||||
// These endpoints are useful for data analysis and do not require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Query, Request, SuccessResponse } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType } from './types';
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
import type { MostFrequentSaleItem } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for statistical analysis endpoints.
|
||||
*
|
||||
* All endpoints are public and do not require authentication.
|
||||
* These endpoints provide aggregated statistics useful for data analysis.
|
||||
*/
|
||||
@Route('stats')
|
||||
@Tags('Stats')
|
||||
export class StatsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// MOST FREQUENT SALES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get most frequent sale items.
|
||||
*
|
||||
* Returns a list of items that have been on sale most frequently
|
||||
* within the specified time period. This is useful for identifying
|
||||
* items that are regularly discounted.
|
||||
*
|
||||
* @summary Get most frequent sale items
|
||||
* @param days Number of days to look back (1-365, default: 30)
|
||||
* @param limit Maximum number of items to return (1-50, default: 10)
|
||||
* @param request Express request for logging
|
||||
* @returns List of most frequently on-sale items
|
||||
*/
|
||||
@Get('most-frequent-sales')
|
||||
@SuccessResponse(200, 'List of most frequently on-sale items')
|
||||
public async getMostFrequentSales(
|
||||
@Query() days?: number,
|
||||
@Query() limit?: number,
|
||||
@Request() request?: ExpressRequest,
|
||||
): Promise<SuccessResponseType<MostFrequentSaleItem[]>> {
|
||||
// Apply defaults and bounds per the original route implementation
|
||||
// Default: 30 days, 10 items. Max: 365 days, 50 items.
|
||||
const normalizedDays = Math.min(365, Math.max(1, Math.floor(days ?? 30)));
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
|
||||
const items = await adminRepo.getMostFrequentSaleItems(
|
||||
normalizedDays,
|
||||
normalizedLimit,
|
||||
request!.log,
|
||||
);
|
||||
return this.success(items);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user