Compare commits

..

8 Commits

Author SHA1 Message Date
Gitea Actions
5fe537b93d ci: Bump version to 0.12.22 [skip ci] 2026-01-29 12:26:33 +05:00
61f24305fb ADR-024 Feature Flagging Strategy
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
2026-01-28 23:23:45 -08:00
Gitea Actions
de3f0cf26e ci: Bump version to 0.12.21 [skip ci] 2026-01-29 05:37:59 +05:00
45ac4fccf5 comprehensive documentation review + test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m15s
2026-01-28 16:35:38 -08:00
Gitea Actions
b6c3ca9abe ci: Bump version to 0.12.20 [skip ci] 2026-01-29 04:36:43 +05:00
4f06698dfd test fixes and doc work
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s
2026-01-28 15:33:48 -08:00
Gitea Actions
e548d1b0cc ci: Bump version to 0.12.19 [skip ci] 2026-01-28 23:03:57 +05:00
771f59d009 more api versioning work -whee
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m47s
2026-01-28 09:58:28 -08:00
80 changed files with 9119 additions and 801 deletions

View File

@@ -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

4
.gitignore vendored
View File

@@ -38,3 +38,7 @@ Thumbs.db
.claude/settings.local.json
nul
tmpclaude*
test.tmp

147
CLAUDE.md
View File

