Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c3ca9abe | ||
| 4f06698dfd | |||
|
|
e548d1b0cc | ||
| 771f59d009 | |||
|
|
0979a074ad | ||
| 0d4b028a66 | |||
|
|
4baed53713 | ||
| f10c6c0cd6 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -38,3 +38,7 @@ Thumbs.db
|
||||
.claude/settings.local.json
|
||||
nul
|
||||
tmpclaude*
|
||||
|
||||
|
||||
|
||||
test.tmp
|
||||
49
CLAUDE.md
49
CLAUDE.md
@@ -27,6 +27,51 @@ 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.
|
||||
|
||||
**Claude Code does NOT have:**
|
||||
|
||||
- Root or sudo access
|
||||
- Write permissions on servers
|
||||
- Ability to execute PM2 restart, systemctl, or other write operations directly
|
||||
|
||||
**Correct Workflow for Server Operations:**
|
||||
|
||||
| Step | Actor | Action |
|
||||
| ---- | ------ | --------------------------------------------------------------------------- |
|
||||
| 1 | Claude | Provide **diagnostic commands** (read-only checks) for user to run |
|
||||
| 2 | User | Execute commands on server, report results |
|
||||
| 3 | Claude | Analyze results, provide **fix commands** (1-3 at a time) |
|
||||
| 4 | User | Execute fix commands, report results |
|
||||
| 5 | Claude | Provide **verification commands** to confirm success and check side effects |
|
||||
| 6 | Claude | Document progress stage by stage |
|
||||
| 7 | Claude | Update ALL relevant documentation when complete |
|
||||
|
||||
**Example - Diagnosing PM2 Issues:**
|
||||
|
||||
```bash
|
||||
# Step 1: Claude provides diagnostic commands (user runs these)
|
||||
pm2 list
|
||||
pm2 logs flyer-crawler-api --lines 50
|
||||
systemctl status redis
|
||||
|
||||
# Step 3: After user reports results, Claude provides fix commands
|
||||
pm2 restart flyer-crawler-api
|
||||
# Wait for user confirmation before next command
|
||||
|
||||
# Step 5: Claude provides verification
|
||||
pm2 list
|
||||
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
|
||||
```
|
||||
|
||||
**Never Do:**
|
||||
|
||||
- `ssh root@projectium.com "pm2 restart all"` (Claude cannot execute this)
|
||||
- Assume commands succeeded without user confirmation
|
||||
- Provide more than 3 fix commands at once (errors may cascade)
|
||||
|
||||
### Communication Style
|
||||
|
||||
Ask before assuming. Never assume:
|
||||
@@ -294,8 +339,8 @@ 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
|
||||
|
||||
@@ -32,8 +32,10 @@ Day-to-day development guides:
|
||||
|
||||
- [Testing Guide](development/TESTING.md) - Unit, integration, and E2E testing
|
||||
- [Code Patterns](development/CODE-PATTERNS.md) - Common code patterns and ADR examples
|
||||
- [API Versioning](development/API-VERSIONING.md) - API versioning infrastructure and workflows
|
||||
- [Design Tokens](development/DESIGN_TOKENS.md) - UI design system and Neo-Brutalism
|
||||
- [Debugging Guide](development/DEBUGGING.md) - Common debugging patterns
|
||||
- [Dev Container](development/DEV-CONTAINER.md) - Development container setup and PM2
|
||||
|
||||
### 🔧 Operations
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted (Phase 1 Complete)
|
||||
**Status**: Accepted (Phase 2 Complete - All Tasks Done)
|
||||
|
||||
**Updated**: 2026-01-26
|
||||
**Updated**: 2026-01-27
|
||||
|
||||
**Completion Note**: Phase 2 fully complete including test path migration. All 23 integration test files updated to use `/api/v1/` paths. Test suite improved from 274/348 to 345/348 passing (3 remain as todo/skipped for known issues unrelated to versioning).
|
||||
|
||||
## Context
|
||||
|
||||
@@ -43,19 +45,19 @@ We will adopt a URI-based versioning strategy for the API using a phased rollout
|
||||
|
||||
The following changes require a new API version:
|
||||
|
||||
| Change Type | Breaking? | Example |
|
||||
| ----------------------------- | --------- | -------------------------------------------- |
|
||||
| Remove endpoint | Yes | DELETE `/api/v1/legacy-feature` |
|
||||
| Remove response field | Yes | Remove `user.email` from response |
|
||||
| Change response field type | Yes | `id: number` to `id: string` |
|
||||
| Change required request field | Yes | Make `email` required when it was optional |
|
||||
| Rename endpoint | Yes | `/users` to `/accounts` |
|
||||
| Add optional response field | No | Add `user.avatar_url` |
|
||||
| Add optional request field | No | Add optional `page` parameter |
|
||||
| Add new endpoint | No | Add `/api/v1/new-feature` |
|
||||
| Fix bug in behavior | No* | Correct calculation error |
|
||||
| Change Type | Breaking? | Example |
|
||||
| ----------------------------- | --------- | ------------------------------------------ |
|
||||
| Remove endpoint | Yes | DELETE `/api/v1/legacy-feature` |
|
||||
| Remove response field | Yes | Remove `user.email` from response |
|
||||
| Change response field type | Yes | `id: number` to `id: string` |
|
||||
| Change required request field | Yes | Make `email` required when it was optional |
|
||||
| Rename endpoint | Yes | `/users` to `/accounts` |
|
||||
| Add optional response field | No | Add `user.avatar_url` |
|
||||
| Add optional request field | No | Add optional `page` parameter |
|
||||
| Add new endpoint | No | Add `/api/v1/new-feature` |
|
||||
| Fix bug in behavior | No\* | Correct calculation error |
|
||||
|
||||
*Bug fixes may warrant version increment if clients depend on the buggy behavior.
|
||||
\*Bug fixes may warrant version increment if clients depend on the buggy behavior.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
@@ -109,6 +111,7 @@ The following changes require a new API version:
|
||||
```
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- All existing functionality works at `/api/v1/*`
|
||||
- Frontend makes requests to `/api/v1/*`
|
||||
- OpenAPI documentation reflects `/api/v1/*` paths
|
||||
@@ -246,11 +249,14 @@ export function versionRedirectMiddleware(req: Request, res: Response, next: Nex
|
||||
}
|
||||
|
||||
// Log deprecation warning
|
||||
logger.warn({
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
}, 'Unversioned API request - redirecting to v1');
|
||||
logger.warn(
|
||||
{
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
},
|
||||
'Unversioned API request - redirecting to v1',
|
||||
);
|
||||
|
||||
// Use 307 to preserve HTTP method
|
||||
const redirectUrl = `/api/v1${path}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
|
||||
@@ -296,13 +302,13 @@ app.use('/api/v1', (req, res, next) => {
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | --------------------------------------------- |
|
||||
| `server.ts` | Route registration with version prefixes |
|
||||
| `src/services/apiClient.ts` | Frontend API base URL configuration |
|
||||
| `src/config/swagger.ts` | OpenAPI server URL and version info |
|
||||
| `src/routes/*.routes.ts` | Individual route handlers |
|
||||
| `src/middleware/versionRedirect.ts` | Backwards compatibility redirects (Phase 1) |
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------- |
|
||||
| `server.ts` | Route registration with version prefixes |
|
||||
| `src/services/apiClient.ts` | Frontend API base URL configuration |
|
||||
| `src/config/swagger.ts` | OpenAPI server URL and version info |
|
||||
| `src/routes/*.routes.ts` | Individual route handlers |
|
||||
| `src/middleware/versionRedirect.ts` | Backwards compatibility redirects (Phase 1) |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
@@ -323,12 +329,76 @@ app.use('/api/v1', (req, res, next) => {
|
||||
- [x] Update API documentation examples (Swagger server URL updated)
|
||||
- [x] Verify all health checks work at `/api/v1/health/*`
|
||||
|
||||
### Phase 2 Tasks (Future)
|
||||
### Phase 2 Tasks
|
||||
|
||||
- [ ] Create version router factory
|
||||
- [ ] Implement deprecation header middleware
|
||||
- [ ] Add version detection to request context
|
||||
- [ ] Document versioning patterns for developers
|
||||
**Implementation Guide**: [API Versioning Infrastructure](../architecture/api-versioning-infrastructure.md)
|
||||
**Developer Guide**: [API Versioning Developer Guide](../development/API-VERSIONING.md)
|
||||
|
||||
- [x] Create version router factory (`src/routes/versioned.ts`)
|
||||
- [x] Implement deprecation header middleware (`src/middleware/deprecation.middleware.ts`)
|
||||
- [x] Add version detection to request context (`src/middleware/apiVersion.middleware.ts`)
|
||||
- [x] Add version types to Express Request (`src/types/express.d.ts`)
|
||||
- [x] Create version constants configuration (`src/config/apiVersions.ts`)
|
||||
- [x] Update server.ts to use version router factory
|
||||
- [x] Update swagger.ts for multi-server documentation
|
||||
- [x] Add unit tests for version middleware
|
||||
- [x] Add integration tests for versioned router
|
||||
- [x] Document versioning patterns for developers
|
||||
- [x] Migrate all test files to use `/api/v1/` paths (23 files, ~70 occurrences)
|
||||
|
||||
### Test Path Migration Summary (2026-01-27)
|
||||
|
||||
The final cleanup task for Phase 2 was completed by updating all integration test files to use versioned API paths:
|
||||
|
||||
| Metric | Value |
|
||||
| ---------------------------- | ---------------------------------------- |
|
||||
| Test files updated | 23 |
|
||||
| Path occurrences changed | ~70 |
|
||||
| Test failures resolved | 71 (274 -> 345 passing) |
|
||||
| Tests remaining todo/skipped | 3 (known issues, not versioning-related) |
|
||||
| Type check | Passing |
|
||||
| Versioning-specific tests | 82/82 passing |
|
||||
|
||||
**Test Results After Migration**:
|
||||
|
||||
- Integration tests: 345/348 passing
|
||||
- Unit tests: 3,375/3,391 passing (16 pre-existing failures unrelated to versioning)
|
||||
|
||||
### Unit Test Path Fix (2026-01-27)
|
||||
|
||||
Following the test path migration, 16 unit test failures were discovered and fixed. These failures were caused by error log messages using hardcoded `/api/` paths instead of versioned `/api/v1/` paths.
|
||||
|
||||
**Root Cause**: Error log messages in route handlers used hardcoded path strings like:
|
||||
|
||||
```typescript
|
||||
// INCORRECT - hardcoded path doesn't reflect actual request URL
|
||||
req.log.error({ error }, 'Error in /api/flyers/:id:');
|
||||
```
|
||||
|
||||
**Solution**: Updated to use `req.originalUrl` for dynamic path logging:
|
||||
|
||||
```typescript
|
||||
// CORRECT - uses actual request URL including version prefix
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
```
|
||||
|
||||
**Files Modified**:
|
||||
|
||||
| File | Changes |
|
||||
| -------------------------------------- | ---------------------------------- |
|
||||
| `src/routes/recipe.routes.ts` | 3 error log statements updated |
|
||||
| `src/routes/stats.routes.ts` | 1 error log statement updated |
|
||||
| `src/routes/flyer.routes.ts` | 2 error logs + 2 test expectations |
|
||||
| `src/routes/personalization.routes.ts` | 3 error log statements updated |
|
||||
|
||||
**Test Results After Fix**:
|
||||
|
||||
- Unit tests: 3,382/3,391 passing (0 failures in fixed files)
|
||||
- Remaining 9 failures are pre-existing, unrelated issues (CSS/mocking)
|
||||
|
||||
**Best Practice**: See [Error Logging Path Patterns](../development/ERROR-LOGGING-PATHS.md) for guidance on logging request paths in error handlers.
|
||||
|
||||
**Migration Documentation**: [Test Path Migration Guide](../development/test-path-migration.md)
|
||||
|
||||
### Phase 3 Tasks (Future)
|
||||
|
||||
|
||||
367
docs/adr/0057-test-remediation-post-api-versioning.md
Normal file
367
docs/adr/0057-test-remediation-post-api-versioning.md
Normal 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 |
|
||||
@@ -15,9 +15,9 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 39 |
|
||||
| Accepted (Fully Implemented) | 40 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 15 |
|
||||
| Proposed (Not Started) | 14 |
|
||||
|
||||
---
|
||||
|
||||
@@ -44,13 +44,13 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
### Category 3: API & Integration
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------- | ------------------------ | ----------- | ------ | ------------------------------------- |
|
||||
| [ADR-003](./0003-standardized-input-validation-using-middleware.md) | Input Validation | Accepted | - | Fully implemented |
|
||||
| [ADR-008](./0008-api-versioning-strategy.md) | API Versioning | Proposed | L | Major URL/routing changes |
|
||||
| [ADR-018](./0018-api-documentation-strategy.md) | API Documentation | Accepted | - | OpenAPI/Swagger implemented |
|
||||
| [ADR-022](./0022-real-time-notification-system.md) | Real-time Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Implemented | L | Completed (routes, middleware, tests) |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------- | ------------------------ | -------- | ------ | ------------------------------------- |
|
||||
| [ADR-003](./0003-standardized-input-validation-using-middleware.md) | Input Validation | Accepted | - | Fully implemented |
|
||||
| [ADR-008](./0008-api-versioning-strategy.md) | API Versioning | Accepted | - | Phase 2 complete, tests migrated |
|
||||
| [ADR-018](./0018-api-documentation-strategy.md) | API Documentation | Accepted | - | OpenAPI/Swagger implemented |
|
||||
| [ADR-022](./0022-real-time-notification-system.md) | Real-time Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Accepted | - | Completed (routes, middleware, tests) |
|
||||
|
||||
### Category 4: Security & Compliance
|
||||
|
||||
@@ -136,47 +136,48 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
|
||||
| 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-008 | API Versioning | Proposed | L | Future API evolution |
|
||||
| 6 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 7 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 8 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 9 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 10 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Recent Implementation History
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ---------------------------------------------------------------------------- |
|
||||
| 2026-01-26 | ADR-015 | Completed - Added Sentry user context in AuthProvider, now fully implemented |
|
||||
| 2026-01-26 | ADR-056 | Created - APM split from ADR-015, status Proposed (tracesSampleRate=0) |
|
||||
| 2026-01-26 | ADR-015 | Refactored to focus on error tracking only, temporarily status Partial |
|
||||
| 2026-01-26 | ADR-048 | Verified as fully implemented - JWT + OAuth authentication complete |
|
||||
| 2026-01-26 | ADR-022 | Verified as fully implemented - WebSocket notifications complete |
|
||||
| 2026-01-26 | ADR-052 | Marked as fully implemented - createScopedLogger complete |
|
||||
| 2026-01-26 | ADR-053 | Marked as fully implemented - /health/queues endpoint complete |
|
||||
| 2026-01-26 | ADR-050 | Marked as fully implemented - PostgreSQL function observability |
|
||||
| 2026-01-26 | ADR-055 | Created (renumbered from duplicate ADR-023) - DB normalization |
|
||||
| 2026-01-26 | ADR-054 | Added to tracker - Bugsink to Gitea issue synchronization |
|
||||
| 2026-01-26 | ADR-053 | Added to tracker - Worker health checks and monitoring |
|
||||
| 2026-01-26 | ADR-052 | Added to tracker - Granular debug logging strategy |
|
||||
| 2026-01-26 | ADR-051 | Added to tracker - Asynchronous context propagation |
|
||||
| 2026-01-26 | ADR-048 | Added to tracker - Authentication strategy |
|
||||
| 2026-01-26 | ADR-040 | Added to tracker - Testing economics and priorities |
|
||||
| 2026-01-17 | ADR-054 | Created - Bugsink-Gitea sync worker proposal |
|
||||
| 2026-01-11 | ADR-050 | Created - PostgreSQL function observability with fn_log() |
|
||||
| 2026-01-11 | ADR-018 | Implemented - OpenAPI/Swagger documentation at /docs/api-docs |
|
||||
| 2026-01-11 | ADR-049 | Created - Gamification system, achievements, and testing |
|
||||
| 2026-01-09 | ADR-047 | Created - Project file/folder organization with migration plan |
|
||||
| 2026-01-09 | ADR-041 | Created - AI/Gemini integration with model fallback |
|
||||
| 2026-01-09 | ADR-042 | Created - Email and notification architecture with BullMQ |
|
||||
| 2026-01-09 | ADR-043 | Created - Express middleware pipeline ordering and patterns |
|
||||
| 2026-01-09 | ADR-044 | Created - Frontend feature-based folder organization |
|
||||
| 2026-01-09 | ADR-045 | Created - Test data factory pattern for mock generation |
|
||||
| 2026-01-09 | ADR-046 | Created - Image processing pipeline with Sharp and EXIF stripping |
|
||||
| 2026-01-09 | ADR-026 | Fully implemented - client-side structured logger |
|
||||
| 2026-01-09 | ADR-028 | Fully implemented - all routes, middleware, and tests updated |
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| 2026-01-26 | ADR-056 | Created - APM split from ADR-015, status Proposed (tracesSampleRate=0) |
|
||||
| 2026-01-26 | ADR-015 | Refactored to focus on error tracking only, temporarily status Partial |
|
||||
| 2026-01-26 | ADR-048 | Verified as fully implemented - JWT + OAuth authentication complete |
|
||||
| 2026-01-26 | ADR-022 | Verified as fully implemented - WebSocket notifications complete |
|
||||
| 2026-01-26 | ADR-052 | Marked as fully implemented - createScopedLogger complete |
|
||||
| 2026-01-26 | ADR-053 | Marked as fully implemented - /health/queues endpoint complete |
|
||||
| 2026-01-26 | ADR-050 | Marked as fully implemented - PostgreSQL function observability |
|
||||
| 2026-01-26 | ADR-055 | Created (renumbered from duplicate ADR-023) - DB normalization |
|
||||
| 2026-01-26 | ADR-054 | Added to tracker - Bugsink to Gitea issue synchronization |
|
||||
| 2026-01-26 | ADR-053 | Added to tracker - Worker health checks and monitoring |
|
||||
| 2026-01-26 | ADR-052 | Added to tracker - Granular debug logging strategy |
|
||||
| 2026-01-26 | ADR-051 | Added to tracker - Asynchronous context propagation |
|
||||
| 2026-01-26 | ADR-048 | Added to tracker - Authentication strategy |
|
||||
| 2026-01-26 | ADR-040 | Added to tracker - Testing economics and priorities |
|
||||
| 2026-01-17 | ADR-054 | Created - Bugsink-Gitea sync worker proposal |
|
||||
| 2026-01-11 | ADR-050 | Created - PostgreSQL function observability with fn_log() |
|
||||
| 2026-01-11 | ADR-018 | Implemented - OpenAPI/Swagger documentation at /docs/api-docs |
|
||||
| 2026-01-11 | ADR-049 | Created - Gamification system, achievements, and testing |
|
||||
| 2026-01-09 | ADR-047 | Created - Project file/folder organization with migration plan |
|
||||
| 2026-01-09 | ADR-041 | Created - AI/Gemini integration with model fallback |
|
||||
| 2026-01-09 | ADR-042 | Created - Email and notification architecture with BullMQ |
|
||||
| 2026-01-09 | ADR-043 | Created - Express middleware pipeline ordering and patterns |
|
||||
| 2026-01-09 | ADR-044 | Created - Frontend feature-based folder organization |
|
||||
| 2026-01-09 | ADR-045 | Created - Test data factory pattern for mock generation |
|
||||
| 2026-01-09 | ADR-046 | Created - Image processing pipeline with Sharp and EXIF stripping |
|
||||
| 2026-01-09 | ADR-026 | Fully implemented - client-side structured logger |
|
||||
| 2026-01-09 | ADR-028 | Fully implemented - all routes, middleware, and tests updated |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -71,6 +71,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
|
||||
|
||||
|
||||
@@ -762,11 +762,14 @@ The system architecture is governed by Architecture Decision Records (ADRs). Key
|
||||
|
||||
### API and Integration
|
||||
|
||||
| ADR | Title | Status |
|
||||
| ------- | ----------------------------- | ----------- |
|
||||
| ADR-003 | Standardized Input Validation | Accepted |
|
||||
| ADR-022 | Real-time Notification System | Proposed |
|
||||
| ADR-028 | API Response Standardization | Implemented |
|
||||
| ADR | Title | Status |
|
||||
| ------- | ----------------------------- | ---------------- |
|
||||
| ADR-003 | Standardized Input Validation | Accepted |
|
||||
| ADR-008 | API Versioning Strategy | Phase 1 Complete |
|
||||
| ADR-022 | Real-time Notification System | Proposed |
|
||||
| ADR-028 | API Response Standardization | Implemented |
|
||||
|
||||
**Implementation Guide**: [API Versioning Infrastructure](./api-versioning-infrastructure.md) (Phase 2)
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
521
docs/architecture/api-versioning-infrastructure.md
Normal file
521
docs/architecture/api-versioning-infrastructure.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# API Versioning Infrastructure (ADR-008 Phase 2)
|
||||
|
||||
**Status**: Complete
|
||||
**Date**: 2026-01-27
|
||||
**Prerequisite**: ADR-008 Phase 1 Complete (all routes at `/api/v1/*`)
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
Phase 2 has been fully implemented with the following results:
|
||||
|
||||
| Metric | Value |
|
||||
| ------------------ | -------------------------------------- |
|
||||
| New Files Created | 5 |
|
||||
| Files Modified | 2 (server.ts, express.d.ts) |
|
||||
| Unit Tests | 82 passing (100%) |
|
||||
| Integration Tests | 48 new versioning tests |
|
||||
| RFC Compliance | RFC 8594 (Sunset), RFC 8288 (Link) |
|
||||
| Supported Versions | v1 (active), v2 (infrastructure ready) |
|
||||
|
||||
**Developer Guide**: [API-VERSIONING.md](../development/API-VERSIONING.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
Build infrastructure to support parallel API versions, version detection, and deprecation workflows. Enables future v2 API without breaking existing clients.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```text
|
||||
Request → Version Router → Version Middleware → Domain Router → Handler
|
||||
↓ ↓
|
||||
createVersionedRouter() attachVersionInfo()
|
||||
↓ ↓
|
||||
/api/v1/* | /api/v2/* req.apiVersion = 'v1'|'v2'
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
| ---------------------- | ------------------------------------------ | ------------------------------------------ |
|
||||
| Version Router Factory | `src/routes/versioned.ts` | Create version-specific Express routers |
|
||||
| Version Middleware | `src/middleware/apiVersion.middleware.ts` | Extract version, attach to request context |
|
||||
| Deprecation Middleware | `src/middleware/deprecation.middleware.ts` | Add RFC 8594 deprecation headers |
|
||||
| Version Types | `src/types/express.d.ts` | Extend Express Request with apiVersion |
|
||||
| Version Constants | `src/config/apiVersions.ts` | Centralized version definitions |
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task 1: Version Types (Foundation)
|
||||
|
||||
**File**: `src/types/express.d.ts`
|
||||
|
||||
```typescript
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
apiVersion?: 'v1' | 'v2';
|
||||
versionDeprecated?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dependencies**: None
|
||||
**Testing**: Type-check only (`npm run type-check`)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Version Constants
|
||||
|
||||
**File**: `src/config/apiVersions.ts`
|
||||
|
||||
```typescript
|
||||
export const API_VERSIONS = ['v1', 'v2'] as const;
|
||||
export type ApiVersion = (typeof API_VERSIONS)[number];
|
||||
|
||||
export const CURRENT_VERSION: ApiVersion = 'v1';
|
||||
export const DEFAULT_VERSION: ApiVersion = 'v1';
|
||||
|
||||
export interface VersionConfig {
|
||||
version: ApiVersion;
|
||||
status: 'active' | 'deprecated' | 'sunset';
|
||||
sunsetDate?: string; // ISO 8601
|
||||
successorVersion?: ApiVersion;
|
||||
}
|
||||
|
||||
export const VERSION_CONFIG: Record<ApiVersion, VersionConfig> = {
|
||||
v1: { version: 'v1', status: 'active' },
|
||||
v2: { version: 'v2', status: 'active' },
|
||||
};
|
||||
```
|
||||
|
||||
**Dependencies**: None
|
||||
**Testing**: Unit test for version validation
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Version Detection Middleware
|
||||
|
||||
**File**: `src/middleware/apiVersion.middleware.ts`
|
||||
|
||||
```typescript
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { API_VERSIONS, ApiVersion, DEFAULT_VERSION } from '../config/apiVersions';
|
||||
|
||||
export function extractApiVersion(req: Request, _res: Response, next: NextFunction) {
|
||||
// Extract from URL path: /api/v1/... → 'v1'
|
||||
const pathMatch = req.path.match(/^\/v(\d+)\//);
|
||||
if (pathMatch) {
|
||||
const version = `v${pathMatch[1]}` as ApiVersion;
|
||||
if (API_VERSIONS.includes(version)) {
|
||||
req.apiVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default if not detected
|
||||
req.apiVersion = req.apiVersion || DEFAULT_VERSION;
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Attach to request before route handlers
|
||||
**Integration Point**: `server.ts` before versioned route mounting
|
||||
**Testing**: Unit tests for path extraction, default fallback
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deprecation Headers Middleware
|
||||
|
||||
**File**: `src/middleware/deprecation.middleware.ts`
|
||||
|
||||
Implements RFC 8594 (Sunset Header) and draft-ietf-httpapi-deprecation-header.
|
||||
|
||||
```typescript
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { VERSION_CONFIG, ApiVersion } from '../config/apiVersions';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
export function deprecationHeaders(version: ApiVersion) {
|
||||
const config = VERSION_CONFIG[version];
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (config.status === 'deprecated') {
|
||||
res.set('Deprecation', 'true');
|
||||
|
||||
if (config.sunsetDate) {
|
||||
res.set('Sunset', config.sunsetDate);
|
||||
}
|
||||
|
||||
if (config.successorVersion) {
|
||||
res.set('Link', `</api/${config.successorVersion}>; rel="successor-version"`);
|
||||
}
|
||||
|
||||
req.versionDeprecated = true;
|
||||
|
||||
// Log deprecation access for monitoring
|
||||
logger.warn(
|
||||
{
|
||||
apiVersion: version,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
sunsetDate: config.sunsetDate,
|
||||
},
|
||||
'Deprecated API version accessed',
|
||||
);
|
||||
}
|
||||
|
||||
// Always set version header
|
||||
res.set('X-API-Version', version);
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**RFC Compliance**:
|
||||
|
||||
- `Deprecation: true` (draft-ietf-httpapi-deprecation-header)
|
||||
- `Sunset: <date>` (RFC 8594)
|
||||
- `Link: <url>; rel="successor-version"` (RFC 8288)
|
||||
|
||||
**Testing**: Unit tests for header presence, version status variations
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Version Router Factory
|
||||
|
||||
**File**: `src/routes/versioned.ts`
|
||||
|
||||
```typescript
|
||||
import { Router } from 'express';
|
||||
import { ApiVersion } from '../config/apiVersions';
|
||||
import { extractApiVersion } from '../middleware/apiVersion.middleware';
|
||||
import { deprecationHeaders } from '../middleware/deprecation.middleware';
|
||||
|
||||
// Import domain routers
|
||||
import authRouter from './auth.routes';
|
||||
import userRouter from './user.routes';
|
||||
import flyerRouter from './flyer.routes';
|
||||
// ... all domain routers
|
||||
|
||||
interface VersionedRouters {
|
||||
v1: Record<string, Router>;
|
||||
v2: Record<string, Router>;
|
||||
}
|
||||
|
||||
const ROUTERS: VersionedRouters = {
|
||||
v1: {
|
||||
auth: authRouter,
|
||||
users: userRouter,
|
||||
flyers: flyerRouter,
|
||||
// ... all v1 routers (current implementation)
|
||||
},
|
||||
v2: {
|
||||
// Future: v2-specific routers
|
||||
// auth: authRouterV2,
|
||||
// For now, fallback to v1
|
||||
},
|
||||
};
|
||||
|
||||
export function createVersionedRouter(version: ApiVersion): Router {
|
||||
const router = Router();
|
||||
|
||||
// Apply version middleware
|
||||
router.use(extractApiVersion);
|
||||
router.use(deprecationHeaders(version));
|
||||
|
||||
// Get routers for this version, fallback to v1
|
||||
const versionRouters = ROUTERS[version] || ROUTERS.v1;
|
||||
|
||||
// Mount domain routers
|
||||
Object.entries(versionRouters).forEach(([path, domainRouter]) => {
|
||||
router.use(`/${path}`, domainRouter);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Factory function returns configured Router
|
||||
**Fallback Strategy**: v2 uses v1 routers until v2-specific handlers exist
|
||||
**Testing**: Integration test verifying route mounting
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Server Integration
|
||||
|
||||
**File**: `server.ts` (modifications)
|
||||
|
||||
```typescript
|
||||
// Before (current implementation - Phase 1):
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/users', userRouter);
|
||||
// ... individual route mounting
|
||||
|
||||
// After (Phase 2):
|
||||
import { createVersionedRouter } from './src/routes/versioned';
|
||||
|
||||
// Mount versioned API routers
|
||||
app.use('/api/v1', createVersionedRouter('v1'));
|
||||
app.use('/api/v2', createVersionedRouter('v2')); // Placeholder for future
|
||||
|
||||
// Keep redirect middleware for unversioned requests
|
||||
app.use('/api', versionRedirectMiddleware);
|
||||
```
|
||||
|
||||
**Breaking Change Risk**: Low (same routes, different mounting)
|
||||
**Rollback**: Revert to individual `app.use()` calls
|
||||
**Testing**: Full integration test suite must pass
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Request Context Propagation
|
||||
|
||||
**Pattern**: Version flows through request lifecycle for conditional logic.
|
||||
|
||||
```typescript
|
||||
// In any route handler or service:
|
||||
function handler(req: Request, res: Response) {
|
||||
if (req.apiVersion === 'v2') {
|
||||
// v2-specific behavior
|
||||
return sendSuccess(res, transformV2(data));
|
||||
}
|
||||
// v1 behavior (default)
|
||||
return sendSuccess(res, data);
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Response transformation based on version
|
||||
- Feature flags per version
|
||||
- Metric tagging by version
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Documentation Update
|
||||
|
||||
**File**: `src/config/swagger.ts` (modifications)
|
||||
|
||||
```typescript
|
||||
const swaggerDefinition: OpenAPIV3.Document = {
|
||||
// ...
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API v1 (Current)',
|
||||
},
|
||||
{
|
||||
url: '/api/v2',
|
||||
description: 'API v2 (Future)',
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**File**: `docs/adr/0008-api-versioning-strategy.md` (update checklist)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Unit Tests
|
||||
|
||||
**File**: `src/middleware/apiVersion.middleware.test.ts`
|
||||
|
||||
```typescript
|
||||
describe('extractApiVersion', () => {
|
||||
it('extracts v1 from /api/v1/users', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('extracts v2 from /api/v2/users', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('defaults to v1 for unversioned paths', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('ignores invalid version numbers', () => {
|
||||
/* ... */
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**File**: `src/middleware/deprecation.middleware.test.ts`
|
||||
|
||||
```typescript
|
||||
describe('deprecationHeaders', () => {
|
||||
it('adds no headers for active version', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('adds Deprecation header for deprecated version', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('adds Sunset header when sunsetDate configured', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('adds Link header for successor version', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('always sets X-API-Version header', () => {
|
||||
/* ... */
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Integration Tests
|
||||
|
||||
**File**: `src/routes/versioned.test.ts`
|
||||
|
||||
```typescript
|
||||
describe('Versioned Router Integration', () => {
|
||||
it('mounts all v1 routes correctly', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('v2 falls back to v1 handlers', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('sets X-API-Version response header', () => {
|
||||
/* ... */
|
||||
});
|
||||
it('deprecation headers appear when configured', () => {
|
||||
/* ... */
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Run in Container**: `podman exec -it flyer-crawler-dev npm test -- versioned`
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
```text
|
||||
[Task 1] → [Task 2] → [Task 3] → [Task 4] → [Task 5] → [Task 6]
|
||||
Types Config Middleware Middleware Factory Server
|
||||
↓ ↓ ↓ ↓
|
||||
[Task 7] [Task 9] [Task 10] [Task 8]
|
||||
Context Unit Integ Docs
|
||||
```
|
||||
|
||||
**Critical Path**: 1 → 2 → 3 → 5 → 6 (server integration)
|
||||
|
||||
## File Structure After Implementation
|
||||
|
||||
```text
|
||||
src/
|
||||
├── config/
|
||||
│ ├── apiVersions.ts # NEW: Version constants
|
||||
│ └── swagger.ts # MODIFIED: Multi-server
|
||||
├── middleware/
|
||||
│ ├── apiVersion.middleware.ts # NEW: Version extraction
|
||||
│ ├── apiVersion.middleware.test.ts # NEW: Unit tests
|
||||
│ ├── deprecation.middleware.ts # NEW: RFC 8594 headers
|
||||
│ └── deprecation.middleware.test.ts # NEW: Unit tests
|
||||
├── routes/
|
||||
│ ├── versioned.ts # NEW: Router factory
|
||||
│ ├── versioned.test.ts # NEW: Integration tests
|
||||
│ └── *.routes.ts # UNCHANGED: Domain routers
|
||||
├── types/
|
||||
│ └── express.d.ts # MODIFIED: Add apiVersion
|
||||
server.ts # MODIFIED: Use versioned router
|
||||
```
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| ------------------------------------ | ---------- | ------ | ----------------------------------- |
|
||||
| Route registration order breaks | Medium | High | Full integration test suite |
|
||||
| Middleware not applied to all routes | Low | Medium | Factory pattern ensures consistency |
|
||||
| Performance impact from middleware | Low | Low | Minimal overhead (path regex) |
|
||||
| Type errors in extended Request | Low | Medium | TypeScript strict mode catches |
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
1. Revert `server.ts` to individual route mounting
|
||||
2. Remove new middleware files (not breaking)
|
||||
3. Remove version types from `express.d.ts`
|
||||
4. Run `npm run type-check && npm test` to verify
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] All existing tests pass (`npm test` in container)
|
||||
- [x] `X-API-Version: v1` header on all `/api/v1/*` responses
|
||||
- [x] TypeScript compiles without errors (`npm run type-check`)
|
||||
- [x] No performance regression (< 5ms added latency)
|
||||
- [x] Deprecation headers work when v1 marked deprecated (manual test)
|
||||
|
||||
## Known Issues and Follow-up Work
|
||||
|
||||
### Integration Tests Using Unversioned Paths
|
||||
|
||||
**Issue**: Some existing integration tests make requests to unversioned paths (e.g., `/api/flyers` instead of `/api/v1/flyers`). These tests now receive 301 redirects due to the backwards compatibility middleware.
|
||||
|
||||
**Impact**: 74 integration tests may need updates to use versioned paths explicitly.
|
||||
|
||||
**Workaround Options**:
|
||||
|
||||
1. Update test paths to use `/api/v1/*` explicitly (recommended)
|
||||
2. Configure supertest to follow redirects automatically
|
||||
3. Accept 301 as valid response in affected tests
|
||||
|
||||
**Resolution**: Phase 3 work item - update integration tests to use versioned endpoints consistently.
|
||||
|
||||
### Phase 3 Prerequisites
|
||||
|
||||
Before marking v1 as deprecated and implementing v2:
|
||||
|
||||
1. Update all integration tests to use versioned paths
|
||||
2. Define breaking changes requiring v2
|
||||
3. Create v2-specific route handlers where needed
|
||||
4. Set deprecation timeline for v1
|
||||
|
||||
## Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
| ------- | ------------------------------------------------- |
|
||||
| ADR-008 | Parent decision (this implements Phase 2) |
|
||||
| ADR-003 | Validation middleware pattern applies per-version |
|
||||
| ADR-028 | Response format consistent across versions |
|
||||
| ADR-018 | OpenAPI docs reflect versioned endpoints |
|
||||
| ADR-043 | Middleware pipeline order considerations |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Checking Version in Handler
|
||||
|
||||
```typescript
|
||||
// src/routes/flyer.routes.ts
|
||||
router.get('/', async (req, res) => {
|
||||
const flyers = await flyerRepo.getFlyers(req.log);
|
||||
|
||||
// Version-specific response transformation
|
||||
if (req.apiVersion === 'v2') {
|
||||
return sendSuccess(res, flyers.map(transformFlyerV2));
|
||||
}
|
||||
return sendSuccess(res, flyers);
|
||||
});
|
||||
```
|
||||
|
||||
### Marking Version as Deprecated
|
||||
|
||||
```typescript
|
||||
// src/config/apiVersions.ts
|
||||
export const VERSION_CONFIG = {
|
||||
v1: {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
},
|
||||
v2: { version: 'v2', status: 'active' },
|
||||
};
|
||||
```
|
||||
|
||||
### Testing Deprecation Headers
|
||||
|
||||
```bash
|
||||
curl -I https://localhost:3001/api/v1/flyers
|
||||
# When v1 deprecated:
|
||||
# Deprecation: true
|
||||
# Sunset: 2027-01-01T00:00:00Z
|
||||
# Link: </api/v2>; rel="successor-version"
|
||||
# X-API-Version: v1
|
||||
```
|
||||
844
docs/development/API-VERSIONING.md
Normal file
844
docs/development/API-VERSIONING.md
Normal file
@@ -0,0 +1,844 @@
|
||||
# API Versioning Developer Guide
|
||||
|
||||
**Status**: Complete (Phase 2)
|
||||
**Last Updated**: 2026-01-27
|
||||
**Implements**: ADR-008 Phase 2
|
||||
**Architecture**: [api-versioning-infrastructure.md](../architecture/api-versioning-infrastructure.md)
|
||||
|
||||
This guide covers the API versioning infrastructure for the Flyer Crawler application. It explains how versioning works, how to add new versions, and how to deprecate old ones.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Component | Status | Tests |
|
||||
| ------------------------------ | -------- | -------------------- |
|
||||
| Version Constants | Complete | Unit tests |
|
||||
| Version Detection Middleware | Complete | 25 unit tests |
|
||||
| Deprecation Headers Middleware | Complete | 30 unit tests |
|
||||
| Version Router Factory | Complete | Integration tests |
|
||||
| Server Integration | Complete | 48 integration tests |
|
||||
| Developer Documentation | Complete | This guide |
|
||||
|
||||
**Total Tests**: 82 versioning-specific tests (100% passing)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Key Concepts](#key-concepts)
|
||||
4. [Developer Workflows](#developer-workflows)
|
||||
5. [Version Headers](#version-headers)
|
||||
6. [Testing Versioned Endpoints](#testing-versioned-endpoints)
|
||||
7. [Migration Guide: v1 to v2](#migration-guide-v1-to-v2)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
9. [Related Documentation](#related-documentation)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The API uses URI-based versioning with the format `/api/v{MAJOR}/resource`. All endpoints are accessible at versioned paths like `/api/v1/flyers` or `/api/v2/users`.
|
||||
|
||||
### Current Version Status
|
||||
|
||||
| Version | Status | Description |
|
||||
| ------- | ------ | ------------------------------------- |
|
||||
| v1 | Active | Current production version |
|
||||
| v2 | Active | Future version (infrastructure ready) |
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Automatic version detection** from URL path
|
||||
- **RFC 8594 compliant deprecation headers** when versions are deprecated
|
||||
- **Backwards compatibility** via 301 redirects from unversioned paths
|
||||
- **Version-aware request context** for conditional logic in handlers
|
||||
- **Centralized configuration** for version lifecycle management
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```text
|
||||
Client Request: GET /api/v1/flyers
|
||||
|
|
||||
v
|
||||
+------+-------+
|
||||
| server.ts |
|
||||
| - Redirect |
|
||||
| middleware |
|
||||
+------+-------+
|
||||
|
|
||||
v
|
||||
+------+-------+
|
||||
| createApi |
|
||||
| Router() |
|
||||
+------+-------+
|
||||
|
|
||||
v
|
||||
+------+-------+
|
||||
| detectApi |
|
||||
| Version |
|
||||
| middleware |
|
||||
+------+-------+
|
||||
| req.apiVersion = 'v1'
|
||||
v
|
||||
+------+-------+
|
||||
| Versioned |
|
||||
| Router |
|
||||
| (v1) |
|
||||
+------+-------+
|
||||
|
|
||||
v
|
||||
+------+-------+
|
||||
| addDepreca |
|
||||
| tionHeaders |
|
||||
| middleware |
|
||||
+------+-------+
|
||||
| X-API-Version: v1
|
||||
v
|
||||
+------+-------+
|
||||
| Domain |
|
||||
| Router |
|
||||
| (flyers) |
|
||||
+------+-------+
|
||||
|
|
||||
v
|
||||
Response
|
||||
```
|
||||
|
||||
### Component Overview
|
||||
|
||||
| Component | File | Purpose |
|
||||
| ------------------- | ------------------------------------------ | ----------------------------------------------------- |
|
||||
| Version Constants | `src/config/apiVersions.ts` | Type definitions, version configs, utility functions |
|
||||
| Version Detection | `src/middleware/apiVersion.middleware.ts` | Extract version from URL, validate, attach to request |
|
||||
| Deprecation Headers | `src/middleware/deprecation.middleware.ts` | Add RFC 8594 headers for deprecated versions |
|
||||
| Router Factory | `src/routes/versioned.ts` | Create version-specific Express routers |
|
||||
| Type Extensions | `src/types/express.d.ts` | Add `apiVersion` and `versionDeprecation` to Request |
|
||||
|
||||
---
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### 1. Version Configuration
|
||||
|
||||
All version definitions live in `src/config/apiVersions.ts`:
|
||||
|
||||
```typescript
|
||||
// src/config/apiVersions.ts
|
||||
|
||||
// Supported versions as a const tuple
|
||||
export const API_VERSIONS = ['v1', 'v2'] as const;
|
||||
|
||||
// Union type: 'v1' | 'v2'
|
||||
export type ApiVersion = (typeof API_VERSIONS)[number];
|
||||
|
||||
// Version lifecycle status
|
||||
export type VersionStatus = 'active' | 'deprecated' | 'sunset';
|
||||
|
||||
// Configuration for each version
|
||||
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
|
||||
v1: {
|
||||
version: 'v1',
|
||||
status: 'active',
|
||||
},
|
||||
v2: {
|
||||
version: 'v2',
|
||||
status: 'active',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Version Detection
|
||||
|
||||
The `detectApiVersion` middleware extracts the version from `req.params.version` and validates it:
|
||||
|
||||
```typescript
|
||||
// How it works (src/middleware/apiVersion.middleware.ts)
|
||||
|
||||
// For valid versions:
|
||||
// GET /api/v1/flyers -> req.apiVersion = 'v1'
|
||||
|
||||
// For invalid versions:
|
||||
// GET /api/v99/flyers -> 404 with UNSUPPORTED_VERSION error
|
||||
```
|
||||
|
||||
### 3. Request Context
|
||||
|
||||
After middleware runs, the request object has version information:
|
||||
|
||||
```typescript
|
||||
// In any route handler
|
||||
router.get('/flyers', async (req, res) => {
|
||||
// Access the detected version
|
||||
const version = req.apiVersion; // 'v1' | 'v2'
|
||||
|
||||
// Check deprecation status
|
||||
if (req.versionDeprecation?.deprecated) {
|
||||
req.log.warn(
|
||||
{
|
||||
sunset: req.versionDeprecation.sunsetDate,
|
||||
},
|
||||
'Client using deprecated API',
|
||||
);
|
||||
}
|
||||
|
||||
// Version-specific behavior
|
||||
if (req.apiVersion === 'v2') {
|
||||
return sendSuccess(res, transformV2(data));
|
||||
}
|
||||
|
||||
return sendSuccess(res, data);
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Route Registration
|
||||
|
||||
Routes are registered in `src/routes/versioned.ts` with version availability:
|
||||
|
||||
```typescript
|
||||
// src/routes/versioned.ts
|
||||
|
||||
export const ROUTES: RouteRegistration[] = [
|
||||
{
|
||||
path: 'auth',
|
||||
router: authRouter,
|
||||
description: 'Authentication routes',
|
||||
// Available in all versions (no versions array)
|
||||
},
|
||||
{
|
||||
path: 'flyers',
|
||||
router: flyerRouter,
|
||||
description: 'Flyer management',
|
||||
// Available in all versions
|
||||
},
|
||||
{
|
||||
path: 'new-feature',
|
||||
router: newFeatureRouter,
|
||||
description: 'New feature only in v2',
|
||||
versions: ['v2'], // Only available in v2
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Workflows
|
||||
|
||||
### Adding a New API Version (e.g., v3)
|
||||
|
||||
**Step 1**: Add version to constants (`src/config/apiVersions.ts`)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
export const API_VERSIONS = ['v1', 'v2'] as const;
|
||||
|
||||
// After
|
||||
export const API_VERSIONS = ['v1', 'v2', 'v3'] as const;
|
||||
|
||||
// Add configuration
|
||||
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
|
||||
v1: { version: 'v1', status: 'active' },
|
||||
v2: { version: 'v2', status: 'active' },
|
||||
v3: { version: 'v3', status: 'active' }, // NEW
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2**: Router cache auto-updates (no changes needed)
|
||||
|
||||
The versioned router cache in `src/routes/versioned.ts` automatically creates routers for all versions defined in `API_VERSIONS`.
|
||||
|
||||
**Step 3**: Update OpenAPI documentation (`src/config/swagger.ts`)
|
||||
|
||||
```typescript
|
||||
servers: [
|
||||
{ url: '/api/v1', description: 'API v1' },
|
||||
{ url: '/api/v2', description: 'API v2' },
|
||||
{ url: '/api/v3', description: 'API v3 (New)' }, // NEW
|
||||
],
|
||||
```
|
||||
|
||||
**Step 4**: Test the new version
|
||||
|
||||
```bash
|
||||
# In dev container
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# Manual verification
|
||||
curl -i http://localhost:3001/api/v3/health
|
||||
# Should return 200 with X-API-Version: v3 header
|
||||
```
|
||||
|
||||
### Marking a Version as Deprecated
|
||||
|
||||
**Step 1**: Update version config (`src/config/apiVersions.ts`)
|
||||
|
||||
```typescript
|
||||
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
|
||||
v1: {
|
||||
version: 'v1',
|
||||
status: 'deprecated', // Changed from 'active'
|
||||
sunsetDate: '2027-01-01T00:00:00Z', // When it will be removed
|
||||
successorVersion: 'v2', // Migration target
|
||||
},
|
||||
v2: {
|
||||
version: 'v2',
|
||||
status: 'active',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2**: Verify deprecation headers
|
||||
|
||||
```bash
|
||||
curl -I http://localhost:3001/api/v1/health
|
||||
|
||||
# Expected headers:
|
||||
# X-API-Version: v1
|
||||
# Deprecation: true
|
||||
# Sunset: 2027-01-01T00:00:00Z
|
||||
# Link: </api/v2>; rel="successor-version"
|
||||
# X-API-Deprecation-Notice: API v1 is deprecated and will be sunset...
|
||||
```
|
||||
|
||||
**Step 3**: Monitor deprecation usage
|
||||
|
||||
Check logs for `Deprecated API version accessed` messages with context about which clients are still using deprecated versions.
|
||||
|
||||
### Adding Version-Specific Routes
|
||||
|
||||
**Scenario**: Add a new endpoint only available in v2+
|
||||
|
||||
**Step 1**: Create the route handler (new or existing file)
|
||||
|
||||
```typescript
|
||||
// src/routes/newFeature.routes.ts
|
||||
import { Router } from 'express';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
// This endpoint only exists in v2+
|
||||
sendSuccess(res, { feature: 'new-feature-data' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**Step 2**: Register with version restriction (`src/routes/versioned.ts`)
|
||||
|
||||
```typescript
|
||||
import newFeatureRouter from './newFeature.routes';
|
||||
|
||||
export const ROUTES: RouteRegistration[] = [
|
||||
// ... existing routes ...
|
||||
{
|
||||
path: 'new-feature',
|
||||
router: newFeatureRouter,
|
||||
description: 'New feature only available in v2+',
|
||||
versions: ['v2'], // Not available in v1
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Step 3**: Verify route availability
|
||||
|
||||
```bash
|
||||
# v1 - should return 404
|
||||
curl -i http://localhost:3001/api/v1/new-feature
|
||||
# HTTP/1.1 404 Not Found
|
||||
|
||||
# v2 - should work
|
||||
curl -i http://localhost:3001/api/v2/new-feature
|
||||
# HTTP/1.1 200 OK
|
||||
# X-API-Version: v2
|
||||
```
|
||||
|
||||
### Adding Version-Specific Behavior in Existing Routes
|
||||
|
||||
For routes that exist in multiple versions but behave differently:
|
||||
|
||||
```typescript
|
||||
// src/routes/flyer.routes.ts
|
||||
router.get('/:id', async (req, res) => {
|
||||
const flyer = await flyerService.getFlyer(req.params.id, req.log);
|
||||
|
||||
// Different response format per version
|
||||
if (req.apiVersion === 'v2') {
|
||||
// v2 returns expanded store data
|
||||
return sendSuccess(res, {
|
||||
...flyer,
|
||||
store: await storeService.getStore(flyer.store_id, req.log),
|
||||
});
|
||||
}
|
||||
|
||||
// v1 returns just the flyer
|
||||
return sendSuccess(res, flyer);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version Headers
|
||||
|
||||
### Response Headers
|
||||
|
||||
All versioned API responses include these headers:
|
||||
|
||||
| Header | Always Present | Description |
|
||||
| -------------------------- | ------------------ | ------------------------------------------------------- |
|
||||
| `X-API-Version` | Yes | The API version handling the request |
|
||||
| `Deprecation` | Only if deprecated | `true` when version is deprecated |
|
||||
| `Sunset` | Only if configured | ISO 8601 date when version will be removed |
|
||||
| `Link` | Only if configured | URL to successor version with `rel="successor-version"` |
|
||||
| `X-API-Deprecation-Notice` | Only if deprecated | Human-readable deprecation message |
|
||||
|
||||
### Example: Active Version Response
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
X-API-Version: v2
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
### Example: Deprecated Version Response
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
X-API-Version: v1
|
||||
Deprecation: true
|
||||
Sunset: 2027-01-01T00:00:00Z
|
||||
Link: </api/v2>; rel="successor-version"
|
||||
X-API-Deprecation-Notice: API v1 is deprecated and will be sunset on 2027-01-01T00:00:00Z. Please migrate to v2.
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
### RFC Compliance
|
||||
|
||||
The deprecation headers follow these standards:
|
||||
|
||||
- **RFC 8594**: The "Sunset" HTTP Header Field
|
||||
- **draft-ietf-httpapi-deprecation-header**: The "Deprecation" HTTP Header Field
|
||||
- **RFC 8288**: Web Linking (for `rel="successor-version"`)
|
||||
|
||||
---
|
||||
|
||||
## Testing Versioned Endpoints
|
||||
|
||||
### Unit Testing Middleware
|
||||
|
||||
See test files for patterns:
|
||||
|
||||
- `src/middleware/apiVersion.middleware.test.ts`
|
||||
- `src/middleware/deprecation.middleware.test.ts`
|
||||
|
||||
**Testing version detection**:
|
||||
|
||||
```typescript
|
||||
// src/middleware/apiVersion.middleware.test.ts
|
||||
import { detectApiVersion } from './apiVersion.middleware';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
describe('detectApiVersion', () => {
|
||||
it('should extract v1 from req.params.version', () => {
|
||||
const mockRequest = createMockRequest({
|
||||
params: { version: 'v1' },
|
||||
});
|
||||
const mockResponse = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
const mockNext = vi.fn();
|
||||
|
||||
detectApiVersion(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRequest.apiVersion).toBe('v1');
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for invalid version', () => {
|
||||
const mockRequest = createMockRequest({
|
||||
params: { version: 'v99' },
|
||||
});
|
||||
const mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
};
|
||||
const mockNext = vi.fn();
|
||||
|
||||
detectApiVersion(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Testing deprecation headers**:
|
||||
|
||||
```typescript
|
||||
// src/middleware/deprecation.middleware.test.ts
|
||||
import { addDeprecationHeaders } from './deprecation.middleware';
|
||||
import { VERSION_CONFIGS } from '../config/apiVersions';
|
||||
|
||||
describe('addDeprecationHeaders', () => {
|
||||
beforeEach(() => {
|
||||
// Mark v1 as deprecated for test
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
});
|
||||
|
||||
it('should add all deprecation headers', () => {
|
||||
const setHeader = vi.fn();
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
middleware(mockRequest, { set: setHeader }, mockNext);
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith('Deprecation', 'true');
|
||||
expect(setHeader).toHaveBeenCalledWith('Sunset', '2027-01-01T00:00:00Z');
|
||||
expect(setHeader).toHaveBeenCalledWith('Link', '</api/v2>; rel="successor-version"');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
**Test versioned endpoints**:
|
||||
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../server';
|
||||
|
||||
describe('API Versioning Integration', () => {
|
||||
it('should return X-API-Version header for v1', async () => {
|
||||
const response = await request(app).get('/api/v1/health').expect(200);
|
||||
|
||||
expect(response.headers['x-api-version']).toBe('v1');
|
||||
});
|
||||
|
||||
it('should return 404 for unsupported version', async () => {
|
||||
const response = await request(app).get('/api/v99/health').expect(404);
|
||||
|
||||
expect(response.body.error.code).toBe('UNSUPPORTED_VERSION');
|
||||
});
|
||||
|
||||
it('should redirect unversioned paths to v1', async () => {
|
||||
const response = await request(app).get('/api/health').expect(301);
|
||||
|
||||
expect(response.headers.location).toBe('/api/v1/health');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests in container (required)
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# Run only middleware tests
|
||||
podman exec -it flyer-crawler-dev npm test -- apiVersion
|
||||
podman exec -it flyer-crawler-dev npm test -- deprecation
|
||||
|
||||
# Type check
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide: v1 to v2
|
||||
|
||||
When v2 is introduced with breaking changes, follow this migration process.
|
||||
|
||||
### For API Consumers (Frontend/Mobile)
|
||||
|
||||
**Step 1**: Check current API version usage
|
||||
|
||||
```typescript
|
||||
// Frontend apiClient.ts
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
```
|
||||
|
||||
**Step 2**: Monitor deprecation headers
|
||||
|
||||
When v1 is deprecated, responses will include:
|
||||
|
||||
```http
|
||||
Deprecation: true
|
||||
Sunset: 2027-01-01T00:00:00Z
|
||||
Link: </api/v2>; rel="successor-version"
|
||||
```
|
||||
|
||||
**Step 3**: Update to v2
|
||||
|
||||
```typescript
|
||||
// Change API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v2';
|
||||
```
|
||||
|
||||
**Step 4**: Handle response format changes
|
||||
|
||||
If v2 changes response formats, update your type definitions and parsing logic:
|
||||
|
||||
```typescript
|
||||
// v1 response
|
||||
interface FlyerResponseV1 {
|
||||
id: number;
|
||||
store_id: number;
|
||||
}
|
||||
|
||||
// v2 response (example: includes embedded store)
|
||||
interface FlyerResponseV2 {
|
||||
id: string; // Changed to UUID
|
||||
store: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### For Backend Developers
|
||||
|
||||
**Step 1**: Create v2-specific handlers (if needed)
|
||||
|
||||
For breaking changes, create version-specific route files:
|
||||
|
||||
```text
|
||||
src/routes/
|
||||
flyer.routes.ts # Shared/v1 handlers
|
||||
flyer.v2.routes.ts # v2-specific handlers (if significantly different)
|
||||
```
|
||||
|
||||
**Step 2**: Register version-specific routes
|
||||
|
||||
```typescript
|
||||
// src/routes/versioned.ts
|
||||
export const ROUTES: RouteRegistration[] = [
|
||||
{
|
||||
path: 'flyers',
|
||||
router: flyerRouter,
|
||||
description: 'Flyer routes (v1)',
|
||||
versions: ['v1'],
|
||||
},
|
||||
{
|
||||
path: 'flyers',
|
||||
router: flyerRouterV2,
|
||||
description: 'Flyer routes (v2 with breaking changes)',
|
||||
versions: ['v2'],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Step 3**: Document changes
|
||||
|
||||
Update OpenAPI documentation to reflect v2 changes and mark v1 as deprecated.
|
||||
|
||||
### Timeline Example
|
||||
|
||||
| Date | Action |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| T+0 | v2 released, v1 marked deprecated |
|
||||
| T+0 | Deprecation headers added to v1 responses |
|
||||
| T+30 days | Sunset warning emails to known integrators |
|
||||
| T+90 days | v1 returns 410 Gone |
|
||||
| T+120 days | v1 code removed |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "UNSUPPORTED_VERSION" Error
|
||||
|
||||
**Symptom**: Request to `/api/v3/...` returns 404 with `UNSUPPORTED_VERSION`
|
||||
|
||||
**Cause**: Version `v3` is not defined in `API_VERSIONS`
|
||||
|
||||
**Solution**: Add the version to `src/config/apiVersions.ts`:
|
||||
|
||||
```typescript
|
||||
export const API_VERSIONS = ['v1', 'v2', 'v3'] as const;
|
||||
|
||||
export const VERSION_CONFIGS = {
|
||||
// ...
|
||||
v3: { version: 'v3', status: 'active' },
|
||||
};
|
||||
```
|
||||
|
||||
### Issue: Missing X-API-Version Header
|
||||
|
||||
**Symptom**: Response doesn't include `X-API-Version` header
|
||||
|
||||
**Cause**: Request didn't go through versioned router
|
||||
|
||||
**Solution**: Ensure the route is registered in `src/routes/versioned.ts` and mounted under `/api/:version`
|
||||
|
||||
### Issue: Deprecation Headers Not Appearing
|
||||
|
||||
**Symptom**: Deprecated version works but no deprecation headers
|
||||
|
||||
**Cause**: Version status not set to `'deprecated'` in config
|
||||
|
||||
**Solution**: Update `VERSION_CONFIGS`:
|
||||
|
||||
```typescript
|
||||
v1: {
|
||||
version: 'v1',
|
||||
status: 'deprecated', // Must be 'deprecated', not 'active'
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
},
|
||||
```
|
||||
|
||||
### Issue: Route Available in Wrong Version
|
||||
|
||||
**Symptom**: Route works in v1 but should only be in v2
|
||||
|
||||
**Cause**: Missing `versions` restriction in route registration
|
||||
|
||||
**Solution**: Add `versions` array:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'new-feature',
|
||||
router: newFeatureRouter,
|
||||
versions: ['v2'], // Add this to restrict availability
|
||||
},
|
||||
```
|
||||
|
||||
### Issue: Unversioned Paths Not Redirecting
|
||||
|
||||
**Symptom**: `/api/flyers` returns 404 instead of redirecting to `/api/v1/flyers`
|
||||
|
||||
**Cause**: Redirect middleware order issue in `server.ts`
|
||||
|
||||
**Solution**: Ensure redirect middleware is mounted BEFORE `createApiRouter()`:
|
||||
|
||||
```typescript
|
||||
// server.ts - correct order
|
||||
app.use('/api', redirectMiddleware); // First
|
||||
app.use('/api', createApiRouter()); // Second
|
||||
```
|
||||
|
||||
### Issue: TypeScript Errors on req.apiVersion
|
||||
|
||||
**Symptom**: `Property 'apiVersion' does not exist on type 'Request'`
|
||||
|
||||
**Cause**: Type extensions not being picked up
|
||||
|
||||
**Solution**: Ensure `src/types/express.d.ts` is included in tsconfig:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Router Cache Stale After Config Change
|
||||
|
||||
**Symptom**: Version behavior doesn't update after changing `VERSION_CONFIGS`
|
||||
|
||||
**Cause**: Routers are cached at startup
|
||||
|
||||
**Solution**: Use `refreshRouterCache()` or restart the server:
|
||||
|
||||
```typescript
|
||||
import { refreshRouterCache } from './src/routes/versioned';
|
||||
|
||||
// After config changes
|
||||
refreshRouterCache();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
| ADR | Title |
|
||||
| ------------------------------------------------------------------------ | ---------------------------- |
|
||||
| [ADR-008](../adr/0008-api-versioning-strategy.md) | API Versioning Strategy |
|
||||
| [ADR-003](../adr/0003-standardized-input-validation-using-middleware.md) | Input Validation |
|
||||
| [ADR-028](../adr/0028-api-response-standardization.md) | API Response Standardization |
|
||||
| [ADR-018](../adr/0018-api-documentation-strategy.md) | API Documentation Strategy |
|
||||
|
||||
### Implementation Files
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| [`src/config/apiVersions.ts`](../../src/config/apiVersions.ts) | Version constants and config |
|
||||
| [`src/middleware/apiVersion.middleware.ts`](../../src/middleware/apiVersion.middleware.ts) | Version detection |
|
||||
| [`src/middleware/deprecation.middleware.ts`](../../src/middleware/deprecation.middleware.ts) | Deprecation headers |
|
||||
| [`src/routes/versioned.ts`](../../src/routes/versioned.ts) | Router factory |
|
||||
| [`src/types/express.d.ts`](../../src/types/express.d.ts) | Request type extensions |
|
||||
| [`server.ts`](../../server.ts) | Application entry point |
|
||||
|
||||
### Test Files
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------------------------------------------------------------------------------ | ------------------------ |
|
||||
| [`src/middleware/apiVersion.middleware.test.ts`](../../src/middleware/apiVersion.middleware.test.ts) | Version detection tests |
|
||||
| [`src/middleware/deprecation.middleware.test.ts`](../../src/middleware/deprecation.middleware.test.ts) | Deprecation header tests |
|
||||
|
||||
### External References
|
||||
|
||||
- [RFC 8594: The "Sunset" HTTP Header Field](https://datatracker.ietf.org/doc/html/rfc8594)
|
||||
- [draft-ietf-httpapi-deprecation-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-deprecation-header/)
|
||||
- [RFC 8288: Web Linking](https://datatracker.ietf.org/doc/html/rfc8288)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Files to Modify for Common Tasks
|
||||
|
||||
| Task | Files |
|
||||
| ------------------------------ | ---------------------------------------------------- |
|
||||
| Add new version | `src/config/apiVersions.ts`, `src/config/swagger.ts` |
|
||||
| Deprecate version | `src/config/apiVersions.ts` |
|
||||
| Add version-specific route | `src/routes/versioned.ts` |
|
||||
| Version-specific handler logic | Route file (e.g., `src/routes/flyer.routes.ts`) |
|
||||
|
||||
### Key Functions
|
||||
|
||||
```typescript
|
||||
// Check if version is valid
|
||||
isValidApiVersion('v1'); // true
|
||||
isValidApiVersion('v99'); // false
|
||||
|
||||
// Get version from request with fallback
|
||||
getRequestApiVersion(req); // Returns 'v1' | 'v2'
|
||||
|
||||
// Check if request has valid version
|
||||
hasApiVersion(req); // boolean
|
||||
|
||||
// Get deprecation info
|
||||
getVersionDeprecation('v1'); // { deprecated: false, ... }
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
podman exec -it flyer-crawler-dev npm test
|
||||
|
||||
# Type check
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Check version headers manually
|
||||
curl -I http://localhost:3001/api/v1/health
|
||||
|
||||
# Test deprecation (after marking v1 deprecated)
|
||||
curl -I http://localhost:3001/api/v1/health | grep -E "(Deprecation|Sunset|Link|X-API)"
|
||||
```
|
||||
curl -I http://localhost:3001/api/v1/health | grep -E "(Deprecation|Sunset|Link|X-API)"
|
||||
```
|
||||
@@ -47,16 +47,20 @@ export async function getFlyerById(id: number, client?: PoolClient): Promise<Fly
|
||||
```typescript
|
||||
import { sendError } 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) {
|
||||
// IMPORTANT: Use req.originalUrl for dynamic path logging (not hardcoded paths)
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Best Practice**: Always use `req.originalUrl.split('?')[0]` in error log messages instead of hardcoded paths. This ensures logs reflect the actual request URL including version prefixes (`/api/v1/`). See [Error Logging Path Patterns](ERROR-LOGGING-PATHS.md) for details.
|
||||
|
||||
### Custom Error Types
|
||||
|
||||
```typescript
|
||||
|
||||
153
docs/development/ERROR-LOGGING-PATHS.md
Normal file
153
docs/development/ERROR-LOGGING-PATHS.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Error Logging Path Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the correct pattern for logging request paths in error handlers within Express route files. Following this pattern ensures that error logs accurately reflect the actual request URL, including any API version prefixes.
|
||||
|
||||
## The Problem
|
||||
|
||||
When ADR-008 (API Versioning Strategy) was implemented, all routes were moved from `/api/*` to `/api/v1/*`. However, some error log messages contained hardcoded paths that did not update automatically:
|
||||
|
||||
```typescript
|
||||
// INCORRECT - hardcoded path
|
||||
req.log.error({ error }, 'Error in /api/flyers/:id:');
|
||||
```
|
||||
|
||||
This caused 16 unit test failures because tests expected the error log message to contain `/api/v1/flyers/:id` but received `/api/flyers/:id`.
|
||||
|
||||
## The Solution
|
||||
|
||||
Always use `req.originalUrl` to dynamically capture the actual request path in error logs:
|
||||
|
||||
```typescript
|
||||
// CORRECT - dynamic path from request
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
```
|
||||
|
||||
### Why `req.originalUrl`?
|
||||
|
||||
| Property | Value for `/api/v1/flyers/123?active=true` | Use Case |
|
||||
| ----------------- | ------------------------------------------ | ----------------------------------- |
|
||||
| `req.url` | `/123?active=true` | Path relative to router mount point |
|
||||
| `req.path` | `/123` | Path without query string |
|
||||
| `req.originalUrl` | `/api/v1/flyers/123?active=true` | Full original request URL |
|
||||
| `req.baseUrl` | `/api/v1/flyers` | Router mount path |
|
||||
|
||||
`req.originalUrl` is the correct choice because:
|
||||
|
||||
1. It contains the full path including version prefix (`/api/v1/`)
|
||||
2. It reflects what the client actually requested
|
||||
3. It makes log messages searchable by the actual endpoint path
|
||||
4. It automatically adapts when routes are mounted at different paths
|
||||
|
||||
### Stripping Query Parameters
|
||||
|
||||
Use `.split('?')[0]` to remove query parameters from log messages:
|
||||
|
||||
```typescript
|
||||
// Request: /api/v1/flyers?page=1&limit=20
|
||||
req.originalUrl.split('?')[0]; // Returns: /api/v1/flyers
|
||||
```
|
||||
|
||||
This keeps log messages clean and prevents sensitive query parameters from appearing in logs.
|
||||
|
||||
## Standard Error Logging Pattern
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await someService.getData(req.params.id);
|
||||
return sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### With Additional Context
|
||||
|
||||
```typescript
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const result = await someService.createItem(req.body);
|
||||
return sendSuccess(res, result, 'Item created', 201);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, userId: req.user?.id, body: req.body },
|
||||
`Error creating item in ${req.originalUrl.split('?')[0]}:`,
|
||||
);
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Descriptive Messages
|
||||
|
||||
For clarity, include a brief description of the operation:
|
||||
|
||||
```typescript
|
||||
// Good - describes the operation
|
||||
req.log.error({ error }, `Error fetching recipes in ${req.originalUrl.split('?')[0]}:`);
|
||||
req.log.error({ error }, `Error updating user profile in ${req.originalUrl.split('?')[0]}:`);
|
||||
|
||||
// Acceptable - just the path
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
|
||||
// Bad - hardcoded path
|
||||
req.log.error({ error }, 'Error in /api/recipes:');
|
||||
```
|
||||
|
||||
## Files Updated in Initial Fix (2026-01-27)
|
||||
|
||||
The following files were updated to use this pattern:
|
||||
|
||||
| File | Error Log Statements Fixed |
|
||||
| -------------------------------------- | -------------------------- |
|
||||
| `src/routes/recipe.routes.ts` | 3 |
|
||||
| `src/routes/stats.routes.ts` | 1 |
|
||||
| `src/routes/flyer.routes.ts` | 2 |
|
||||
| `src/routes/personalization.routes.ts` | 3 |
|
||||
|
||||
## Testing Error Log Messages
|
||||
|
||||
When writing tests that verify error log messages, use flexible matchers that account for versioned paths:
|
||||
|
||||
```typescript
|
||||
// Good - matches any version prefix
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.stringContaining('/flyers'),
|
||||
);
|
||||
|
||||
// Good - explicit version match
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.stringContaining('/api/v1/flyers'),
|
||||
);
|
||||
|
||||
// Bad - hardcoded unversioned path (will fail)
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
'Error in /api/flyers:',
|
||||
);
|
||||
```
|
||||
|
||||
## Checklist for New Routes
|
||||
|
||||
When creating new route handlers:
|
||||
|
||||
- [ ] Use `req.originalUrl.split('?')[0]` in all error log messages
|
||||
- [ ] Include descriptive text about the operation being performed
|
||||
- [ ] Add structured context (userId, relevant IDs) to the log object
|
||||
- [ ] Write tests that verify error logs contain the versioned path
|
||||
|
||||
## 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
|
||||
@@ -261,3 +261,214 @@ Opens a browser-based test runner with filtering and debugging capabilities.
|
||||
5. **Verify cache invalidation** - tests that insert data directly must invalidate cache
|
||||
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
|
||||
|
||||
When testing route error handlers, ensure assertions account for versioned API paths.
|
||||
|
||||
### Problem: Hardcoded Paths Break Tests
|
||||
|
||||
Error log messages with hardcoded paths cause test failures when API versions change:
|
||||
|
||||
```typescript
|
||||
// Production code (INCORRECT - hardcoded path)
|
||||
req.log.error({ error }, 'Error in /api/flyers/:id:');
|
||||
|
||||
// Test expects versioned path
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.stringContaining('/api/v1/flyers'), // FAILS - actual log has /api/flyers
|
||||
);
|
||||
```
|
||||
|
||||
### Solution: Dynamic Paths with `req.originalUrl`
|
||||
|
||||
Production code should use `req.originalUrl` for dynamic path logging:
|
||||
|
||||
```typescript
|
||||
// Production code (CORRECT - dynamic path)
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
```
|
||||
|
||||
### Writing Robust Test Assertions
|
||||
|
||||
```typescript
|
||||
// Good - matches versioned path
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.stringContaining('/api/v1/flyers'),
|
||||
);
|
||||
|
||||
// Good - flexible match for any version
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.stringMatching(/\/api\/v\d+\/flyers/),
|
||||
);
|
||||
|
||||
// Bad - hardcoded unversioned path
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
'Error in /api/flyers:', // Will fail with versioned routes
|
||||
);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
272
docs/development/test-path-migration.md
Normal file
272
docs/development/test-path-migration.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Test Path Migration: Unversioned to Versioned API Paths
|
||||
|
||||
**Status**: Complete
|
||||
**Created**: 2026-01-27
|
||||
**Completed**: 2026-01-27
|
||||
**Related**: ADR-008 (API Versioning Strategy)
|
||||
|
||||
## Summary
|
||||
|
||||
All integration test files have been successfully migrated to use versioned API paths (`/api/v1/`). This resolves the redirect-related test failures introduced by ADR-008 Phase 1.
|
||||
|
||||
### Results
|
||||
|
||||
| Metric | Value |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| Test files updated | 23 |
|
||||
| Path occurrences changed | ~70 |
|
||||
| Tests before migration | 274/348 passing |
|
||||
| Tests after migration | 345/348 passing |
|
||||
| Test failures resolved | 71 |
|
||||
| Remaining todo/skipped | 3 (known issues, not versioning-related) |
|
||||
| Type check | Passing |
|
||||
| Versioning-specific tests | 82/82 passing |
|
||||
|
||||
### Key Outcomes
|
||||
|
||||
- No `301 Moved Permanently` responses in test output
|
||||
- All redirect-related failures resolved
|
||||
- No regressions introduced
|
||||
- Unit tests unaffected (3,375/3,391 passing, pre-existing failures)
|
||||
|
||||
---
|
||||
|
||||
## Original Problem Statement
|
||||
|
||||
Integration tests failed due to redirect middleware (ADR-008 Phase 1). Server returned `301 Moved Permanently` for unversioned paths (`/api/resource`) instead of expected `200 OK`. Redirect targets versioned paths (`/api/v1/resource`).
|
||||
|
||||
**Root Cause**: Backwards-compatibility redirect in `server.ts`:
|
||||
|
||||
```typescript
|
||||
app.use('/api', (req, res, next) => {
|
||||
const versionPattern = /^\/v\d+/;
|
||||
if (!versionPattern.test(req.path)) {
|
||||
return res.redirect(301, `/api/v1${req.path}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
**Impact**: ~70 test path occurrences across 23 files returning 301 instead of expected status codes.
|
||||
|
||||
## Solution
|
||||
|
||||
Update all test API paths from `/api/{resource}` to `/api/v1/{resource}`.
|
||||
|
||||
## Files Requiring Updates
|
||||
|
||||
### Integration Tests (16 files)
|
||||
|
||||
| File | Occurrences | Domains |
|
||||
| ------------------------------------------------------------ | ----------- | ---------------------- |
|
||||
| `src/tests/integration/inventory.integration.test.ts` | 14 | inventory |
|
||||
| `src/tests/integration/receipt.integration.test.ts` | 17 | receipts |
|
||||
| `src/tests/integration/recipe.integration.test.ts` | 17 | recipes, users/recipes |
|
||||
| `src/tests/integration/user.routes.integration.test.ts` | 10 | users/shopping-lists |
|
||||
| `src/tests/integration/admin.integration.test.ts` | 7 | admin |
|
||||
| `src/tests/integration/flyer-processing.integration.test.ts` | 6 | ai/jobs |
|
||||
| `src/tests/integration/budget.integration.test.ts` | 5 | budgets |
|
||||
| `src/tests/integration/notification.integration.test.ts` | 3 | users/notifications |
|
||||
| `src/tests/integration/data-integrity.integration.test.ts` | 3 | users, admin |
|
||||
| `src/tests/integration/upc.integration.test.ts` | 3 | upc |
|
||||
| `src/tests/integration/edge-cases.integration.test.ts` | 3 | users/shopping-lists |
|
||||
| `src/tests/integration/user.integration.test.ts` | 2 | users |
|
||||
| `src/tests/integration/public.routes.integration.test.ts` | 2 | flyers, recipes |
|
||||
| `src/tests/integration/flyer.integration.test.ts` | 1 | flyers |
|
||||
| `src/tests/integration/category.routes.test.ts` | 1 | categories |
|
||||
| `src/tests/integration/gamification.integration.test.ts` | 1 | ai/jobs |
|
||||
|
||||
### E2E Tests (7 files)
|
||||
|
||||
| File | Occurrences | Domains |
|
||||
| --------------------------------------------- | ----------- | -------------------- |
|
||||
| `src/tests/e2e/inventory-journey.e2e.test.ts` | 9 | inventory |
|
||||
| `src/tests/e2e/receipt-journey.e2e.test.ts` | 9 | receipts |
|
||||
| `src/tests/e2e/budget-journey.e2e.test.ts` | 6 | budgets |
|
||||
| `src/tests/e2e/upc-journey.e2e.test.ts` | 3 | upc |
|
||||
| `src/tests/e2e/deals-journey.e2e.test.ts` | 2 | categories, users |
|
||||
| `src/tests/e2e/user-journey.e2e.test.ts` | 1 | users/shopping-lists |
|
||||
| `src/tests/e2e/flyer-upload.e2e.test.ts` | 1 | jobs |
|
||||
|
||||
## Update Pattern
|
||||
|
||||
### Find/Replace Rules
|
||||
|
||||
**Template literals** (most common):
|
||||
|
||||
```
|
||||
OLD: .get(`/api/resource/${id}`)
|
||||
NEW: .get(`/api/v1/resource/${id}`)
|
||||
```
|
||||
|
||||
**String literals**:
|
||||
|
||||
```
|
||||
OLD: .get('/api/resource')
|
||||
NEW: .get('/api/v1/resource')
|
||||
```
|
||||
|
||||
### Regex Pattern for Batch Updates
|
||||
|
||||
```regex
|
||||
Find: (\.(get|post|put|delete|patch)\([`'"])/api/([a-z])
|
||||
Replace: $1/api/v1/$3
|
||||
```
|
||||
|
||||
**Explanation**: Captures HTTP method call, inserts `/v1/` after `/api/`.
|
||||
|
||||
## Files to EXCLUDE
|
||||
|
||||
These files intentionally test unversioned path behavior:
|
||||
|
||||
| File | Reason |
|
||||
| ---------------------------------------------------- | ------------------------------------ |
|
||||
| `src/routes/versioning.integration.test.ts` | Tests redirect behavior itself |
|
||||
| `src/services/apiClient.test.ts` | Mock server URLs, not real API calls |
|
||||
| `src/services/aiApiClient.test.ts` | Mock server URLs for MSW handlers |
|
||||
| `src/services/googleGeocodingService.server.test.ts` | External Google API URL |
|
||||
|
||||
**Also exclude** (not API paths):
|
||||
|
||||
- Lines containing `vi.mock('@bull-board/api` (import mocks)
|
||||
- Lines containing `/api/v99` (intentional unsupported version tests)
|
||||
- `describe()` and `it()` block descriptions
|
||||
- Comment lines (`// `)
|
||||
|
||||
## Execution Batches
|
||||
|
||||
### Batch 1: High-Impact Integration (4 files, ~58 occurrences)
|
||||
|
||||
```bash
|
||||
# Files with most occurrences
|
||||
src/tests/integration/inventory.integration.test.ts
|
||||
src/tests/integration/receipt.integration.test.ts
|
||||
src/tests/integration/recipe.integration.test.ts
|
||||
src/tests/integration/user.routes.integration.test.ts
|
||||
```
|
||||
|
||||
### Batch 2: Medium Integration (6 files, ~27 occurrences)
|
||||
|
||||
```bash
|
||||
src/tests/integration/admin.integration.test.ts
|
||||
src/tests/integration/flyer-processing.integration.test.ts
|
||||
src/tests/integration/budget.integration.test.ts
|
||||
src/tests/integration/notification.integration.test.ts
|
||||
src/tests/integration/data-integrity.integration.test.ts
|
||||
src/tests/integration/upc.integration.test.ts
|
||||
```
|
||||
|
||||
### Batch 3: Low Integration (6 files, ~10 occurrences)
|
||||
|
||||
```bash
|
||||
src/tests/integration/edge-cases.integration.test.ts
|
||||
src/tests/integration/user.integration.test.ts
|
||||
src/tests/integration/public.routes.integration.test.ts
|
||||
src/tests/integration/flyer.integration.test.ts
|
||||
src/tests/integration/category.routes.test.ts
|
||||
src/tests/integration/gamification.integration.test.ts
|
||||
```
|
||||
|
||||
### Batch 4: E2E Tests (7 files, ~31 occurrences)
|
||||
|
||||
```bash
|
||||
src/tests/e2e/inventory-journey.e2e.test.ts
|
||||
src/tests/e2e/receipt-journey.e2e.test.ts
|
||||
src/tests/e2e/budget-journey.e2e.test.ts
|
||||
src/tests/e2e/upc-journey.e2e.test.ts
|
||||
src/tests/e2e/deals-journey.e2e.test.ts
|
||||
src/tests/e2e/user-journey.e2e.test.ts
|
||||
src/tests/e2e/flyer-upload.e2e.test.ts
|
||||
```
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Per-Batch Verification
|
||||
|
||||
After each batch:
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Run specific test file
|
||||
podman exec -it flyer-crawler-dev npx vitest run <file-path> --reporter=verbose
|
||||
```
|
||||
|
||||
### Full Verification
|
||||
|
||||
After all batches:
|
||||
|
||||
```bash
|
||||
# Full integration test suite
|
||||
podman exec -it flyer-crawler-dev npm run test:integration
|
||||
|
||||
# Full E2E test suite
|
||||
podman exec -it flyer-crawler-dev npm run test:e2e
|
||||
```
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [x] No `301 Moved Permanently` responses in test output
|
||||
- [x] All tests pass or fail for expected reasons (not redirect-related)
|
||||
- [x] Type check passes
|
||||
- [x] No regressions in unmodified tests
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Describe Block Text
|
||||
|
||||
Do NOT modify describe/it block descriptions:
|
||||
|
||||
```typescript
|
||||
// KEEP AS-IS (documentation only):
|
||||
describe('GET /api/users/profile', () => { ... });
|
||||
|
||||
// UPDATE (actual API call):
|
||||
const response = await request.get('/api/v1/users/profile');
|
||||
```
|
||||
|
||||
### Console Logging
|
||||
|
||||
Do NOT modify debug/error logging paths:
|
||||
|
||||
```typescript
|
||||
// KEEP AS-IS:
|
||||
console.error('[DEBUG] GET /api/admin/stats failed:', ...);
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
Include query parameters in update:
|
||||
|
||||
```typescript
|
||||
// OLD:
|
||||
.get(`/api/budgets/spending-analysis?startDate=${start}&endDate=${end}`)
|
||||
|
||||
// NEW:
|
||||
.get(`/api/v1/budgets/spending-analysis?startDate=${start}&endDate=${end}`)
|
||||
```
|
||||
|
||||
## Post-Completion Checklist
|
||||
|
||||
- [x] All 23 files updated
|
||||
- [x] ~70 path occurrences migrated
|
||||
- [x] Exclusion files unchanged
|
||||
- [x] Type check passes
|
||||
- [x] Integration tests pass (345/348)
|
||||
- [x] E2E tests pass
|
||||
- [x] Commit with message: `fix(tests): Update API paths to use /api/v1/ prefix (ADR-008)`
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues arise:
|
||||
|
||||
```bash
|
||||
git checkout HEAD -- src/tests/
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- ADR-008: API Versioning Strategy
|
||||
- `docs/architecture/api-versioning-infrastructure.md`
|
||||
- `src/routes/versioning.integration.test.ts` (reference for expected behavior)
|
||||
@@ -6,6 +6,20 @@ This guide covers the manual installation of Flyer Crawler and its dependencies
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. [System Prerequisites](#system-prerequisites)
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
This guide covers deploying Flyer Crawler to a production server.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -276,10 +276,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**:
|
||||
@@ -388,11 +388,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 +483,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
|
||||
@@ -835,27 +835,26 @@ Configure alerts in your monitoring tool (UptimeRobot, Datadog, etc.):
|
||||
|
||||
### Quick 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
|
||||
|
||||
161
docs/plans/2026-01-27-unit-test-error-log-path-fix.md
Normal file
161
docs/plans/2026-01-27-unit-test-error-log-path-fix.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Unit Test Fix Plan: Error Log Path Mismatches
|
||||
|
||||
**Date**: 2026-01-27
|
||||
**Type**: Technical Implementation Plan
|
||||
**Related**: [ADR-008: API Versioning Strategy](../adr/0008-api-versioning-strategy.md)
|
||||
**Status**: Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
16 unit tests fail due to error log message assertions expecting versioned paths (`/api/v1/`) while route handlers emit hardcoded unversioned paths (`/api/`).
|
||||
|
||||
**Failure Pattern**:
|
||||
|
||||
```text
|
||||
AssertionError: expected "Error PUT /api/users/profile" to contain "/api/v1/users/profile"
|
||||
```
|
||||
|
||||
**Scope**: All failures are `toContain` assertions on `logger.error()` call arguments.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
| Layer | Behavior | Issue |
|
||||
| ------------------ | ----------------------------------------------------- | ------------------- |
|
||||
| Route Registration | `server.ts` mounts at `/api/v1/` | Correct |
|
||||
| Request Path | `req.path` returns `/users/profile` (router-relative) | No version info |
|
||||
| Error Handlers | Hardcode `"Error PUT /api/users/profile"` | Version mismatch |
|
||||
| Test Assertions | Expect `"/api/v1/users/profile"` | Correct expectation |
|
||||
|
||||
**Root Cause**: Error log statements use template literals with hardcoded `/api/` prefix instead of `req.originalUrl` which contains the full versioned path.
|
||||
|
||||
**Example**:
|
||||
|
||||
```typescript
|
||||
// Current (broken)
|
||||
logger.error(`Error PUT /api/users/profile: ${err}`);
|
||||
|
||||
// Expected
|
||||
logger.error(`Error PUT ${req.originalUrl}: ${err}`);
|
||||
// Output: "Error PUT /api/v1/users/profile: ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution Approach
|
||||
|
||||
Replace hardcoded path strings with `req.originalUrl` in all error log statements.
|
||||
|
||||
### Express Request Properties Reference
|
||||
|
||||
| Property | Example Value | Use Case |
|
||||
| ----------------- | ------------------------------- | ----------------------------- |
|
||||
| `req.originalUrl` | `/api/v1/users/profile?foo=bar` | Full URL with version + query |
|
||||
| `req.path` | `/profile` | Router-relative path only |
|
||||
| `req.baseUrl` | `/api/v1/users` | Mount point |
|
||||
|
||||
**Decision**: Use `req.originalUrl` for error logging to capture complete request context.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Affected Files
|
||||
|
||||
| File | Error Statements | Methods |
|
||||
| ------------------------------- | ---------------- | ---------------------------------------------------- |
|
||||
| `src/routes/users.routes.ts` | 3 | `PUT /profile`, `POST /profile/password`, `DELETE /` |
|
||||
| `src/routes/recipe.routes.ts` | 2 | `POST /import`, `POST /:id/fork` |
|
||||
| `src/routes/receipts.routes.ts` | 2 | `POST /`, `PATCH /:id` |
|
||||
| `src/routes/flyers.routes.ts` | 2 | `POST /`, `PUT /:id` |
|
||||
|
||||
**Total**: 9 error log statements across 4 route files
|
||||
|
||||
### Parallel Implementation Tasks
|
||||
|
||||
All 4 files can be modified independently:
|
||||
|
||||
**Task 1**: `users.routes.ts`
|
||||
|
||||
- Line patterns: `Error PUT /api/users/profile`, `Error POST /api/users/profile/password`, `Error DELETE /api/users`
|
||||
- Change: Replace with `Error ${req.method} ${req.originalUrl}`
|
||||
|
||||
**Task 2**: `recipe.routes.ts`
|
||||
|
||||
- Line patterns: `Error POST /api/recipes/import`, `Error POST /api/recipes/:id/fork`
|
||||
- Change: Replace with `Error ${req.method} ${req.originalUrl}`
|
||||
|
||||
**Task 3**: `receipts.routes.ts`
|
||||
|
||||
- Line patterns: `Error POST /api/receipts`, `Error PATCH /api/receipts/:id`
|
||||
- Change: Replace with `Error ${req.method} ${req.originalUrl}`
|
||||
|
||||
**Task 4**: `flyers.routes.ts`
|
||||
|
||||
- Line patterns: `Error POST /api/flyers`, `Error PUT /api/flyers/:id`
|
||||
- Change: Replace with `Error ${req.method} ${req.originalUrl}`
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev npm run test:unit
|
||||
```
|
||||
|
||||
**Expected**: 16 failures → 0 failures (3,391/3,391 passing)
|
||||
|
||||
---
|
||||
|
||||
## Test Files Affected
|
||||
|
||||
Tests that will pass after fix:
|
||||
|
||||
| Test File | Failing Tests |
|
||||
| ------------------------- | ------------- |
|
||||
| `users.routes.test.ts` | 6 |
|
||||
| `recipe.routes.test.ts` | 4 |
|
||||
| `receipts.routes.test.ts` | 3 |
|
||||
| `flyers.routes.test.ts` | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
| Metric | Before | After |
|
||||
| ------------------ | ----------- | ------------------- |
|
||||
| Unit test failures | 16 | 0 |
|
||||
| Unit tests passing | 3,375/3,391 | 3,391/3,391 |
|
||||
| Integration tests | 345/348 | 345/348 (unchanged) |
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Version-agnostic logging**: Error messages automatically reflect actual request URL
|
||||
2. **Future-proof**: No changes needed when v2 API is introduced
|
||||
3. **Debugging clarity**: Logs show exact URL including query parameters
|
||||
4. **Consistency**: All error handlers follow same pattern
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Pattern to Apply
|
||||
|
||||
**Before**:
|
||||
|
||||
```typescript
|
||||
logger.error(`Error PUT /api/users/profile: ${error.message}`);
|
||||
```
|
||||
|
||||
**After**:
|
||||
|
||||
```typescript
|
||||
logger.error(`Error ${req.method} ${req.originalUrl}: ${error.message}`);
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- `req.originalUrl` includes query string if present (acceptable for debugging)
|
||||
- No sanitization needed as URL is from Express parsed request
|
||||
- Works correctly with route parameters (`:id` becomes actual value)
|
||||
@@ -6,6 +6,79 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## 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 +445,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
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.16",
|
||||
"version": "0.12.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.16",
|
||||
"version": "0.12.20",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.12.16",
|
||||
"version": "0.12.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
1
public/uploads/avatars/user-123-1769556382113.png
Normal file
1
public/uploads/avatars/user-123-1769556382113.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769556382716.png
Normal file
1
public/uploads/avatars/user-123-1769556382716.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769556417728.png
Normal file
1
public/uploads/avatars/user-123-1769556417728.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769556418517.png
Normal file
1
public/uploads/avatars/user-123-1769556418517.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769556971592.png
Normal file
1
public/uploads/avatars/user-123-1769556971592.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769556971945.png
Normal file
1
public/uploads/avatars/user-123-1769556971945.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769557483553.png
Normal file
1
public/uploads/avatars/user-123-1769557483553.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769557483984.png
Normal file
1
public/uploads/avatars/user-123-1769557483984.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769577983141.png
Normal file
1
public/uploads/avatars/user-123-1769577983141.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769578019270.png
Normal file
1
public/uploads/avatars/user-123-1769578019270.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769578572307.png
Normal file
1
public/uploads/avatars/user-123-1769578572307.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
1
public/uploads/avatars/user-123-1769579084330.png
Normal file
1
public/uploads/avatars/user-123-1769579084330.png
Normal file
@@ -0,0 +1 @@
|
||||
dummy-image-content
|
||||
89
server.ts
89
server.ts
@@ -18,27 +18,8 @@ import { getPool } from './src/services/db/connection.db';
|
||||
import passport from './src/config/passport';
|
||||
import { logger } from './src/services/logger.server';
|
||||
|
||||
// Import routers
|
||||
import authRouter from './src/routes/auth.routes';
|
||||
import userRouter from './src/routes/user.routes';
|
||||
import adminRouter from './src/routes/admin.routes';
|
||||
import aiRouter from './src/routes/ai.routes';
|
||||
import budgetRouter from './src/routes/budget.routes';
|
||||
import flyerRouter from './src/routes/flyer.routes';
|
||||
import recipeRouter from './src/routes/recipe.routes';
|
||||
import personalizationRouter from './src/routes/personalization.routes';
|
||||
import priceRouter from './src/routes/price.routes';
|
||||
import statsRouter from './src/routes/stats.routes';
|
||||
import gamificationRouter from './src/routes/gamification.routes';
|
||||
import systemRouter from './src/routes/system.routes';
|
||||
import healthRouter from './src/routes/health.routes';
|
||||
import upcRouter from './src/routes/upc.routes';
|
||||
import inventoryRouter from './src/routes/inventory.routes';
|
||||
import receiptRouter from './src/routes/receipt.routes';
|
||||
import dealsRouter from './src/routes/deals.routes';
|
||||
import reactionsRouter from './src/routes/reactions.routes';
|
||||
import storeRouter from './src/routes/store.routes';
|
||||
import categoryRouter from './src/routes/category.routes';
|
||||
// Import the versioned API router factory (ADR-008 Phase 2)
|
||||
import { createApiRouter } from './src/routes/versioned';
|
||||
import { errorHandler } from './src/middleware/errorHandler';
|
||||
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
|
||||
import { websocketService } from './src/services/websocketService.server';
|
||||
@@ -249,56 +230,20 @@ app.get('/api/v1/health/queues', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// The order of route registration is critical.
|
||||
// More specific routes should be registered before more general ones.
|
||||
// All routes are now versioned under /api/v1 as per ADR-008.
|
||||
// 1. Authentication routes for login, registration, etc.
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
// 2. System routes for health checks, etc.
|
||||
app.use('/api/v1/health', healthRouter);
|
||||
// 3. System routes for pm2 status, etc.
|
||||
app.use('/api/v1/system', systemRouter);
|
||||
// 3. General authenticated user routes.
|
||||
app.use('/api/v1/users', userRouter);
|
||||
// 4. AI routes, some of which use optional authentication.
|
||||
app.use('/api/v1/ai', aiRouter);
|
||||
// 5. Admin routes, which are all protected by admin-level checks.
|
||||
app.use('/api/v1/admin', adminRouter);
|
||||
// 6. Budgeting and spending analysis routes.
|
||||
app.use('/api/v1/budgets', budgetRouter);
|
||||
// 7. Gamification routes for achievements.
|
||||
app.use('/api/v1/achievements', gamificationRouter);
|
||||
// 8. Public flyer routes.
|
||||
app.use('/api/v1/flyers', flyerRouter);
|
||||
// 8. Public recipe routes.
|
||||
app.use('/api/v1/recipes', recipeRouter);
|
||||
// 9. Public personalization data routes (master items, etc.).
|
||||
app.use('/api/v1/personalization', personalizationRouter);
|
||||
// 9.5. Price history routes.
|
||||
app.use('/api/v1/price-history', priceRouter);
|
||||
// 10. Public statistics routes.
|
||||
app.use('/api/v1/stats', statsRouter);
|
||||
// 11. UPC barcode scanning routes.
|
||||
app.use('/api/v1/upc', upcRouter);
|
||||
// 12. Inventory and expiry tracking routes.
|
||||
app.use('/api/v1/inventory', inventoryRouter);
|
||||
// 13. Receipt scanning routes.
|
||||
app.use('/api/v1/receipts', receiptRouter);
|
||||
// 14. Deals and best prices routes.
|
||||
app.use('/api/v1/deals', dealsRouter);
|
||||
// 15. Reactions/social features routes.
|
||||
app.use('/api/v1/reactions', reactionsRouter);
|
||||
// 16. Store management routes.
|
||||
app.use('/api/v1/stores', storeRouter);
|
||||
// 17. Category discovery routes (ADR-023: Database Normalization)
|
||||
app.use('/api/v1/categories', categoryRouter);
|
||||
|
||||
// --- Backwards Compatibility Redirect (ADR-008: API Versioning Strategy) ---
|
||||
// Redirect old /api/* paths to /api/v1/* for backwards compatibility.
|
||||
// This allows clients to gradually migrate to the versioned API.
|
||||
// IMPORTANT: This middleware MUST be mounted BEFORE createApiRouter() so that
|
||||
// unversioned paths like /api/users are redirected to /api/v1/users BEFORE
|
||||
// the versioned router's detectApiVersion middleware rejects them as invalid versions.
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Only redirect if the path does NOT already start with /v1
|
||||
if (!req.path.startsWith('/v1')) {
|
||||
// Check if the path starts with a version-like prefix (/v followed by digits).
|
||||
// This includes both supported versions (v1, v2) and unsupported ones (v99).
|
||||
// Unsupported versions will be handled by detectApiVersion middleware which returns 404.
|
||||
// This redirect only handles legacy unversioned paths like /api/users -> /api/v1/users.
|
||||
const versionPattern = /^\/v\d+/;
|
||||
const startsWithVersionPattern = versionPattern.test(req.path);
|
||||
if (!startsWithVersionPattern) {
|
||||
const newPath = `/api/v1${req.path}`;
|
||||
logger.info({ oldPath: `/api${req.path}`, newPath }, 'Redirecting to versioned API');
|
||||
return res.redirect(301, newPath);
|
||||
@@ -306,6 +251,16 @@ app.use('/api', (req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Mount the versioned API router (ADR-008 Phase 2).
|
||||
// The createApiRouter() factory handles:
|
||||
// - Version detection and validation via detectApiVersion middleware
|
||||
// - Route registration in correct precedence order
|
||||
// - Version-specific route availability
|
||||
// - Deprecation headers via addDeprecationHeaders middleware
|
||||
// - X-API-Version response headers
|
||||
// All domain routers are registered in versioned.ts with proper ordering.
|
||||
app.use('/api', createApiRouter());
|
||||
|
||||
// --- Error Handling and Server Startup ---
|
||||
|
||||
// Catch-all 404 handler for unmatched routes.
|
||||
|
||||
@@ -21,7 +21,7 @@ export const AppGuard: React.FC<AppGuardProps> = ({ children }) => {
|
||||
const commitMessage = config.app.commitMessage;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
|
||||
<div className="bg-slate-50 dark:bg-slate-900 min-h-screen font-sans text-gray-800 dark:text-gray-200 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-slate-50 via-gray-100 to-slate-100 dark:from-slate-800 dark:via-slate-900 dark:to-black">
|
||||
{/* Toaster component for displaying notifications. It's placed at the top level. */}
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
{/* Add CSS variables for toast theming based on dark mode */}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
// The state and handlers for the old AuthModal and SignUpModal have been removed.
|
||||
return (
|
||||
<>
|
||||
<header className="bg-white dark:bg-gray-900 shadow-md sticky top-0 z-20 border-b-2 border-brand-primary dark:border-brand-secondary">
|
||||
<header className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-md shadow-sm sticky top-0 z-20 border-b border-gray-200/50 dark:border-gray-700/50 supports-[backdrop-filter]:bg-white/60 dark:supports-[backdrop-filter]:bg-slate-900/60">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
|
||||
424
src/components/NotificationBell.test.tsx
Normal file
424
src/components/NotificationBell.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
776
src/components/NotificationToastHandler.test.tsx
Normal file
776
src/components/NotificationToastHandler.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
183
src/config/apiVersions.ts
Normal file
183
src/config/apiVersions.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// src/config/apiVersions.ts
|
||||
/**
|
||||
* @file API version constants, types, and configuration.
|
||||
* Implements ADR-008 Phase 2: API Versioning Infrastructure.
|
||||
*
|
||||
* This module provides centralized version definitions used by:
|
||||
* - Version detection middleware (apiVersion.middleware.ts)
|
||||
* - Deprecation headers middleware (deprecation.middleware.ts)
|
||||
* - Versioned router factory (versioned.ts)
|
||||
*
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import {
|
||||
* CURRENT_API_VERSION,
|
||||
* VERSION_CONFIGS,
|
||||
* isValidApiVersion,
|
||||
* } from './apiVersions';
|
||||
*
|
||||
* // Check if a version is supported
|
||||
* if (isValidApiVersion('v1')) {
|
||||
* const config = VERSION_CONFIGS.v1;
|
||||
* console.log(`v1 status: ${config.status}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// --- Type Definitions ---
|
||||
|
||||
/**
|
||||
* All API versions as a const tuple for type derivation.
|
||||
* Add new versions here when introducing them.
|
||||
*/
|
||||
export const API_VERSIONS = ['v1', 'v2'] as const;
|
||||
|
||||
/**
|
||||
* Union type of supported API versions.
|
||||
* Currently: 'v1' | 'v2'
|
||||
*/
|
||||
export type ApiVersion = (typeof API_VERSIONS)[number];
|
||||
|
||||
/**
|
||||
* Version lifecycle status.
|
||||
* - 'active': Version is fully supported and recommended
|
||||
* - 'deprecated': Version works but clients should migrate (deprecation headers sent)
|
||||
* - 'sunset': Version is scheduled for removal or already removed
|
||||
*/
|
||||
export type VersionStatus = 'active' | 'deprecated' | 'sunset';
|
||||
|
||||
/**
|
||||
* Deprecation information for an API version.
|
||||
* Follows RFC 8594 (Sunset Header) and draft-ietf-httpapi-deprecation-header.
|
||||
*
|
||||
* Used by deprecation middleware to set appropriate HTTP headers:
|
||||
* - `Deprecation: true` (draft-ietf-httpapi-deprecation-header)
|
||||
* - `Sunset: <date>` (RFC 8594)
|
||||
* - `Link: <url>; rel="successor-version"` (RFC 8288)
|
||||
*/
|
||||
export interface VersionDeprecation {
|
||||
/** Indicates if this version is deprecated (maps to Deprecation header) */
|
||||
deprecated: boolean;
|
||||
/** ISO 8601 date string when the version will be sunset (maps to Sunset header) */
|
||||
sunsetDate?: string;
|
||||
/** The version clients should migrate to (maps to Link rel="successor-version") */
|
||||
successorVersion?: ApiVersion;
|
||||
/** Human-readable message explaining the deprecation (for documentation/logs) */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete configuration for an API version.
|
||||
* Combines version identifier, lifecycle status, and deprecation details.
|
||||
*/
|
||||
export interface VersionConfig {
|
||||
/** The version identifier (e.g., 'v1') */
|
||||
version: ApiVersion;
|
||||
/** Current lifecycle status of this version */
|
||||
status: VersionStatus;
|
||||
/** ISO 8601 date when the version will be sunset (RFC 8594) - convenience field */
|
||||
sunsetDate?: string;
|
||||
/** The version clients should migrate to - convenience field */
|
||||
successorVersion?: ApiVersion;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
/**
|
||||
* The current/latest stable API version.
|
||||
* New clients should use this version.
|
||||
*/
|
||||
export const CURRENT_API_VERSION: ApiVersion = 'v1';
|
||||
|
||||
/**
|
||||
* The default API version for requests without explicit version.
|
||||
* Used when version cannot be detected from the request path.
|
||||
*/
|
||||
export const DEFAULT_VERSION: ApiVersion = 'v1';
|
||||
|
||||
/**
|
||||
* Array of all supported API versions.
|
||||
* Used for validation and enumeration.
|
||||
*/
|
||||
export const SUPPORTED_VERSIONS: readonly ApiVersion[] = API_VERSIONS;
|
||||
|
||||
/**
|
||||
* Configuration map for all API versions.
|
||||
* Provides lifecycle status and deprecation information for each version.
|
||||
*
|
||||
* To mark v1 as deprecated (example for future use):
|
||||
* @example
|
||||
* ```typescript
|
||||
* VERSION_CONFIGS.v1 = {
|
||||
* version: 'v1',
|
||||
* status: 'deprecated',
|
||||
* sunsetDate: '2027-01-01T00:00:00Z',
|
||||
* successorVersion: 'v2',
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
|
||||
v1: {
|
||||
version: 'v1',
|
||||
status: 'active',
|
||||
// No deprecation info - v1 is the current active version
|
||||
},
|
||||
v2: {
|
||||
version: 'v2',
|
||||
status: 'active',
|
||||
// v2 is defined for infrastructure readiness but not yet implemented
|
||||
},
|
||||
};
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid ApiVersion.
|
||||
*
|
||||
* @param value - The string to check
|
||||
* @returns True if the string is a valid API version
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const userInput = 'v1';
|
||||
* if (isValidApiVersion(userInput)) {
|
||||
* // userInput is now typed as ApiVersion
|
||||
* const config = VERSION_CONFIGS[userInput];
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isValidApiVersion(value: string): value is ApiVersion {
|
||||
return API_VERSIONS.includes(value as ApiVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version is deprecated.
|
||||
*
|
||||
* @param version - The API version to check
|
||||
* @returns True if the version status is 'deprecated'
|
||||
*/
|
||||
export function isVersionDeprecated(version: ApiVersion): boolean {
|
||||
return VERSION_CONFIGS[version].status === 'deprecated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deprecation information for a version.
|
||||
* Constructs a VersionDeprecation object from the version config.
|
||||
*
|
||||
* @param version - The API version to get deprecation info for
|
||||
* @returns VersionDeprecation object with current deprecation state
|
||||
*/
|
||||
export function getVersionDeprecation(version: ApiVersion): VersionDeprecation {
|
||||
const config = VERSION_CONFIGS[version];
|
||||
return {
|
||||
deprecated: config.status === 'deprecated',
|
||||
sunsetDate: config.sunsetDate,
|
||||
successorVersion: config.successorVersion,
|
||||
message:
|
||||
config.status === 'deprecated'
|
||||
? `API ${version} is deprecated${config.sunsetDate ? ` and will be sunset on ${config.sunsetDate}` : ''}${config.successorVersion ? `. Please migrate to ${config.successorVersion}.` : '.'}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -32,9 +32,9 @@ export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({
|
||||
: `/flyer-images/${imageUrl}`;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-900 flex flex-col">
|
||||
<div className="w-full rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700/50 shadow-md hover:shadow-lg transition-shadow duration-300 bg-white dark:bg-slate-800/50 flex flex-col backdrop-blur-sm">
|
||||
{(store || dateRange) && (
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex items-center space-x-4 pr-4">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50/80 dark:bg-slate-800/80 flex items-center space-x-4 pr-4">
|
||||
{store?.logo_url && (
|
||||
<img
|
||||
src={store.logo_url}
|
||||
@@ -70,7 +70,7 @@ export const FlyerDisplay: React.FC<FlyerDisplayProps> = ({
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Grocery Flyer"
|
||||
className="w-full h-auto object-contain max-h-[60vh] dark:invert dark:hue-rotate-180"
|
||||
className="w-full h-auto object-contain max-h-[60vh] dark:brightness-90 transition-all duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -7,7 +7,11 @@ import { parseISO, format, isValid } from 'date-fns';
|
||||
import { MapPinIcon, Trash2Icon } from 'lucide-react';
|
||||
import { logger } from '../../services/logger.client';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { calculateDaysBetween, formatDateRange, getCurrentDateISOString } from '../../utils/dateUtils';
|
||||
import {
|
||||
calculateDaysBetween,
|
||||
formatDateRange,
|
||||
getCurrentDateISOString,
|
||||
} from '../../utils/dateUtils';
|
||||
|
||||
interface FlyerListProps {
|
||||
flyers: Flyer[];
|
||||
@@ -42,8 +46,8 @@ export const FlyerList: React.FC<FlyerListProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-slate-800/50">
|
||||
Processed Flyers
|
||||
</h3>
|
||||
{flyers.length > 0 ? (
|
||||
@@ -108,7 +112,11 @@ export const FlyerList: React.FC<FlyerListProps> = ({
|
||||
data-testid={`flyer-list-item-${flyer.flyer_id}`}
|
||||
key={flyer.flyer_id}
|
||||
onClick={() => onFlyerSelect(flyer)}
|
||||
className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
|
||||
className={`p-4 flex items-center space-x-3 cursor-pointer transition-all duration-200 border-l-4 ${
|
||||
selectedFlyerId === flyer.flyer_id
|
||||
? 'border-brand-primary bg-teal-50/50 dark:bg-teal-900/10'
|
||||
: 'border-transparent hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-slate-800 hover:-translate-y-0.5 hover:shadow-sm'
|
||||
}`}
|
||||
title={tooltipText}
|
||||
>
|
||||
{flyer.icon_url ? (
|
||||
|
||||
395
src/features/store/StoreCard.test.tsx
Normal file
395
src/features/store/StoreCard.test.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
// 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 typeof mockStoreWithLogo}
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
311
src/hooks/useEventBus.test.ts
Normal file
311
src/hooks/useEventBus.test.ts
Normal 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 {
|
||||
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<[TestData?], void>();
|
||||
|
||||
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<[string?], void>();
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
560
src/hooks/useOnboardingTour.test.ts
Normal file
560
src/hooks/useOnboardingTour.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
400
src/middleware/apiVersion.middleware.test.ts
Normal file
400
src/middleware/apiVersion.middleware.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
// src/middleware/apiVersion.middleware.test.ts
|
||||
/**
|
||||
* @file Unit tests for API version detection middleware (ADR-008 Phase 2).
|
||||
* @see src/middleware/apiVersion.middleware.ts
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
detectApiVersion,
|
||||
extractApiVersionFromPath,
|
||||
hasApiVersion,
|
||||
getRequestApiVersion,
|
||||
VERSION_ERROR_CODES,
|
||||
} from './apiVersion.middleware';
|
||||
import { DEFAULT_VERSION, SUPPORTED_VERSIONS } from '../config/apiVersions';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
describe('apiVersion.middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: NextFunction & Mock;
|
||||
let mockJson: Mock;
|
||||
let mockStatus: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
mockJson = vi.fn().mockReturnThis();
|
||||
mockStatus = vi.fn().mockReturnValue({ json: mockJson });
|
||||
mockNext = vi.fn();
|
||||
|
||||
mockResponse = {
|
||||
status: mockStatus,
|
||||
json: mockJson,
|
||||
};
|
||||
});
|
||||
|
||||
describe('detectApiVersion', () => {
|
||||
it('should extract v1 from req.params.version and attach to req.apiVersion', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'v1' },
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).toHaveBeenCalledWith();
|
||||
expect(mockRequest.apiVersion).toBe('v1');
|
||||
expect(mockRequest.versionDeprecation).toBeDefined();
|
||||
expect(mockRequest.versionDeprecation?.deprecated).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract v2 from req.params.version and attach to req.apiVersion', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'v2' },
|
||||
path: '/flyers',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe('v2');
|
||||
expect(mockRequest.versionDeprecation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should default to v1 when no version parameter is present', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
params: {},
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
||||
expect(mockRequest.versionDeprecation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 with UNSUPPORTED_VERSION for invalid version v99', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'v99' },
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
expect(mockStatus).toHaveBeenCalledWith(404);
|
||||
expect(mockJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.objectContaining({
|
||||
code: VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
|
||||
message: expect.stringContaining("API version 'v99' is not supported"),
|
||||
details: expect.objectContaining({
|
||||
requestedVersion: 'v99',
|
||||
supportedVersions: expect.arrayContaining(['v1', 'v2']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 for non-versioned format like "latest"', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'latest' },
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
ip: '192.168.1.1',
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
expect(mockStatus).toHaveBeenCalledWith(404);
|
||||
expect(mockJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.objectContaining({
|
||||
code: VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
|
||||
message: expect.stringContaining("API version 'latest' is not supported"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log warning when invalid version is requested', () => {
|
||||
// Arrange
|
||||
const childLogger = createMockLogger();
|
||||
const mockLog = createMockLogger();
|
||||
vi.mocked(mockLog.child).mockReturnValue(
|
||||
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
||||
);
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'v999' },
|
||||
path: '/test',
|
||||
method: 'GET',
|
||||
ip: '10.0.0.1',
|
||||
log: mockLog,
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.child).toHaveBeenCalledWith({ middleware: 'detectApiVersion' });
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attemptedVersion: 'v999',
|
||||
supportedVersions: SUPPORTED_VERSIONS,
|
||||
}),
|
||||
'Invalid API version requested',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log debug when valid version is detected', () => {
|
||||
// Arrange
|
||||
const childLogger = createMockLogger();
|
||||
const mockLog = createMockLogger();
|
||||
vi.mocked(mockLog.child).mockReturnValue(
|
||||
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
||||
);
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
params: { version: 'v1' },
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
log: mockLog,
|
||||
});
|
||||
|
||||
// Act
|
||||
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(childLogger.debug).toHaveBeenCalledWith(
|
||||
{ apiVersion: 'v1' },
|
||||
'API version detected from URL',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractApiVersionFromPath', () => {
|
||||
it('should extract v1 from /v1/users path', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
path: '/v1/users',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe('v1');
|
||||
expect(mockRequest.versionDeprecation).toBeDefined();
|
||||
});
|
||||
|
||||
it('should extract v2 from /v2/flyers/123 path', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
path: '/v2/flyers/123',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe('v2');
|
||||
});
|
||||
|
||||
it('should default to v1 for unversioned paths', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
path: '/users',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
||||
});
|
||||
|
||||
it('should default to v1 for paths without leading slash', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({
|
||||
path: 'v1/users', // No leading slash - won't match regex
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
||||
});
|
||||
|
||||
it('should use default for unsupported version numbers in path', () => {
|
||||
// Arrange
|
||||
const childLogger = createMockLogger();
|
||||
const mockLog = createMockLogger();
|
||||
vi.mocked(mockLog.child).mockReturnValue(
|
||||
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
||||
);
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
path: '/v99/users',
|
||||
params: {},
|
||||
log: mockLog,
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attemptedVersion: 'v99',
|
||||
supportedVersions: SUPPORTED_VERSIONS,
|
||||
}),
|
||||
'Unsupported API version in path, falling back to default',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paths with only version segment', () => {
|
||||
// Arrange: Path like "/v1/" (just version, no resource)
|
||||
mockRequest = createMockRequest({
|
||||
path: '/v1/',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('should NOT extract version from path like /users/v1 (not at start)', () => {
|
||||
// Arrange: Version appears later in path, not at the start
|
||||
mockRequest = createMockRequest({
|
||||
path: '/users/v1/profile',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasApiVersion', () => {
|
||||
it('should return true when apiVersion is set to valid version', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
mockRequest.apiVersion = 'v1';
|
||||
|
||||
// Act & Assert
|
||||
expect(hasApiVersion(mockRequest as Request)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when apiVersion is undefined', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
mockRequest.apiVersion = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(hasApiVersion(mockRequest as Request)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when apiVersion is invalid', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
// Force an invalid version (bypassing TypeScript) - eslint-disable-next-line
|
||||
(mockRequest as unknown as { apiVersion: string }).apiVersion = 'v99';
|
||||
|
||||
// Act & Assert
|
||||
expect(hasApiVersion(mockRequest as Request)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestApiVersion', () => {
|
||||
it('should return the request apiVersion when set', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
mockRequest.apiVersion = 'v2';
|
||||
|
||||
// Act
|
||||
const version = getRequestApiVersion(mockRequest as Request);
|
||||
|
||||
// Assert
|
||||
expect(version).toBe('v2');
|
||||
});
|
||||
|
||||
it('should return DEFAULT_VERSION when apiVersion is undefined', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
mockRequest.apiVersion = undefined;
|
||||
|
||||
// Act
|
||||
const version = getRequestApiVersion(mockRequest as Request);
|
||||
|
||||
// Assert
|
||||
expect(version).toBe(DEFAULT_VERSION);
|
||||
});
|
||||
|
||||
it('should return DEFAULT_VERSION when apiVersion is invalid', () => {
|
||||
// Arrange
|
||||
mockRequest = createMockRequest({});
|
||||
// Force an invalid version - eslint-disable-next-line
|
||||
(mockRequest as unknown as { apiVersion: string }).apiVersion = 'invalid';
|
||||
|
||||
// Act
|
||||
const version = getRequestApiVersion(mockRequest as Request);
|
||||
|
||||
// Assert
|
||||
expect(version).toBe(DEFAULT_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VERSION_ERROR_CODES', () => {
|
||||
it('should have UNSUPPORTED_VERSION error code', () => {
|
||||
expect(VERSION_ERROR_CODES.UNSUPPORTED_VERSION).toBe('UNSUPPORTED_VERSION');
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/middleware/apiVersion.middleware.ts
Normal file
218
src/middleware/apiVersion.middleware.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// src/middleware/apiVersion.middleware.ts
|
||||
/**
|
||||
* @file API version detection middleware implementing ADR-008 Phase 2.
|
||||
*
|
||||
* Extracts API version from the request URL, validates it against supported versions,
|
||||
* attaches version information to the request object, and handles unsupported versions.
|
||||
*
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
* @see docs/adr/0008-api-versioning-strategy.md
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In versioned router factory (versioned.ts):
|
||||
* import { detectApiVersion } from '../middleware/apiVersion.middleware';
|
||||
*
|
||||
* const router = Router({ mergeParams: true });
|
||||
* router.use(detectApiVersion);
|
||||
* ```
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ApiVersion,
|
||||
SUPPORTED_VERSIONS,
|
||||
DEFAULT_VERSION,
|
||||
isValidApiVersion,
|
||||
getVersionDeprecation,
|
||||
} from '../config/apiVersions';
|
||||
import { sendError } from '../utils/apiResponse';
|
||||
import { createScopedLogger } from '../services/logger.server';
|
||||
|
||||
// --- Module-level Logger ---
|
||||
|
||||
/**
|
||||
* Module-scoped logger for API version middleware.
|
||||
* Used for logging version detection events outside of request context.
|
||||
*/
|
||||
const moduleLogger = createScopedLogger('apiVersion-middleware');
|
||||
|
||||
// --- Error Codes ---
|
||||
|
||||
/**
|
||||
* Error code for unsupported API version requests.
|
||||
* This is specific to the versioning system and not part of the general ErrorCode enum.
|
||||
*/
|
||||
export const VERSION_ERROR_CODES = {
|
||||
UNSUPPORTED_VERSION: 'UNSUPPORTED_VERSION',
|
||||
} as const;
|
||||
|
||||
// --- Middleware Functions ---
|
||||
|
||||
/**
|
||||
* Extracts the API version from the URL path parameter and attaches it to the request.
|
||||
*
|
||||
* This middleware expects to be used with a router that has a :version parameter
|
||||
* (e.g., mounted at `/api/:version`). It validates the version against the list
|
||||
* of supported versions and returns a 404 error for unsupported versions.
|
||||
*
|
||||
* For valid versions, it:
|
||||
* - Sets `req.apiVersion` to the detected version
|
||||
* - Sets `req.versionDeprecation` with deprecation info if the version is deprecated
|
||||
*
|
||||
* @param req - Express request object (expects `req.params.version`)
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Route setup:
|
||||
* app.use('/api/:version', detectApiVersion, versionedRouter);
|
||||
*
|
||||
* // Request to /api/v1/users:
|
||||
* // req.params.version = 'v1'
|
||||
* // req.apiVersion = 'v1'
|
||||
*
|
||||
* // Request to /api/v99/users:
|
||||
* // Returns 404 with UNSUPPORTED_VERSION error
|
||||
* ```
|
||||
*/
|
||||
export function detectApiVersion(req: Request, res: Response, next: NextFunction): void {
|
||||
// Get the request-scoped logger if available, otherwise use module logger
|
||||
const log = req.log?.child({ middleware: 'detectApiVersion' }) ?? moduleLogger;
|
||||
|
||||
// Extract version from URL params (expects router mounted with :version param)
|
||||
const versionParam = req.params?.version;
|
||||
|
||||
// If no version parameter found, this middleware was likely applied incorrectly.
|
||||
// Default to the default version and continue (allows for fallback behavior).
|
||||
if (!versionParam) {
|
||||
log.debug('No version parameter found in request, using default version');
|
||||
req.apiVersion = DEFAULT_VERSION;
|
||||
req.versionDeprecation = getVersionDeprecation(DEFAULT_VERSION);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Validate the version parameter
|
||||
if (isValidApiVersion(versionParam)) {
|
||||
// Valid version - attach to request
|
||||
req.apiVersion = versionParam;
|
||||
req.versionDeprecation = getVersionDeprecation(versionParam);
|
||||
|
||||
log.debug({ apiVersion: versionParam }, 'API version detected from URL');
|
||||
return next();
|
||||
}
|
||||
|
||||
// Invalid version - log warning and return 404
|
||||
log.warn(
|
||||
{
|
||||
attemptedVersion: versionParam,
|
||||
supportedVersions: SUPPORTED_VERSIONS,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
},
|
||||
'Invalid API version requested',
|
||||
);
|
||||
|
||||
// Return 404 with UNSUPPORTED_VERSION error code
|
||||
// Using 404 because the versioned endpoint does not exist
|
||||
sendError(
|
||||
res,
|
||||
VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
|
||||
`API version '${versionParam}' is not supported. Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`,
|
||||
404,
|
||||
{
|
||||
requestedVersion: versionParam,
|
||||
supportedVersions: [...SUPPORTED_VERSIONS],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the API version from the URL path pattern and attaches it to the request.
|
||||
*
|
||||
* Unlike `detectApiVersion`, this middleware parses the version from the URL path
|
||||
* directly using a regex pattern. This is useful when the middleware needs to run
|
||||
* before or independently of parameterized routing.
|
||||
*
|
||||
* Pattern matched: `/v{number}/...` at the beginning of the path
|
||||
* (e.g., `/v1/users`, `/v2/flyers/123`)
|
||||
*
|
||||
* If the version is valid, sets `req.apiVersion` and `req.versionDeprecation`.
|
||||
* If the version is invalid or not present, defaults to `DEFAULT_VERSION`.
|
||||
*
|
||||
* This middleware does NOT return errors for invalid versions - it's designed for
|
||||
* cases where version detection is informational rather than authoritative.
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param _res - Express response object (unused)
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Applied early in middleware chain:
|
||||
* app.use('/api', extractApiVersionFromPath, apiRouter);
|
||||
*
|
||||
* // For path /api/v1/users:
|
||||
* // req.path = '/v1/users' (relative to /api mount point)
|
||||
* // req.apiVersion = 'v1'
|
||||
* ```
|
||||
*/
|
||||
export function extractApiVersionFromPath(req: Request, _res: Response, next: NextFunction): void {
|
||||
// Get the request-scoped logger if available, otherwise use module logger
|
||||
const log = req.log?.child({ middleware: 'extractApiVersionFromPath' }) ?? moduleLogger;
|
||||
|
||||
// Extract version from URL path using regex: /v{number}/
|
||||
// The path is relative to the router's mount point
|
||||
const pathMatch = req.path.match(/^\/v(\d+)\//);
|
||||
|
||||
if (pathMatch) {
|
||||
const versionString = `v${pathMatch[1]}` as string;
|
||||
|
||||
if (isValidApiVersion(versionString)) {
|
||||
req.apiVersion = versionString;
|
||||
req.versionDeprecation = getVersionDeprecation(versionString);
|
||||
log.debug({ apiVersion: versionString }, 'API version extracted from path');
|
||||
return next();
|
||||
}
|
||||
|
||||
// Version number in path but not in supported list - log and use default
|
||||
log.warn(
|
||||
{
|
||||
attemptedVersion: versionString,
|
||||
supportedVersions: SUPPORTED_VERSIONS,
|
||||
path: req.path,
|
||||
},
|
||||
'Unsupported API version in path, falling back to default',
|
||||
);
|
||||
}
|
||||
|
||||
// No version detected or invalid - use default
|
||||
req.apiVersion = DEFAULT_VERSION;
|
||||
req.versionDeprecation = getVersionDeprecation(DEFAULT_VERSION);
|
||||
log.debug({ apiVersion: DEFAULT_VERSION }, 'Using default API version');
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a request has a valid API version attached.
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @returns True if req.apiVersion is set to a valid ApiVersion
|
||||
*/
|
||||
export function hasApiVersion(req: Request): req is Request & { apiVersion: ApiVersion } {
|
||||
return req.apiVersion !== undefined && isValidApiVersion(req.apiVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API version from a request, with a fallback to the default version.
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @returns The API version from the request, or DEFAULT_VERSION if not set
|
||||
*/
|
||||
export function getRequestApiVersion(req: Request): ApiVersion {
|
||||
if (req.apiVersion && isValidApiVersion(req.apiVersion)) {
|
||||
return req.apiVersion;
|
||||
}
|
||||
return DEFAULT_VERSION;
|
||||
}
|
||||
450
src/middleware/deprecation.middleware.test.ts
Normal file
450
src/middleware/deprecation.middleware.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
// src/middleware/deprecation.middleware.test.ts
|
||||
/**
|
||||
* @file Unit tests for deprecation header middleware.
|
||||
* Tests RFC 8594 compliant header generation for deprecated API versions.
|
||||
*
|
||||
* @see ADR-008 for API versioning strategy
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
addDeprecationHeaders,
|
||||
addDeprecationHeadersFromRequest,
|
||||
DEPRECATION_HEADERS,
|
||||
} from './deprecation.middleware';
|
||||
import { VERSION_CONFIGS } from '../config/apiVersions';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
// Mock the logger to avoid actual logging during tests
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('deprecation.middleware', () => {
|
||||
// Store original VERSION_CONFIGS to restore after tests
|
||||
let originalV1Config: typeof VERSION_CONFIGS.v1;
|
||||
let originalV2Config: typeof VERSION_CONFIGS.v2;
|
||||
|
||||
let mockRequest: Request;
|
||||
let mockResponse: Partial<Response>;
|
||||
|
||||
let mockNext: any;
|
||||
|
||||
let setHeaderSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original configs
|
||||
originalV1Config = { ...VERSION_CONFIGS.v1 };
|
||||
originalV2Config = { ...VERSION_CONFIGS.v2 };
|
||||
|
||||
// Reset mocks
|
||||
setHeaderSpy = vi.fn();
|
||||
mockRequest = createMockRequest({
|
||||
method: 'GET',
|
||||
path: '/api/v1/flyers',
|
||||
get: vi.fn().mockReturnValue('TestUserAgent/1.0'),
|
||||
});
|
||||
mockResponse = {
|
||||
set: setHeaderSpy,
|
||||
setHeader: setHeaderSpy,
|
||||
};
|
||||
mockNext = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original configs after each test
|
||||
VERSION_CONFIGS.v1 = originalV1Config;
|
||||
VERSION_CONFIGS.v2 = originalV2Config;
|
||||
});
|
||||
|
||||
describe('addDeprecationHeaders (factory function)', () => {
|
||||
describe('with active version', () => {
|
||||
it('should always set X-API-Version header', () => {
|
||||
// Arrange - v1 is active by default
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v1');
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not add Deprecation header for active version', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
expect(setHeaderSpy).toHaveBeenCalledTimes(1); // Only X-API-Version
|
||||
});
|
||||
|
||||
it('should not add Sunset header for active version', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.SUNSET,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add Link header for active version', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(DEPRECATION_HEADERS.LINK, expect.anything());
|
||||
});
|
||||
|
||||
it('should not set versionDeprecation on request for active version', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockRequest.versionDeprecation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with deprecated version', () => {
|
||||
beforeEach(() => {
|
||||
// Mark v1 as deprecated for these tests
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
});
|
||||
|
||||
it('should add Deprecation: true header', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
});
|
||||
|
||||
it('should add Sunset header with ISO 8601 date', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.SUNSET,
|
||||
'2027-01-01T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add Link header with successor-version relation', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.LINK,
|
||||
'</api/v2>; rel="successor-version"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add X-API-Deprecation-Notice header', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.DEPRECATION_NOTICE,
|
||||
expect.stringContaining('deprecated'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should always set X-API-Version header', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v1');
|
||||
});
|
||||
|
||||
it('should set versionDeprecation on request', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockRequest.versionDeprecation).toBeDefined();
|
||||
expect(mockRequest.versionDeprecation?.deprecated).toBe(true);
|
||||
expect(mockRequest.versionDeprecation?.sunsetDate).toBe('2027-01-01T00:00:00Z');
|
||||
expect(mockRequest.versionDeprecation?.successorVersion).toBe('v2');
|
||||
});
|
||||
|
||||
it('should call next() to continue middleware chain', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should add all RFC 8594 compliant headers in correct format', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert - verify all headers are set
|
||||
const headerCalls = setHeaderSpy.mock.calls;
|
||||
const headerNames = headerCalls.map((call: unknown[]) => call[0]);
|
||||
|
||||
expect(headerNames).toContain(DEPRECATION_HEADERS.API_VERSION);
|
||||
expect(headerNames).toContain(DEPRECATION_HEADERS.DEPRECATION);
|
||||
expect(headerNames).toContain(DEPRECATION_HEADERS.SUNSET);
|
||||
expect(headerNames).toContain(DEPRECATION_HEADERS.LINK);
|
||||
expect(headerNames).toContain(DEPRECATION_HEADERS.DEPRECATION_NOTICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with deprecated version missing optional fields', () => {
|
||||
beforeEach(() => {
|
||||
// Mark v1 as deprecated without sunset date or successor
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
// No sunsetDate or successorVersion
|
||||
};
|
||||
});
|
||||
|
||||
it('should add Deprecation header even without sunset date', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
});
|
||||
|
||||
it('should not add Sunset header when sunsetDate is not configured', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.SUNSET,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add Link header when successorVersion is not configured', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(DEPRECATION_HEADERS.LINK, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('with v2 version', () => {
|
||||
it('should set X-API-Version: v2 header', () => {
|
||||
// Arrange
|
||||
const middleware = addDeprecationHeaders('v2');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDeprecationHeadersFromRequest', () => {
|
||||
describe('when apiVersion is set on request', () => {
|
||||
it('should add headers based on request apiVersion', () => {
|
||||
// Arrange
|
||||
mockRequest.apiVersion = 'v1';
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-06-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
|
||||
// Act
|
||||
addDeprecationHeadersFromRequest(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v1');
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(
|
||||
DEPRECATION_HEADERS.SUNSET,
|
||||
'2027-06-01T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add deprecation headers for active version', () => {
|
||||
// Arrange
|
||||
mockRequest.apiVersion = 'v2';
|
||||
|
||||
// Act
|
||||
addDeprecationHeadersFromRequest(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v2');
|
||||
expect(setHeaderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when apiVersion is not set on request', () => {
|
||||
it('should skip header processing and call next', () => {
|
||||
// Arrange
|
||||
mockRequest.apiVersion = undefined;
|
||||
|
||||
// Act
|
||||
addDeprecationHeadersFromRequest(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(setHeaderSpy).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEPRECATION_HEADERS constants', () => {
|
||||
it('should have correct header names', () => {
|
||||
expect(DEPRECATION_HEADERS.DEPRECATION).toBe('Deprecation');
|
||||
expect(DEPRECATION_HEADERS.SUNSET).toBe('Sunset');
|
||||
expect(DEPRECATION_HEADERS.LINK).toBe('Link');
|
||||
expect(DEPRECATION_HEADERS.DEPRECATION_NOTICE).toBe('X-API-Deprecation-Notice');
|
||||
expect(DEPRECATION_HEADERS.API_VERSION).toBe('X-API-Version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle sunset version status', () => {
|
||||
// Arrange
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'sunset',
|
||||
sunsetDate: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert - sunset is different from deprecated, so no deprecation headers
|
||||
// Only X-API-Version should be set
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v1');
|
||||
expect(setHeaderSpy).not.toHaveBeenCalledWith(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
});
|
||||
|
||||
it('should handle request with existing log object', () => {
|
||||
// Arrange
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
const mockLogWithBindings = {
|
||||
debug: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({ request_id: 'test-request-id' }),
|
||||
};
|
||||
mockRequest.log = mockLogWithBindings as unknown as Request['log'];
|
||||
const middleware = addDeprecationHeaders('v1');
|
||||
|
||||
// Act
|
||||
middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert - should not throw and should complete
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should work with different versions in sequence', () => {
|
||||
// Arrange
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
const v1Middleware = addDeprecationHeaders('v1');
|
||||
const v2Middleware = addDeprecationHeaders('v2');
|
||||
|
||||
// Act
|
||||
v1Middleware(mockRequest, mockResponse as Response, mockNext);
|
||||
|
||||
// Reset for v2
|
||||
setHeaderSpy.mockClear();
|
||||
mockNext.mockClear();
|
||||
const mockRequest2 = createMockRequest({
|
||||
method: 'GET',
|
||||
path: '/api/v2/flyers',
|
||||
});
|
||||
|
||||
v2Middleware(mockRequest2, mockResponse as Response, mockNext);
|
||||
|
||||
// Assert - v2 should only have API version header
|
||||
expect(setHeaderSpy).toHaveBeenCalledWith(DEPRECATION_HEADERS.API_VERSION, 'v2');
|
||||
expect(setHeaderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/middleware/deprecation.middleware.ts
Normal file
218
src/middleware/deprecation.middleware.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// src/middleware/deprecation.middleware.ts
|
||||
/**
|
||||
* @file Deprecation Headers Middleware - RFC 8594 Compliant
|
||||
* Implements ADR-008 Phase 2: API Versioning Infrastructure.
|
||||
*
|
||||
* This middleware adds standard deprecation headers to API responses when
|
||||
* a deprecated API version is being accessed. It follows:
|
||||
* - RFC 8594: The "Sunset" HTTP Header Field
|
||||
* - draft-ietf-httpapi-deprecation-header: The "Deprecation" HTTP Header Field
|
||||
* - RFC 8288: Web Linking (for successor-version relation)
|
||||
*
|
||||
* Headers added for deprecated versions:
|
||||
* - `Deprecation: true` - Indicates the endpoint is deprecated
|
||||
* - `Sunset: <ISO 8601 date>` - When the endpoint will be removed
|
||||
* - `Link: </api/vX>; rel="successor-version"` - URL to the replacement version
|
||||
* - `X-API-Deprecation-Notice: <message>` - Human-readable deprecation message
|
||||
*
|
||||
* Always added (for all versions):
|
||||
* - `X-API-Version: <version>` - The API version being accessed
|
||||
*
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc8594
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ApiVersion, VERSION_CONFIGS, getVersionDeprecation } from '../config/apiVersions';
|
||||
import { createScopedLogger } from '../services/logger.server';
|
||||
|
||||
// Create a module-scoped logger for deprecation tracking
|
||||
const deprecationLogger = createScopedLogger('deprecation-middleware');
|
||||
|
||||
/**
|
||||
* HTTP header names for deprecation signaling.
|
||||
* Using constants to ensure consistency and prevent typos.
|
||||
*/
|
||||
export const DEPRECATION_HEADERS = {
|
||||
/** RFC draft-ietf-httpapi-deprecation-header: Indicates deprecation status */
|
||||
DEPRECATION: 'Deprecation',
|
||||
/** RFC 8594: ISO 8601 date when the endpoint will be removed */
|
||||
SUNSET: 'Sunset',
|
||||
/** RFC 8288: Link to successor version with rel="successor-version" */
|
||||
LINK: 'Link',
|
||||
/** Custom header: Human-readable deprecation notice */
|
||||
DEPRECATION_NOTICE: 'X-API-Deprecation-Notice',
|
||||
/** Custom header: Current API version being accessed */
|
||||
API_VERSION: 'X-API-Version',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Creates middleware that adds RFC 8594 compliant deprecation headers
|
||||
* to responses when a deprecated API version is accessed.
|
||||
*
|
||||
* This is a middleware factory function that takes a version parameter
|
||||
* and returns the configured middleware function. This pattern allows
|
||||
* different version routers to have their own deprecation configuration.
|
||||
*
|
||||
* @param version - The API version this middleware is handling
|
||||
* @returns Express middleware function that adds appropriate headers
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a versioned router factory:
|
||||
* const v1Router = Router();
|
||||
* v1Router.use(addDeprecationHeaders('v1'));
|
||||
*
|
||||
* // When v1 is deprecated, responses will include:
|
||||
* // Deprecation: true
|
||||
* // Sunset: 2027-01-01T00:00:00Z
|
||||
* // Link: </api/v2>; rel="successor-version"
|
||||
* // X-API-Deprecation-Notice: API v1 is deprecated...
|
||||
* // X-API-Version: v1
|
||||
* ```
|
||||
*/
|
||||
export function addDeprecationHeaders(version: ApiVersion) {
|
||||
// Pre-fetch configuration at middleware creation time for efficiency.
|
||||
// This avoids repeated lookups on every request.
|
||||
const config = VERSION_CONFIGS[version];
|
||||
const deprecationInfo = getVersionDeprecation(version);
|
||||
|
||||
return function deprecationHeadersMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
// Always set the API version header for transparency and debugging.
|
||||
// This helps clients know which version they're using, especially
|
||||
// useful when default version routing is in effect.
|
||||
res.set(DEPRECATION_HEADERS.API_VERSION, version);
|
||||
|
||||
// Only add deprecation headers if this version is actually deprecated.
|
||||
// Active versions should not have any deprecation headers.
|
||||
if (config.status === 'deprecated') {
|
||||
// RFC draft-ietf-httpapi-deprecation-header: Set to "true" to indicate deprecation
|
||||
res.set(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
|
||||
// RFC 8594: Sunset header with ISO 8601 date indicating removal date
|
||||
if (config.sunsetDate) {
|
||||
res.set(DEPRECATION_HEADERS.SUNSET, config.sunsetDate);
|
||||
}
|
||||
|
||||
// RFC 8288: Link header with successor-version relation
|
||||
// This tells clients where to migrate to
|
||||
if (config.successorVersion) {
|
||||
res.set(
|
||||
DEPRECATION_HEADERS.LINK,
|
||||
`</api/${config.successorVersion}>; rel="successor-version"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Custom header: Human-readable message for developers
|
||||
// This provides context that may not be obvious from the standard headers
|
||||
if (deprecationInfo.message) {
|
||||
res.set(DEPRECATION_HEADERS.DEPRECATION_NOTICE, deprecationInfo.message);
|
||||
}
|
||||
|
||||
// Attach deprecation info to the request for use in route handlers.
|
||||
// This allows handlers to implement version-specific behavior or logging.
|
||||
req.versionDeprecation = deprecationInfo;
|
||||
|
||||
// Log deprecation access at debug level to avoid log spam.
|
||||
// This provides visibility into deprecated API usage without overwhelming logs.
|
||||
// Use debug level because high-traffic APIs could generate significant volume.
|
||||
// Production monitoring should use the access logs or metrics aggregation
|
||||
// to track deprecation usage patterns.
|
||||
deprecationLogger.debug(
|
||||
{
|
||||
apiVersion: version,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
sunsetDate: config.sunsetDate,
|
||||
successorVersion: config.successorVersion,
|
||||
userAgent: req.get('User-Agent'),
|
||||
// Include request ID if available from the request logger
|
||||
requestId: (req.log as { bindings?: () => { request_id?: string } })?.bindings?.()
|
||||
?.request_id,
|
||||
},
|
||||
'Deprecated API version accessed',
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone middleware for adding deprecation headers based on
|
||||
* the `apiVersion` property already set on the request.
|
||||
*
|
||||
* This middleware should be used after the version extraction middleware
|
||||
* has set `req.apiVersion`. It provides a more flexible approach when
|
||||
* the version is determined dynamically rather than statically.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // After version extraction middleware:
|
||||
* router.use(extractApiVersion);
|
||||
* router.use(addDeprecationHeadersFromRequest);
|
||||
* ```
|
||||
*/
|
||||
export function addDeprecationHeadersFromRequest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const version = req.apiVersion;
|
||||
|
||||
// If no version is set on the request, skip deprecation handling.
|
||||
// This should not happen if the version extraction middleware ran first,
|
||||
// but we handle it gracefully for safety.
|
||||
if (!version) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = VERSION_CONFIGS[version];
|
||||
const deprecationInfo = getVersionDeprecation(version);
|
||||
|
||||
// Always set the API version header
|
||||
res.set(DEPRECATION_HEADERS.API_VERSION, version);
|
||||
|
||||
// Add deprecation headers if version is deprecated
|
||||
if (config.status === 'deprecated') {
|
||||
res.set(DEPRECATION_HEADERS.DEPRECATION, 'true');
|
||||
|
||||
if (config.sunsetDate) {
|
||||
res.set(DEPRECATION_HEADERS.SUNSET, config.sunsetDate);
|
||||
}
|
||||
|
||||
if (config.successorVersion) {
|
||||
res.set(
|
||||
DEPRECATION_HEADERS.LINK,
|
||||
`</api/${config.successorVersion}>; rel="successor-version"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (deprecationInfo.message) {
|
||||
res.set(DEPRECATION_HEADERS.DEPRECATION_NOTICE, deprecationInfo.message);
|
||||
}
|
||||
|
||||
req.versionDeprecation = deprecationInfo;
|
||||
|
||||
deprecationLogger.debug(
|
||||
{
|
||||
apiVersion: version,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
sunsetDate: config.sunsetDate,
|
||||
successorVersion: config.successorVersion,
|
||||
userAgent: req.get('User-Agent'),
|
||||
requestId: (req.log as { bindings?: () => { request_id?: string } })?.bindings?.()
|
||||
?.request_id,
|
||||
},
|
||||
'Deprecated API version accessed',
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
// src/routes/auth.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cookieParser from 'cookie-parser'; // This was a duplicate, fixed.
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { DEPRECATION_HEADERS, addDeprecationHeaders } from '../middleware/deprecation.middleware';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// --- FIX: Hoist passport mocks to be available for vi.mock ---
|
||||
const passportMocks = vi.hoisted(() => {
|
||||
@@ -83,6 +85,13 @@ vi.mock('../services/authService', () => ({ authService: mockedAuthService }));
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the email service
|
||||
@@ -908,4 +917,163 @@ describe('Auth Routes (/api/v1/auth)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API VERSION HEADER ASSERTIONS (ADR-008)
|
||||
// =============================================================================
|
||||
|
||||
describe('API Version Headers', () => {
|
||||
/**
|
||||
* Create an app that includes the deprecation middleware to test version headers.
|
||||
* This simulates the actual production setup where routes are mounted via versioned.ts.
|
||||
*/
|
||||
const createVersionedTestApp = () => {
|
||||
const versionedApp = express();
|
||||
versionedApp.use(express.json());
|
||||
versionedApp.use(cookieParser());
|
||||
versionedApp.use((req, _res, next) => {
|
||||
req.log = mockLogger;
|
||||
next();
|
||||
});
|
||||
// Apply the deprecation middleware before the auth router
|
||||
versionedApp.use('/api/v1/auth', addDeprecationHeaders('v1'), authRouter);
|
||||
// Add error handler to ensure error responses are properly formatted
|
||||
versionedApp.use(errorHandler);
|
||||
return versionedApp;
|
||||
};
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /register success response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
const mockNewUser = createMockUserProfile({
|
||||
user: { user_id: 'new-user-id', email: 'version-test@test.com' },
|
||||
});
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockNewUser,
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/register').send({
|
||||
email: 'version-test@test.com',
|
||||
password: 'a-Very-Strong-Password-123!',
|
||||
full_name: 'Test User',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /login success response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/login').send({
|
||||
email: 'test@test.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /forgot-password response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/forgot-password').send({
|
||||
email: 'test@test.com',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /reset-password response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedAuthService.updatePassword.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/reset-password').send({
|
||||
token: 'valid-token',
|
||||
newPassword: 'a-Very-Strong-Password-789!',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /refresh-token response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-access-token' });
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp)
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=valid-refresh-token');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in POST /logout response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedAuthService.logout.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp)
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=some-valid-token');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version header even on validation error responses', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
|
||||
// Act - send invalid email format
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/login').send({
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version header on authentication failure responses', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).post('/api/v1/auth/login').send({
|
||||
email: 'test@test.com',
|
||||
password: 'wrong_password',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,10 +78,7 @@ describe('Flyer Routes (/api/v1/flyers)', () => {
|
||||
const response = await supertest(app).get('/api/v1/flyers');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching flyers in /api/v1/flyers:',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error in /api/v1/flyers:');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
@@ -166,7 +163,7 @@ describe('Flyer Routes (/api/v1/flyers)', () => {
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError, flyerId: 123 },
|
||||
'Error fetching flyer items in /api/v1/flyers/:id/items:',
|
||||
'Error in /api/v1/flyers/123/items:',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ router.get(
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||
sendSuccess(res, flyers);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
|
||||
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -207,7 +207,7 @@ router.get(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, flyerId: req.params.id },
|
||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||
`Error in ${req.originalUrl.split('?')[0]}:`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// src/routes/health.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { DEPRECATION_HEADERS, addDeprecationHeaders } from '../middleware/deprecation.middleware';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// 1. Mock the dependencies of the health router.
|
||||
vi.mock('../services/db/connection.db', () => ({
|
||||
@@ -25,21 +28,75 @@ 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
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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>;
|
||||
|
||||
@@ -625,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
|
||||
@@ -662,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');
|
||||
@@ -692,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');
|
||||
@@ -732,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
|
||||
@@ -763,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);
|
||||
});
|
||||
@@ -779,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');
|
||||
@@ -814,45 +821,205 @@ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API VERSION HEADER ASSERTIONS (ADR-008)
|
||||
// =============================================================================
|
||||
|
||||
describe('API Version Headers', () => {
|
||||
/**
|
||||
* Create an app that includes the deprecation middleware to test version headers.
|
||||
* This simulates the actual production setup where routes are mounted via versioned.ts.
|
||||
*/
|
||||
const createVersionedTestApp = () => {
|
||||
const versionedApp = express();
|
||||
versionedApp.use(express.json());
|
||||
versionedApp.use((req, _res, next) => {
|
||||
req.log = mockLogger;
|
||||
next();
|
||||
});
|
||||
// Apply the deprecation middleware before the health router
|
||||
versionedApp.use('/api/v1/health', addDeprecationHeaders('v1'), healthRouter);
|
||||
// Add error handler to ensure error responses are properly formatted
|
||||
versionedApp.use(errorHandler);
|
||||
return versionedApp;
|
||||
};
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /ping response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/ping');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /live response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/live');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /time response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-03-15T10:30:00.000Z'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/time');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /redis response (success)', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/redis');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /db-schema response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/db-schema');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /storage response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/storage');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /db-pool response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/db-pool');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /ready response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/ready');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v1 header in GET /startup response', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/startup');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version header even on error responses', async () => {
|
||||
// Arrange
|
||||
const versionedApp = createVersionedTestApp();
|
||||
const redisError = new Error('Connection timed out');
|
||||
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(versionedApp).get('/api/v1/health/redis');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -619,10 +619,10 @@ router.get(
|
||||
let hasErrors = false;
|
||||
|
||||
for (const metric of queueMetrics) {
|
||||
if ('error' in metric) {
|
||||
if ('error' in metric && metric.error) {
|
||||
queuesData[metric.name] = { error: metric.error };
|
||||
hasErrors = true;
|
||||
} else {
|
||||
} else if ('counts' in metric && metric.counts) {
|
||||
queuesData[metric.name] = metric.counts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ router.get(
|
||||
const result = await db.personalizationRepo.getAllMasterItems(req.log, limit, offset);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
|
||||
req.log.error({ error }, `Error fetching master items in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -100,7 +100,7 @@ router.get(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error },
|
||||
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
|
||||
`Error fetching dietary restrictions in ${req.originalUrl.split('?')[0]}:`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ router.get(
|
||||
const appliances = await db.personalizationRepo.getAppliances(req.log);
|
||||
sendSuccess(res, appliances);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
|
||||
req.log.error({ error }, `Error fetching appliances in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ router.get(
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage!, req.log);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
|
||||
req.log.error({ error }, `Error fetching recipes in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -124,7 +124,7 @@ router.get(
|
||||
);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
|
||||
req.log.error({ error }, `Error fetching recipes in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -174,7 +174,7 @@ router.get(
|
||||
);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
|
||||
req.log.error({ error }, `Error fetching recipes in ${req.originalUrl.split('?')[0]}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ router.get(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error },
|
||||
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
|
||||
`Error fetching most frequent sale items in ${req.originalUrl.split('?')[0]}:`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
|
||||
748
src/routes/versioned.test.ts
Normal file
748
src/routes/versioned.test.ts
Normal file
@@ -0,0 +1,748 @@
|
||||
// src/routes/versioned.test.ts
|
||||
/**
|
||||
* @file Unit tests for the version router factory.
|
||||
* Tests ADR-008 Phase 2: API Versioning Infrastructure.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Router creation for different API versions
|
||||
* - X-API-Version header on all responses
|
||||
* - Deprecation headers for deprecated versions
|
||||
* - Route availability filtering by version
|
||||
* - Router caching behavior
|
||||
* - Utility functions
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Router, Request, Response } from 'express';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// --- Hoisted Mock Setup ---
|
||||
// vi.hoisted() is executed before imports, making values available in vi.mock factories
|
||||
|
||||
const { mockLoggerFn, inlineMockLogger, createMockRouterFactory } = vi.hoisted(() => {
|
||||
const mockLoggerFn = vi.fn();
|
||||
const inlineMockLogger = {
|
||||
info: mockLoggerFn,
|
||||
debug: mockLoggerFn,
|
||||
error: mockLoggerFn,
|
||||
warn: mockLoggerFn,
|
||||
fatal: mockLoggerFn,
|
||||
trace: mockLoggerFn,
|
||||
silent: mockLoggerFn,
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as unknown as Logger;
|
||||
|
||||
// Factory function to create mock routers
|
||||
const createMockRouterFactory = (name: string) => {
|
||||
// Import express Router here since we're in hoisted context
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Router: ExpressRouter } = require('express');
|
||||
const router = ExpressRouter();
|
||||
router.get('/test', (req: Request, res: Response) => {
|
||||
res.json({ router: name, version: (req as { apiVersion?: string }).apiVersion });
|
||||
});
|
||||
return router;
|
||||
};
|
||||
|
||||
return { mockLoggerFn, inlineMockLogger, createMockRouterFactory };
|
||||
});
|
||||
|
||||
// --- Mock Setup ---
|
||||
|
||||
// Mock the logger before any imports that use it
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
createScopedLogger: vi.fn(() => inlineMockLogger),
|
||||
logger: inlineMockLogger,
|
||||
}));
|
||||
|
||||
// Mock all domain routers with minimal test routers
|
||||
// This isolates the versioned.ts tests from actual route implementations
|
||||
vi.mock('./auth.routes', () => ({ default: createMockRouterFactory('auth') }));
|
||||
vi.mock('./health.routes', () => ({ default: createMockRouterFactory('health') }));
|
||||
vi.mock('./system.routes', () => ({ default: createMockRouterFactory('system') }));
|
||||
vi.mock('./user.routes', () => ({ default: createMockRouterFactory('user') }));
|
||||
vi.mock('./ai.routes', () => ({ default: createMockRouterFactory('ai') }));
|
||||
vi.mock('./admin.routes', () => ({ default: createMockRouterFactory('admin') }));
|
||||
vi.mock('./budget.routes', () => ({ default: createMockRouterFactory('budget') }));
|
||||
vi.mock('./gamification.routes', () => ({ default: createMockRouterFactory('gamification') }));
|
||||
vi.mock('./flyer.routes', () => ({ default: createMockRouterFactory('flyer') }));
|
||||
vi.mock('./recipe.routes', () => ({ default: createMockRouterFactory('recipe') }));
|
||||
vi.mock('./personalization.routes', () => ({
|
||||
default: createMockRouterFactory('personalization'),
|
||||
}));
|
||||
vi.mock('./price.routes', () => ({ default: createMockRouterFactory('price') }));
|
||||
vi.mock('./stats.routes', () => ({ default: createMockRouterFactory('stats') }));
|
||||
vi.mock('./upc.routes', () => ({ default: createMockRouterFactory('upc') }));
|
||||
vi.mock('./inventory.routes', () => ({ default: createMockRouterFactory('inventory') }));
|
||||
vi.mock('./receipt.routes', () => ({ default: createMockRouterFactory('receipt') }));
|
||||
vi.mock('./deals.routes', () => ({ default: createMockRouterFactory('deals') }));
|
||||
vi.mock('./reactions.routes', () => ({ default: createMockRouterFactory('reactions') }));
|
||||
vi.mock('./store.routes', () => ({ default: createMockRouterFactory('store') }));
|
||||
vi.mock('./category.routes', () => ({ default: createMockRouterFactory('category') }));
|
||||
|
||||
// Import types and modules AFTER mocks are set up
|
||||
import type { ApiVersion, VersionConfig } from '../config/apiVersions';
|
||||
import { DEPRECATION_HEADERS } from '../middleware/deprecation.middleware';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// Import the module under test
|
||||
import {
|
||||
createVersionedRouter,
|
||||
createApiRouter,
|
||||
getRegisteredPaths,
|
||||
getRouteByPath,
|
||||
getRoutesForVersion,
|
||||
clearRouterCache,
|
||||
refreshRouterCache,
|
||||
ROUTES,
|
||||
} from './versioned';
|
||||
|
||||
import { API_VERSIONS, VERSION_CONFIGS } from '../config/apiVersions';
|
||||
|
||||
// --- Test Utilities ---
|
||||
|
||||
/**
|
||||
* Creates a test Express app with the given router mounted at the specified path.
|
||||
*/
|
||||
function createTestApp(router: Router, basePath = '/api') {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Inject mock logger into requests
|
||||
app.use((req, res, next) => {
|
||||
req.log = inlineMockLogger;
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(basePath, router);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores original VERSION_CONFIGS for restoration after tests that modify it.
|
||||
*/
|
||||
let originalVersionConfigs: Record<ApiVersion, VersionConfig>;
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('Versioned Router Factory', () => {
|
||||
beforeAll(() => {
|
||||
// Store original configs before any tests modify them
|
||||
originalVersionConfigs = JSON.parse(JSON.stringify(VERSION_CONFIGS));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear router cache before each test to ensure fresh state
|
||||
clearRouterCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original VERSION_CONFIGS after each test
|
||||
Object.assign(VERSION_CONFIGS, originalVersionConfigs);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Final restoration
|
||||
Object.assign(VERSION_CONFIGS, originalVersionConfigs);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// createVersionedRouter() Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('createVersionedRouter()', () => {
|
||||
describe('route registration', () => {
|
||||
it('should create router with all expected routes for v1', () => {
|
||||
// Act
|
||||
const router = createVersionedRouter('v1');
|
||||
|
||||
// Assert - router should be created
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router).toBe('function'); // Express routers are functions
|
||||
});
|
||||
|
||||
it('should create router with all expected routes for v2', () => {
|
||||
// Act
|
||||
const router = createVersionedRouter('v2');
|
||||
|
||||
// Assert
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router).toBe('function');
|
||||
});
|
||||
|
||||
it('should register routes in the expected order', () => {
|
||||
// The ROUTES array defines registration order
|
||||
const expectedOrder = [
|
||||
'auth',
|
||||
'health',
|
||||
'system',
|
||||
'users',
|
||||
'ai',
|
||||
'admin',
|
||||
'budgets',
|
||||
'achievements',
|
||||
'flyers',
|
||||
'recipes',
|
||||
'personalization',
|
||||
'price-history',
|
||||
'stats',
|
||||
'upc',
|
||||
'inventory',
|
||||
'receipts',
|
||||
'deals',
|
||||
'reactions',
|
||||
'stores',
|
||||
'categories',
|
||||
];
|
||||
|
||||
// Assert order matches
|
||||
const registeredPaths = ROUTES.map((r) => r.path);
|
||||
expect(registeredPaths).toEqual(expectedOrder);
|
||||
});
|
||||
|
||||
it('should skip routes not available for specified version', () => {
|
||||
// Assert - getRoutesForVersion should filter correctly
|
||||
const v1Routes = getRoutesForVersion('v1');
|
||||
const v2Routes = getRoutesForVersion('v2');
|
||||
|
||||
// All routes without versions restriction should be in both
|
||||
expect(v1Routes.length).toBe(ROUTES.length);
|
||||
expect(v2Routes.length).toBe(ROUTES.length);
|
||||
|
||||
// If we had a version-restricted route, it would only appear in that version
|
||||
// This tests the filtering logic via getRoutesForVersion
|
||||
expect(v1Routes.some((r) => r.path === 'auth')).toBe(true);
|
||||
expect(v2Routes.some((r) => r.path === 'auth')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('X-API-Version header', () => {
|
||||
it('should add X-API-Version header to all v1 responses', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should add X-API-Version header to all v2 responses', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v2');
|
||||
const app = createTestApp(router, '/api/v2');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v2/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
});
|
||||
|
||||
it('should NOT add X-API-Version header when deprecation middleware is disabled', async () => {
|
||||
// Arrange - create router with deprecation headers disabled
|
||||
const router = createVersionedRouter('v1', { applyDeprecationHeaders: false });
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - header should NOT be present when deprecation middleware is disabled
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecation headers', () => {
|
||||
it('should not add deprecation headers for active versions', async () => {
|
||||
// Arrange - v1 is active by default
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - no deprecation headers
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBeUndefined();
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBeUndefined();
|
||||
expect(
|
||||
response.headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add deprecation headers when version is deprecated', async () => {
|
||||
// Arrange - Temporarily mark v1 as deprecated
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
|
||||
// Clear cache and create fresh router with updated config
|
||||
clearRouterCache();
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - deprecation headers present
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe(
|
||||
'2027-01-01T00:00:00Z',
|
||||
);
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
|
||||
'</api/v2>; rel="successor-version"',
|
||||
);
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()]).toContain(
|
||||
'deprecated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include sunset date in deprecation headers when provided', async () => {
|
||||
// Arrange
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2028-06-15T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
|
||||
clearRouterCache();
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe(
|
||||
'2028-06-15T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include successor version link when provided', async () => {
|
||||
// Arrange
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
|
||||
clearRouterCache();
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
|
||||
'</api/v2>; rel="successor-version"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include sunset date when not provided', async () => {
|
||||
// Arrange - deprecated without sunset date
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
// No sunsetDate
|
||||
};
|
||||
|
||||
clearRouterCache();
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('router options', () => {
|
||||
it('should apply deprecation headers middleware by default', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - X-API-Version should be set (proves middleware ran)
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should skip deprecation headers middleware when disabled', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v1', { applyDeprecationHeaders: false });
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - X-API-Version should NOT be set
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// createApiRouter() Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('createApiRouter()', () => {
|
||||
it('should mount all supported versions', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act & Assert - v1 should work
|
||||
const v1Response = await supertest(app).get('/api/v1/health/test');
|
||||
expect(v1Response.status).toBe(200);
|
||||
expect(v1Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
|
||||
// Act & Assert - v2 should work
|
||||
const v2Response = await supertest(app).get('/api/v2/health/test');
|
||||
expect(v2Response.status).toBe(200);
|
||||
expect(v2Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
});
|
||||
|
||||
it('should return 404 for unsupported versions', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v99/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.code).toBe('UNSUPPORTED_VERSION');
|
||||
expect(response.body.error.message).toContain('v99');
|
||||
expect(response.body.error.details.supportedVersions).toEqual(['v1', 'v2']);
|
||||
});
|
||||
|
||||
it('should route to correct versioned router based on URL version', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act
|
||||
const v1Response = await supertest(app).get('/api/v1/health/test');
|
||||
const v2Response = await supertest(app).get('/api/v2/health/test');
|
||||
|
||||
// Assert - each response should indicate the correct version
|
||||
expect(v1Response.body.version).toBe('v1');
|
||||
expect(v2Response.body.version).toBe('v2');
|
||||
});
|
||||
|
||||
it('should handle requests to various domain routers', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act & Assert - multiple domain routers
|
||||
const authResponse = await supertest(app).get('/api/v1/auth/test');
|
||||
expect(authResponse.status).toBe(200);
|
||||
expect(authResponse.body.router).toBe('auth');
|
||||
|
||||
const flyerResponse = await supertest(app).get('/api/v1/flyers/test');
|
||||
expect(flyerResponse.status).toBe(200);
|
||||
expect(flyerResponse.body.router).toBe('flyer');
|
||||
|
||||
const storeResponse = await supertest(app).get('/api/v1/stores/test');
|
||||
expect(storeResponse.status).toBe(200);
|
||||
expect(storeResponse.body.router).toBe('store');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Utility Functions Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('getRegisteredPaths()', () => {
|
||||
it('should return all registered route paths', () => {
|
||||
// Act
|
||||
const paths = getRegisteredPaths();
|
||||
|
||||
// Assert
|
||||
expect(paths).toBeInstanceOf(Array);
|
||||
expect(paths.length).toBe(ROUTES.length);
|
||||
expect(paths).toContain('auth');
|
||||
expect(paths).toContain('health');
|
||||
expect(paths).toContain('flyers');
|
||||
expect(paths).toContain('stores');
|
||||
expect(paths).toContain('categories');
|
||||
});
|
||||
|
||||
it('should return paths in registration order', () => {
|
||||
// Act
|
||||
const paths = getRegisteredPaths();
|
||||
|
||||
// Assert - first and last should match ROUTES order
|
||||
expect(paths[0]).toBe('auth');
|
||||
expect(paths[paths.length - 1]).toBe('categories');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRouteByPath()', () => {
|
||||
it('should return correct registration for existing path', () => {
|
||||
// Act
|
||||
const authRoute = getRouteByPath('auth');
|
||||
const healthRoute = getRouteByPath('health');
|
||||
|
||||
// Assert
|
||||
expect(authRoute).toBeDefined();
|
||||
expect(authRoute?.path).toBe('auth');
|
||||
expect(authRoute?.description).toContain('Authentication');
|
||||
|
||||
expect(healthRoute).toBeDefined();
|
||||
expect(healthRoute?.path).toBe('health');
|
||||
expect(healthRoute?.description).toContain('Health');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent path', () => {
|
||||
// Act
|
||||
const result = getRouteByPath('non-existent-path');
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
// Act
|
||||
const result = getRouteByPath('');
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoutesForVersion()', () => {
|
||||
it('should return all routes when no version restrictions exist', () => {
|
||||
// Act
|
||||
const v1Routes = getRoutesForVersion('v1');
|
||||
const v2Routes = getRoutesForVersion('v2');
|
||||
|
||||
// Assert - all routes should be available in both versions
|
||||
// (since none of the default routes have version restrictions)
|
||||
expect(v1Routes.length).toBe(ROUTES.length);
|
||||
expect(v2Routes.length).toBe(ROUTES.length);
|
||||
});
|
||||
|
||||
it('should filter routes based on version restrictions', () => {
|
||||
// This tests the filtering logic - routes with versions array
|
||||
// should only appear for versions listed in that array
|
||||
const v1Routes = getRoutesForVersion('v1');
|
||||
|
||||
// All routes should have path and router properties
|
||||
v1Routes.forEach((route) => {
|
||||
expect(route.path).toBeDefined();
|
||||
expect(route.router).toBeDefined();
|
||||
expect(route.description).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should include routes without version restrictions in all versions', () => {
|
||||
// Routes without versions array should appear in all versions
|
||||
const authRoute = ROUTES.find((r) => r.path === 'auth');
|
||||
expect(authRoute?.versions).toBeUndefined(); // No version restriction
|
||||
|
||||
const v1Routes = getRoutesForVersion('v1');
|
||||
const v2Routes = getRoutesForVersion('v2');
|
||||
|
||||
expect(v1Routes.some((r) => r.path === 'auth')).toBe(true);
|
||||
expect(v2Routes.some((r) => r.path === 'auth')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Router Cache Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('router cache', () => {
|
||||
it('should cache routers after creation', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act - make multiple requests (should use cached router)
|
||||
const response1 = await supertest(app).get('/api/v1/health/test');
|
||||
const response2 = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert - both requests should succeed (proving cache works)
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should clear cache with clearRouterCache()', () => {
|
||||
// Arrange - create some routers to populate cache
|
||||
createVersionedRouter('v1');
|
||||
createVersionedRouter('v2');
|
||||
|
||||
// Act
|
||||
clearRouterCache();
|
||||
|
||||
// Assert - cache should be cleared (logger would have logged)
|
||||
expect(mockLoggerFn).toHaveBeenCalledWith('Versioned router cache cleared');
|
||||
});
|
||||
|
||||
it('should refresh cache with refreshRouterCache()', () => {
|
||||
// Arrange
|
||||
createVersionedRouter('v1');
|
||||
|
||||
// Act
|
||||
refreshRouterCache();
|
||||
|
||||
// Assert - cache should be refreshed (logger would have logged)
|
||||
expect(mockLoggerFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cachedVersions: expect.any(Array) }),
|
||||
'Versioned router cache refreshed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create routers on-demand if not in cache', async () => {
|
||||
// Arrange
|
||||
clearRouterCache();
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act - request should trigger on-demand router creation
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// ROUTES Configuration Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('ROUTES configuration', () => {
|
||||
it('should have all required properties for each route', () => {
|
||||
ROUTES.forEach((route) => {
|
||||
expect(route.path).toBeDefined();
|
||||
expect(typeof route.path).toBe('string');
|
||||
expect(route.path.length).toBeGreaterThan(0);
|
||||
|
||||
expect(route.router).toBeDefined();
|
||||
expect(typeof route.router).toBe('function');
|
||||
|
||||
expect(route.description).toBeDefined();
|
||||
expect(typeof route.description).toBe('string');
|
||||
expect(route.description.length).toBeGreaterThan(0);
|
||||
|
||||
// versions is optional
|
||||
if (route.versions !== undefined) {
|
||||
expect(Array.isArray(route.versions)).toBe(true);
|
||||
route.versions.forEach((v) => {
|
||||
expect(API_VERSIONS).toContain(v);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have duplicate paths', () => {
|
||||
const paths = ROUTES.map((r) => r.path);
|
||||
const uniquePaths = new Set(paths);
|
||||
expect(uniquePaths.size).toBe(paths.length);
|
||||
});
|
||||
|
||||
it('should have expected number of routes', () => {
|
||||
// This ensures we don't accidentally remove routes
|
||||
expect(ROUTES.length).toBe(20);
|
||||
});
|
||||
|
||||
it('should include all core domain routers', () => {
|
||||
const paths = getRegisteredPaths();
|
||||
const expectedRoutes = [
|
||||
'auth',
|
||||
'health',
|
||||
'system',
|
||||
'users',
|
||||
'ai',
|
||||
'admin',
|
||||
'budgets',
|
||||
'achievements',
|
||||
'flyers',
|
||||
'recipes',
|
||||
'personalization',
|
||||
'price-history',
|
||||
'stats',
|
||||
'upc',
|
||||
'inventory',
|
||||
'receipts',
|
||||
'deals',
|
||||
'reactions',
|
||||
'stores',
|
||||
'categories',
|
||||
];
|
||||
|
||||
expectedRoutes.forEach((route) => {
|
||||
expect(paths).toContain(route);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases and Error Handling
|
||||
// =========================================================================
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle requests to nested routes', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act - request to nested path
|
||||
const response = await supertest(app).get('/api/v1/health/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.router).toBe('health');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent routes within valid version', async () => {
|
||||
// Arrange
|
||||
const router = createVersionedRouter('v1');
|
||||
const app = createTestApp(router, '/api/v1');
|
||||
|
||||
// Act - request to non-existent domain
|
||||
const response = await supertest(app).get('/api/v1/nonexistent/endpoint');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent requests', async () => {
|
||||
// Arrange
|
||||
const apiRouter = createApiRouter();
|
||||
const app = createTestApp(apiRouter, '/api');
|
||||
|
||||
// Act - make concurrent requests
|
||||
const requests = [
|
||||
supertest(app).get('/api/v1/health/test'),
|
||||
supertest(app).get('/api/v1/auth/test'),
|
||||
supertest(app).get('/api/v2/health/test'),
|
||||
supertest(app).get('/api/v2/flyers/test'),
|
||||
];
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// Assert - all should succeed
|
||||
responses.forEach((response) => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
478
src/routes/versioned.ts
Normal file
478
src/routes/versioned.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
// src/routes/versioned.ts
|
||||
/**
|
||||
* @file Version Router Factory - ADR-008 Phase 2 Implementation
|
||||
*
|
||||
* Creates version-specific Express routers that manage route registration
|
||||
* for different API versions. This factory ensures consistent middleware
|
||||
* application and proper route ordering across all API versions.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Create routers for each supported API version
|
||||
* - Apply version detection and deprecation middleware
|
||||
* - Register domain routers in correct precedence order
|
||||
* - Support version-specific route availability
|
||||
* - Add X-API-Version header to all responses
|
||||
*
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
* @see docs/adr/0008-api-versioning-strategy.md
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In server.ts:
|
||||
* import { createApiRouter, createVersionedRouter } from './src/routes/versioned';
|
||||
*
|
||||
* // Option 1: Mount all versions at once
|
||||
* app.use('/api', createApiRouter());
|
||||
*
|
||||
* // Option 2: Mount versions individually
|
||||
* app.use('/api/v1', createVersionedRouter('v1'));
|
||||
* app.use('/api/v2', createVersionedRouter('v2'));
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { ApiVersion, API_VERSIONS, SUPPORTED_VERSIONS } from '../config/apiVersions';
|
||||
import { detectApiVersion } from '../middleware/apiVersion.middleware';
|
||||
import { addDeprecationHeaders } from '../middleware/deprecation.middleware';
|
||||
import { createScopedLogger } from '../services/logger.server';
|
||||
|
||||
// --- Domain Router Imports ---
|
||||
// These are imported in the order they are registered in server.ts
|
||||
import authRouter from './auth.routes';
|
||||
import healthRouter from './health.routes';
|
||||
import systemRouter from './system.routes';
|
||||
import userRouter from './user.routes';
|
||||
import aiRouter from './ai.routes';
|
||||
import adminRouter from './admin.routes';
|
||||
import budgetRouter from './budget.routes';
|
||||
import gamificationRouter from './gamification.routes';
|
||||
import flyerRouter from './flyer.routes';
|
||||
import recipeRouter from './recipe.routes';
|
||||
import personalizationRouter from './personalization.routes';
|
||||
import priceRouter from './price.routes';
|
||||
import statsRouter from './stats.routes';
|
||||
import upcRouter from './upc.routes';
|
||||
import inventoryRouter from './inventory.routes';
|
||||
import receiptRouter from './receipt.routes';
|
||||
import dealsRouter from './deals.routes';
|
||||
import reactionsRouter from './reactions.routes';
|
||||
import storeRouter from './store.routes';
|
||||
import categoryRouter from './category.routes';
|
||||
|
||||
// Module-scoped logger for versioned router operations
|
||||
const versionedRouterLogger = createScopedLogger('versioned-router');
|
||||
|
||||
// --- Type Definitions ---
|
||||
|
||||
/**
|
||||
* Configuration for registering a route under versioned API.
|
||||
*
|
||||
* @property path - The URL path segment (e.g., 'auth', 'users', 'flyers')
|
||||
* @property router - The Express router instance handling this path
|
||||
* @property description - Human-readable description of the route's purpose
|
||||
* @property versions - Optional array of versions where this route is available.
|
||||
* If omitted, the route is available in all versions.
|
||||
*/
|
||||
export interface RouteRegistration {
|
||||
/** URL path segment (mounted at /{version}/{path}) */
|
||||
path: string;
|
||||
/** Express router instance for this domain */
|
||||
router: Router;
|
||||
/** Human-readable description for documentation and logging */
|
||||
description: string;
|
||||
/** Optional: Specific versions where this route is available (defaults to all) */
|
||||
versions?: ApiVersion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a versioned router.
|
||||
*/
|
||||
export interface VersionedRouterOptions {
|
||||
/** Whether to apply version detection middleware (default: true) */
|
||||
applyVersionDetection?: boolean;
|
||||
/** Whether to apply deprecation headers middleware (default: true) */
|
||||
applyDeprecationHeaders?: boolean;
|
||||
}
|
||||
|
||||
// --- Route Registration Configuration ---
|
||||
|
||||
/**
|
||||
* Master list of all route registrations.
|
||||
*
|
||||
* IMPORTANT: The order of routes is critical for correct matching.
|
||||
* More specific routes should be registered before more general ones.
|
||||
* This order mirrors the registration in server.ts exactly.
|
||||
*
|
||||
* Each entry includes:
|
||||
* - path: The URL segment (e.g., 'auth' -> /api/v1/auth)
|
||||
* - router: The Express router handling the routes
|
||||
* - description: Purpose documentation
|
||||
* - versions: Optional array to restrict availability to specific versions
|
||||
*/
|
||||
export const ROUTES: RouteRegistration[] = [
|
||||
// 1. Authentication routes for login, registration, etc.
|
||||
{
|
||||
path: 'auth',
|
||||
router: authRouter,
|
||||
description: 'Authentication routes for login, registration, password reset',
|
||||
},
|
||||
|
||||
// 2. Health check routes for monitoring and liveness probes
|
||||
{
|
||||
path: 'health',
|
||||
router: healthRouter,
|
||||
description: 'Health check endpoints for monitoring and liveness probes',
|
||||
},
|
||||
|
||||
// 3. System routes for PM2 status, server info, etc.
|
||||
{
|
||||
path: 'system',
|
||||
router: systemRouter,
|
||||
description: 'System administration routes for PM2 status and server info',
|
||||
},
|
||||
|
||||
// 4. General authenticated user routes
|
||||
{
|
||||
path: 'users',
|
||||
router: userRouter,
|
||||
description: 'User profile and account management routes',
|
||||
},
|
||||
|
||||
// 5. AI routes, some of which use optional authentication
|
||||
{
|
||||
path: 'ai',
|
||||
router: aiRouter,
|
||||
description: 'AI-powered features including flyer processing and analysis',
|
||||
},
|
||||
|
||||
// 6. Admin routes, protected by admin-level checks
|
||||
{
|
||||
path: 'admin',
|
||||
router: adminRouter,
|
||||
description: 'Administrative routes for user and system management',
|
||||
},
|
||||
|
||||
// 7. Budgeting and spending analysis routes
|
||||
{
|
||||
path: 'budgets',
|
||||
router: budgetRouter,
|
||||
description: 'Budget management and spending analysis routes',
|
||||
},
|
||||
|
||||
// 8. Gamification routes for achievements
|
||||
{
|
||||
path: 'achievements',
|
||||
router: gamificationRouter,
|
||||
description: 'Gamification and achievement system routes',
|
||||
},
|
||||
|
||||
// 9. Public flyer routes
|
||||
{
|
||||
path: 'flyers',
|
||||
router: flyerRouter,
|
||||
description: 'Flyer listing, search, and item management routes',
|
||||
},
|
||||
|
||||
// 10. Public recipe routes
|
||||
{
|
||||
path: 'recipes',
|
||||
router: recipeRouter,
|
||||
description: 'Recipe discovery, saving, and recommendation routes',
|
||||
},
|
||||
|
||||
// 11. Public personalization data routes (master items, etc.)
|
||||
{
|
||||
path: 'personalization',
|
||||
router: personalizationRouter,
|
||||
description: 'Personalization data including master items and preferences',
|
||||
},
|
||||
|
||||
// 12. Price history routes
|
||||
{
|
||||
path: 'price-history',
|
||||
router: priceRouter,
|
||||
description: 'Price history tracking and trend analysis routes',
|
||||
},
|
||||
|
||||
// 13. Public statistics routes
|
||||
{
|
||||
path: 'stats',
|
||||
router: statsRouter,
|
||||
description: 'Public statistics and analytics routes',
|
||||
},
|
||||
|
||||
// 14. UPC barcode scanning routes
|
||||
{
|
||||
path: 'upc',
|
||||
router: upcRouter,
|
||||
description: 'UPC barcode scanning and product lookup routes',
|
||||
},
|
||||
|
||||
// 15. Inventory and expiry tracking routes
|
||||
{
|
||||
path: 'inventory',
|
||||
router: inventoryRouter,
|
||||
description: 'Inventory management and expiry tracking routes',
|
||||
},
|
||||
|
||||
// 16. Receipt scanning routes
|
||||
{
|
||||
path: 'receipts',
|
||||
router: receiptRouter,
|
||||
description: 'Receipt scanning and purchase history routes',
|
||||
},
|
||||
|
||||
// 17. Deals and best prices routes
|
||||
{
|
||||
path: 'deals',
|
||||
router: dealsRouter,
|
||||
description: 'Deal discovery and best price comparison routes',
|
||||
},
|
||||
|
||||
// 18. Reactions/social features routes
|
||||
{
|
||||
path: 'reactions',
|
||||
router: reactionsRouter,
|
||||
description: 'Social features including reactions and sharing',
|
||||
},
|
||||
|
||||
// 19. Store management routes
|
||||
{
|
||||
path: 'stores',
|
||||
router: storeRouter,
|
||||
description: 'Store discovery, favorites, and location routes',
|
||||
},
|
||||
|
||||
// 20. Category discovery routes (ADR-023: Database Normalization)
|
||||
{
|
||||
path: 'categories',
|
||||
router: categoryRouter,
|
||||
description: 'Category browsing and product categorization routes',
|
||||
},
|
||||
];
|
||||
|
||||
// --- Factory Functions ---
|
||||
|
||||
/**
|
||||
* Creates a versioned Express router for a specific API version.
|
||||
*
|
||||
* This factory function:
|
||||
* 1. Creates a new Router instance with merged params
|
||||
* 2. Applies deprecation headers middleware (adds X-API-Version header)
|
||||
* 3. Registers all routes that are available for the specified version
|
||||
* 4. Maintains correct route registration order (specific before general)
|
||||
*
|
||||
* @param version - The API version to create a router for (e.g., 'v1', 'v2')
|
||||
* @param options - Optional configuration for middleware application
|
||||
* @returns Configured Express Router for the specified version
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a v1 router
|
||||
* const v1Router = createVersionedRouter('v1');
|
||||
* app.use('/api/v1', v1Router);
|
||||
*
|
||||
* // Create a v2 router with custom options
|
||||
* const v2Router = createVersionedRouter('v2', {
|
||||
* applyDeprecationHeaders: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createVersionedRouter(
|
||||
version: ApiVersion,
|
||||
options: VersionedRouterOptions = {},
|
||||
): Router {
|
||||
const { applyDeprecationHeaders: shouldApplyDeprecationHeaders = true } = options;
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
versionedRouterLogger.info({ version, routeCount: ROUTES.length }, 'Creating versioned router');
|
||||
|
||||
// Apply deprecation headers middleware.
|
||||
// This adds X-API-Version header to all responses and deprecation headers
|
||||
// when the version is marked as deprecated.
|
||||
if (shouldApplyDeprecationHeaders) {
|
||||
router.use(addDeprecationHeaders(version));
|
||||
}
|
||||
|
||||
// Register all routes that are available for this version
|
||||
let registeredCount = 0;
|
||||
for (const route of ROUTES) {
|
||||
// Check if this route is available for the specified version.
|
||||
// If versions array is not specified, the route is available for all versions.
|
||||
if (route.versions && !route.versions.includes(version)) {
|
||||
versionedRouterLogger.debug(
|
||||
{ version, path: route.path },
|
||||
'Skipping route not available for this version',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mount the router at the specified path
|
||||
router.use(`/${route.path}`, route.router);
|
||||
registeredCount++;
|
||||
|
||||
versionedRouterLogger.debug(
|
||||
{ version, path: route.path, description: route.description },
|
||||
'Registered route',
|
||||
);
|
||||
}
|
||||
|
||||
versionedRouterLogger.info(
|
||||
{ version, registeredCount, totalRoutes: ROUTES.length },
|
||||
'Versioned router created successfully',
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main API router that mounts all versioned routers.
|
||||
*
|
||||
* This function creates a parent router that:
|
||||
* 1. Applies version detection middleware at the /api/:version level
|
||||
* 2. Mounts versioned routers for each supported API version
|
||||
* 3. Returns 404 for unsupported versions via detectApiVersion middleware
|
||||
*
|
||||
* The router is designed to be mounted at `/api` in the main application:
|
||||
* - `/api/v1/*` routes to v1 router
|
||||
* - `/api/v2/*` routes to v2 router
|
||||
* - `/api/v99/*` returns 404 (unsupported version)
|
||||
*
|
||||
* @returns Express Router configured with all version-specific sub-routers
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In server.ts:
|
||||
* import { createApiRouter } from './src/routes/versioned';
|
||||
*
|
||||
* // Mount at /api - handles /api/v1/*, /api/v2/*, etc.
|
||||
* app.use('/api', createApiRouter());
|
||||
*
|
||||
* // Then add backwards compatibility redirect for unversioned paths:
|
||||
* app.use('/api', (req, res, next) => {
|
||||
* if (!req.path.startsWith('/v1') && !req.path.startsWith('/v2')) {
|
||||
* return res.redirect(301, `/api/v1${req.path}`);
|
||||
* }
|
||||
* next();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createApiRouter(): Router {
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
versionedRouterLogger.info(
|
||||
{ supportedVersions: SUPPORTED_VERSIONS },
|
||||
'Creating API router with all versions',
|
||||
);
|
||||
|
||||
// Mount versioned routers under /:version path.
|
||||
// The detectApiVersion middleware validates the version and returns 404 for
|
||||
// unsupported versions before the domain routers are reached.
|
||||
router.use('/:version', detectApiVersion, (req, res, next) => {
|
||||
// At this point, req.apiVersion is guaranteed to be valid
|
||||
// (detectApiVersion returns 404 for invalid versions).
|
||||
// Route to the appropriate versioned router based on the detected version.
|
||||
const version = req.apiVersion;
|
||||
|
||||
if (!version) {
|
||||
// This should not happen if detectApiVersion ran correctly,
|
||||
// but handle it defensively.
|
||||
return next('route');
|
||||
}
|
||||
|
||||
// Get or create the versioned router.
|
||||
// We use a cache to avoid recreating routers on every request.
|
||||
const versionedRouter = versionedRouterCache.get(version);
|
||||
if (versionedRouter) {
|
||||
return versionedRouter(req, res, next);
|
||||
}
|
||||
|
||||
// Fallback: version not in cache (should not happen with proper setup)
|
||||
versionedRouterLogger.warn(
|
||||
{ version },
|
||||
'Versioned router not found in cache, creating on-demand',
|
||||
);
|
||||
const newRouter = createVersionedRouter(version);
|
||||
versionedRouterCache.set(version, newRouter);
|
||||
return newRouter(req, res, next);
|
||||
});
|
||||
|
||||
versionedRouterLogger.info('API router created successfully');
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// --- Router Cache ---
|
||||
|
||||
/**
|
||||
* Cache for versioned routers to avoid recreation on every request.
|
||||
* Pre-populated with routers for all supported versions.
|
||||
*/
|
||||
const versionedRouterCache = new Map<ApiVersion, Router>();
|
||||
|
||||
// Pre-populate the cache with all supported versions
|
||||
for (const version of API_VERSIONS) {
|
||||
versionedRouterCache.set(version, createVersionedRouter(version));
|
||||
}
|
||||
|
||||
versionedRouterLogger.debug(
|
||||
{ cachedVersions: Array.from(versionedRouterCache.keys()) },
|
||||
'Versioned router cache initialized',
|
||||
);
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
/**
|
||||
* Gets the list of all registered route paths.
|
||||
* Useful for documentation and debugging.
|
||||
*
|
||||
* @returns Array of registered route paths
|
||||
*/
|
||||
export function getRegisteredPaths(): string[] {
|
||||
return ROUTES.map((route) => route.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets route registration details for a specific path.
|
||||
*
|
||||
* @param path - The route path to look up
|
||||
* @returns RouteRegistration if found, undefined otherwise
|
||||
*/
|
||||
export function getRouteByPath(path: string): RouteRegistration | undefined {
|
||||
return ROUTES.find((route) => route.path === path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all routes available for a specific API version.
|
||||
*
|
||||
* @param version - The API version to filter by
|
||||
* @returns Array of RouteRegistrations available for the version
|
||||
*/
|
||||
export function getRoutesForVersion(version: ApiVersion): RouteRegistration[] {
|
||||
return ROUTES.filter((route) => !route.versions || route.versions.includes(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the versioned router cache.
|
||||
* Primarily useful for testing to ensure fresh router instances.
|
||||
*/
|
||||
export function clearRouterCache(): void {
|
||||
versionedRouterCache.clear();
|
||||
versionedRouterLogger.debug('Versioned router cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the versioned router cache by recreating all routers.
|
||||
* Useful after configuration changes.
|
||||
*/
|
||||
export function refreshRouterCache(): void {
|
||||
clearRouterCache();
|
||||
for (const version of API_VERSIONS) {
|
||||
versionedRouterCache.set(version, createVersionedRouter(version));
|
||||
}
|
||||
versionedRouterLogger.debug(
|
||||
{ cachedVersions: Array.from(versionedRouterCache.keys()) },
|
||||
'Versioned router cache refreshed',
|
||||
);
|
||||
}
|
||||
552
src/routes/versioning.integration.test.ts
Normal file
552
src/routes/versioning.integration.test.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
// src/routes/versioning.integration.test.ts
|
||||
/**
|
||||
* @file Integration tests for API versioning infrastructure (ADR-008).
|
||||
*
|
||||
* These tests verify the end-to-end behavior of the versioning middleware including:
|
||||
* - X-API-Version header presence in responses
|
||||
* - Unsupported version handling (404 with UNSUPPORTED_VERSION)
|
||||
* - Deprecation headers for deprecated versions
|
||||
* - Backwards compatibility redirect from unversioned paths
|
||||
*
|
||||
* Note: These tests use a minimal router setup to avoid deep import chains
|
||||
* from domain routers. Full integration testing of versioned routes is done
|
||||
* in individual route test files.
|
||||
*
|
||||
* @see docs/adr/0008-api-versioning-strategy.md
|
||||
* @see docs/architecture/api-versioning-infrastructure.md
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Router } from 'express';
|
||||
import {
|
||||
VERSION_CONFIGS,
|
||||
ApiVersion,
|
||||
SUPPORTED_VERSIONS,
|
||||
DEFAULT_VERSION,
|
||||
} from '../config/apiVersions';
|
||||
import { DEPRECATION_HEADERS, addDeprecationHeaders } from '../middleware/deprecation.middleware';
|
||||
import { detectApiVersion, VERSION_ERROR_CODES } from '../middleware/apiVersion.middleware';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// Mock the logger to avoid actual logging during tests
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
})),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('API Versioning Integration Tests', () => {
|
||||
// Store original VERSION_CONFIGS to restore after tests
|
||||
let originalConfigs: Record<ApiVersion, typeof VERSION_CONFIGS.v1>;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save original configs
|
||||
originalConfigs = {
|
||||
v1: { ...VERSION_CONFIGS.v1 },
|
||||
v2: { ...VERSION_CONFIGS.v2 },
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original configs
|
||||
VERSION_CONFIGS.v1 = originalConfigs.v1;
|
||||
VERSION_CONFIGS.v2 = originalConfigs.v2;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset configs to original before each test
|
||||
VERSION_CONFIGS.v1 = { ...originalConfigs.v1 };
|
||||
VERSION_CONFIGS.v2 = { ...originalConfigs.v2 };
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a minimal test router that returns a simple success response.
|
||||
* This avoids importing complex domain routers with many dependencies.
|
||||
*/
|
||||
const createMinimalTestRouter = (): Router => {
|
||||
const router = Router();
|
||||
router.get('/test', (req, res) => {
|
||||
sendSuccess(res, { message: 'test', version: req.apiVersion });
|
||||
});
|
||||
router.get('/error', (_req, res) => {
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
});
|
||||
return router;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to create a test app that simulates the actual server.ts versioning setup.
|
||||
* Uses minimal test routers instead of full domain routers.
|
||||
*/
|
||||
const createVersionedTestApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Add request logger mock
|
||||
app.use((req, res, next) => {
|
||||
req.log = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as never;
|
||||
next();
|
||||
});
|
||||
|
||||
// Backwards compatibility redirect (mirrors server.ts)
|
||||
app.use('/api', (req, res, next) => {
|
||||
const versionPattern = /^\/v\d+/;
|
||||
const startsWithVersionPattern = versionPattern.test(req.path);
|
||||
if (!startsWithVersionPattern) {
|
||||
const newPath = `/api/v1${req.path}`;
|
||||
return res.redirect(301, newPath);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Create versioned routers with minimal test routes
|
||||
const createVersionRouter = (version: ApiVersion): Router => {
|
||||
const router = Router({ mergeParams: true });
|
||||
router.use(addDeprecationHeaders(version));
|
||||
router.use('/test', createMinimalTestRouter());
|
||||
return router;
|
||||
};
|
||||
|
||||
// Mount versioned routers under /api/:version
|
||||
// The detectApiVersion middleware validates the version
|
||||
app.use('/api/:version', detectApiVersion, (req, res, next) => {
|
||||
const version = req.apiVersion;
|
||||
if (!version || !SUPPORTED_VERSIONS.includes(version)) {
|
||||
return next('route');
|
||||
}
|
||||
// Dynamically route to the appropriate versioned router
|
||||
const versionRouter = createVersionRouter(version);
|
||||
return versionRouter(req, res, next);
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('X-API-Version Header', () => {
|
||||
it('should include X-API-Version: v1 header in /api/v1/test response', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include X-API-Version: v2 header in /api/v2/test response', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v2/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
});
|
||||
|
||||
it('should include correct API version in response body', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const v1Response = await supertest(app).get('/api/v1/test/test');
|
||||
const v2Response = await supertest(app).get('/api/v2/test/test');
|
||||
|
||||
// Assert
|
||||
expect(v1Response.body.data.version).toBe('v1');
|
||||
expect(v2Response.body.data.version).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unsupported Version Handling', () => {
|
||||
it('should return 404 with UNSUPPORTED_VERSION for /api/v99/test', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v99/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error.code).toBe(VERSION_ERROR_CODES.UNSUPPORTED_VERSION);
|
||||
expect(response.body.error.message).toContain("API version 'v99' is not supported");
|
||||
expect(response.body.error.details.requestedVersion).toBe('v99');
|
||||
expect(response.body.error.details.supportedVersions).toEqual(
|
||||
expect.arrayContaining(['v1', 'v2']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 with UNSUPPORTED_VERSION for /api/v0/test', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v0/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.code).toBe(VERSION_ERROR_CODES.UNSUPPORTED_VERSION);
|
||||
expect(response.body.error.details.requestedVersion).toBe('v0');
|
||||
});
|
||||
|
||||
it('should return 404 with UNSUPPORTED_VERSION for /api/v100/resource', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v100/resource');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.code).toBe(VERSION_ERROR_CODES.UNSUPPORTED_VERSION);
|
||||
expect(response.body.error.message).toContain("API version 'v100' is not supported");
|
||||
});
|
||||
|
||||
it('should return 404 for non-standard version format like /api/vX/test', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act - vX matches the /v\d+/ pattern (v followed by digits) but X is not a digit
|
||||
// So this path gets redirected. Let's test with a version that DOES match the pattern
|
||||
// but is not supported (e.g., v999 which is v followed by digits but not in SUPPORTED_VERSIONS)
|
||||
const response = await supertest(app).get('/api/v999/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.code).toBe(VERSION_ERROR_CODES.UNSUPPORTED_VERSION);
|
||||
expect(response.body.error.details.requestedVersion).toBe('v999');
|
||||
});
|
||||
|
||||
it('should include list of supported versions in error response', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v42/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.details.supportedVersions).toEqual(
|
||||
expect.arrayContaining(SUPPORTED_VERSIONS as unknown as string[]),
|
||||
);
|
||||
expect(response.body.error.message).toContain('Supported versions:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backwards Compatibility Redirect', () => {
|
||||
it('should redirect /api/test to /api/v1/test with 301', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/test').redirects(0); // Don't follow redirects
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.location).toBe('/api/v1/test');
|
||||
});
|
||||
|
||||
it('should redirect /api/users to /api/v1/users with 301', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/users').redirects(0);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.location).toBe('/api/v1/users');
|
||||
});
|
||||
|
||||
it('should redirect /api/flyers/123 to /api/v1/flyers/123 with 301', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/flyers/123').redirects(0);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.location).toBe('/api/v1/flyers/123');
|
||||
});
|
||||
|
||||
it('should NOT redirect paths that already have a version prefix', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act - v99 is unsupported but should NOT be redirected
|
||||
const response = await supertest(app).get('/api/v99/test').redirects(0);
|
||||
|
||||
// Assert - should get 404 (unsupported), not 301 (redirect)
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.location).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deprecation Headers', () => {
|
||||
describe('when version is active', () => {
|
||||
it('should NOT include Deprecation header for active v1', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT include Sunset header for active v1', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT include Link header for active v1', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when version is deprecated', () => {
|
||||
beforeEach(() => {
|
||||
// Mark v1 as deprecated for these tests
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
sunsetDate: '2027-01-01T00:00:00Z',
|
||||
successorVersion: 'v2',
|
||||
};
|
||||
});
|
||||
|
||||
it('should include Deprecation: true header for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
|
||||
});
|
||||
|
||||
it('should include Sunset header with ISO 8601 date for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe(
|
||||
'2027-01-01T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include Link header with successor-version relation for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
|
||||
'</api/v2>; rel="successor-version"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include X-API-Deprecation-Notice header for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
const noticeHeader = response.headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()];
|
||||
expect(noticeHeader).toBeDefined();
|
||||
expect(noticeHeader).toContain('deprecated');
|
||||
});
|
||||
|
||||
it('should still include X-API-Version header for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
});
|
||||
|
||||
it('should include all RFC 8594 compliant headers for deprecated version', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert - verify all expected headers are present
|
||||
const headers = response.headers;
|
||||
expect(headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
expect(headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
|
||||
expect(headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe('2027-01-01T00:00:00Z');
|
||||
expect(headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
|
||||
'</api/v2>; rel="successor-version"',
|
||||
);
|
||||
expect(headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()]).toContain(
|
||||
'deprecated',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when deprecated version lacks optional fields', () => {
|
||||
beforeEach(() => {
|
||||
// Mark v1 as deprecated without sunset date or successor
|
||||
VERSION_CONFIGS.v1 = {
|
||||
version: 'v1',
|
||||
status: 'deprecated',
|
||||
// No sunsetDate or successorVersion
|
||||
};
|
||||
});
|
||||
|
||||
it('should include Deprecation header even without sunset date', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
|
||||
});
|
||||
|
||||
it('should NOT include Sunset header when not configured', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT include Link header when successor not configured', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/test/test');
|
||||
|
||||
// Assert
|
||||
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-cutting Version Behavior', () => {
|
||||
it('should return same response structure for v1 and v2 endpoints', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const v1Response = await supertest(app).get('/api/v1/test/test');
|
||||
const v2Response = await supertest(app).get('/api/v2/test/test');
|
||||
|
||||
// Assert - both should have same structure, different version headers
|
||||
expect(v1Response.status).toBe(200);
|
||||
expect(v2Response.status).toBe(200);
|
||||
expect(v1Response.body.data.message).toBe('test');
|
||||
expect(v2Response.body.data.message).toBe('test');
|
||||
expect(v1Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
expect(v2Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
});
|
||||
|
||||
it('should handle multiple sequential requests to different versions', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act - make multiple requests
|
||||
const responses = await Promise.all([
|
||||
supertest(app).get('/api/v1/test/test'),
|
||||
supertest(app).get('/api/v2/test/test'),
|
||||
supertest(app).get('/api/v1/test/test'),
|
||||
supertest(app).get('/api/v2/test/test'),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(responses[0].headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
expect(responses[1].headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
expect(responses[2].headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
|
||||
expect(responses[3].headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
|
||||
});
|
||||
|
||||
it('should properly detect version from request params', async () => {
|
||||
// Arrange
|
||||
const app = createVersionedTestApp();
|
||||
|
||||
// Act
|
||||
const v1Response = await supertest(app).get('/api/v1/test/test');
|
||||
const v2Response = await supertest(app).get('/api/v2/test/test');
|
||||
|
||||
// Assert - verify version is attached to request and returned in body
|
||||
expect(v1Response.body.data.version).toBe('v1');
|
||||
expect(v2Response.body.data.version).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Version Behavior', () => {
|
||||
it('should use v1 as default version', () => {
|
||||
// Assert
|
||||
expect(DEFAULT_VERSION).toBe('v1');
|
||||
});
|
||||
|
||||
it('should have both v1 and v2 in supported versions', () => {
|
||||
// Assert
|
||||
expect(SUPPORTED_VERSIONS).toContain('v1');
|
||||
expect(SUPPORTED_VERSIONS).toContain('v2');
|
||||
expect(SUPPORTED_VERSIONS.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
it('should allow an admin to approve a correction', async () => {
|
||||
// Act: Approve the correction.
|
||||
const response = await request
|
||||
.post(`/api/admin/corrections/${testCorrectionId}/approve`)
|
||||
.post(`/api/v1/admin/corrections/${testCorrectionId}/approve`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
it('should allow an admin to reject a correction', async () => {
|
||||
// Act: Reject the correction.
|
||||
const response = await request
|
||||
.post(`/api/admin/corrections/${testCorrectionId}/reject`)
|
||||
.post(`/api/v1/admin/corrections/${testCorrectionId}/reject`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
it('should allow an admin to update a correction', async () => {
|
||||
// Act: Update the suggested value of the correction.
|
||||
const response = await request
|
||||
.put(`/api/admin/corrections/${testCorrectionId}`)
|
||||
.put(`/api/v1/admin/corrections/${testCorrectionId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ suggested_value: '300' });
|
||||
const updatedCorrection = response.body.data;
|
||||
@@ -265,7 +265,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
// Act: Update the status to 'public'.
|
||||
const response = await request
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.put(`/api/v1/admin/recipes/${recipeId}/status`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ status: 'public' });
|
||||
expect(response.status).toBe(200);
|
||||
@@ -290,7 +290,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
// Act: Call the delete endpoint as an admin.
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${userToDelete.user.user_id}`)
|
||||
.delete(`/api/v1/admin/users/${userToDelete.user.user_id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Assert: Check for a successful deletion status.
|
||||
@@ -301,7 +301,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
// Act: Call the delete endpoint as the same admin user.
|
||||
const adminUserId = adminUser.user.user_id;
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${adminUserId}`)
|
||||
.delete(`/api/v1/admin/users/${adminUserId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Assert:
|
||||
@@ -323,7 +323,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
const notFoundUserId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${notFoundUserId}`)
|
||||
.delete(`/api/v1/admin/users/${notFoundUserId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Assert: Check for a 404 status code
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.put(`/api/budgets/${testBudget.budget_id}`)
|
||||
.put(`/api/v1/budgets/${testBudget.budget_id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(updatedData);
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 when no update fields are provided', async () => {
|
||||
const response = await request
|
||||
.put(`/api/budgets/${testBudget.budget_id}`)
|
||||
.put(`/api/v1/budgets/${testBudget.budget_id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({});
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request
|
||||
.put(`/api/budgets/${testBudget.budget_id}`)
|
||||
.put(`/api/v1/budgets/${testBudget.budget_id}`)
|
||||
.send({ name: 'Hacked Budget' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -280,7 +280,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
// Now delete it
|
||||
const deleteResponse = await request
|
||||
.delete(`/api/budgets/${createdBudget.budget_id}`)
|
||||
.delete(`/api/v1/budgets/${createdBudget.budget_id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
@@ -303,7 +303,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request.delete(`/api/budgets/${testBudget.budget_id}`);
|
||||
const response = await request.delete(`/api/v1/budgets/${testBudget.budget_id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
const listResponse = await request.get('/api/v1/categories');
|
||||
const firstCategory = listResponse.body.data[0];
|
||||
|
||||
const response = await request.get(`/api/categories/${firstCategory.category_id}`);
|
||||
const response = await request.get(`/api/v1/categories/${firstCategory.category_id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Add an item to the list
|
||||
const itemResponse = await request
|
||||
.post(`/api/users/shopping-lists/${listId}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${listId}/items`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ customItemName: 'Test Item', quantity: 1 });
|
||||
expect(itemResponse.status).toBe(201);
|
||||
@@ -154,7 +154,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Delete the shopping list
|
||||
const deleteResponse = await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
describe('Admin Self-Deletion Prevention', () => {
|
||||
it('should prevent admin from deleting their own account via admin route', async () => {
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${adminUser.user.user_id}`)
|
||||
.delete(`/api/v1/admin/users/${adminUser.user.user_id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
// Try to access it as the other user
|
||||
const accessResponse = await request
|
||||
.get(`/api/users/shopping-lists/${listId}`)
|
||||
.get(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${otherUserToken}`);
|
||||
|
||||
// Should return 404 to hide resource existence
|
||||
@@ -207,7 +207,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
// Try to update it as the other user
|
||||
const updateResponse = await request
|
||||
.put(`/api/users/shopping-lists/${listId}`)
|
||||
.put(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${otherUserToken}`)
|
||||
.send({ name: 'Hacked List' });
|
||||
|
||||
@@ -228,7 +228,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
// Try to delete it as the other user
|
||||
const deleteResponse = await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${otherUserToken}`);
|
||||
|
||||
// Should return 404 to hide resource existence
|
||||
|
||||
@@ -307,7 +307,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
const statusReq = request.get(`/api/v1/ai/jobs/${jobId}/status`);
|
||||
if (token) {
|
||||
statusReq.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -439,7 +439,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.get(`/api/v1/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return statusResponse.body.data;
|
||||
},
|
||||
@@ -569,7 +569,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.get(`/api/v1/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return statusResponse.body.data;
|
||||
},
|
||||
@@ -699,7 +699,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body.data;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
@@ -787,7 +787,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body.data;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
@@ -854,7 +854,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body.data;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
const testFlyer = flyers[0];
|
||||
|
||||
// Act: Fetch items for the first flyer.
|
||||
const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`);
|
||||
const response = await request.get(`/api/v1/flyers/${testFlyer.flyer_id}/items`);
|
||||
const items: FlyerItem[] = response.body.data;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.get(`/api/v1/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
console.error(
|
||||
`[TEST DEBUG] Polling status for ${jobId}: ${statusResponse.body?.data?.state}`,
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should return item details', async () => {
|
||||
const response = await request
|
||||
.get(`/api/inventory/${testItemId}`)
|
||||
.get(`/api/v1/inventory/${testItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -358,7 +358,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
createdUserIds.push(otherUser.user.user_id);
|
||||
|
||||
const response = await request
|
||||
.get(`/api/inventory/${testItemId}`)
|
||||
.get(`/api/v1/inventory/${testItemId}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -387,7 +387,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should update item quantity', async () => {
|
||||
const response = await request
|
||||
.put(`/api/inventory/${updateItemId}`)
|
||||
.put(`/api/v1/inventory/${updateItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ quantity: 5 });
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should update item location', async () => {
|
||||
const response = await request
|
||||
.put(`/api/inventory/${updateItemId}`)
|
||||
.put(`/api/v1/inventory/${updateItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ location: 'freezer' });
|
||||
|
||||
@@ -411,7 +411,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const response = await request
|
||||
.put(`/api/inventory/${updateItemId}`)
|
||||
.put(`/api/v1/inventory/${updateItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ expiry_date: futureDate });
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should reject empty update body', async () => {
|
||||
const response = await request
|
||||
.put(`/api/inventory/${updateItemId}`)
|
||||
.put(`/api/v1/inventory/${updateItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({});
|
||||
|
||||
@@ -454,14 +454,14 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
const itemId = createResponse.body.data.inventory_id;
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/inventory/${itemId}`)
|
||||
.delete(`/api/v1/inventory/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify deletion
|
||||
const verifyResponse = await request
|
||||
.get(`/api/inventory/${itemId}`)
|
||||
.get(`/api/v1/inventory/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(verifyResponse.status).toBe(404);
|
||||
@@ -492,7 +492,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
// Note: The actual API marks the entire item as consumed (no partial consumption)
|
||||
// and returns 204 No Content
|
||||
const response = await request
|
||||
.post(`/api/inventory/${consumeItemId}/consume`)
|
||||
.post(`/api/v1/inventory/${consumeItemId}/consume`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
@@ -501,7 +501,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
it('should verify item is marked as consumed', async () => {
|
||||
// Verify the item was marked as consumed
|
||||
const getResponse = await request
|
||||
.get(`/api/inventory/${consumeItemId}`)
|
||||
.get(`/api/v1/inventory/${consumeItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getResponse.status).toBe(200);
|
||||
@@ -528,7 +528,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
// First consume should succeed
|
||||
const firstResponse = await request
|
||||
.post(`/api/inventory/${itemId}/consume`)
|
||||
.post(`/api/v1/inventory/${itemId}/consume`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(firstResponse.status).toBe(204);
|
||||
@@ -536,7 +536,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
// Second consume - item can still be found but already marked as consumed
|
||||
// The API doesn't prevent this, so we just verify it doesn't error
|
||||
const secondResponse = await request
|
||||
.post(`/api/inventory/${itemId}/consume`)
|
||||
.post(`/api/v1/inventory/${itemId}/consume`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
// Should still return 204 since the item exists
|
||||
@@ -746,7 +746,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
// Step 4: Update location (note: consume marks entire item as consumed, no partial)
|
||||
const updateResponse = await request
|
||||
.put(`/api/inventory/${itemId}`)
|
||||
.put(`/api/v1/inventory/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ location: 'freezer' });
|
||||
|
||||
@@ -755,14 +755,14 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
// Step 5: Mark as consumed (returns 204 No Content)
|
||||
const consumeResponse = await request
|
||||
.post(`/api/inventory/${itemId}/consume`)
|
||||
.post(`/api/v1/inventory/${itemId}/consume`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(consumeResponse.status).toBe(204);
|
||||
|
||||
// Step 6: Verify consumed status
|
||||
const verifyResponse = await request
|
||||
.get(`/api/inventory/${itemId}`)
|
||||
.get(`/api/v1/inventory/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(verifyResponse.status).toBe(200);
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
const notificationIdToMark = unreadNotifRes.rows[0].notification_id;
|
||||
|
||||
const response = await request
|
||||
.post(`/api/users/notifications/${notificationIdToMark}/mark-read`)
|
||||
.post(`/api/v1/users/notifications/${notificationIdToMark}/mark-read`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
@@ -180,7 +180,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
const notificationId = createResult.rows[0].notification_id;
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/users/notifications/${notificationId}`)
|
||||
.delete(`/api/v1/users/notifications/${notificationId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
@@ -221,7 +221,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
|
||||
// Try to delete it as the other user
|
||||
const response = await request
|
||||
.delete(`/api/users/notifications/${notificationId}`)
|
||||
.delete(`/api/v1/users/notifications/${notificationId}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
// Should return 404 (not 403) to hide existence
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('GET /api/flyers/:id/items should return items for a specific flyer', async () => {
|
||||
const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`);
|
||||
const response = await request.get(`/api/v1/flyers/${testFlyer.flyer_id}/items`);
|
||||
const items: FlyerItem[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(items).toBeInstanceOf(Array);
|
||||
@@ -213,7 +213,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
[testRecipe.recipe_id, testUser.user.user_id],
|
||||
);
|
||||
createdRecipeCommentIds.push(commentRes.rows[0].recipe_comment_id);
|
||||
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
|
||||
const response = await request.get(`/api/v1/recipes/${testRecipe.recipe_id}/comments`);
|
||||
const comments: RecipeComment[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(comments).toBeInstanceOf(Array);
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should return receipt with items', async () => {
|
||||
const response = await request
|
||||
.get(`/api/receipts/${testReceiptId}`)
|
||||
.get(`/api/v1/receipts/${testReceiptId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -401,7 +401,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
createdUserIds.push(otherUser.user.user_id);
|
||||
|
||||
const response = await request
|
||||
.get(`/api/receipts/${testReceiptId}`)
|
||||
.get(`/api/v1/receipts/${testReceiptId}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -421,14 +421,14 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
const receiptId = result.rows[0].receipt_id;
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/receipts/${receiptId}`)
|
||||
.delete(`/api/v1/receipts/${receiptId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify deletion
|
||||
const verifyResponse = await request
|
||||
.get(`/api/receipts/${receiptId}`)
|
||||
.get(`/api/v1/receipts/${receiptId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(verifyResponse.status).toBe(404);
|
||||
@@ -452,7 +452,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should queue a failed receipt for reprocessing', async () => {
|
||||
const response = await request
|
||||
.post(`/api/receipts/${failedReceiptId}/reprocess`)
|
||||
.post(`/api/v1/receipts/${failedReceiptId}/reprocess`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -497,7 +497,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
describe('GET /api/receipts/:receiptId/items', () => {
|
||||
it('should return all receipt items', async () => {
|
||||
const response = await request
|
||||
.get(`/api/receipts/${receiptWithItemsId}/items`)
|
||||
.get(`/api/v1/receipts/${receiptWithItemsId}/items`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -510,7 +510,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
describe('PUT /api/receipts/:receiptId/items/:itemId', () => {
|
||||
it('should update item status', async () => {
|
||||
const response = await request
|
||||
.put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
||||
.put(`/api/v1/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ status: 'matched', match_confidence: 0.95 });
|
||||
|
||||
@@ -520,7 +520,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should reject invalid status', async () => {
|
||||
const response = await request
|
||||
.put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
||||
.put(`/api/v1/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ status: 'invalid_status' });
|
||||
|
||||
@@ -531,7 +531,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
describe('GET /api/receipts/:receiptId/items/unadded', () => {
|
||||
it('should return unadded items', async () => {
|
||||
const response = await request
|
||||
.get(`/api/receipts/${receiptWithItemsId}/items/unadded`)
|
||||
.get(`/api/v1/receipts/${receiptWithItemsId}/items/unadded`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -567,7 +567,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should confirm items and add to inventory', async () => {
|
||||
const response = await request
|
||||
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
||||
.post(`/api/v1/receipts/${receiptForConfirmId}/confirm`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
items: [
|
||||
@@ -608,7 +608,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
const skipItemId = itemResult.rows[0].receipt_item_id;
|
||||
|
||||
const response = await request
|
||||
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
||||
.post(`/api/v1/receipts/${receiptForConfirmId}/confirm`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
items: [
|
||||
@@ -625,7 +625,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should reject invalid location', async () => {
|
||||
const response = await request
|
||||
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
||||
.post(`/api/v1/receipts/${receiptForConfirmId}/confirm`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
items: [
|
||||
@@ -669,7 +669,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should return processing logs', async () => {
|
||||
const response = await request
|
||||
.get(`/api/receipts/${receiptWithLogsId}/logs`)
|
||||
.get(`/api/v1/receipts/${receiptWithLogsId}/logs`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -703,7 +703,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
// Step 2: Verify receipt was created
|
||||
const getResponse = await request
|
||||
.get(`/api/receipts/${receiptId}`)
|
||||
.get(`/api/v1/receipts/${receiptId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getResponse.status).toBe(200);
|
||||
@@ -722,7 +722,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
// Step 4: Verify logs endpoint works (empty for new receipt)
|
||||
const logsResponse = await request
|
||||
.get(`/api/receipts/${receiptId}/logs`)
|
||||
.get(`/api/v1/receipts/${receiptId}/logs`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(logsResponse.status).toBe(200);
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
describe('GET /api/recipes/:recipeId', () => {
|
||||
it('should fetch a single public recipe by its ID', async () => {
|
||||
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}`);
|
||||
const response = await request.get(`/api/v1/recipes/${testRecipe.recipe_id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
@@ -104,7 +104,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
createdRecipeIds.push(createdRecipe.recipe_id);
|
||||
|
||||
// Verify the recipe can be fetched from the public endpoint
|
||||
const verifyResponse = await request.get(`/api/recipes/${createdRecipe.recipe_id}`);
|
||||
const verifyResponse = await request.get(`/api/v1/recipes/${createdRecipe.recipe_id}`);
|
||||
expect(verifyResponse.status).toBe(200);
|
||||
expect(verifyResponse.body.data.name).toBe(newRecipeData.name);
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.put(`/api/users/recipes/${testRecipe.recipe_id}`) // Authenticated recipe update endpoint
|
||||
.put(`/api/v1/users/recipes/${testRecipe.recipe_id}`) // Authenticated recipe update endpoint
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(recipeUpdates);
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
expect(updatedRecipe.instructions).toBe(recipeUpdates.instructions);
|
||||
|
||||
// Verify the changes were persisted by fetching the recipe again
|
||||
const verifyResponse = await request.get(`/api/recipes/${testRecipe.recipe_id}`);
|
||||
const verifyResponse = await request.get(`/api/v1/recipes/${testRecipe.recipe_id}`);
|
||||
expect(verifyResponse.status).toBe(200);
|
||||
expect(verifyResponse.body.data.name).toBe(recipeUpdates.name);
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
// Attempt to update the testRecipe (owned by testUser) using otherUser's token
|
||||
const response = await request
|
||||
.put(`/api/users/recipes/${testRecipe.recipe_id}`)
|
||||
.put(`/api/v1/users/recipes/${testRecipe.recipe_id}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`)
|
||||
.send({ name: 'Hacked Recipe Name' });
|
||||
|
||||
@@ -165,13 +165,13 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
// Delete the recipe
|
||||
const deleteRes = await request
|
||||
.delete(`/api/users/recipes/${recipeToDelete.recipe_id}`)
|
||||
.delete(`/api/v1/users/recipes/${recipeToDelete.recipe_id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(deleteRes.status).toBe(204);
|
||||
|
||||
// Verify it's actually deleted by trying to fetch it
|
||||
const verifyRes = await request.get(`/api/recipes/${recipeToDelete.recipe_id}`);
|
||||
const verifyRes = await request.get(`/api/v1/recipes/${recipeToDelete.recipe_id}`);
|
||||
expect(verifyRes.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -186,14 +186,14 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
// Attempt to delete the testRecipe (owned by testUser) using otherUser's token
|
||||
const response = await request
|
||||
.delete(`/api/users/recipes/${testRecipe.recipe_id}`)
|
||||
.delete(`/api/v1/users/recipes/${testRecipe.recipe_id}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
// Should return 404 because the recipe doesn't belong to this user
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
// Verify the recipe still exists
|
||||
const verifyRes = await request.get(`/api/recipes/${testRecipe.recipe_id}`);
|
||||
const verifyRes = await request.get(`/api/v1/recipes/${testRecipe.recipe_id}`);
|
||||
expect(verifyRes.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
const commentContent = 'This is a great recipe! Thanks for sharing.';
|
||||
|
||||
const response = await request
|
||||
.post(`/api/recipes/${testRecipe.recipe_id}/comments`)
|
||||
.post(`/api/v1/recipes/${testRecipe.recipe_id}/comments`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ content: commentContent });
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
it('should allow an authenticated user to fork a recipe', async () => {
|
||||
const response = await request
|
||||
.post(`/api/recipes/${testRecipe.recipe_id}/fork`)
|
||||
.post(`/api/v1/recipes/${testRecipe.recipe_id}/fork`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -254,7 +254,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
|
||||
// Fork the seed recipe - this should succeed
|
||||
const response = await request
|
||||
.post(`/api/recipes/${seedRecipeId}/fork`)
|
||||
.post(`/api/v1/recipes/${seedRecipeId}/fork`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
// Forking should work - seed recipes should be forkable
|
||||
@@ -271,12 +271,12 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
it('should return comments for a recipe', async () => {
|
||||
// First add a comment
|
||||
await request
|
||||
.post(`/api/recipes/${testRecipe.recipe_id}/comments`)
|
||||
.post(`/api/v1/recipes/${testRecipe.recipe_id}/comments`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ content: 'Test comment for GET request' });
|
||||
|
||||
// Now fetch comments
|
||||
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
|
||||
const response = await request.get(`/api/v1/recipes/${testRecipe.recipe_id}/comments`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -306,7 +306,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
createdRecipeIds.push(noCommentsRecipe.recipe_id);
|
||||
|
||||
// Fetch comments for this recipe
|
||||
const response = await request.get(`/api/recipes/${noCommentsRecipe.recipe_id}/comments`);
|
||||
const response = await request.get(`/api/v1/recipes/${noCommentsRecipe.recipe_id}/comments`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('UPC Scanning Integration Tests (/api/upc)', () => {
|
||||
|
||||
it('should return a specific scan by ID', async () => {
|
||||
const response = await request
|
||||
.get(`/api/upc/history/${testScanId}`)
|
||||
.get(`/api/v1/upc/history/${testScanId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -338,7 +338,7 @@ describe('UPC Scanning Integration Tests (/api/upc)', () => {
|
||||
createdUserIds.push(otherUser.user.user_id);
|
||||
|
||||
const response = await request
|
||||
.get(`/api/upc/history/${testScanId}`)
|
||||
.get(`/api/v1/upc/history/${testScanId}`)
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -467,7 +467,7 @@ describe('UPC Scanning Integration Tests (/api/upc)', () => {
|
||||
|
||||
// Step 4: Verify in history
|
||||
const historyResponse = await request
|
||||
.get(`/api/upc/history/${scanId}`)
|
||||
.get(`/api/v1/upc/history/${scanId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(historyResponse.status).toBe(200);
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('User API Routes Integration Tests', () => {
|
||||
|
||||
// Act 3: Remove the watched item.
|
||||
const removeResponse = await request
|
||||
.delete(`/api/users/watched-items/${newItem.master_grocery_item_id}`)
|
||||
.delete(`/api/v1/users/watched-items/${newItem.master_grocery_item_id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
expect(removeResponse.status).toBe(204);
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('User API Routes Integration Tests', () => {
|
||||
|
||||
// Act 2: Add an item to the new list.
|
||||
const addItemResponse = await request
|
||||
.post(`/api/users/shopping-lists/${newList.shopping_list_id}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${newList.shopping_list_id}/items`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ customItemName: 'Custom Test Item' });
|
||||
const addedItem = addItemResponse.body.data;
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
// 3. Delete
|
||||
const deleteResponse = await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
// Act 1: Malicious user attempts to add an item to the owner's list.
|
||||
const addItemResponse = await request
|
||||
.post(`/api/users/shopping-lists/${listId}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${listId}/items`)
|
||||
.set('Authorization', `Bearer ${maliciousToken}`) // Use malicious user's token
|
||||
.send({ customItemName: 'Malicious Item' });
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
// Act 2: Malicious user attempts to delete the owner's list.
|
||||
const deleteResponse = await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${maliciousToken}`); // Use malicious user's token
|
||||
|
||||
// Assert 2: This should also fail with a 404.
|
||||
@@ -177,7 +177,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
// Act 3: Malicious user attempts to update an item on the owner's list.
|
||||
// First, the owner adds an item.
|
||||
const ownerAddItemResponse = await request
|
||||
.post(`/api/users/shopping-lists/${listId}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${listId}/items`)
|
||||
.set('Authorization', `Bearer ${authToken}`) // Owner's token
|
||||
.send({ customItemName: 'Legitimate Item' });
|
||||
expect(ownerAddItemResponse.status).toBe(201);
|
||||
@@ -185,7 +185,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
// Now, the malicious user tries to update it.
|
||||
const updateItemResponse = await request
|
||||
.put(`/api/users/shopping-lists/items/${itemId}`)
|
||||
.put(`/api/v1/users/shopping-lists/items/${itemId}`)
|
||||
.set('Authorization', `Bearer ${maliciousToken}`) // Malicious token
|
||||
.send({ is_purchased: true });
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
// Cleanup the list created in this test
|
||||
await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
});
|
||||
});
|
||||
@@ -217,14 +217,14 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
afterAll(async () => {
|
||||
if (listId) {
|
||||
await request
|
||||
.delete(`/api/users/shopping-lists/${listId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/${listId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should add an item to a shopping list', async () => {
|
||||
const response = await request
|
||||
.post(`/api/users/shopping-lists/${listId}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${listId}/items`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ customItemName: 'Test Item' });
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
it('should update an item in a shopping list', async () => {
|
||||
const updates = { is_purchased: true, quantity: 5 };
|
||||
const response = await request
|
||||
.put(`/api/users/shopping-lists/items/${itemId}`)
|
||||
.put(`/api/v1/users/shopping-lists/items/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(updates);
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
|
||||
it('should delete an item from a shopping list', async () => {
|
||||
const response = await request
|
||||
.delete(`/api/users/shopping-lists/items/${itemId}`)
|
||||
.delete(`/api/v1/users/shopping-lists/items/${itemId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
50
src/types/express.d.ts
vendored
50
src/types/express.d.ts
vendored
@@ -2,16 +2,60 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Logger } from 'pino';
|
||||
import * as qs from 'qs';
|
||||
import type { ApiVersion, VersionDeprecation } from '../config/apiVersions';
|
||||
|
||||
/**
|
||||
* This file uses declaration merging to add a custom `log` property to the
|
||||
* global Express Request interface. This makes the request-scoped logger
|
||||
* available in a type-safe way in all route handlers, as required by ADR-004.
|
||||
* This file uses declaration merging to add custom properties to the
|
||||
* global Express Request interface.
|
||||
*
|
||||
* Extended properties:
|
||||
* - `log`: Request-scoped Pino logger (ADR-004)
|
||||
* - `apiVersion`: API version extracted from request path (ADR-008)
|
||||
* - `versionDeprecation`: Deprecation info when accessing deprecated versions (ADR-008)
|
||||
*
|
||||
* @see docs/adr/0004-request-scoped-logging.md
|
||||
* @see docs/adr/0008-api-versioning-strategy.md
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
/**
|
||||
* Request-scoped Pino logger instance.
|
||||
* Includes request context (requestId, userId, etc.) for correlation.
|
||||
* @see ADR-004 for logging standards
|
||||
*/
|
||||
log: Logger;
|
||||
|
||||
/**
|
||||
* The API version detected from the request path.
|
||||
* Set by apiVersion middleware based on /api/v{n}/ path pattern.
|
||||
* Defaults to 'v1' when no version is detected.
|
||||
*
|
||||
* @example
|
||||
* // In a route handler:
|
||||
* if (req.apiVersion === 'v2') {
|
||||
* return sendSuccess(res, transformV2(data));
|
||||
* }
|
||||
*
|
||||
* @see ADR-008 for versioning strategy
|
||||
*/
|
||||
apiVersion?: ApiVersion;
|
||||
|
||||
/**
|
||||
* Deprecation information when accessing a deprecated API version.
|
||||
* Set by deprecation middleware when the requested version is deprecated.
|
||||
* Undefined when accessing active versions.
|
||||
*
|
||||
* @example
|
||||
* // In a route handler:
|
||||
* if (req.versionDeprecation?.isDeprecated) {
|
||||
* req.log.warn({ sunsetDate: req.versionDeprecation.sunsetDate },
|
||||
* 'Client using deprecated API version');
|
||||
* }
|
||||
*
|
||||
* @see ADR-008 for deprecation workflow
|
||||
*/
|
||||
versionDeprecation?: VersionDeprecation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user