@@ -27,6 +27,24 @@ podman exec -it flyer-crawler-dev npm run type-check
Out-of-sync = test failures.
### Server Access: READ-ONLY (Production/Test Servers)
**CRITICAL**: The `claude-win10` user has **READ-ONLY** access to production and test servers.
| Capability | Status |
| ---------------------- | ---------------------- |
| Root/sudo access | NO |
| Write permissions | NO |
| PM2 restart, systemctl | NO - User must execute |
**Server Operations Workflow**: Diagnose → User executes → Analyze → Fix (1-3 commands) → User executes → Verify
**Rules**:
- 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
### Communication Style
Ask before assuming. Never assume:
@@ -60,25 +78,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` |
---
@@ -121,7 +141,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:
@@ -204,7 +224,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.
```
@@ -285,8 +305,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):
@@ -294,15 +314,15 @@ podman cp "d:/path/file" container:/tmp/file
# Dev container
MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink -e SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security flyer-crawler-dev sh -c 'cd /opt/bugsink/conf && DJANGO_SETTINGS_MODULE=bugsink_conf PYTHONPATH=/opt/bugsink/conf:/opt/bugsink/lib/python3.10/site-packages /opt/bugsink/bin/python -m django create_auth_token'
# Production (via SSH)
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token"
# Production (user executes on server)
cd /opt/bugsink && bugsink-manage create_auth_token
```
### Logstash
**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)
---
@@ -322,84 +342,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.

View 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_

View File

@@ -1,5 +1,21 @@
# DevOps Subagent Reference
## Critical Rule: Server Access is READ-ONLY
**Claude Code has READ-ONLY access to production/test servers.** The `claude-win10` user cannot execute write operations directly.
When working with production/test servers:
1. **Provide commands** for the user to execute (do not attempt SSH)
2. **Wait for user** to report command output
3. **Provide fix commands** 1-3 at a time (errors may cascade)
4. **Verify success** with read-only commands after user executes fixes
5. **Document findings** in relevant documentation
Commands in this reference are for the **user to run on the server**, not for Claude to execute.
---
## Critical Rule: Git Bash Path Conversion
Git Bash on Windows auto-converts Unix paths, breaking container commands.
@@ -69,12 +85,11 @@ MSYS_NO_PATHCONV=1 podman exec -it flyer-crawler-dev psql -U postgres -d flyer_c
## PM2 Commands
### Production Server (via SSH)
### Production Server
> **Note**: These commands are for the **user to execute on the server**. Claude Code provides commands but cannot run them directly. See [Server Access is READ-ONLY](#critical-rule-server-access-is-read-only) above.
```bash
# SSH to server
ssh root@projectium.com
# List all apps
pm2 list
@@ -210,9 +225,10 @@ INFO
### Production
> **Note**: User executes these commands on the server.
```bash
# Via SSH
ssh root@projectium.com
# Access Redis CLI
redis-cli -a $REDIS_PASSWORD
# Flush cache (use with caution)
@@ -278,10 +294,9 @@ Trigger `manual-db-backup.yml` from Gitea Actions UI.
### Manual Backup
```bash
# SSH to server
ssh root@projectium.com
> **Note**: User executes these commands on the server.
```bash
# Backup
PGPASSWORD=$DB_PASSWORD pg_dump -h $DB_HOST -U $DB_USER $DB_NAME > backup_$(date +%Y%m%d).sql
@@ -301,8 +316,10 @@ MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_
### Production Token Generation
> **Note**: User executes this command on the server.
```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token"
cd /opt/bugsink && bugsink-manage create_auth_token
```
---

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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) |

View File

@@ -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

View File

@@ -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)

View File

@@ -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/)

View File

@@ -0,0 +1,367 @@
# ADR-057: Test Remediation Post-API Versioning and Frontend Rework
**Date**: 2026-01-28
**Status**: Accepted
**Context**: Major test remediation effort completed after ADR-008 API versioning implementation and frontend style rework
## Context
Following the completion of ADR-008 Phase 2 (API Versioning Strategy) and a concurrent frontend style/design rework, the test suite experienced 105 test failures across unit tests and E2E tests. This ADR documents the systematic remediation effort, root cause analysis, and lessons learned to prevent similar issues in future migrations.
### Scope of Failures
| Test Type | Failures | Total Tests | Pass Rate After Fix |
| ---------- | -------- | ----------- | ------------------- |
| Unit Tests | 69 | 3,392 | 100% |
| E2E Tests | 36 | 36 | 100% |
| **Total** | **105** | **3,428** | **100%** |
### Root Causes Identified
The failures were categorized into six distinct categories:
1. **API Versioning Path Mismatches** (71 failures)
- Test files using `/api/` instead of `/api/v1/`
- Environment variables not set for API base URL
- Integration and E2E tests calling unversioned endpoints
2. **Dark Mode Class Assertion Failures** (8 failures)
- Frontend rework changed Tailwind dark mode utility classes
- Test assertions checking for outdated class names
3. **Selected Item Styling Changes** (6 failures)
- Component styling refactored to new design tokens
- Test assertions expecting old CSS class combinations
4. **Admin-Only Component Visibility** (12 failures)
- MainLayout tests not properly mocking admin role
- ActivityLog component visibility tied to role-based access
5. **Mock Hoisting Issues** (5 failures)
- Queue mocks not available during module initialization
- Vitest's module hoisting order causing mock setup failures
6. **Error Log Path Hardcoding** (3 failures)
- Route handlers logging hardcoded paths like `/api/flyers`
- Test assertions expecting versioned paths `/api/v1/flyers`
## Decision
We implemented a systematic remediation approach addressing each failure category with targeted fixes while establishing patterns to prevent regression.
### 1. API Versioning Configuration Updates
**Files Modified**:
- `vite.config.ts`
- `vitest.config.e2e.ts`
- `vitest.config.integration.ts`
**Pattern Applied**: Centralize API base URL in Vitest environment variables
```typescript
// vite.config.ts - Unit test configuration
test: {
env: {
// ADR-008: Ensure API versioning is correctly set for unit tests
VITE_API_BASE_URL: '/api/v1',
},
// ...
}
// vitest.config.e2e.ts - E2E test configuration
test: {
env: {
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3098/api/v1',
},
// ...
}
// vitest.config.integration.ts - Integration test configuration
test: {
env: {
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3099/api/v1',
},
// ...
}
```
### 2. E2E Test URL Path Updates
**Files Modified** (7 files, 31 URL occurrences):
- `src/tests/e2e/budget-journey.e2e.test.ts`
- `src/tests/e2e/deals-journey.e2e.test.ts`
- `src/tests/e2e/flyer-upload.e2e.test.ts`
- `src/tests/e2e/inventory-journey.e2e.test.ts`
- `src/tests/e2e/receipt-journey.e2e.test.ts`
- `src/tests/e2e/upc-journey.e2e.test.ts`
- `src/tests/e2e/user-journey.e2e.test.ts`
**Pattern Applied**: Update all hardcoded API paths to versioned endpoints
```typescript
// Before
const response = await getRequest().post('/api/auth/register').send({...});
// After
const response = await getRequest().post('/api/v1/auth/register').send({...});
```
### 3. Unit Test Assertion Updates for UI Changes
**Files Modified**:
- `src/features/flyer/FlyerDisplay.test.tsx`
- `src/features/flyer/FlyerList.test.tsx`
**Pattern Applied**: Update CSS class assertions to match new design system
```typescript
// FlyerDisplay.test.tsx - Dark mode class update
// Before
expect(image).toHaveClass('dark:brightness-75');
// After
expect(image).toHaveClass('dark:brightness-90');
// FlyerList.test.tsx - Selected item styling update
// Before
expect(selectedItem).toHaveClass('ring-2', 'ring-brand-primary');
// After
expect(selectedItem).toHaveClass('border-brand-primary', 'bg-teal-50/50', 'dark:bg-teal-900/10');
```
### 4. Admin-Only Component Test Separation
**File Modified**: `src/layouts/MainLayout.test.tsx`
**Pattern Applied**: Separate test cases for admin vs. regular user visibility
```typescript
describe('for authenticated users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser }),
});
});
it('renders auth-gated components for regular users (PriceHistoryChart, Leaderboard)', () => {
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
expect(screen.getByTestId('leaderboard')).toBeInTheDocument();
// ActivityLog is admin-only, should NOT be present for regular users
expect(screen.queryByTestId('activity-log')).not.toBeInTheDocument();
});
it('renders ActivityLog for admin users', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('activity-log')).toBeInTheDocument();
});
});
```
### 5. vi.hoisted() Pattern for Queue Mocks
**File Modified**: `src/routes/health.routes.test.ts`
**Pattern Applied**: Use `vi.hoisted()` to ensure mocks are available during module hoisting
```typescript
// Use vi.hoisted to create mock queue objects that are available during vi.mock hoisting.
// This ensures the mock objects exist when the factory function runs.
const { mockQueuesModule } = vi.hoisted(() => {
// Helper function to create a mock queue object with vi.fn()
const createMockQueue = () => ({
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
});
return {
mockQueuesModule: {
flyerQueue: createMockQueue(),
emailQueue: createMockQueue(),
// ... additional queues
},
};
});
// Mock the queues.server module BEFORE the health router imports it.
vi.mock('../services/queues.server', () => mockQueuesModule);
// Import the router AFTER all mocks are defined.
import healthRouter from './health.routes';
```
### 6. Dynamic Error Log Paths
**Pattern Applied**: Use `req.originalUrl` instead of hardcoded paths in error handlers
```typescript
// Before (INCORRECT - hardcoded path)
req.log.error({ error }, 'Error in /api/flyers/:id:');
// After (CORRECT - dynamic path)
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
```
## Implementation Summary
### Files Modified (14 total)
| Category | Files | Changes |
| -------------------- | ----- | ------------------------------------------------- |
| Vitest Configuration | 3 | Added `VITE_API_BASE_URL` environment variables |
| E2E Tests | 7 | Updated 31 API endpoint URLs |
| Unit Tests | 4 | Updated assertions for UI, mocks, and admin roles |
### Verification Results
After remediation, all tests pass in the dev container environment:
```text
Unit Tests: 3,392 passing
E2E Tests: 36 passing
Integration: 345/348 passing (3 known issues, unrelated)
Type Check: Passing
```
## Consequences
### Positive
1. **Test Suite Stability**: All tests now pass consistently in the dev container
2. **API Versioning Compliance**: Tests enforce the `/api/v1/` path requirement
3. **Pattern Documentation**: Clear patterns established for future test maintenance
4. **Separation of Concerns**: Admin vs. user test cases properly separated
5. **Mock Reliability**: `vi.hoisted()` pattern prevents mock timing issues
### Negative
1. **Maintenance Overhead**: Future API version changes will require test updates
2. **Manual Migration**: No automated tool to update test paths during versioning
### Neutral
1. **Test Execution Time**: No significant impact on test execution duration
2. **Coverage Metrics**: Coverage percentages unchanged
## Best Practices Established
### 1. API Versioning in Tests
**Always use versioned API paths in tests**:
```typescript
// Good
const response = await request.get('/api/v1/users/profile');
// Bad
const response = await request.get('/api/users/profile');
```
**Configure environment variables centrally in Vitest configs** rather than in individual test files.
### 2. vi.hoisted() for Module-Level Mocks
When mocking modules that are imported at the top level of other modules:
```typescript
// Pattern: Define mocks with vi.hoisted() BEFORE vi.mock() calls
const { mockModule } = vi.hoisted(() => ({
mockModule: {
someFunction: vi.fn(),
},
}));
vi.mock('./some-module', () => mockModule);
// Import AFTER mocks
import { something } from './module-that-imports-some-module';
```
### 3. Testing Conditional Component Rendering
When testing components that render differently based on user role:
1. Create separate `describe` blocks for each role
2. Set up role-specific mocks in `beforeEach`
3. Explicitly test both presence AND absence of role-gated components
### 4. CSS Class Assertions After UI Refactors
After frontend style changes:
1. Review component implementation for new class names
2. Update test assertions to match actual CSS classes
3. Consider using partial matching for complex class combinations:
```typescript
// Flexible matching for Tailwind classes
expect(element).toHaveClass('border-brand-primary');
// vs exact matching
expect(element).toHaveClass('border-brand-primary', 'bg-teal-50/50', 'dark:bg-teal-900/10');
```
### 5. Error Logging Paths
**Always use dynamic paths in error logs**:
```typescript
// Pattern: Use req.originalUrl for request path logging
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
```
This ensures error logs reflect the actual request URL including version prefixes.
## Migration Checklist for Future API Version Changes
When implementing a new API version (e.g., v2), follow this checklist:
- [ ] Update `vite.config.ts` test environment `VITE_API_BASE_URL`
- [ ] Update `vitest.config.e2e.ts` test environment `VITE_API_BASE_URL`
- [ ] Update `vitest.config.integration.ts` test environment `VITE_API_BASE_URL`
- [ ] Search and replace `/api/v1/` with `/api/v2/` in E2E test files
- [ ] Search and replace `/api/v1/` with `/api/v2/` in integration test files
- [ ] Verify route handler error logs use `req.originalUrl`
- [ ] Run full test suite in dev container to verify
**Search command for finding hardcoded paths**:
```bash
grep -r "/api/v1/" src/tests/
grep -r "'/api/" src/routes/*.ts
```
## Related ADRs
- [ADR-008](./0008-api-versioning-strategy.md) - API Versioning Strategy
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy and Standards
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Platform: Linux Only
- [ADR-040](./0040-testing-economics-and-priorities.md) - Testing Economics and Priorities
- [ADR-012](./0012-frontend-component-library-and-design-system.md) - Frontend Component Library
## Key Files
| File | Purpose |
| ------------------------------ | -------------------------------------------- |
| `vite.config.ts` | Unit test environment configuration |
| `vitest.config.e2e.ts` | E2E test environment configuration |
| `vitest.config.integration.ts` | Integration test environment configuration |
| `src/tests/e2e/*.e2e.test.ts` | E2E test files with versioned API paths |
| `src/routes/*.routes.test.ts` | Route test files with `vi.hoisted()` pattern |
| `docs/development/TESTING.md` | Testing guide with best practices |

View File

@@ -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 |

View File

@@ -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-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 2 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-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
@@ -71,6 +73,7 @@ This directory contains a log of the architectural decisions made for the Flyer
**[ADR-040](./0040-testing-economics-and-priorities.md)**: Testing Economics and Priorities (Accepted)
**[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)
## 9. Architecture Patterns

View File

@@ -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:

View File

@@ -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

View File

@@ -2,6 +2,22 @@
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 |
| ------------------ | ------------------------------------------------- | ------------------------------------- |
| 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` | `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
- [Error Handling](#error-handling)
@@ -12,12 +28,13 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
- [Authentication](#authentication)
- [Caching](#caching)
- [Background Jobs](#background-jobs)
- [Feature Flags](#feature-flags)
---
## 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 +95,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 +172,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 +191,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 +225,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 +282,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 +318,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 +351,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 +367,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 +394,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 +434,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 +493,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

View File

@@ -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.

View File

@@ -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)

View File

@@ -147,6 +147,7 @@ When creating new route handlers:
## Related Documentation
- [ADR-008: API Versioning Strategy](../adr/0008-api-versioning-strategy.md) - Versioning implementation details
- [ADR-057: Test Remediation Post-API Versioning](../adr/0057-test-remediation-post-api-versioning.md) - Comprehensive remediation guide
- [ADR-004: Structured Logging](../adr/0004-standardized-application-wide-structured-logging.md) - Logging standards
- [CODE-PATTERNS.md](CODE-PATTERNS.md) - General code patterns
- [TESTING.md](TESTING.md) - Testing guidelines

View File

@@ -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
@@ -262,6 +295,8 @@ Opens a browser-based test runner with filtering and debugging capabilities.
6. **Use unique filenames** - file upload tests need timestamp-based filenames
7. **Check exit codes** - `npm run type-check` returns 0 on success, non-zero on error
8. **Use `req.originalUrl` in error logs** - never hardcode API paths in error messages
9. **Use versioned API paths** - always use `/api/v1/` prefix in test requests
10. **Use `vi.hoisted()` for module mocks** - ensure mocks are available during module initialization
## Testing Error Log Messages
@@ -314,3 +349,159 @@ expect(logSpy).toHaveBeenCalledWith(
```
See [Error Logging Path Patterns](ERROR-LOGGING-PATHS.md) for complete documentation.
## API Versioning in Tests (ADR-008, ADR-057)
All API endpoints use the `/api/v1/` prefix. Tests must use versioned paths.
### Configuration
API base URLs are configured centrally in Vitest config files:
| Config File | Environment Variable | Value |
| ------------------------------ | -------------------- | ------------------------------ |
| `vite.config.ts` | `VITE_API_BASE_URL` | `/api/v1` |
| `vitest.config.e2e.ts` | `VITE_API_BASE_URL` | `http://localhost:3098/api/v1` |
| `vitest.config.integration.ts` | `VITE_API_BASE_URL` | `http://localhost:3099/api/v1` |
### Writing API Tests
```typescript
// Good - versioned path
const response = await request.post('/api/v1/auth/login').send({...});
// Bad - unversioned path (will fail)
const response = await request.post('/api/auth/login').send({...});
```
### Migration Checklist
When API version changes (e.g., v1 to v2):
1. Update all Vitest config `VITE_API_BASE_URL` values
2. Search and replace API paths in E2E tests: `grep -r "/api/v1/" src/tests/e2e/`
3. Search and replace API paths in integration tests
4. Verify route handler error logs use `req.originalUrl`
5. Run full test suite in dev container
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for complete migration guidance.
## vi.hoisted() Pattern for Module Mocks
When mocking modules that are imported at module initialization time (like queues or database connections), use `vi.hoisted()` to ensure mocks are available during hoisting.
### Problem: Mock Not Available During Import
```typescript
// BAD: Mock might not be ready when module imports it
vi.mock('../services/queues.server', () => ({
flyerQueue: { getJobCounts: vi.fn() }, // May not exist yet
}));
import healthRouter from './health.routes'; // Imports queues.server
```
### Solution: Use vi.hoisted()
```typescript
// GOOD: Mocks are created during hoisting, before vi.mock runs
const { mockQueuesModule } = vi.hoisted(() => {
const createMockQueue = () => ({
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
});
return {
mockQueuesModule: {
flyerQueue: createMockQueue(),
emailQueue: createMockQueue(),
// ... additional queues
},
};
});
// Now the mock object exists when vi.mock factory runs
vi.mock('../services/queues.server', () => mockQueuesModule);
// Safe to import after mocks are defined
import healthRouter from './health.routes';
```
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for additional patterns.
## Testing Role-Based Component Visibility
When testing components that render differently based on user roles:
### Pattern: Separate Test Cases by Role
```typescript
describe('for authenticated users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ role: 'user' }),
});
});
it('renders user-accessible components', () => {
render(<MyComponent />);
expect(screen.getByTestId('user-component')).toBeInTheDocument();
// Admin-only should NOT be present
expect(screen.queryByTestId('admin-only')).not.toBeInTheDocument();
});
});
describe('for admin users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ role: 'admin' }),
});
});
it('renders admin-only components', () => {
render(<MyComponent />);
expect(screen.getByTestId('admin-only')).toBeInTheDocument();
});
});
```
### Key Points
1. Create separate `describe` blocks for each role
2. Set up role-specific mocks in `beforeEach`
3. Test both presence AND absence of role-gated components
4. Use `screen.queryByTestId()` for elements that should NOT exist
## CSS Class Assertions After UI Refactors
After frontend style changes, update test assertions to match new CSS classes.
### Handling Tailwind Class Changes
```typescript
// Before refactor
expect(selectedItem).toHaveClass('ring-2', 'ring-brand-primary');
// After refactor - update to new classes
expect(selectedItem).toHaveClass('border-brand-primary', 'bg-teal-50/50');
```
### Flexible Matching
For complex class combinations, consider partial matching:
```typescript
// Check for key classes, ignore utility classes
expect(element).toHaveClass('border-brand-primary');
// Or use regex for patterns
expect(element.className).toMatch(/dark:bg-teal-\d+/);
```
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for lessons learned from the test remediation effort.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -2,8 +2,68 @@
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
All commands in this guide are intended for the **system administrator** to execute directly on the server. Claude Code and AI tools have **READ-ONLY** access to production servers and cannot execute these commands directly.
When Claude assists with server setup or troubleshooting:
1. Claude provides commands for the administrator to execute
2. Administrator runs commands and reports output
3. Claude analyzes results and provides next steps (1-3 commands at a time)
4. Administrator executes and reports results
5. Claude provides verification commands to confirm success
---
## Table of Contents

View File

@@ -2,14 +2,81 @@
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:
| Actor | Capability |
| ------------ | --------------------------------------------------------------- |
| Gitea CI/CD | Automated deployments via workflows (has write access) |
| User (human) | Manual server access for troubleshooting and emergency fixes |
| Claude Code | Provides commands for user to execute; cannot run them directly |
When troubleshooting deployment issues:
1. Claude provides **diagnostic commands** for the user to run
2. User executes commands and reports output
3. Claude analyzes results and provides **fix commands** (1-3 at a time)
4. User executes fixes and reports results
5. Claude provides **verification commands** to confirm success
---
## 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)
@@ -190,7 +257,7 @@ types {
**Option 2**: Edit `/etc/nginx/mime.types` globally:
```
```text
# Change this line:
application/javascript js;
@@ -321,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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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)
@@ -276,10 +342,10 @@ Dev Container (in `.mcp.json`):
Bugsink 2.0.11 does not have a UI for API tokens. Create via Django management command.
**Production**:
**Production** (user executes on server):
```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token"
cd /opt/bugsink && bugsink-manage create_auth_token
```
**Dev Container**:
@@ -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 │
@@ -388,11 +454,9 @@ Log Sources Logstash Outputs
### Pipeline Status
**Check Logstash Service**:
**Check Logstash Service** (user executes on server):
```bash
ssh root@projectium.com
# Service status
systemctl status logstash
@@ -485,9 +549,11 @@ PM2 manages the Node.js application processes in production.
### Basic Commands
> **Note**: These commands are for the user to execute on the server. Claude Code provides commands but cannot run them directly.
```bash
ssh root@projectium.com
su - gitea-runner # PM2 runs under this user
# Switch to gitea-runner user (PM2 runs under this user)
su - gitea-runner
# List all processes
pm2 list
@@ -520,7 +586,7 @@ pm2 stop flyer-crawler-api
**Healthy Process**:
```
```text
┌─────────────────────┬────┬─────────┬─────────┬───────┬────────┬─────────┬──────────┐
│ Name │ id │ mode │ status │ cpu │ mem │ uptime │ restarts │
├─────────────────────┼────┼─────────┼─────────┼───────┼────────┼─────────┼──────────┤
@@ -833,29 +899,28 @@ 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.
```bash
# Full system health check
ssh root@projectium.com << 'EOF'
echo "=== Service Status ==="
# Service status checks
systemctl status pm2-gitea-runner --no-pager
systemctl status logstash --no-pager
systemctl status redis --no-pager
systemctl status postgresql --no-pager
echo "=== PM2 Processes ==="
# PM2 processes (run as gitea-runner)
su - gitea-runner -c "pm2 list"
echo "=== Disk Space ==="
# Disk space
df -h / /var
echo "=== Memory ==="
# Memory
free -h
echo "=== Recent Errors ==="
# Recent errors
journalctl -p err -n 20 --no-pager
EOF
```
### Runbook Quick Reference

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -6,6 +6,90 @@ 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
**Claude Code has READ-ONLY access to production/test servers.**
The `claude-win10` user cannot execute write operations (PM2 restart, systemctl, file modifications) directly on servers. The devops subagent must **provide commands for the user to execute**, not attempt to run them via SSH.
### Command Delegation Workflow
When troubleshooting or making changes to production/test servers:
| Phase | Actor | Action |
| -------- | ------ | ----------------------------------------------------------- |
| Diagnose | Claude | Provide read-only diagnostic commands |
| Report | User | Execute commands, share output with Claude |
| Analyze | Claude | Interpret results, identify root cause |
| Fix | Claude | Provide 1-3 fix commands (never more, errors may cascade) |
| Execute | User | Run fix commands, report results |
| Verify | Claude | Provide verification commands to confirm success |
| Document | Claude | Update relevant documentation with findings and resolutions |
### Example: PM2 Process Issue
Step 1 - Diagnostic Commands (Claude provides, user runs):
```bash
# Check PM2 process status
pm2 list
# View recent error logs
pm2 logs flyer-crawler-api --err --lines 50
# Check system resources
free -h
df -h /var/www
```
Step 2 - User reports output to Claude
Step 3 - Fix Commands (Claude provides 1-3 at a time):
```bash
# Restart the failing process
pm2 restart flyer-crawler-api
```
Step 4 - User executes and reports result
Step 5 - Verification Commands:
```bash
# Confirm process is running
pm2 list
# Test API health
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
```
### What NOT to Do
```bash
# WRONG - Claude cannot execute this directly
ssh root@projectium.com "pm2 restart all"
# WRONG - Providing too many commands at once
pm2 stop all && rm -rf node_modules && npm install && pm2 start all
# WRONG - Assuming commands succeeded without user confirmation
```
---
## The devops Subagent
### When to Use
@@ -372,6 +456,8 @@ redis-cli -a $REDIS_PASSWORD
## Service Management Commands
> **Note**: These commands are for the **user to execute on the server**. Claude Code provides these commands but cannot run them directly due to read-only server access. See [Server Access Model](#critical-server-access-model) above.
### PM2 Commands
```bash
@@ -468,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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -109,10 +109,10 @@ MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_
### Production Token
SSH into the production server:
User executes this command on the production server:
```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token"
cd /opt/bugsink && bugsink-manage create_auth_token
```
**Output:** Same format - 40-character hex token.
@@ -795,10 +795,10 @@ podman exec flyer-crawler-dev pg_isready -U bugsink -d bugsink -h postgres
podman exec flyer-crawler-dev psql -U postgres -h postgres -c "\l" | grep bugsink
```
**Production:**
**Production** (user executes on server):
```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage check"
cd /opt/bugsink && bugsink-manage check
```
### PostgreSQL Sequence Out of Sync (Duplicate Key Errors)
@@ -834,10 +834,9 @@ SELECT
END as status;
"
# Production
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< "
SELECT MAX(id) as max_id, (SELECT last_value FROM projects_project_id_seq) as seq_value FROM projects_project;
"
# Production (user executes on server)
cd /opt/bugsink && bugsink-manage dbshell
# Then run: SELECT MAX(id) as max_id, (SELECT last_value FROM projects_project_id_seq) as seq_value FROM projects_project;
```
**Solution:**
@@ -850,10 +849,9 @@ podman exec flyer-crawler-dev psql -U bugsink -h postgres -d bugsink -c "
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
"
# Production
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< "
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
"
# Production (user executes on server)
cd /opt/bugsink && bugsink-manage dbshell
# Then run: SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
```
**Verification:**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.12.18",
"version": "0.12.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.12.18",
"version": "0.12.22",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.12.18",
"version": "0.12.22",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View 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();
});
});
});

View 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;
}

View File

@@ -0,0 +1,424 @@
// src/components/NotificationBell.test.tsx
import React from 'react';
import { screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { NotificationBell, ConnectionStatus } from './NotificationBell';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the useWebSocket hook
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
// Mock the useEventBus hook
vi.mock('../hooks/useEventBus', () => ({
useEventBus: vi.fn(),
}));
// Import the mocked modules
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
// Type the mocked functions
const mockUseWebSocket = useWebSocket as Mock;
const mockUseEventBus = useEventBus as Mock;
describe('NotificationBell', () => {
let eventBusCallback: ((data?: unknown) => void) | null = null;
beforeEach(() => {
vi.clearAllMocks();
eventBusCallback = null;
// Default mock: connected state, no error
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
// Capture the callback passed to useEventBus
mockUseEventBus.mockImplementation((_event: string, callback: (data?: unknown) => void) => {
eventBusCallback = callback;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should render the notification bell button', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button', { name: /notifications/i });
expect(button).toBeInTheDocument();
});
it('should render with custom className', () => {
renderWithProviders(<NotificationBell className="custom-class" />);
const container = screen.getByRole('button').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should show connection status indicator by default', () => {
const { container } = renderWithProviders(<NotificationBell />);
// The status indicator is a span with inline style containing backgroundColor
const statusIndicator = container.querySelector('span[title="Connected"]');
expect(statusIndicator).toBeInTheDocument();
});
it('should hide connection status indicator when showConnectionStatus is false', () => {
const { container } = renderWithProviders(<NotificationBell showConnectionStatus={false} />);
// No status indicator should be present (no span with title Connected/Connecting/Disconnected)
const connectedIndicator = container.querySelector('span[title="Connected"]');
const connectingIndicator = container.querySelector('span[title="Connecting"]');
const disconnectedIndicator = container.querySelector('span[title="Disconnected"]');
expect(connectedIndicator).not.toBeInTheDocument();
expect(connectingIndicator).not.toBeInTheDocument();
expect(disconnectedIndicator).not.toBeInTheDocument();
});
});
describe('unread count badge', () => {
it('should not show badge when unread count is zero', () => {
renderWithProviders(<NotificationBell />);
// The badge displays numbers, check that no number badge exists
const badge = screen.queryByText(/^\d+$/);
expect(badge).not.toBeInTheDocument();
});
it('should show badge with count when notifications arrive', () => {
renderWithProviders(<NotificationBell />);
// Simulate a notification arriving via event bus
expect(eventBusCallback).not.toBeNull();
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
const badge = screen.getByText('1');
expect(badge).toBeInTheDocument();
});
it('should increment count when multiple notifications arrive', () => {
renderWithProviders(<NotificationBell />);
// Simulate multiple notifications
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test 1' }] });
eventBusCallback!({ deals: [{ item_name: 'Test 2' }] });
eventBusCallback!({ deals: [{ item_name: 'Test 3' }] });
});
const badge = screen.getByText('3');
expect(badge).toBeInTheDocument();
});
it('should display 99+ when count exceeds 99', () => {
renderWithProviders(<NotificationBell />);
// Simulate 100 notifications
act(() => {
for (let i = 0; i < 100; i++) {
eventBusCallback!({ deals: [{ item_name: `Test ${i}` }] });
}
});
const badge = screen.getByText('99+');
expect(badge).toBeInTheDocument();
});
it('should not increment count when notification data is undefined', () => {
renderWithProviders(<NotificationBell />);
// Simulate a notification with undefined data
act(() => {
eventBusCallback!(undefined);
});
const badge = screen.queryByText(/^\d+$/);
expect(badge).not.toBeInTheDocument();
});
});
describe('click behavior', () => {
it('should reset unread count when clicked', () => {
renderWithProviders(<NotificationBell />);
// First, add some notifications
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
expect(screen.getByText('1')).toBeInTheDocument();
// Click the bell
const button = screen.getByRole('button');
fireEvent.click(button);
// Badge should no longer show
expect(screen.queryByText('1')).not.toBeInTheDocument();
});
it('should call onClick callback when provided', () => {
const mockOnClick = vi.fn();
renderWithProviders(<NotificationBell onClick={mockOnClick} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('should handle click without onClick callback', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
// Should not throw
expect(() => fireEvent.click(button)).not.toThrow();
});
});
describe('connection status', () => {
it('should show green indicator when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Connected"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(16, 185, 129)' });
});
it('should show red indicator when error occurs', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Disconnected"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(239, 68, 68)' });
});
it('should show amber indicator when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Connecting"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(245, 158, 11)' });
});
it('should show error tooltip when disconnected with error', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<NotificationBell />);
expect(screen.getByText('Live notifications unavailable')).toBeInTheDocument();
});
it('should not show error tooltip when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<NotificationBell />);
expect(screen.queryByText('Live notifications unavailable')).not.toBeInTheDocument();
});
});
describe('aria attributes', () => {
it('should have correct aria-label without unread notifications', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Notifications');
});
it('should have correct aria-label with unread notifications', () => {
renderWithProviders(<NotificationBell />);
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
eventBusCallback!({ deals: [{ item_name: 'Test2' }] });
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Notifications (2 unread)');
});
it('should have correct title when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'Connected to live notifications');
});
it('should have correct title when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'Connecting...');
});
it('should have correct title when error occurs', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Network error',
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'WebSocket error: Network error');
});
});
describe('bell icon styling', () => {
it('should have default color when no unread notifications', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toHaveClass('text-gray-600');
});
it('should have highlighted color when there are unread notifications', () => {
renderWithProviders(<NotificationBell />);
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toHaveClass('text-blue-600');
});
});
describe('event bus subscription', () => {
it('should subscribe to notification:deal event', () => {
renderWithProviders(<NotificationBell />);
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
});
});
describe('useWebSocket configuration', () => {
it('should call useWebSocket with autoConnect: true', () => {
renderWithProviders(<NotificationBell />);
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
});
});
});
describe('ConnectionStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should show "Live" text when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('should show "Offline" text when disconnected with error', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Offline')).toBeInTheDocument();
});
it('should show "Connecting..." text when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
it('should call useWebSocket with autoConnect: true', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
});
it('should render Wifi icon when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
const container = screen.getByText('Live').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-green-600');
});
it('should render WifiOff icon when disconnected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<ConnectionStatus />);
const container = screen.getByText('Offline').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-red-600');
});
});

View File

@@ -0,0 +1,776 @@
// src/components/NotificationToastHandler.test.tsx
import React from 'react';
import { render, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { NotificationToastHandler } from './NotificationToastHandler';
import type { DealNotificationData, SystemMessageData } from '../types/websocket';
// Use vi.hoisted to properly hoist mock functions
const { mockToastSuccess, mockToastError, mockToastDefault } = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
mockToastDefault: vi.fn(),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => {
const toastFn = (message: string, options?: unknown) => mockToastDefault(message, options);
toastFn.success = mockToastSuccess;
toastFn.error = mockToastError;
return {
default: toastFn,
};
});
// Mock useWebSocket hook
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
// Mock useEventBus hook
vi.mock('../hooks/useEventBus', () => ({
useEventBus: vi.fn(),
}));
// Mock formatCurrency
vi.mock('../utils/formatUtils', () => ({
formatCurrency: vi.fn((cents: number) => `$${(cents / 100).toFixed(2)}`),
}));
// Import mocked modules
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
const mockUseWebSocket = useWebSocket as Mock;
const mockUseEventBus = useEventBus as Mock;
describe('NotificationToastHandler', () => {
let eventBusCallbacks: Map<string, (data?: unknown) => void>;
let onConnectCallback: (() => void) | undefined;
let onDisconnectCallback: (() => void) | undefined;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Clear toast mocks
mockToastSuccess.mockClear();
mockToastError.mockClear();
mockToastDefault.mockClear();
eventBusCallbacks = new Map();
onConnectCallback = undefined;
onDisconnectCallback = undefined;
// Default mock implementation for useWebSocket
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: true,
error: null,
};
},
);
// Capture callbacks for different event types
mockUseEventBus.mockImplementation((event: string, callback: (data?: unknown) => void) => {
eventBusCallbacks.set(event, callback);
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should render null (no visible output)', () => {
const { container } = render(<NotificationToastHandler />);
expect(container.firstChild).toBeNull();
});
it('should subscribe to event bus on mount', () => {
render(<NotificationToastHandler />);
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
expect(mockUseEventBus).toHaveBeenCalledWith('notification:system', expect.any(Function));
expect(mockUseEventBus).toHaveBeenCalledWith('notification:error', expect.any(Function));
});
});
describe('connection events', () => {
it('should show success toast on connect when enabled', () => {
render(<NotificationToastHandler enabled={true} />);
// Trigger onConnect callback
onConnectCallback?.();
expect(mockToastSuccess).toHaveBeenCalledWith(
'Connected to live notifications',
expect.objectContaining({
duration: 2000,
icon: expect.any(String),
}),
);
});
it('should not show success toast on connect when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
onConnectCallback?.();
expect(mockToastSuccess).not.toHaveBeenCalled();
});
it('should show error toast on disconnect when error exists', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection lost',
};
},
);
render(<NotificationToastHandler enabled={true} />);
onDisconnectCallback?.();
expect(mockToastError).toHaveBeenCalledWith(
'Disconnected from live notifications',
expect.objectContaining({
duration: 3000,
icon: expect.any(String),
}),
);
});
it('should not show disconnect toast when disabled', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection lost',
};
},
);
render(<NotificationToastHandler enabled={false} />);
onDisconnectCallback?.();
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show disconnect toast when no error', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: null,
};
},
);
render(<NotificationToastHandler enabled={true} />);
onDisconnectCallback?.();
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('deal notifications', () => {
it('should show toast for single deal notification', () => {
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
position: 'top-right',
}),
);
});
it('should show toast for multiple deals notification', () => {
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Store A',
store_id: 1,
},
{
item_name: 'Bread',
best_price_in_cents: 299,
store_name: 'Store B',
store_id: 2,
},
{
item_name: 'Eggs',
best_price_in_cents: 499,
store_name: 'Store C',
store_id: 3,
},
],
user_id: 'user-123',
message: 'Multiple deals found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).toHaveBeenCalled();
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:deal');
callback?.(undefined);
expect(mockToastSuccess).not.toHaveBeenCalled();
});
});
describe('system messages', () => {
it('should show error toast for error severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System error occurred',
severity: 'error',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
expect(mockToastError).toHaveBeenCalledWith(
'System error occurred',
expect.objectContaining({
duration: 6000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should show warning toast for warning severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System warning',
severity: 'warning',
};
// For warning, the default toast() is called
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
// Warning uses the regular toast function (mockToastDefault)
expect(mockToastDefault).toHaveBeenCalledWith(
'System warning',
expect.objectContaining({
duration: 4000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should show info toast for info severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System info',
severity: 'info',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
// Info uses the regular toast function (mockToastDefault)
expect(mockToastDefault).toHaveBeenCalledWith(
'System info',
expect.objectContaining({
duration: 4000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const systemData: SystemMessageData = {
message: 'System error',
severity: 'error',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:system');
callback?.(undefined);
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('error notifications', () => {
it('should show error toast with message and code', () => {
render(<NotificationToastHandler />);
const errorData = {
message: 'Something went wrong',
code: 'ERR_001',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).toHaveBeenCalledWith(
'Error: Something went wrong',
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
}),
);
});
it('should show error toast without code', () => {
render(<NotificationToastHandler />);
const errorData = {
message: 'Something went wrong',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).toHaveBeenCalledWith(
'Error: Something went wrong',
expect.objectContaining({
duration: 5000,
}),
);
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const errorData = {
message: 'Something went wrong',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:error');
callback?.(undefined);
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('sound playback', () => {
it('should not play sound by default', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={false} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).not.toHaveBeenCalled();
});
it('should create Audio instance when playSound is true', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
// Verify Audio constructor was called with correct URL
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
});
it('should use custom sound URL', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} soundUrl="/custom-sound.mp3" />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).toHaveBeenCalledWith('/custom-sound.mp3');
});
it('should handle audio play failure gracefully', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const audioPlayMock = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
// Should not throw even if play() fails
expect(() => callback?.(dealData)).not.toThrow();
// Audio constructor should still be called
expect(AudioMock).toHaveBeenCalled();
});
it('should handle Audio constructor failure gracefully', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const AudioMock = vi.fn().mockImplementation(() => {
throw new Error('Audio not supported');
});
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
// Should not throw
expect(() => callback?.(dealData)).not.toThrow();
});
});
describe('persistent connection error', () => {
it('should show error toast after delay when connection error persists', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={true} />);
// Fast-forward 5 seconds
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).toHaveBeenCalledWith(
'Unable to connect to live notifications. Some features may be limited.',
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
}),
);
});
it('should not show error toast before delay', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={true} />);
// Advance only 4 seconds
act(() => {
vi.advanceTimersByTime(4000);
});
expect(mockToastError).not.toHaveBeenCalledWith(
expect.stringContaining('Unable to connect'),
expect.anything(),
);
});
it('should not show persistent error toast when disabled', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={false} />);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).not.toHaveBeenCalled();
});
it('should clear timeout on unmount', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
const { unmount } = render(<NotificationToastHandler enabled={true} />);
// Unmount before timer fires
unmount();
act(() => {
vi.advanceTimersByTime(5000);
});
// The toast should not be called because component unmounted
expect(mockToastError).not.toHaveBeenCalledWith(
expect.stringContaining('Unable to connect'),
expect.anything(),
);
});
it('should not show persistent error toast when there is no error', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: null,
};
},
);
render(<NotificationToastHandler enabled={true} />);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('default props', () => {
it('should default enabled to true', () => {
render(<NotificationToastHandler />);
onConnectCallback?.();
expect(mockToastSuccess).toHaveBeenCalled();
});
it('should default playSound to false', () => {
const AudioMock = vi.fn();
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).not.toHaveBeenCalled();
});
it('should default soundUrl to /notification-sound.mp3', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
});
});
});

View File

@@ -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;

View File

@@ -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];
}

View File

@@ -89,8 +89,7 @@ describe('FlyerDisplay', () => {
it('should apply dark mode image styles', () => {
render(<FlyerDisplay {...defaultProps} />);
const image = screen.getByAltText('Grocery Flyer');
expect(image).toHaveClass('dark:invert');
expect(image).toHaveClass('dark:hue-rotate-180');
expect(image).toHaveClass('dark:brightness-90');
});
describe('"Correct Data" Button', () => {

View File

@@ -147,7 +147,11 @@ describe('FlyerList', () => {
);
const selectedItem = screen.getByText('Metro').closest('li');
expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30');
expect(selectedItem).toHaveClass(
'border-brand-primary',
'bg-teal-50/50',
'dark:bg-teal-900/10',
);
});
describe('UI Details and Edge Cases', () => {

View File

@@ -0,0 +1,392 @@
// src/features/store/StoreCard.test.tsx
import React from 'react';
import { screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { StoreCard } from './StoreCard';
import { renderWithProviders } from '../../tests/utils/renderWithProviders';
describe('StoreCard', () => {
const mockStoreWithLogo = {
store_id: 1,
name: 'Test Store',
logo_url: 'https://example.com/logo.png',
locations: [
{
address_line_1: '123 Main Street',
city: 'Toronto',
province_state: 'ON',
postal_code: 'M5V 1A1',
},
],
};
const mockStoreWithoutLogo = {
store_id: 2,
name: 'Another Store',
logo_url: null,
locations: [
{
address_line_1: '456 Oak Avenue',
city: 'Vancouver',
province_state: 'BC',
postal_code: 'V6B 2M9',
},
],
};
const mockStoreWithMultipleLocations = {
store_id: 3,
name: 'Multi Location Store',
logo_url: 'https://example.com/multi-logo.png',
locations: [
{
address_line_1: '100 First Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H2X 1Y6',
},
{
address_line_1: '200 Second Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H3A 2T1',
},
{
address_line_1: '300 Third Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H4B 3C2',
},
],
};
const mockStoreNoLocations = {
store_id: 4,
name: 'No Location Store',
logo_url: 'https://example.com/no-loc-logo.png',
locations: [],
};
const mockStoreUndefinedLocations = {
store_id: 5,
name: 'Undefined Locations Store',
logo_url: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('store name rendering', () => {
it('should render the store name', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
expect(screen.getByText('Test Store')).toBeInTheDocument();
});
it('should render store name with truncation class', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveClass('truncate');
});
});
describe('logo rendering', () => {
it('should render logo image when logo_url is provided', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', 'https://example.com/logo.png');
});
it('should render initials fallback when logo_url is null', () => {
renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
expect(screen.getByText('AN')).toBeInTheDocument();
});
it('should render initials fallback when logo_url is undefined', () => {
const storeWithUndefinedLogo = {
store_id: 10,
name: 'Test Name',
logo_url: undefined,
};
renderWithProviders(<StoreCard store={storeWithUndefinedLogo} />);
expect(screen.getByText('TE')).toBeInTheDocument();
});
it('should convert initials to uppercase', () => {
const storeWithLowercase = {
store_id: 11,
name: 'lowercase store',
logo_url: null,
};
renderWithProviders(<StoreCard store={storeWithLowercase} />);
expect(screen.getByText('LO')).toBeInTheDocument();
});
it('should handle single character store name', () => {
const singleCharStore = {
store_id: 12,
name: 'X',
logo_url: null,
};
renderWithProviders(<StoreCard store={singleCharStore} />);
// Both the store name and initials will be 'X'
// Check that there are exactly 2 elements with 'X'
const elements = screen.getAllByText('X');
expect(elements).toHaveLength(2);
});
it('should handle empty string store name', () => {
const emptyNameStore = {
store_id: 13,
name: '',
logo_url: null,
};
// This will render empty string for initials
const { container } = renderWithProviders(<StoreCard store={emptyNameStore} />);
// The fallback div should still render
const fallbackDiv = container.querySelector('.h-12.w-12.flex');
expect(fallbackDiv).toBeInTheDocument();
});
});
describe('location display', () => {
it('should not show location when showLocations is false (default)', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
expect(screen.queryByText('123 Main Street')).not.toBeInTheDocument();
expect(screen.queryByText(/Toronto/)).not.toBeInTheDocument();
});
it('should show primary location when showLocations is true', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
expect(screen.getByText('123 Main Street')).toBeInTheDocument();
expect(screen.getByText('Toronto, ON M5V 1A1')).toBeInTheDocument();
});
it('should show "No location data" when showLocations is true but no locations exist', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
expect(screen.getByText('No location data')).toBeInTheDocument();
});
it('should show "No location data" when locations is undefined', () => {
renderWithProviders(
<StoreCard store={mockStoreUndefinedLocations as any} showLocations={true} />,
);
expect(screen.getByText('No location data')).toBeInTheDocument();
});
it('should not show "No location data" message when showLocations is false', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={false} />);
expect(screen.queryByText('No location data')).not.toBeInTheDocument();
});
});
describe('multiple locations', () => {
it('should show additional locations count for 2 locations', () => {
const storeWith2Locations = {
...mockStoreWithLogo,
locations: [
mockStoreWithMultipleLocations.locations[0],
mockStoreWithMultipleLocations.locations[1],
],
};
renderWithProviders(<StoreCard store={storeWith2Locations} showLocations={true} />);
expect(screen.getByText('+ 1 more location')).toBeInTheDocument();
});
it('should show additional locations count for 3+ locations', () => {
renderWithProviders(
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
);
expect(screen.getByText('+ 2 more locations')).toBeInTheDocument();
});
it('should show primary location from multiple locations', () => {
renderWithProviders(
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
);
// Should show first location
expect(screen.getByText('100 First Street')).toBeInTheDocument();
expect(screen.getByText('Montreal, QC H2X 1Y6')).toBeInTheDocument();
// Should NOT show secondary locations directly
expect(screen.queryByText('200 Second Street')).not.toBeInTheDocument();
});
it('should not show additional locations count for single location', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
expect(screen.queryByText(/more location/)).not.toBeInTheDocument();
});
});
describe('accessibility', () => {
it('should have proper alt text for logo', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toBeInTheDocument();
});
it('should use heading level 3 for store name', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Test Store');
});
});
describe('styling', () => {
it('should apply flex layout to container', () => {
const { container } = renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const mainDiv = container.firstChild;
expect(mainDiv).toHaveClass('flex', 'items-start', 'space-x-3');
});
it('should apply proper styling to logo image', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toHaveClass(
'h-12',
'w-12',
'object-contain',
'rounded-md',
'bg-gray-100',
'dark:bg-gray-700',
'p-1',
'flex-shrink-0',
);
});
it('should apply proper styling to initials fallback', () => {
const { container } = renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
const initialsDiv = container.querySelector('.h-12.w-12.flex.items-center.justify-center');
expect(initialsDiv).toHaveClass(
'h-12',
'w-12',
'flex',
'items-center',
'justify-center',
'bg-gray-200',
'dark:bg-gray-700',
'rounded-md',
'text-gray-400',
'text-xs',
'flex-shrink-0',
);
});
it('should apply italic style to "No location data" text', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
const noLocationText = screen.getByText('No location data');
expect(noLocationText).toHaveClass('italic');
});
});
describe('edge cases', () => {
it('should handle store with special characters in name', () => {
const specialCharStore = {
store_id: 20,
name: "Store & Co's <Best>",
logo_url: null,
};
renderWithProviders(<StoreCard store={specialCharStore} />);
expect(screen.getByText("Store & Co's <Best>")).toBeInTheDocument();
expect(screen.getByText('ST')).toBeInTheDocument();
});
it('should handle store with unicode characters', () => {
const unicodeStore = {
store_id: 21,
name: 'Cafe Le Cafe',
logo_url: null,
};
renderWithProviders(<StoreCard store={unicodeStore} />);
expect(screen.getByText('Cafe Le Cafe')).toBeInTheDocument();
expect(screen.getByText('CA')).toBeInTheDocument();
});
it('should handle location with long address', () => {
const longAddressStore = {
store_id: 22,
name: 'Long Address Store',
logo_url: 'https://example.com/logo.png',
locations: [
{
address_line_1: '1234567890 Very Long Street Name That Exceeds Normal Length',
city: 'Vancouver',
province_state: 'BC',
postal_code: 'V6B 2M9',
},
],
};
renderWithProviders(<StoreCard store={longAddressStore} showLocations={true} />);
const addressElement = screen.getByText(
'1234567890 Very Long Street Name That Exceeds Normal Length',
);
expect(addressElement).toHaveClass('truncate');
});
});
describe('data types', () => {
it('should accept store_id as number', () => {
const store = {
store_id: 12345,
name: 'Numeric ID Store',
logo_url: null,
};
// This should compile and render without errors
renderWithProviders(<StoreCard store={store} />);
expect(screen.getByText('Numeric ID Store')).toBeInTheDocument();
});
it('should handle empty logo_url string', () => {
const storeWithEmptyLogo = {
store_id: 30,
name: 'Empty Logo Store',
logo_url: '',
};
// Empty string is truthy check, but might cause issues with img src
// The component checks for truthy logo_url, so empty string will render initials
// Actually, empty string '' is falsy in JavaScript, so this would show initials
renderWithProviders(<StoreCard store={storeWithEmptyLogo} />);
// Empty string is falsy, so initials should show
expect(screen.getByText('EM')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,311 @@
// src/hooks/useEventBus.test.ts
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { useEventBus } from './useEventBus';
// Mock the eventBus service
vi.mock('../services/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
dispatch: vi.fn(),
},
}));
import { eventBus } from '../services/eventBus';
const mockEventBus = eventBus as unknown as {
on: Mock;
off: Mock;
dispatch: Mock;
};
describe('useEventBus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('subscription', () => {
it('should subscribe to the event on mount', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
expect(mockEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('should unsubscribe from the event on unmount', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => useEventBus('test-event', callback));
unmount();
expect(mockEventBus.off).toHaveBeenCalledTimes(1);
expect(mockEventBus.off).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('should pass the same callback reference to on and off', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => useEventBus('test-event', callback));
const onCallback = mockEventBus.on.mock.calls[0][1];
unmount();
const offCallback = mockEventBus.off.mock.calls[0][1];
expect(onCallback).toBe(offCallback);
});
});
describe('callback execution', () => {
it('should call the callback when event is dispatched', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
// Get the registered callback and call it
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ message: 'hello' });
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({ message: 'hello' });
});
it('should call the callback with undefined data', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback(undefined);
expect(callback).toHaveBeenCalledWith(undefined);
});
it('should call the callback with null data', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback(null);
expect(callback).toHaveBeenCalledWith(null);
});
});
describe('callback ref updates', () => {
it('should use the latest callback when event is dispatched', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
initialProps: { callback: callback1 },
});
// Rerender with new callback
rerender({ callback: callback2 });
// Get the registered callback and call it
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ message: 'hello' });
// Should call the new callback, not the old one
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledWith({ message: 'hello' });
});
it('should not re-subscribe when callback changes', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
initialProps: { callback: callback1 },
});
// Clear mock counts
mockEventBus.on.mockClear();
mockEventBus.off.mockClear();
// Rerender with new callback
rerender({ callback: callback2 });
// Should NOT unsubscribe and re-subscribe
expect(mockEventBus.off).not.toHaveBeenCalled();
expect(mockEventBus.on).not.toHaveBeenCalled();
});
});
describe('event name changes', () => {
it('should re-subscribe when event name changes', () => {
const callback = vi.fn();
const { rerender } = renderHook(({ event }) => useEventBus(event, callback), {
initialProps: { event: 'event-1' },
});
// Initial subscription
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
expect(mockEventBus.on).toHaveBeenCalledWith('event-1', expect.any(Function));
// Clear mock
mockEventBus.on.mockClear();
// Rerender with different event
rerender({ event: 'event-2' });
// Should unsubscribe from old event
expect(mockEventBus.off).toHaveBeenCalledWith('event-1', expect.any(Function));
// Should subscribe to new event
expect(mockEventBus.on).toHaveBeenCalledWith('event-2', expect.any(Function));
});
});
describe('multiple hooks', () => {
it('should allow multiple subscriptions to same event', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
renderHook(() => useEventBus('shared-event', callback1));
renderHook(() => useEventBus('shared-event', callback2));
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
// Both should be subscribed to same event
expect(mockEventBus.on.mock.calls[0][0]).toBe('shared-event');
expect(mockEventBus.on.mock.calls[1][0]).toBe('shared-event');
});
it('should allow subscriptions to different events', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
renderHook(() => useEventBus('event-a', callback1));
renderHook(() => useEventBus('event-b', callback2));
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
expect(mockEventBus.on).toHaveBeenCalledWith('event-a', expect.any(Function));
expect(mockEventBus.on).toHaveBeenCalledWith('event-b', expect.any(Function));
});
});
describe('type safety', () => {
it('should correctly type the callback data', () => {
interface TestData {
id: number;
name: string;
}
const callback = vi.fn();
renderHook(() => useEventBus<TestData>('typed-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ id: 1, name: 'test' });
expect(callback).toHaveBeenCalledWith({ id: 1, name: 'test' });
});
it('should handle callback with optional parameter', () => {
const callback = vi.fn();
renderHook(() => useEventBus<string>('optional-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
// Call with data
registeredCallback('hello');
expect(callback).toHaveBeenCalledWith('hello');
// Call without data
registeredCallback();
expect(callback).toHaveBeenCalledWith(undefined);
});
});
describe('edge cases', () => {
it('should handle empty string event name', () => {
const callback = vi.fn();
renderHook(() => useEventBus('', callback));
expect(mockEventBus.on).toHaveBeenCalledWith('', expect.any(Function));
});
it('should handle event names with special characters', () => {
const callback = vi.fn();
renderHook(() => useEventBus('event:with:colons', callback));
expect(mockEventBus.on).toHaveBeenCalledWith('event:with:colons', expect.any(Function));
});
it('should handle rapid mount/unmount cycles', () => {
const callback = vi.fn();
const { unmount: unmount1 } = renderHook(() => useEventBus('rapid-event', callback));
unmount1();
const { unmount: unmount2 } = renderHook(() => useEventBus('rapid-event', callback));
unmount2();
const { unmount: unmount3 } = renderHook(() => useEventBus('rapid-event', callback));
unmount3();
// Should have 3 subscriptions and 3 unsubscriptions
expect(mockEventBus.on).toHaveBeenCalledTimes(3);
expect(mockEventBus.off).toHaveBeenCalledTimes(3);
});
});
describe('stable callback reference', () => {
it('should use useCallback for stable reference', () => {
const callback = vi.fn();
const { rerender } = renderHook(() => useEventBus('stable-event', callback));
const firstCallbackRef = mockEventBus.on.mock.calls[0][1];
// Force a rerender
rerender();
// The callback passed to eventBus.on should remain the same
// (no re-subscription means the same callback is used)
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
// Verify the callback still works after rerender
firstCallbackRef({ data: 'test' });
expect(callback).toHaveBeenCalledWith({ data: 'test' });
});
});
describe('cleanup timing', () => {
it('should unsubscribe before component is fully unmounted', () => {
const callback = vi.fn();
const cleanupOrder: string[] = [];
// Override off to track when it's called
mockEventBus.off.mockImplementation(() => {
cleanupOrder.push('eventBus.off');
});
const { unmount } = renderHook(() => useEventBus('cleanup-event', callback));
cleanupOrder.push('before unmount');
unmount();
cleanupOrder.push('after unmount');
expect(cleanupOrder).toEqual(['before unmount', 'eventBus.off', 'after unmount']);
});
});
});

View File

@@ -0,0 +1,300 @@
// src/hooks/useFeatureFlag.test.ts
/**
* Unit tests for the useFeatureFlag React hook (ADR-024).
*
* These tests verify:
* - useFeatureFlag() returns correct boolean for each flag
* - useFeatureFlag() handles all valid flag names
* - useAllFeatureFlags() returns all flag states
* - Default behavior (all flags disabled when not set)
* - Memoization behavior (stable references)
*/
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Define mock feature flags object that will be mutated in tests
// Note: We use a getter function pattern to avoid hoisting issues with vi.mock
vi.mock('../config', () => {
// Create a mutable flags object
const flags = {
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
};
return {
default: {
featureFlags: flags,
},
// Export the flags object for test mutation
__mockFlags: flags,
};
});
// Import config to get access to the mock flags for mutation
import config from '../config';
// Import after mocking
import { useFeatureFlag, useAllFeatureFlags, type FeatureFlagName } from './useFeatureFlag';
// Helper to reset flags
const resetMockFlags = () => {
config.featureFlags.newDashboard = false;
config.featureFlags.betaRecipes = false;
config.featureFlags.experimentalAi = false;
config.featureFlags.debugMode = false;
};
describe('useFeatureFlag hook', () => {
beforeEach(() => {
// Reset mock flags to default state before each test
resetMockFlags();
});
describe('useFeatureFlag()', () => {
it('should return false for disabled flags', () => {
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(false);
});
it('should return true for enabled flags', () => {
config.featureFlags.newDashboard = true;
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(true);
});
it('should return false for betaRecipes when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(result.current).toBe(false);
});
it('should return true for betaRecipes when enabled', () => {
config.featureFlags.betaRecipes = true;
const { result } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(result.current).toBe(true);
});
it('should return false for experimentalAi when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('experimentalAi'));
expect(result.current).toBe(false);
});
it('should return true for experimentalAi when enabled', () => {
config.featureFlags.experimentalAi = true;
const { result } = renderHook(() => useFeatureFlag('experimentalAi'));
expect(result.current).toBe(true);
});
it('should return false for debugMode when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('debugMode'));
expect(result.current).toBe(false);
});
it('should return true for debugMode when enabled', () => {
config.featureFlags.debugMode = true;
const { result } = renderHook(() => useFeatureFlag('debugMode'));
expect(result.current).toBe(true);
});
it('should return consistent value across multiple calls with same flag', () => {
config.featureFlags.newDashboard = true;
const { result: result1 } = renderHook(() => useFeatureFlag('newDashboard'));
const { result: result2 } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result1.current).toBe(result2.current);
expect(result1.current).toBe(true);
});
it('should return different values for different flags', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = false;
const { result: dashboardResult } = renderHook(() => useFeatureFlag('newDashboard'));
const { result: recipesResult } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(dashboardResult.current).toBe(true);
expect(recipesResult.current).toBe(false);
});
it('should memoize the result (stable reference with same flagName)', () => {
const { result, rerender } = renderHook(
({ flagName }: { flagName: FeatureFlagName }) => useFeatureFlag(flagName),
{ initialProps: { flagName: 'newDashboard' as FeatureFlagName } },
);
const firstValue = result.current;
// Rerender with same flag name
rerender({ flagName: 'newDashboard' as FeatureFlagName });
const secondValue = result.current;
// Values should be equal (both false in this case)
expect(firstValue).toBe(secondValue);
});
it('should update when flag name changes', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = false;
const { result, rerender } = renderHook(
({ flagName }: { flagName: FeatureFlagName }) => useFeatureFlag(flagName),
{ initialProps: { flagName: 'newDashboard' as FeatureFlagName } },
);
expect(result.current).toBe(true);
// Change to a different flag
rerender({ flagName: 'betaRecipes' as FeatureFlagName });
expect(result.current).toBe(false);
});
});
describe('useAllFeatureFlags()', () => {
it('should return all flags with their current states', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.debugMode = true;
const { result } = renderHook(() => useAllFeatureFlags());
expect(result.current).toEqual({
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should return all flags as false when none are enabled', () => {
const { result } = renderHook(() => useAllFeatureFlags());
expect(result.current).toEqual({
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
});
});
it('should return a shallow copy (not the original object)', () => {
const { result } = renderHook(() => useAllFeatureFlags());
// Modifying the returned object should not affect the config
const flags = result.current;
(flags as Record<string, boolean>).newDashboard = true;
// Re-render and get fresh flags
const { result: result2 } = renderHook(() => useAllFeatureFlags());
// The mock config should still have the original value
expect(config.featureFlags.newDashboard).toBe(false);
// Note: result2 reads from the mock, which we didn't modify
expect(result2.current.newDashboard).toBe(false);
});
it('should return an object with all expected flag names', () => {
const { result } = renderHook(() => useAllFeatureFlags());
const expectedFlags = ['newDashboard', 'betaRecipes', 'experimentalAi', 'debugMode'];
expect(Object.keys(result.current).sort()).toEqual(expectedFlags.sort());
});
it('should memoize the result', () => {
const { result, rerender } = renderHook(() => useAllFeatureFlags());
const firstValue = result.current;
// Rerender without any changes
rerender();
const secondValue = result.current;
// Should return the same memoized object reference
expect(firstValue).toBe(secondValue);
});
});
describe('FeatureFlagName type', () => {
it('should accept valid flag names at compile time', () => {
// These should compile without TypeScript errors
const validNames: FeatureFlagName[] = [
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
validNames.forEach((name) => {
const { result } = renderHook(() => useFeatureFlag(name));
expect(typeof result.current).toBe('boolean');
});
});
});
});
describe('useFeatureFlag integration scenarios', () => {
beforeEach(() => {
resetMockFlags();
});
it('should work with conditional rendering pattern', () => {
config.featureFlags.newDashboard = true;
const { result } = renderHook(() => {
const isNewDashboard = useFeatureFlag('newDashboard');
return isNewDashboard ? 'new' : 'legacy';
});
expect(result.current).toBe('new');
});
it('should work with multiple flags in same component', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = true;
config.featureFlags.experimentalAi = false;
const { result } = renderHook(() => ({
dashboard: useFeatureFlag('newDashboard'),
recipes: useFeatureFlag('betaRecipes'),
ai: useFeatureFlag('experimentalAi'),
}));
expect(result.current).toEqual({
dashboard: true,
recipes: true,
ai: false,
});
});
it('should work with useAllFeatureFlags for admin panels', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.debugMode = true;
const { result } = renderHook(() => {
const flags = useAllFeatureFlags();
const enabledCount = Object.values(flags).filter(Boolean).length;
return { flags, enabledCount };
});
expect(result.current.enabledCount).toBe(2);
expect(result.current.flags.newDashboard).toBe(true);
expect(result.current.flags.debugMode).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
// src/hooks/useFeatureFlag.ts
import { useMemo } from 'react';
import config from '../config';
/**
* Union type of all available feature flag names.
* This type is derived from the config.featureFlags object to ensure
* type safety and autocomplete support when checking feature flags.
*
* @example
* const flagName: FeatureFlagName = 'newDashboard'; // Valid
* const invalid: FeatureFlagName = 'nonexistent'; // TypeScript error
*/
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* React hook to check if a feature flag is enabled.
*
* Feature flags are loaded from environment variables at build time and
* cannot change during runtime. This hook memoizes the result to prevent
* unnecessary re-renders when the component re-renders.
*
* @param flagName - The name of the feature flag to check (must be a valid FeatureFlagName)
* @returns boolean indicating if the feature is enabled (true) or disabled (false)
*
* @example
* // Basic usage - conditionally render UI
* function Dashboard() {
* const isNewDashboard = useFeatureFlag('newDashboard');
*
* if (isNewDashboard) {
* return <NewDashboard />;
* }
* return <LegacyDashboard />;
* }
*
* @example
* // Track feature flag usage with analytics
* function FeatureComponent() {
* const isExperimentalAi = useFeatureFlag('experimentalAi');
*
* useEffect(() => {
* if (isExperimentalAi) {
* analytics.track('experimental_ai_enabled');
* }
* }, [isExperimentalAi]);
*
* return isExperimentalAi ? <AiFeature /> : null;
* }
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
return useMemo(() => config.featureFlags[flagName], [flagName]);
}
/**
* React hook to get all feature flags and their current states.
*
* This hook is useful for debugging, admin panels, or components that
* need to display the current feature flag configuration. The returned
* object is a shallow copy to prevent accidental mutation of the config.
*
* @returns Record mapping each feature flag name to its boolean state
*
* @example
* // Display feature flag status in an admin panel
* function FeatureFlagDebugPanel() {
* const flags = useAllFeatureFlags();
*
* return (
* <ul>
* {Object.entries(flags).map(([name, enabled]) => (
* <li key={name}>
* {name}: {enabled ? 'Enabled' : 'Disabled'}
* </li>
* ))}
* </ul>
* );
* }
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return useMemo(() => ({ ...config.featureFlags }), []);
}

View File

@@ -0,0 +1,560 @@
// src/hooks/useOnboardingTour.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { useOnboardingTour } from './useOnboardingTour';
// Mock driver.js
const mockDrive = vi.fn();
const mockDestroy = vi.fn();
const mockDriverInstance = {
drive: mockDrive,
destroy: mockDestroy,
};
vi.mock('driver.js', () => ({
driver: vi.fn(() => mockDriverInstance),
Driver: vi.fn(),
DriveStep: vi.fn(),
}));
import { driver } from 'driver.js';
const mockDriver = driver as Mock;
describe('useOnboardingTour', () => {
const STORAGE_KEY = 'flyer_crawler_onboarding_completed';
// Mock localStorage
let mockLocalStorage: { [key: string]: string };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Reset mock driver instance methods
mockDrive.mockClear();
mockDestroy.mockClear();
// Reset localStorage mock
mockLocalStorage = {};
// Mock localStorage
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(
(key: string) => mockLocalStorage[key] || null,
);
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => {
mockLocalStorage[key] = value;
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => {
delete mockLocalStorage[key];
});
// Mock document.getElementById for style injection check
vi.spyOn(document, 'getElementById').mockReturnValue(null);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('initialization', () => {
it('should return startTour, skipTour, and replayTour functions', () => {
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
const { result } = renderHook(() => useOnboardingTour());
expect(result.current.startTour).toBeInstanceOf(Function);
expect(result.current.skipTour).toBeInstanceOf(Function);
expect(result.current.replayTour).toBeInstanceOf(Function);
});
it('should auto-start tour if not completed', async () => {
// Don't set the storage key - tour not completed
renderHook(() => useOnboardingTour());
// Fast-forward past the 500ms delay
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
it('should not auto-start tour if already completed', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
renderHook(() => useOnboardingTour());
// Fast-forward past the 500ms delay
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDrive).not.toHaveBeenCalled();
});
});
describe('startTour', () => {
it('should create and start the driver tour', () => {
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(mockDriver).toHaveBeenCalledWith(
expect.objectContaining({
showProgress: true,
steps: expect.any(Array),
nextBtnText: 'Next',
prevBtnText: 'Previous',
doneBtnText: 'Done',
progressText: 'Step {{current}} of {{total}}',
onDestroyed: expect.any(Function),
}),
);
expect(mockDrive).toHaveBeenCalled();
});
it('should inject custom CSS styles', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const appendChildSpy = vi.spyOn(document.head, 'appendChild');
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(createElementSpy).toHaveBeenCalledWith('style');
expect(appendChildSpy).toHaveBeenCalled();
});
it('should not inject styles if they already exist', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Mock that the style element already exists
vi.spyOn(document, 'getElementById').mockReturnValue({
id: 'driver-js-custom-styles',
} as HTMLElement);
const createElementSpy = vi.spyOn(document, 'createElement');
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// createElement should not be called for the style element
const styleCreateCalls = createElementSpy.mock.calls.filter((call) => call[0] === 'style');
expect(styleCreateCalls).toHaveLength(0);
});
it('should destroy existing tour before starting new one', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Start tour twice
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.startTour();
});
expect(mockDestroy).toHaveBeenCalled();
});
it('should mark tour complete when onDestroyed is called', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// Get the onDestroyed callback
const driverConfig = mockDriver.mock.calls[0][0];
const onDestroyed = driverConfig.onDestroyed;
act(() => {
onDestroyed();
});
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
});
describe('skipTour', () => {
it('should destroy the tour if active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Start the tour first
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.skipTour();
});
expect(mockDestroy).toHaveBeenCalled();
});
it('should mark tour as complete', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.skipTour();
});
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
it('should handle skip when no tour is active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Skip without starting
expect(() => {
act(() => {
result.current.skipTour();
});
}).not.toThrow();
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
});
describe('replayTour', () => {
it('should start the tour', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
it('should work even if tour was previously completed', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
expect(mockDrive).toHaveBeenCalled();
});
});
describe('cleanup', () => {
it('should destroy tour on unmount', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result, unmount } = renderHook(() => useOnboardingTour());
// Start the tour
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
unmount();
expect(mockDestroy).toHaveBeenCalled();
});
it('should clear timeout on unmount if tour not started yet', () => {
// Don't set storage key - tour will try to auto-start
const { unmount } = renderHook(() => useOnboardingTour());
// Unmount before the 500ms delay
unmount();
// Now advance timers - tour should NOT start
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDrive).not.toHaveBeenCalled();
});
it('should not throw on unmount when no tour is active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { unmount } = renderHook(() => useOnboardingTour());
// Unmount without starting tour
expect(() => unmount()).not.toThrow();
});
});
describe('auto-start delay', () => {
it('should wait 500ms before auto-starting tour', () => {
// Don't set storage key
renderHook(() => useOnboardingTour());
// Tour should not have started yet
expect(mockDrive).not.toHaveBeenCalled();
// Advance 499ms
act(() => {
vi.advanceTimersByTime(499);
});
expect(mockDrive).not.toHaveBeenCalled();
// Advance 1 more ms
act(() => {
vi.advanceTimersByTime(1);
});
expect(mockDrive).toHaveBeenCalled();
});
});
describe('tour steps configuration', () => {
it('should configure tour with 6 steps', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
expect(driverConfig.steps).toHaveLength(6);
});
it('should have correct step elements', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
const steps = driverConfig.steps;
expect(steps[0].element).toBe('[data-tour="flyer-uploader"]');
expect(steps[1].element).toBe('[data-tour="extracted-data-table"]');
expect(steps[2].element).toBe('[data-tour="watch-button"]');
expect(steps[3].element).toBe('[data-tour="watched-items"]');
expect(steps[4].element).toBe('[data-tour="price-chart"]');
expect(steps[5].element).toBe('[data-tour="shopping-list"]');
});
it('should have popover configuration for each step', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
const steps = driverConfig.steps;
steps.forEach(
(step: {
popover: { title: string; description: string; side: string; align: string };
}) => {
expect(step.popover).toBeDefined();
expect(step.popover.title).toBeDefined();
expect(step.popover.description).toBeDefined();
expect(step.popover.side).toBeDefined();
expect(step.popover.align).toBeDefined();
},
);
});
});
describe('function stability', () => {
it('should maintain stable function references across rerenders', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result, rerender } = renderHook(() => useOnboardingTour());
const initialStartTour = result.current.startTour;
const initialSkipTour = result.current.skipTour;
const initialReplayTour = result.current.replayTour;
rerender();
expect(result.current.startTour).toBe(initialStartTour);
expect(result.current.skipTour).toBe(initialSkipTour);
expect(result.current.replayTour).toBe(initialReplayTour);
});
});
describe('localStorage key', () => {
it('should use correct storage key', () => {
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.skipTour();
});
expect(localStorage.setItem).toHaveBeenCalledWith(
'flyer_crawler_onboarding_completed',
'true',
);
});
it('should read from correct storage key on mount', () => {
mockLocalStorage['flyer_crawler_onboarding_completed'] = 'true';
renderHook(() => useOnboardingTour());
expect(localStorage.getItem).toHaveBeenCalledWith('flyer_crawler_onboarding_completed');
});
});
describe('edge cases', () => {
it('should handle multiple startTour calls gracefully', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
result.current.startTour();
result.current.startTour();
});
// Each startTour destroys the previous one
expect(mockDestroy).toHaveBeenCalledTimes(2); // Called before 2nd and 3rd startTour
expect(mockDrive).toHaveBeenCalledTimes(3);
});
it('should handle skipTour after startTour', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.skipTour();
});
expect(mockDestroy).toHaveBeenCalledTimes(1);
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
it('should handle replayTour multiple times', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
mockDriver.mockClear();
mockDrive.mockClear();
act(() => {
result.current.replayTour();
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
});
describe('CSS injection', () => {
it('should set correct id on style element', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(createdStyleElement.id).toBe('driver-js-custom-styles');
});
it('should inject CSS containing custom styles', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// Check that textContent contains expected CSS rules
expect(createdStyleElement.textContent).toContain('.driver-popover');
expect(createdStyleElement.textContent).toContain('background-color');
});
});
});

View File

@@ -237,7 +237,20 @@ describe('MainLayout Component', () => {
expect(screen.queryByTestId('anonymous-banner')).not.toBeInTheDocument();
});
it('renders auth-gated components (PriceHistoryChart, Leaderboard, ActivityLog)', () => {
it('renders auth-gated components for regular users (PriceHistoryChart, Leaderboard)', () => {
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
expect(screen.getByTestId('leaderboard')).toBeInTheDocument();
// ActivityLog is admin-only, should NOT be present for regular users
expect(screen.queryByTestId('activity-log')).not.toBeInTheDocument();
});
it('renders ActivityLog for admin users', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
expect(screen.getByTestId('leaderboard')).toBeInTheDocument();
@@ -245,6 +258,11 @@ describe('MainLayout Component', () => {
});
it('calls setActiveListId when a list is shared via ActivityLog and the list exists', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
shoppingLists: [
@@ -260,6 +278,11 @@ describe('MainLayout Component', () => {
});
it('does not call setActiveListId for actions other than list_shared', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
renderWithRouter(<MainLayout {...defaultProps} />);
const otherLogAction = screen.getByTestId('activity-log-other');
fireEvent.click(otherLogAction);
@@ -268,6 +291,11 @@ describe('MainLayout Component', () => {
});
it('does not call setActiveListId if the shared list does not exist', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
renderWithRouter(<MainLayout {...defaultProps} />);
const activityLog = screen.getByTestId('activity-log');
fireEvent.click(activityLog); // Mock click simulates sharing list with id 1

View File

@@ -108,6 +108,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),
@@ -212,7 +220,9 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/approve should approve a correction', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/approve`,
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ message: 'Correction approved successfully.' });
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
@@ -224,14 +234,18 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/approve`,
);
expect(response.status).toBe(500);
});
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/reject`,
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ message: 'Correction rejected successfully.' });
});
@@ -239,7 +253,9 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/reject`,
);
expect(response.status).toBe(500);
});

View File

@@ -74,9 +74,41 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),
isDevelopment: false,
}));
// Mock the feature flags service
vi.mock('../services/featureFlags.server', () => ({
getFeatureFlags: vi.fn(() => ({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
})),
isFeatureEnabled: vi.fn((flag: string) => {
const flags: Record<string, boolean> = {
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
};
return flags[flag] ?? false;
}),
}));
// Mock Passport to allow admin access
@@ -93,6 +125,7 @@ vi.mock('../config/passport', () => ({
import adminRouter from './admin.routes';
import { cacheService } from '../services/cacheService.server';
import { getFeatureFlags } from '../services/featureFlags.server';
import { mockLogger } from '../tests/utils/mockLogger';
describe('Admin Routes Rate Limiting', () => {
@@ -177,4 +210,67 @@ describe('Admin Routes Rate Limiting', () => {
);
});
});
describe('GET /feature-flags (ADR-024)', () => {
it('should return 200 and the current feature flag states', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.flags).toEqual({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should call getFeatureFlags service function', async () => {
await supertest(app).get('/api/v1/admin/feature-flags');
expect(vi.mocked(getFeatureFlags)).toHaveBeenCalled();
});
it('should return flags with all expected keys', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
const expectedFlags = [
'bugsinkSync',
'advancedRbac',
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
expect(response.status).toBe(200);
expect(Object.keys(response.body.data.flags).sort()).toEqual(expectedFlags.sort());
});
it('should return boolean values for all flags', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(200);
Object.values(response.body.data.flags).forEach((value) => {
expect(typeof value).toBe('boolean');
});
});
it('should return 500 if getFeatureFlags throws an error', async () => {
const featureFlagsError = new Error('Feature flags service error');
vi.mocked(getFeatureFlags).mockImplementationOnce(() => {
throw featureFlagsError;
});
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: featureFlagsError },
'Error fetching feature flags',
);
});
});
});

View File

@@ -33,6 +33,7 @@ import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
import { getFeatureFlags } from '../services/featureFlags.server';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -1229,6 +1230,59 @@ router.get(
},
);
/**
* @openapi
* /admin/feature-flags:
* get:
* tags: [Admin]
* summary: Get feature flags status
* description: Get the current state of all feature flags. Requires admin role. (ADR-024)
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Feature flags and their current states
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* flags:
* type: object
* additionalProperties:
* type: boolean
* example:
* bugsinkSync: false
* advancedRbac: false
* newDashboard: true
* betaRecipes: false
* experimentalAi: false
* debugMode: false
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.get(
'/feature-flags',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const flags = getFeatureFlags();
sendSuccess(res, { flags });
} catch (error) {
req.log.error({ error }, 'Error fetching feature flags');
next(error);
}
},
);
/**
* @openapi
* /admin/websocket/stats:

View File

@@ -40,6 +40,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -45,6 +45,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -47,6 +47,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -28,13 +28,58 @@ vi.mock('../services/queueService.server', () => ({
// We need to mock the `connection` export which is an object with a `ping` method.
connection: {
ping: vi.fn(),
get: vi.fn(), // Add get method for worker heartbeat checks
},
}));
// Use vi.hoisted to create mock queue objects that are available during vi.mock hoisting.
// This ensures the mock objects exist when the factory function runs.
const { mockQueuesModule } = vi.hoisted(() => {
// Helper function to create a mock queue object with vi.fn()
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 BEFORE the health router imports it.
vi.mock('../services/queues.server', () => mockQueuesModule);
// Import the router and mocked modules AFTER all mocks are defined.
import healthRouter from './health.routes';
import * as dbConnection from '../services/db/connection.db';
// Use the hoisted mock module directly for test assertions and configuration
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> };
};
// Mock the logger to keep test output clean.
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
@@ -49,7 +94,9 @@ vi.mock('../services/logger.server', async () => ({
}));
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection>;
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection> & {
get: ReturnType<typeof vi.fn>;
};
const mockedDbConnection = dbConnection as Mocked<typeof dbConnection>;
const mockedFs = fs as Mocked<typeof fs>;
@@ -635,34 +682,27 @@ describe('Health Routes (/api/v1/health)', () => {
// =============================================================================
describe('GET /queues', () => {
// Mock the queues module
beforeEach(async () => {
vi.resetModules();
// Re-import after mocks are set up
});
// Helper function to set all queue mocks to return the same job counts
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 200 OK with queue metrics and worker heartbeats when all healthy', async () => {
// Arrange: Mock queue getJobCounts() and Redis heartbeats
const mockQueues = await import('../services/queues.server');
const mockQueue = {
getJobCounts: vi.fn().mockResolvedValue({
waiting: 5,
active: 2,
failed: 1,
delayed: 0,
}),
};
// Mock all queues
vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never);
// Arrange: Mock queue getJobCounts() to return specific values
setAllQueueMocks({ waiting: 5, active: 2, failed: 1, delayed: 0 });
// Mock Redis heartbeat responses (all healthy, last seen < 60s ago)
const recentTimestamp = new Date(Date.now() - 10000).toISOString(); // 10 seconds ago
@@ -672,7 +712,7 @@ describe('Health Routes (/api/v1/health)', () => {
host: 'test-host',
});
mockedRedisConnection.get = vi.fn().mockResolvedValue(heartbeatValue);
mockedRedisConnection.get.mockResolvedValue(heartbeatValue);
// Act
const response = await supertest(app).get('/api/v1/health/queues');
@@ -702,31 +742,22 @@ describe('Health Routes (/api/v1/health)', () => {
});
it('should return 503 when a queue is unavailable', async () => {
// Arrange: Mock one queue to fail
const mockQueues = await import('../services/queues.server');
const healthyQueue = {
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
};
const failingQueue = {
getJobCounts: vi.fn().mockRejectedValue(new Error('Redis connection lost')),
};
// Arrange: Mock flyerQueue to fail, others succeed
mockedQueues.flyerQueue.getJobCounts.mockRejectedValue(new Error('Redis connection lost'));
vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(failingQueue as never);
vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(healthyQueue as never);
vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(healthyQueue as never);
// Set other queues to succeed with healthy job counts
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 = vi.fn().mockResolvedValue(null);
// No heartbeats (workers not running)
mockedRedisConnection.get.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/health/queues');
@@ -742,26 +773,9 @@ describe('Health Routes (/api/v1/health)', () => {
});
it('should return 503 when a worker heartbeat is stale', async () => {
// Arrange: Mock queues as healthy but one worker heartbeat as stale
const mockQueues = await import('../services/queues.server');
const mockQueue = {
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
};
vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never);
// Arrange: Mock queues as healthy
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
setAllQueueMocks(healthyJobCounts);
// Mock heartbeat - one worker is stale (> 60s ago)
const staleTimestamp = new Date(Date.now() - 120000).toISOString(); // 120 seconds ago
@@ -773,7 +787,7 @@ describe('Health Routes (/api/v1/health)', () => {
// First call returns stale heartbeat for flyer-processing, rest return null (no heartbeat)
let callCount = 0;
mockedRedisConnection.get = vi.fn().mockImplementation(() => {
mockedRedisConnection.get.mockImplementation(() => {
callCount++;
return Promise.resolve(callCount === 1 ? staleHeartbeat : null);
});
@@ -789,29 +803,12 @@ describe('Health Routes (/api/v1/health)', () => {
});
it('should return 503 when worker heartbeat is missing', async () => {
// Arrange: Mock queues as healthy but no worker heartbeats in Redis
const mockQueues = await import('../services/queues.server');
const mockQueue = {
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
};
vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never);
// Arrange: Mock queues as healthy
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
setAllQueueMocks(healthyJobCounts);
// Mock Redis to return null (no heartbeat found)
mockedRedisConnection.get = vi.fn().mockResolvedValue(null);
mockedRedisConnection.get.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/health/queues');
@@ -824,42 +821,30 @@ describe('Health Routes (/api/v1/health)', () => {
});
it('should handle Redis connection errors gracefully', async () => {
// Arrange: Mock queues to succeed but Redis get() to fail
const mockQueues = await import('../services/queues.server');
const mockQueue = {
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
};
vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never);
vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never);
// Arrange: Mock queues as healthy
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
setAllQueueMocks(healthyJobCounts);
// Mock Redis get() to throw error
mockedRedisConnection.get = vi.fn().mockRejectedValue(new Error('Redis connection lost'));
mockedRedisConnection.get.mockRejectedValue(new Error('Redis connection lost'));
// Act
const response = await supertest(app).get('/api/v1/health/queues');
// Assert: Should still return queue metrics but mark workers as unhealthy
expect(response.status).toBe(503);
expect(response.body.error.details.queues['flyer-processing']).toEqual({
// Assert: Production code treats heartbeat fetch errors as non-critical.
// When Redis get() fails for heartbeat checks, the endpoint returns 200 (healthy)
// with error details in the workers object. This is intentional - a heartbeat
// fetch error could be transient and shouldn't immediately mark the system unhealthy.
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.status).toBe('healthy');
expect(response.body.data.queues['flyer-processing']).toEqual({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
});
expect(response.body.error.details.workers['flyer-processing']).toEqual({
expect(response.body.data.workers['flyer-processing']).toEqual({
alive: false,
error: 'Redis connection lost',
});

View File

@@ -0,0 +1,465 @@
// src/services/featureFlags.server.test.ts
/**
* Unit tests for the Feature Flags Service (ADR-024).
*
* These tests verify:
* - isFeatureEnabled() returns correct boolean for each flag
* - isFeatureEnabled() handles all valid flag names
* - getFeatureFlags() returns all flags and their states
* - getEnabledFeatureFlags() returns only enabled flags
* - Convenience exports return correct values
* - Default behavior (all flags disabled when not set)
* - Environment variable parsing for enabled/disabled states
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Store original process.env
const originalEnv = { ...process.env };
describe('featureFlags.server', () => {
beforeEach(() => {
// Reset modules before each test to allow re-importing with different env vars
vi.resetModules();
// Reset process.env to original state
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original process.env
process.env = originalEnv;
});
/**
* Helper to set up the minimum required environment variables for config to load.
* This includes database, redis, and auth config that are required by Zod validation.
*/
const setMinimalValidEnv = (overrides: Record<string, string> = {}) => {
process.env = {
...process.env,
// Required config
NODE_ENV: 'test',
DB_HOST: 'localhost',
DB_USER: 'test',
DB_PASSWORD: 'test',
DB_NAME: 'test',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long',
// Feature flags default to false, so we override as needed
...overrides,
};
};
describe('isFeatureEnabled()', () => {
it('should return false for all flags when no feature flags are set', async () => {
setMinimalValidEnv();
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('bugsinkSync')).toBe(false);
expect(isFeatureEnabled('advancedRbac')).toBe(false);
expect(isFeatureEnabled('newDashboard')).toBe(false);
expect(isFeatureEnabled('betaRecipes')).toBe(false);
expect(isFeatureEnabled('experimentalAi')).toBe(false);
expect(isFeatureEnabled('debugMode')).toBe(false);
});
it('should return true for bugsinkSync when FEATURE_BUGSINK_SYNC is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_BUGSINK_SYNC: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('bugsinkSync')).toBe(true);
});
it('should return true for advancedRbac when FEATURE_ADVANCED_RBAC is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_ADVANCED_RBAC: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('advancedRbac')).toBe(true);
});
it('should return true for newDashboard when FEATURE_NEW_DASHBOARD is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(true);
});
it('should return true for betaRecipes when FEATURE_BETA_RECIPES is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_BETA_RECIPES: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('betaRecipes')).toBe(true);
});
it('should return true for experimentalAi when FEATURE_EXPERIMENTAL_AI is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_EXPERIMENTAL_AI: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('experimentalAi')).toBe(true);
});
it('should return true for debugMode when FEATURE_DEBUG_MODE is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_DEBUG_MODE: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('debugMode')).toBe(true);
});
it('should return false when flag is set to "false"', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'false',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return false for non-"true" string values', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'TRUE', // uppercase
});
const { isFeatureEnabled } = await import('./featureFlags.server');
// The booleanString helper only checks for exact 'true' match
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return false for empty string value', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: '',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should handle multiple flags enabled simultaneously', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_BETA_RECIPES: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(true);
expect(isFeatureEnabled('betaRecipes')).toBe(true);
expect(isFeatureEnabled('debugMode')).toBe(true);
// These should still be false
expect(isFeatureEnabled('bugsinkSync')).toBe(false);
expect(isFeatureEnabled('advancedRbac')).toBe(false);
expect(isFeatureEnabled('experimentalAi')).toBe(false);
});
});
describe('getFeatureFlags()', () => {
it('should return all flags with their current states', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
expect(flags).toEqual({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should return a copy of flags (not the original object)', async () => {
setMinimalValidEnv();
const { getFeatureFlags, isFeatureEnabled } = await import('./featureFlags.server');
const flags = getFeatureFlags();
// Modifying the returned object should not affect the original
(flags as Record<string, boolean>).newDashboard = true;
// The original should still be false
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return all flags as false when no flags are set', async () => {
setMinimalValidEnv();
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
// All values should be false
Object.values(flags).forEach((value) => {
expect(value).toBe(false);
});
});
it('should include all expected flag names', async () => {
setMinimalValidEnv();
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
const expectedFlags = [
'bugsinkSync',
'advancedRbac',
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
expect(Object.keys(flags).sort()).toEqual(expectedFlags.sort());
});
});
describe('getEnabledFeatureFlags()', () => {
it('should return an empty array when no flags are enabled', async () => {
setMinimalValidEnv();
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toEqual([]);
});
it('should return only enabled flag names', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toHaveLength(2);
expect(enabledFlags).toContain('newDashboard');
expect(enabledFlags).toContain('debugMode');
expect(enabledFlags).not.toContain('bugsinkSync');
expect(enabledFlags).not.toContain('advancedRbac');
});
it('should return all flag names when all flags are enabled', async () => {
setMinimalValidEnv({
FEATURE_BUGSINK_SYNC: 'true',
FEATURE_ADVANCED_RBAC: 'true',
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_BETA_RECIPES: 'true',
FEATURE_EXPERIMENTAL_AI: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toHaveLength(6);
expect(enabledFlags).toContain('bugsinkSync');
expect(enabledFlags).toContain('advancedRbac');
expect(enabledFlags).toContain('newDashboard');
expect(enabledFlags).toContain('betaRecipes');
expect(enabledFlags).toContain('experimentalAi');
expect(enabledFlags).toContain('debugMode');
});
});
describe('convenience exports', () => {
it('should export isBugsinkSyncEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isBugsinkSyncEnabled } = await import('./featureFlags.server');
expect(isBugsinkSyncEnabled).toBe(false);
});
it('should export isBugsinkSyncEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_BUGSINK_SYNC: 'true' });
const { isBugsinkSyncEnabled } = await import('./featureFlags.server');
expect(isBugsinkSyncEnabled).toBe(true);
});
it('should export isAdvancedRbacEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isAdvancedRbacEnabled } = await import('./featureFlags.server');
expect(isAdvancedRbacEnabled).toBe(false);
});
it('should export isAdvancedRbacEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_ADVANCED_RBAC: 'true' });
const { isAdvancedRbacEnabled } = await import('./featureFlags.server');
expect(isAdvancedRbacEnabled).toBe(true);
});
it('should export isNewDashboardEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isNewDashboardEnabled } = await import('./featureFlags.server');
expect(isNewDashboardEnabled).toBe(false);
});
it('should export isNewDashboardEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isNewDashboardEnabled } = await import('./featureFlags.server');
expect(isNewDashboardEnabled).toBe(true);
});
it('should export isBetaRecipesEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isBetaRecipesEnabled } = await import('./featureFlags.server');
expect(isBetaRecipesEnabled).toBe(false);
});
it('should export isBetaRecipesEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_BETA_RECIPES: 'true' });
const { isBetaRecipesEnabled } = await import('./featureFlags.server');
expect(isBetaRecipesEnabled).toBe(true);
});
it('should export isExperimentalAiEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isExperimentalAiEnabled } = await import('./featureFlags.server');
expect(isExperimentalAiEnabled).toBe(false);
});
it('should export isExperimentalAiEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_EXPERIMENTAL_AI: 'true' });
const { isExperimentalAiEnabled } = await import('./featureFlags.server');
expect(isExperimentalAiEnabled).toBe(true);
});
it('should export isDebugModeEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isDebugModeEnabled } = await import('./featureFlags.server');
expect(isDebugModeEnabled).toBe(false);
});
it('should export isDebugModeEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_DEBUG_MODE: 'true' });
const { isDebugModeEnabled } = await import('./featureFlags.server');
expect(isDebugModeEnabled).toBe(true);
});
});
describe('FeatureFlagName type', () => {
it('should re-export the FeatureFlagName type from env.ts', async () => {
setMinimalValidEnv();
const featureFlagsModule = await import('./featureFlags.server');
// TypeScript will enforce that FeatureFlagName is properly exported
// This test verifies the export exists at runtime
expect(featureFlagsModule).toHaveProperty('isFeatureEnabled');
// The type export is verified by TypeScript compilation
// This runtime test ensures the module loads correctly
});
});
describe('development mode logging', () => {
it('should log feature flag checks in development mode', async () => {
setMinimalValidEnv();
// Override NODE_ENV to development for this test
process.env.NODE_ENV = 'development';
// Mock the logger
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
};
vi.doMock('./logger.server', () => ({
logger: mockLogger,
}));
const { isFeatureEnabled } = await import('./featureFlags.server');
isFeatureEnabled('newDashboard');
// In development mode, the logger.debug should be called
expect(mockLogger.debug).toHaveBeenCalledWith(
{ flag: 'newDashboard', enabled: false },
'Feature flag checked',
);
});
it('should not log in test mode', async () => {
setMinimalValidEnv();
// Mock the logger
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
};
vi.doMock('./logger.server', () => ({
logger: mockLogger,
}));
const { isFeatureEnabled } = await import('./featureFlags.server');
isFeatureEnabled('newDashboard');
// In test mode (NODE_ENV=test), the logger.debug should not be called
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});
});
describe('isFeatureFlagEnabled in env.ts', () => {
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
const setMinimalValidEnv = (overrides: Record<string, string> = {}) => {
process.env = {
...process.env,
NODE_ENV: 'test',
DB_HOST: 'localhost',
DB_USER: 'test',
DB_PASSWORD: 'test',
DB_NAME: 'test',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long',
...overrides,
};
};
it('should return correct value from isFeatureFlagEnabled in env.ts', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isFeatureFlagEnabled } = await import('../config/env');
expect(isFeatureFlagEnabled('newDashboard')).toBe(true);
expect(isFeatureFlagEnabled('betaRecipes')).toBe(false);
});
it('should default to false for undefined flags', async () => {
setMinimalValidEnv();
const { isFeatureFlagEnabled } = await import('../config/env');
expect(isFeatureFlagEnabled('bugsinkSync')).toBe(false);
expect(isFeatureFlagEnabled('advancedRbac')).toBe(false);
expect(isFeatureFlagEnabled('newDashboard')).toBe(false);
expect(isFeatureFlagEnabled('betaRecipes')).toBe(false);
expect(isFeatureFlagEnabled('experimentalAi')).toBe(false);
expect(isFeatureFlagEnabled('debugMode')).toBe(false);
});
});

View File

@@ -0,0 +1,169 @@
// src/services/featureFlags.server.ts
/**
* Feature Flags Service (ADR-024)
*
* This module provides a centralized service for accessing feature flags
* on the backend. It integrates with the Zod-validated configuration in
* `src/config/env.ts` and provides type-safe access patterns.
*
* All feature flags default to `false` (disabled) following an opt-in model.
* Set the corresponding `FEATURE_*` environment variable to 'true' to enable.
*
* @example
* ```typescript
* import { isFeatureEnabled, getFeatureFlags } from './services/featureFlags.server';
*
* // Check a specific flag
* if (isFeatureEnabled('newDashboard')) {
* // Use new dashboard logic
* }
*
* // Get all flags (for admin endpoints)
* const allFlags = getFeatureFlags();
* ```
*
* @see docs/adr/0024-feature-flagging-strategy.md for architecture details
*/
import { config, isDevelopment, FeatureFlagName } from '../config/env';
import { logger } from './logger.server';
// Re-export FeatureFlagName for convenience
export type { FeatureFlagName };
/**
* Check if a feature flag is enabled.
*
* In development mode, this function logs the flag check for debugging purposes.
* In production/test, logging is omitted to avoid performance overhead.
*
* @param flagName - The name of the feature flag to check (type-safe)
* @returns boolean indicating if the feature is enabled
*
* @example
* ```typescript
* import { isFeatureEnabled } from '../services/featureFlags.server';
*
* // In a 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() });
* });
*
* // In a service
* function processFlyer(flyer: Flyer): ProcessedFlyer {
* if (isFeatureEnabled('experimentalAi')) {
* return processWithExperimentalAi(flyer);
* }
* return processWithStandardAi(flyer);
* }
* ```
*/
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.
*
* This function returns a shallow copy of all feature flags,
* useful for admin/debug endpoints and monitoring dashboards.
*
* @returns Record of all feature flag names to their boolean states
*
* @example
* ```typescript
* import { getFeatureFlags } from '../services/featureFlags.server';
*
* // In an admin route handler
* router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
* const flags = getFeatureFlags();
* sendSuccess(res, { flags });
* });
*
* // Result:
* // {
* // "bugsinkSync": false,
* // "advancedRbac": false,
* // "newDashboard": true,
* // "betaRecipes": false,
* // "experimentalAi": false,
* // "debugMode": false
* // }
* ```
*/
export function getFeatureFlags(): Record<FeatureFlagName, boolean> {
// Return a shallow copy to prevent external mutation
return { ...config.featureFlags };
}
/**
* Get a list of all enabled feature flags.
*
* Useful for logging and diagnostics to quickly see which features are active.
*
* @returns Array of feature flag names that are currently enabled
*
* @example
* ```typescript
* import { getEnabledFeatureFlags } from '../services/featureFlags.server';
*
* // Log enabled features at startup
* const enabled = getEnabledFeatureFlags();
* if (enabled.length > 0) {
* logger.info({ enabledFlags: enabled }, 'Active feature flags');
* }
* ```
*/
export function getEnabledFeatureFlags(): FeatureFlagName[] {
const flags = config.featureFlags;
return (Object.keys(flags) as FeatureFlagName[]).filter((key) => flags[key]);
}
// --- Convenience Exports ---
// These are evaluated once at module load time (startup).
// Use these for simple boolean checks when you don't need dynamic behavior.
/**
* True if Bugsink error sync integration is enabled.
* @see FEATURE_BUGSINK_SYNC environment variable
*/
export const isBugsinkSyncEnabled = config.featureFlags.bugsinkSync;
/**
* True if advanced RBAC features are enabled.
* @see FEATURE_ADVANCED_RBAC environment variable
*/
export const isAdvancedRbacEnabled = config.featureFlags.advancedRbac;
/**
* True if new dashboard experience is enabled.
* @see FEATURE_NEW_DASHBOARD environment variable
*/
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
/**
* True if beta recipe features are enabled.
* @see FEATURE_BETA_RECIPES environment variable
*/
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
/**
* True if experimental AI features are enabled.
* @see FEATURE_EXPERIMENTAL_AI environment variable
*/
export const isExperimentalAiEnabled = config.featureFlags.experimentalAi;
/**
* True if debug mode is enabled.
* @see FEATURE_DEBUG_MODE environment variable
*/
export const isDebugModeEnabled = config.featureFlags.debugMode;

View File

@@ -135,7 +135,7 @@ describe('Worker Service Lifecycle', () => {
cleanupWorker = workerService.cleanupWorker;
weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker;
tokenCleanupWorker = workerService.tokenCleanupWorker;
});
}, 15000); // Increase timeout for module re-import which can be slow
afterEach(() => {
// Clean up all event listeners on the mock connection to prevent open handles.
@@ -144,8 +144,8 @@ describe('Worker Service Lifecycle', () => {
});
it('should log a success message when Redis connects', () => {
// Re-import redis.server to trigger its event listeners with the mock
import('./redis.server');
// redis.server is already imported via workers.server in beforeEach,
// which attaches event listeners to mockRedisConnection.
// Act: Simulate the 'connect' event on the mock Redis connection
mockRedisConnection.emit('connect');
@@ -154,7 +154,8 @@ describe('Worker Service Lifecycle', () => {
});
it('should log an error message when Redis connection fails', () => {
import('./redis.server');
// redis.server is already imported via workers.server in beforeEach,
// which attaches event listeners to mockRedisConnection.
const redisError = new Error('Connection refused');
mockRedisConnection.emit('error', redisError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');

View File

@@ -143,7 +143,7 @@ describe('E2E Budget Management Journey', () => {
// Step 6: Update a budget
const updateBudgetResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.put(`/api/v1/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
amount_cents: 55000, // Increase to $550.00
@@ -189,7 +189,7 @@ describe('E2E Budget Management Journey', () => {
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const spendingResponse = await getRequest()
.get(
`/api/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
`/api/v1/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
)
.set('Authorization', `Bearer ${authToken}`);
@@ -227,7 +227,7 @@ describe('E2E Budget Management Journey', () => {
// Step 11: Test update validation - empty update
const emptyUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.put(`/api/v1/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({}); // No fields to update
@@ -264,7 +264,7 @@ describe('E2E Budget Management Journey', () => {
// Other user should not be able to update our budget
const otherUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.put(`/api/v1/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({
amount_cents: 99999,
@@ -274,7 +274,7 @@ describe('E2E Budget Management Journey', () => {
// Other user should not be able to delete our budget
const otherDeleteAttemptResponse = await getRequest()
.delete(`/api/budgets/${budgetId}`)
.delete(`/api/v1/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDeleteAttemptResponse.status).toBe(404);
@@ -284,7 +284,7 @@ describe('E2E Budget Management Journey', () => {
// Step 13: Delete the weekly budget
const deleteBudgetResponse = await getRequest()
.delete(`/api/budgets/${weeklyBudgetResponse.body.data.budget_id}`)
.delete(`/api/v1/budgets/${weeklyBudgetResponse.body.data.budget_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteBudgetResponse.status).toBe(204);

View File

@@ -96,7 +96,9 @@ describe('E2E Deals and Price Tracking Journey', () => {
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await getRequest().get(`/api/categories/${dairyEggsCategoryId}`);
const categoryByIdResponse = await getRequest().get(
`/api/v1/categories/${dairyEggsCategoryId}`,
);
expect(categoryByIdResponse.status).toBe(200);
expect(categoryByIdResponse.body.success).toBe(true);
expect(categoryByIdResponse.body.data.category_id).toBe(dairyEggsCategoryId);
@@ -314,7 +316,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Step 8: Remove an item from watch list
const milkMasterItemId = createdMasterItemIds[0];
const removeResponse = await getRequest()
.delete(`/api/users/watched-items/${milkMasterItemId}`)
.delete(`/api/v1/users/watched-items/${milkMasterItemId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(removeResponse.status).toBe(204);

View File

@@ -77,22 +77,22 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
// Calculate checksum (required by the API)
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 4. Upload the flyer
// 4. Upload the flyer (uses /ai/upload-and-process endpoint with flyerFile field)
const uploadResponse = await getRequest()
.post('/api/v1/flyers/upload')
.post('/api/v1/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyer', fileBuffer, fileName)
.attach('flyerFile', fileBuffer, fileName)
.field('checksum', checksum);
expect(uploadResponse.status).toBe(202);
const jobId = uploadResponse.body.data.jobId;
expect(jobId).toBeDefined();
// 5. Poll for job completion using the new utility
// 5. Poll for job completion using the new utility (endpoint is /ai/jobs/:jobId/status)
const jobStatusResponse = await poll(
async () => {
const statusResponse = await getRequest()
.get(`/api/jobs/${jobId}`)
.get(`/api/v1/ai/jobs/${jobId}/status`)
.set('Authorization', `Bearer ${authToken}`);
return statusResponse.body;
},

View File

@@ -243,7 +243,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 8: Get specific item details
const milkId = createdInventoryIds[0];
const detailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.get(`/api/v1/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
@@ -252,7 +252,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 9: Update item quantity and location
const updateResponse = await getRequest()
.put(`/api/inventory/${milkId}`)
.put(`/api/v1/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
quantity: 1,
@@ -266,7 +266,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// First, reduce quantity via update
const applesId = createdInventoryIds[3];
const partialConsumeResponse = await getRequest()
.put(`/api/inventory/${applesId}`)
.put(`/api/v1/inventory/${applesId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ quantity: 4 }); // 6 - 2 = 4
@@ -310,14 +310,14 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 14: Fully consume an item (marks as consumed, returns 204)
const breadId = createdInventoryIds[2];
const fullConsumeResponse = await getRequest()
.post(`/api/inventory/${breadId}/consume`)
.post(`/api/v1/inventory/${breadId}/consume`)
.set('Authorization', `Bearer ${authToken}`);
expect(fullConsumeResponse.status).toBe(204);
// Verify the item is now marked as consumed
const consumedItemResponse = await getRequest()
.get(`/api/inventory/${breadId}`)
.get(`/api/v1/inventory/${breadId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(consumedItemResponse.status).toBe(200);
expect(consumedItemResponse.body.data.is_consumed).toBe(true);
@@ -325,7 +325,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 15: Delete an item
const riceId = createdInventoryIds[4];
const deleteResponse = await getRequest()
.delete(`/api/inventory/${riceId}`)
.delete(`/api/v1/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -338,7 +338,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 16: Verify deletion
const verifyDeleteResponse = await getRequest()
.get(`/api/inventory/${riceId}`)
.get(`/api/v1/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
@@ -366,7 +366,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Other user should not see our inventory
const otherDetailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.get(`/api/v1/inventory/${milkId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
@@ -385,7 +385,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
// Step 18: Move frozen item to fridge (simulating thawing)
const pizzaId = createdInventoryIds[1];
const moveResponse = await getRequest()
.put(`/api/inventory/${pizzaId}`)
.put(`/api/v1/inventory/${pizzaId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
location: 'fridge',

View File

@@ -149,7 +149,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 5: View receipt details
const detailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.get(`/api/v1/receipts/${receiptId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
@@ -158,7 +158,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 6: View receipt items
const itemsResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items`)
.get(`/api/v1/receipts/${receiptId}/items`)
.set('Authorization', `Bearer ${authToken}`);
expect(itemsResponse.status).toBe(200);
@@ -166,7 +166,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 7: Update an item's status
const updateItemResponse = await getRequest()
.put(`/api/receipts/${receiptId}/items/${itemIds[1]}`)
.put(`/api/v1/receipts/${receiptId}/items/${itemIds[1]}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
status: 'matched',
@@ -178,7 +178,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 8: View unadded items
const unaddedResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items/unadded`)
.get(`/api/v1/receipts/${receiptId}/items/unadded`)
.set('Authorization', `Bearer ${authToken}`);
expect(unaddedResponse.status).toBe(200);
@@ -186,7 +186,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 9: Confirm items to add to inventory
const confirmResponse = await getRequest()
.post(`/api/receipts/${receiptId}/confirm`)
.post(`/api/v1/receipts/${receiptId}/confirm`)
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [
@@ -260,7 +260,7 @@ describe('E2E Receipt Processing Journey', () => {
// Other user should not see our receipt
const otherDetailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.get(`/api/v1/receipts/${receiptId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
@@ -290,7 +290,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 16: Test reprocessing a failed receipt
const reprocessResponse = await getRequest()
.post(`/api/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`)
.post(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`)
.set('Authorization', `Bearer ${authToken}`);
expect(reprocessResponse.status).toBe(200);
@@ -298,7 +298,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 17: Delete the failed receipt
const deleteResponse = await getRequest()
.delete(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.delete(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -311,7 +311,7 @@ describe('E2E Receipt Processing Journey', () => {
// Step 18: Verify deletion
const verifyDeleteResponse = await getRequest()
.get(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.get(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);

View File

@@ -115,7 +115,7 @@ describe('E2E UPC Scanning Journey', () => {
// Step 5: Lookup the product by UPC
const lookupResponse = await getRequest()
.get(`/api/upc/lookup?upc_code=${testUpc}`)
.get(`/api/v1/upc/lookup?upc_code=${testUpc}`)
.set('Authorization', `Bearer ${authToken}`);
expect(lookupResponse.status).toBe(200);
@@ -152,7 +152,7 @@ describe('E2E UPC Scanning Journey', () => {
// Step 8: View specific scan details
const scanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.get(`/api/v1/upc/history/${scanId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(scanDetailResponse.status).toBe(200);
@@ -201,7 +201,7 @@ describe('E2E UPC Scanning Journey', () => {
// Other user should not see our scan
const otherScanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.get(`/api/v1/upc/history/${scanId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherScanDetailResponse.status).toBe(404);

View File

@@ -72,7 +72,7 @@ describe('E2E User Journey', () => {
// 4. Add an item to the list
const addItemResponse = await getRequest()
.post(`/api/users/shopping-lists/${shoppingListId}/items`)
.post(`/api/v1/users/shopping-lists/${shoppingListId}/items`)
.set('Authorization', `Bearer ${authToken}`)
.send({ customItemName: 'Chips' });

26
src/vite-env.d.ts vendored
View File

@@ -5,7 +5,31 @@ interface ImportMetaEnv {
readonly VITE_APP_COMMIT_MESSAGE: string;
readonly VITE_APP_COMMIT_URL: string;
readonly VITE_GOOGLE_MAPS_EMBED_API_KEY: string;
// Add any other environment variables you use here
// Sentry/Bugsink Configuration (ADR-015)
readonly VITE_SENTRY_DSN?: string;
readonly VITE_SENTRY_ENVIRONMENT?: string;
readonly VITE_SENTRY_DEBUG?: string;
readonly VITE_SENTRY_ENABLED?: string;
/**
* Feature Flags (ADR-024)
*
* All feature flag environment variables are optional and default to disabled
* when not set. Set to 'true' to enable a feature.
*
* Naming convention: VITE_FEATURE_SNAKE_CASE
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
/** Enable the redesigned dashboard UI */
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
/** Enable beta recipe features */
readonly VITE_FEATURE_BETA_RECIPES?: string;
/** Enable experimental AI features */
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
/** Enable debug mode UI elements */
readonly VITE_FEATURE_DEBUG_MODE?: string;
}
interface ImportMeta {

View File

@@ -123,6 +123,11 @@ export default defineConfig({
test: {
// Name this project 'unit' to distinguish it in the workspace.
name: 'unit',
// Set environment variables for unit tests
env: {
// ADR-008: Ensure API versioning is correctly set for unit tests
VITE_API_BASE_URL: '/api/v1',
},
// By default, Vitest does not suppress console logs.
// The onConsoleLog hook is only needed if you want to conditionally filter specific logs.
// Keeping the default behavior is often safer to avoid missing important warnings.

View File

@@ -32,7 +32,8 @@ const e2eConfig = mergeConfig(
FRONTEND_URL: 'https://example.com',
// Use port 3098 for E2E tests (integration uses 3099)
TEST_PORT: '3098',
VITE_API_BASE_URL: 'http://localhost:3098/api',
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3098/api/v1',
},
// E2E tests have their own dedicated global setup file
globalSetup: './src/tests/setup/e2e-global-setup.ts',

View File

@@ -68,7 +68,8 @@ const finalConfig = mergeConfig(
// Use a dedicated test port (3099) to avoid conflicts with production servers
// that might be running on port 3000 or 3001
TEST_PORT: '3099',
VITE_API_BASE_URL: 'http://localhost:3099/api',
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3099/api/v1',
},
// This setup script starts the backend server before tests run.
globalSetup: './src/tests/setup/integration-global-setup.ts',