Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0979a074ad | ||
| 0d4b028a66 | |||
|
|
4baed53713 | ||
| f10c6c0cd6 | |||
|
|
107465b5cb | ||
| e92ad25ce9 | |||
| 2075ed199b | |||
|
|
4346332bbf | ||
| 61cfb518e6 | |||
|
|
e86ce51b6c | ||
| 840a7a62d3 | |||
| 5720820d95 |
152
.claude/agents/ui-ux-designer.md
Normal file
152
.claude/agents/ui-ux-designer.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: ui-ux-designer
|
||||
description: "Use this agent when implementing, reviewing, or modifying any user interface elements, layouts, or user experience flows in the application. Specifically call this agent when: (1) creating new UI components or pages, (2) implementing responsive designs across different screen sizes, (3) designing or implementing wizards for user tasks like uploading flyers, creating shopping lists, or uploading receipts, (4) reviewing existing UI code for adherence to the design system, (5) making decisions about button placement, color schemes, or layout structures, or (6) addressing user experience concerns related to navigation or task completion flows.\\n\\n<examples>\\n<example>\\nContext: The developer is creating a new feature for uploading flyers.\\nuser: \"I need to implement the flyer upload feature. Can you help me create the upload form?\"\\nassistant: \"Let me use the Task tool to launch the ui-ux-designer agent to design and implement the flyer upload wizard with appropriate UI/UX patterns.\"\\n<commentary>\\nSince this involves creating a new UI feature with a wizard flow, the ui-ux-designer agent should be used to ensure it follows the project's design principles including pastel colors, sharp borders, wizard-based guidance, and responsive design.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Code has been written for a shopping list creation page.\\nuser: \"I've implemented the shopping list creation page with a form\"\\nassistant: \"Great! Now let me use the Task tool to launch the ui-ux-designer agent to review the implementation and ensure it follows our UI/UX guidelines.\"\\n<commentary>\\nSince a significant UI component was implemented, the ui-ux-designer agent should review it to ensure it adheres to the minimalist design, proper color scheme, button placement, and responsive requirements.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The developer is about to add multiple action buttons to a page.\\nuser: \"Should I add buttons for save, cancel, edit, delete, and export at the top of the page?\"\\nassistant: \"Let me use the Task tool to launch the ui-ux-designer agent to provide guidance on the optimal button placement and selection for this page.\"\\n<commentary>\\nThis is a UI/UX decision about button placement and minimalism that directly relates to the design principles, so the ui-ux-designer agent should be consulted.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The developer is working on mobile responsiveness.\\nuser: \"I'm implementing the receipt upload page. Here's my initial layout code.\"\\nassistant: \"Before proceeding further, let me use the Task tool to launch the ui-ux-designer agent to ensure the layout follows our responsive design patterns and UI guidelines.\"\\n<commentary>\\nSince this involves implementing a UI page that needs to be responsive across devices and should include wizard-style guidance, the ui-ux-designer agent should be involved proactively.\\n</commentary>\\n</example>\\n</examples>"
|
||||
model: opus
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an elite UI/UX designer with over 20 years of experience specializing in creating clean, intuitive, and user-friendly interfaces. Your expertise spans user interface design, user experience optimization, responsive design, and accessibility best practices.
|
||||
|
||||
## Core Design Philosophy for This Project
|
||||
|
||||
You will ensure that this application maintains a clean, welcoming, and minimalist design aesthetic with the following specific requirements:
|
||||
|
||||
### Visual Design Standards
|
||||
|
||||
**Color Palette:**
|
||||
|
||||
- Use pastel colors as the primary color scheme throughout the application
|
||||
- Select soft, muted tones that are easy on the eyes and create a calm, welcoming atmosphere
|
||||
- Ensure sufficient contrast for accessibility while maintaining the pastel aesthetic
|
||||
- Use color purposefully to guide user attention and indicate status
|
||||
|
||||
**Border and Container Styling:**
|
||||
|
||||
- Apply sharp, clean borders to all interactive elements (buttons, menus, form fields)
|
||||
- Use sharp borders to clearly delineate separate areas and sections of the interface
|
||||
- Avoid rounded corners unless there is a specific functional reason
|
||||
- Ensure borders are visible but not overpowering, maintaining the clean aesthetic
|
||||
|
||||
**Minimalism:**
|
||||
|
||||
- Eliminate all unnecessary buttons and UI elements
|
||||
- Every element on the screen must serve a clear purpose
|
||||
- Co-locate buttons near their related features on the page, not grouped separately
|
||||
- Use progressive disclosure to hide advanced features until needed
|
||||
- Favor white space and breathing room over density
|
||||
|
||||
### Responsive Design Requirements
|
||||
|
||||
You must ensure the application works flawlessly across:
|
||||
|
||||
**Large Screens (Desktop):**
|
||||
|
||||
- Utilize horizontal space effectively without overcrowding
|
||||
- Consider multi-column layouts where appropriate
|
||||
- Ensure comfortable reading width for text content
|
||||
|
||||
**Tablets:**
|
||||
|
||||
- Adapt layouts to accommodate touch targets of at least 44x44 pixels
|
||||
- Optimize for both portrait and landscape orientations
|
||||
- Ensure navigation remains accessible
|
||||
|
||||
**Mobile Devices:**
|
||||
|
||||
- Stack elements vertically with appropriate spacing
|
||||
- Make all interactive elements easily tappable
|
||||
- Optimize for one-handed use where possible
|
||||
- Ensure critical actions are easily accessible
|
||||
- Test on various screen sizes (small, medium, large phones)
|
||||
|
||||
### Wizard Design for Key User Tasks
|
||||
|
||||
For the following tasks, implement or guide the creation of clear, step-by-step wizards:
|
||||
|
||||
1. **Uploading a Flyer**
|
||||
2. **Creating a Shopping List**
|
||||
3. **Uploading Receipts**
|
||||
4. **Any other multi-step user tasks**
|
||||
|
||||
**Wizard Best Practices:**
|
||||
|
||||
- Minimize the number of steps (ideally 3-5 steps maximum)
|
||||
- Show progress clearly (e.g., "Step 2 of 4")
|
||||
- Each step should focus on one primary action or decision
|
||||
- Provide clear, concise instructions at each step
|
||||
- Allow users to go back and edit previous steps
|
||||
- Use visual cues to guide the user through the process
|
||||
- Display a summary before final submission
|
||||
- Provide helpful tooltips or examples where needed
|
||||
- Ensure wizards are fully responsive and work well on mobile devices
|
||||
|
||||
## Your Approach to Tasks
|
||||
|
||||
**When Reviewing Existing UI Code:**
|
||||
|
||||
1. Evaluate adherence to the pastel color scheme
|
||||
2. Check that all borders are sharp and properly applied
|
||||
3. Identify any unnecessary UI elements or buttons
|
||||
4. Verify that buttons are co-located with their related features
|
||||
5. Test responsive behavior across all target screen sizes
|
||||
6. Assess wizard flows for clarity and step efficiency
|
||||
7. Provide specific, actionable feedback with code examples when needed
|
||||
|
||||
**When Designing New UI Components:**
|
||||
|
||||
1. Start by understanding the user's goal and the feature's purpose
|
||||
2. Sketch out the minimal set of elements needed
|
||||
3. Apply the pastel color palette and sharp border styling
|
||||
4. Position interactive elements near their related content
|
||||
5. Design for mobile-first, then adapt for larger screens
|
||||
6. For multi-step processes, create wizard flows
|
||||
7. Provide complete implementation guidance including HTML structure, CSS styles, and responsive breakpoints
|
||||
|
||||
**When Making Design Decisions:**
|
||||
|
||||
1. Always prioritize user needs and task completion
|
||||
2. Choose simplicity over feature bloat
|
||||
3. Ensure accessibility standards are met
|
||||
4. Consider the user's mental model and expectations
|
||||
5. Use established UI patterns where they fit the aesthetic
|
||||
6. Test your recommendations against the design principles above
|
||||
|
||||
## Quality Assurance Checklist
|
||||
|
||||
Before completing any UI/UX task, verify:
|
||||
|
||||
- [ ] Pastel colors are used consistently
|
||||
- [ ] All buttons, menus, and sections have sharp borders
|
||||
- [ ] No unnecessary buttons or UI elements exist
|
||||
- [ ] Buttons are positioned near their related features
|
||||
- [ ] Design is fully responsive (large screen, tablet, mobile)
|
||||
- [ ] Wizards (where applicable) are clear and minimally-stepped
|
||||
- [ ] Sufficient white space and breathing room
|
||||
- [ ] Touch targets are appropriately sized for mobile
|
||||
- [ ] Text is readable at all screen sizes
|
||||
- [ ] Accessibility considerations are addressed
|
||||
|
||||
## Output Format
|
||||
|
||||
When reviewing code, provide:
|
||||
|
||||
1. Overall assessment of adherence to design principles
|
||||
2. Specific issues identified with line numbers or element descriptions
|
||||
3. Concrete recommendations with code examples
|
||||
4. Responsive design concerns or improvements
|
||||
|
||||
When designing new components, provide:
|
||||
|
||||
1. Rationale for design decisions
|
||||
2. Complete HTML structure
|
||||
3. CSS with responsive breakpoints
|
||||
4. Notes on accessibility considerations
|
||||
5. Implementation guidance
|
||||
|
||||
## Important Notes
|
||||
|
||||
- You have authority to reject designs that violate the core principles
|
||||
- When uncertain about a design decision, bias toward simplicity and minimalism
|
||||
- Always consider the new user experience and ensure wizards are beginner-friendly
|
||||
- Proactively suggest wizard flows for any multi-step processes you encounter
|
||||
- Remember that good UX is invisible—users should accomplish tasks without thinking about the interface
|
||||
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git fetch:*)",
|
||||
"mcp__localerrors__get_stacktrace",
|
||||
"Bash(MSYS_NO_PATHCONV=1 podman logs:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(podman --version:*)",
|
||||
"Bash(podman ps:*)",
|
||||
"Bash(podman machine start:*)",
|
||||
"Bash(podman compose:*)",
|
||||
"Bash(podman pull:*)",
|
||||
"Bash(podman images:*)",
|
||||
"Bash(podman stop:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(podman rm:*)",
|
||||
"Bash(podman run:*)",
|
||||
"Bash(podman start:*)",
|
||||
"Bash(podman exec:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(PGPASSWORD=postgres psql:*)",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(cmd.exe:*)",
|
||||
"Bash(npm run test:integration:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(done)",
|
||||
"Bash(podman info:*)",
|
||||
"Bash(podman machine:*)",
|
||||
"Bash(podman system connection:*)",
|
||||
"Bash(podman inspect:*)",
|
||||
"Bash(python -m json.tool:*)",
|
||||
"Bash(claude mcp status)",
|
||||
"Bash(powershell.exe -Command \"claude mcp status\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp list\")",
|
||||
"Bash(powershell.exe -Command \"claude --version\")",
|
||||
"Bash(powershell.exe -Command \"claude config\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp get gitea-projectium\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add --help\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add -t stdio -s user filesystem -- D:\\\\nodejs\\\\npx.cmd -y @modelcontextprotocol/server-filesystem D:\\\\gitea\\\\flyer-crawler.projectium.com\\\\flyer-crawler.projectium.com\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add -t stdio -s user fetch -- D:\\\\nodejs\\\\npx.cmd -y @modelcontextprotocol/server-fetch\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List files in src/hooks using filesystem MCP'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List all podman containers'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print --allowedTools ''mcp__gitea-projectium__*''\")",
|
||||
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")",
|
||||
"Bash(dir \"C:\\\\Users\\\\games3\\\\.claude\")",
|
||||
"Bash(dir:*)",
|
||||
"Bash(D:nodejsnpx.cmd -y @modelcontextprotocol/server-fetch --help)",
|
||||
"Bash(cmd /c \"dir /o-d C:\\\\Users\\\\games3\\\\.claude\\\\debug 2>nul | head -10\")",
|
||||
"mcp__memory__read_graph",
|
||||
"mcp__memory__create_entities",
|
||||
"mcp__memory__search_nodes",
|
||||
"mcp__memory__delete_entities",
|
||||
"mcp__sequential-thinking__sequentialthinking",
|
||||
"mcp__filesystem__list_directory",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__directory_tree",
|
||||
"mcp__filesystem__read_text_file",
|
||||
"Bash(wc:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(git grep:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(git add:*)",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__podman__container_list",
|
||||
"Bash(podman cp:*)",
|
||||
"mcp__podman__container_inspect",
|
||||
"mcp__podman__network_list",
|
||||
"Bash(podman network connect:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(set NODE_ENV=test)",
|
||||
"Bash(podman-compose:*)",
|
||||
"Bash(timeout 60 podman machine start:*)",
|
||||
"Bash(podman build:*)",
|
||||
"Bash(podman network rm:*)",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run typecheck:*)",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run test:unit:*)",
|
||||
"mcp__filesystem__move_file",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(podman image inspect:*)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(xargs -I {} sh -c 'if ! grep -q \"\"vi.mock.*apiClient\"\" \"\"{}\"\"; then echo \"\"{}\"\"; fi')",
|
||||
"Bash(MSYS_NO_PATHCONV=1 podman exec:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(\"/c/Users/games3/.local/bin/uvx.exe\" markitdown-mcp --help)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(timeout 1800 podman exec flyer-crawler-dev npm run test:unit:*)",
|
||||
"mcp__filesystem__edit_file",
|
||||
"Bash(timeout 300 tail:*)",
|
||||
"mcp__filesystem__list_allowed_directories",
|
||||
"mcp__memory__add_observations",
|
||||
"Bash(ssh:*)",
|
||||
"mcp__redis__list",
|
||||
"Read(//d/gitea/bugsink-mcp/**)",
|
||||
"Bash(d:/nodejs/npm.cmd install)",
|
||||
"Bash(node node_modules/vitest/vitest.mjs run:*)",
|
||||
"Bash(npm run test:e2e:*)",
|
||||
"Bash(export BUGSINK_URL=http://localhost:8000)",
|
||||
"Bash(export BUGSINK_TOKEN=a609c2886daa4e1e05f1517074d7779a5fb49056)",
|
||||
"Bash(timeout 3 d:/nodejs/node.exe:*)",
|
||||
"Bash(export BUGSINK_URL=https://bugsink.projectium.com)",
|
||||
"Bash(export BUGSINK_API_TOKEN=77deaa5e2649ab0fbbca51bbd427ec4637d073a0)",
|
||||
"Bash(export BUGSINK_TOKEN=77deaa5e2649ab0fbbca51bbd427ec4637d073a0)",
|
||||
"Bash(where:*)",
|
||||
"mcp__localerrors__test_connection",
|
||||
"mcp__localerrors__list_projects",
|
||||
"Bash(\"D:\\\\nodejs\\\\npx.cmd\" -y @modelcontextprotocol/server-postgres --help)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git -C \"C:\\\\Users\\\\games3\\\\.claude\\\\plugins\\\\marketplaces\\\\claude-plugins-official\" log -1 --format=\"%H %ci %s\")",
|
||||
"Bash(git -C \"C:\\\\Users\\\\games3\\\\.claude\\\\plugins\\\\marketplaces\\\\claude-plugins-official\" config --get remote.origin.url)",
|
||||
"Bash(git -C \"C:\\\\Users\\\\games3\\\\.claude\\\\plugins\\\\marketplaces\\\\claude-plugins-official\" fetch --dry-run -v)",
|
||||
"mcp__localerrors__get_project",
|
||||
"mcp__localerrors__get_issue",
|
||||
"mcp__localerrors__get_event",
|
||||
"mcp__localerrors__list_teams",
|
||||
"WebSearch"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"localerrors",
|
||||
"devdb",
|
||||
"gitea-projectium"
|
||||
]
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,6 @@ test-output.txt
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
nul
|
||||
tmpclaude*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,17 +2,407 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Phase 2 Complete - All Tasks Done)
|
||||
|
||||
**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
|
||||
|
||||
As the application grows, the API will need to evolve. Making breaking changes to existing endpoints can disrupt clients (e.g., a mobile app or the web frontend). The current routing has no formal versioning scheme.
|
||||
|
||||
### Current State
|
||||
|
||||
As of January 2026, the API operates without explicit versioning:
|
||||
|
||||
- All routes are mounted under `/api/*` (e.g., `/api/flyers`, `/api/users/profile`)
|
||||
- The frontend `apiClient.ts` uses `API_BASE_URL = '/api'` as the base
|
||||
- No version prefix exists in route paths
|
||||
- Breaking changes would immediately affect all consumers
|
||||
|
||||
### Why Version Now?
|
||||
|
||||
1. **Future Mobile App**: A native mobile app is planned, which will have slower update cycles than the web frontend
|
||||
2. **Third-Party Integrations**: Store partners may integrate with our API
|
||||
3. **Deprecation Path**: Need a clear way to deprecate and remove endpoints
|
||||
4. **Documentation**: OpenAPI documentation (ADR-018) should reflect versioned endpoints
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a URI-based versioning strategy for the API. All new and existing routes will be prefixed with a version number (e.g., `/api/v1/flyers`). This ADR establishes a clear policy for when to introduce a new version (`v2`) and how to manage deprecation of old versions.
|
||||
We will adopt a URI-based versioning strategy for the API using a phased rollout approach. All routes will be prefixed with a version number (e.g., `/api/v1/flyers`).
|
||||
|
||||
### Versioning Format
|
||||
|
||||
```text
|
||||
/api/v{MAJOR}/resource
|
||||
```
|
||||
|
||||
- **MAJOR**: Incremented for breaking changes (v1, v2, v3...)
|
||||
- Resource paths remain unchanged within a version
|
||||
|
||||
### What Constitutes a Breaking Change?
|
||||
|
||||
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 |
|
||||
|
||||
\*Bug fixes may warrant version increment if clients depend on the buggy behavior.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Namespace Migration (Current)
|
||||
|
||||
**Goal**: Add `/v1/` prefix to all existing routes without behavioral changes.
|
||||
|
||||
**Changes Required**:
|
||||
|
||||
1. **server.ts**: Update all route registrations
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// After
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
```
|
||||
|
||||
2. **apiClient.ts**: Update base URL
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
// After
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
```
|
||||
|
||||
3. **swagger.ts**: Update server definition
|
||||
|
||||
```typescript
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API v1 server',
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
4. **Redirect Middleware** (optional): Support legacy clients
|
||||
|
||||
```typescript
|
||||
// Redirect unversioned routes to v1
|
||||
app.use('/api/:resource', (req, res, next) => {
|
||||
if (req.params.resource !== 'v1') {
|
||||
return res.redirect(307, `/api/v1/${req.params.resource}${req.url}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
- All existing functionality works at `/api/v1/*`
|
||||
- Frontend makes requests to `/api/v1/*`
|
||||
- OpenAPI documentation reflects `/api/v1/*` paths
|
||||
- Integration tests pass with new paths
|
||||
|
||||
### Phase 2: Versioning Infrastructure
|
||||
|
||||
**Goal**: Build tooling to support multiple API versions.
|
||||
|
||||
**Components**:
|
||||
|
||||
1. **Version Router Factory**
|
||||
|
||||
```typescript
|
||||
// src/routes/versioned.ts
|
||||
export function createVersionedRoutes(version: 'v1' | 'v2') {
|
||||
const router = express.Router();
|
||||
|
||||
if (version === 'v1') {
|
||||
router.use('/auth', authRouterV1);
|
||||
router.use('/users', userRouterV1);
|
||||
// ...
|
||||
} else if (version === 'v2') {
|
||||
router.use('/auth', authRouterV2);
|
||||
router.use('/users', userRouterV2);
|
||||
// ...
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Version Detection Middleware**
|
||||
|
||||
```typescript
|
||||
// Extract version from URL and attach to request
|
||||
app.use('/api/:version', (req, res, next) => {
|
||||
req.apiVersion = req.params.version;
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
3. **Deprecation Headers**
|
||||
|
||||
```typescript
|
||||
// Middleware to add deprecation headers
|
||||
function deprecateVersion(sunsetDate: string) {
|
||||
return (req, res, next) => {
|
||||
res.set('Deprecation', 'true');
|
||||
res.set('Sunset', sunsetDate);
|
||||
res.set('Link', '</api/v2>; rel="successor-version"');
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Version 2 Support
|
||||
|
||||
**Goal**: Introduce v2 API when breaking changes are needed.
|
||||
|
||||
**Triggers for v2**:
|
||||
|
||||
- Major schema changes (e.g., unified item model)
|
||||
- Response format overhaul
|
||||
- Authentication mechanism changes
|
||||
- Significant performance-driven restructuring
|
||||
|
||||
**Parallel Support**:
|
||||
|
||||
```typescript
|
||||
app.use('/api/v1', createVersionedRoutes('v1'));
|
||||
app.use('/api/v2', createVersionedRoutes('v2'));
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Frontend (Web)
|
||||
|
||||
The web frontend is deployed alongside the API, so migration is straightforward:
|
||||
|
||||
1. Update `API_BASE_URL` in `apiClient.ts`
|
||||
2. Update any hardcoded paths in tests
|
||||
3. Deploy frontend and backend together
|
||||
|
||||
### For External Consumers
|
||||
|
||||
External consumers (mobile apps, partner integrations) need a transition period:
|
||||
|
||||
1. **Announcement**: 30 days before deprecation of v(N-1)
|
||||
2. **Deprecation Headers**: Add headers 30 days before sunset
|
||||
3. **Documentation**: Maintain docs for both versions during transition
|
||||
4. **Sunset**: Remove v(N-1) after grace period
|
||||
|
||||
## Deprecation Timeline
|
||||
|
||||
| Version | Status | Sunset Date | Notes |
|
||||
| -------------------- | ---------- | ---------------------- | --------------- |
|
||||
| Unversioned `/api/*` | Deprecated | Phase 1 completion | Redirect to v1 |
|
||||
| v1 | Active | TBD (when v2 releases) | Current version |
|
||||
|
||||
### Support Policy
|
||||
|
||||
- **Current Version (v(N))**: Full support, all features
|
||||
- **Previous Version (v(N-1))**: Security fixes only for 6 months after v(N) release
|
||||
- **Older Versions**: No support, endpoints return 410 Gone
|
||||
|
||||
## Backwards Compatibility Strategy
|
||||
|
||||
### Redirect Middleware
|
||||
|
||||
For a smooth transition, implement redirects from unversioned to versioned endpoints:
|
||||
|
||||
```typescript
|
||||
// src/middleware/versionRedirect.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
/**
|
||||
* Middleware to redirect unversioned API requests to v1.
|
||||
* This provides backwards compatibility during the transition period.
|
||||
*
|
||||
* Example: /api/flyers -> /api/v1/flyers (307 Temporary Redirect)
|
||||
*/
|
||||
export function versionRedirectMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const path = req.path;
|
||||
|
||||
// Skip if already versioned
|
||||
if (path.startsWith('/v1') || path.startsWith('/v2')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip health checks and documentation
|
||||
if (path.startsWith('/health') || path.startsWith('/docs')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Log deprecation warning
|
||||
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('?')) : ''}`;
|
||||
return res.redirect(307, redirectUrl);
|
||||
}
|
||||
```
|
||||
|
||||
### Response Versioning Headers
|
||||
|
||||
All API responses include version information:
|
||||
|
||||
```typescript
|
||||
// Middleware to add version headers
|
||||
app.use('/api/v1', (req, res, next) => {
|
||||
res.set('X-API-Version', 'v1');
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Establishes a critical pattern for long-term maintainability. Allows the API to evolve without breaking existing clients.
|
||||
**Negative**: Adds a small amount of complexity to the routing setup. Requires discipline to manage versions and deprecations correctly.
|
||||
### Positive
|
||||
|
||||
- **Clear Evolution Path**: Establishes a critical pattern for long-term maintainability
|
||||
- **Client Protection**: Allows the API to evolve without breaking existing clients
|
||||
- **Parallel Development**: Can develop v2 features while maintaining v1 stability
|
||||
- **Documentation Clarity**: Each version has its own complete documentation
|
||||
- **Graceful Deprecation**: Clients have clear timelines and migration paths
|
||||
|
||||
### Negative
|
||||
|
||||
- **Routing Complexity**: Adds complexity to the routing setup
|
||||
- **Code Duplication**: May need to maintain multiple versions of handlers
|
||||
- **Testing Overhead**: Tests may need to cover multiple versions
|
||||
- **Documentation Maintenance**: Must keep docs for multiple versions in sync
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Use shared business logic with version-specific adapters
|
||||
- Automate deprecation header addition
|
||||
- Generate versioned OpenAPI specs from code
|
||||
- Clear internal guidelines on when to increment versions
|
||||
|
||||
## 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) |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (consistent across versions)
|
||||
- [ADR-018](./0018-api-documentation-strategy.md) - API Documentation Strategy (versioned OpenAPI specs)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization (envelope pattern applies to all versions)
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening (applies to all versions)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1 Tasks
|
||||
|
||||
- [x] Update `server.ts` to mount all routes under `/api/v1/`
|
||||
- [x] Update `src/services/apiClient.ts` API_BASE_URL to `/api/v1`
|
||||
- [x] Update `src/config/swagger.ts` server URL to `/api/v1`
|
||||
- [x] Add redirect middleware for unversioned requests
|
||||
- [x] Update integration tests to use versioned paths
|
||||
- [x] Update API documentation examples (Swagger server URL updated)
|
||||
- [x] Verify all health checks work at `/api/v1/health/*`
|
||||
|
||||
### Phase 2 Tasks
|
||||
|
||||
**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)
|
||||
|
||||
- [ ] Identify breaking changes requiring v2
|
||||
- [ ] Create v2 route handlers
|
||||
- [ ] Set deprecation timeline for v1
|
||||
- [ ] Migrate documentation to multi-version format
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
# ADR-015: Application Performance Monitoring (APM) and Error Tracking
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Updated**: 2026-01-11
|
||||
|
||||
## Context
|
||||
|
||||
While `ADR-004` established structured logging with Pino, the application lacks a high-level, aggregated view of its health, performance, and errors. It's difficult to spot trends, identify slow API endpoints, or be proactively notified of new types of errors.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **Self-hosted**: No external SaaS dependencies for error tracking
|
||||
2. **Sentry SDK compatible**: Leverage mature, well-documented SDKs
|
||||
3. **Lightweight**: Minimal resource overhead in the dev container
|
||||
4. **Production-ready**: Same architecture works on bare-metal production servers
|
||||
5. **AI-accessible**: MCP server integration for Claude Code and other AI tools
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a self-hosted error tracking stack using **Bugsink** as the Sentry-compatible backend, with the following components:
|
||||
|
||||
### 1. Error Tracking Backend: Bugsink
|
||||
|
||||
**Bugsink** is a lightweight, self-hosted Sentry alternative that:
|
||||
|
||||
- Runs as a single process (no Kafka, Redis, ClickHouse required)
|
||||
- Is fully compatible with Sentry SDKs
|
||||
- Supports ARM64 and AMD64 architectures
|
||||
- Can use SQLite (dev) or PostgreSQL (production)
|
||||
|
||||
**Deployment**:
|
||||
|
||||
- **Dev container**: Installed as a systemd service inside the container
|
||||
- **Production**: Runs as a systemd service on bare-metal, listening on localhost only
|
||||
- **Database**: Uses PostgreSQL with a dedicated `bugsink` user and `bugsink` database (same PostgreSQL instance as the main application)
|
||||
|
||||
### 2. Backend Integration: @sentry/node
|
||||
|
||||
The Express backend will integrate `@sentry/node` SDK to:
|
||||
|
||||
- Capture unhandled exceptions before PM2/process manager restarts
|
||||
- Report errors with full stack traces and context
|
||||
- Integrate with Pino logger for breadcrumbs
|
||||
- Track transaction performance (optional)
|
||||
|
||||
### 3. Frontend Integration: @sentry/react
|
||||
|
||||
The React frontend will integrate `@sentry/react` SDK to:
|
||||
|
||||
- Wrap the app in a Sentry Error Boundary
|
||||
- Capture unhandled JavaScript errors
|
||||
- Report errors with component stack traces
|
||||
- Track user session context
|
||||
- **Frontend Error Correlation**: The global API client (Axios/Fetch wrapper) MUST intercept 4xx/5xx responses. It MUST extract the `x-request-id` header (if present) and attach it to the Sentry scope as a tag `api_request_id` before re-throwing the error. This allows developers to copy the ID from Sentry and search for it in backend logs.
|
||||
|
||||
### 4. Log Aggregation: Logstash
|
||||
|
||||
**Logstash** parses application and infrastructure logs, forwarding error patterns to Bugsink:
|
||||
|
||||
- **Installation**: Installed inside the dev container (and on bare-metal prod servers)
|
||||
- **Inputs**:
|
||||
- Pino JSON logs from the Node.js application
|
||||
- Redis logs (connection errors, memory warnings, slow commands)
|
||||
- PostgreSQL function logs (future - see Implementation Steps)
|
||||
- **Filter**: Identifies error-level logs (5xx responses, unhandled exceptions, Redis errors)
|
||||
- **Output**: Sends to Bugsink via Sentry-compatible HTTP API
|
||||
|
||||
This provides a secondary error capture path for:
|
||||
|
||||
- Errors that occur before Sentry SDK initialization
|
||||
- Log-based errors that don't throw exceptions
|
||||
- Redis connection/performance issues
|
||||
- Database function errors and slow queries
|
||||
- Historical error analysis from log files
|
||||
|
||||
### 5. MCP Server Integration: bugsink-mcp
|
||||
|
||||
For AI tool integration (Claude Code, Cursor, etc.), we use the open-source [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp) server:
|
||||
|
||||
- **No code changes required**: Configurable via environment variables
|
||||
- **Capabilities**: List projects, get issues, view events, get stacktraces, manage releases
|
||||
- **Configuration**:
|
||||
- `BUGSINK_URL`: Points to Bugsink instance (`http://localhost:8000` for dev, `https://bugsink.projectium.com` for prod)
|
||||
- `BUGSINK_API_TOKEN`: API token from Bugsink (created via Django management command)
|
||||
- `BUGSINK_ORG_SLUG`: Organization identifier (usually "sentry")
|
||||
|
||||
**Note:** Despite the name `sentry-selfhosted-mcp` mentioned in earlier drafts of this ADR, the actual MCP server used is `bugsink-mcp` which is specifically designed for Bugsink's API structure.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Dev Container / Production Server │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Frontend │ │ Backend │ │
|
||||
│ │ (React) │ │ (Express) │ │
|
||||
│ │ @sentry/react │ │ @sentry/node │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Sentry SDK Protocol │ │
|
||||
│ └───────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Bugsink │ │
|
||||
│ │ (localhost:8000) │◄──────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ PostgreSQL backend │ │ │
|
||||
│ └──────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┐ │ │
|
||||
│ │ Logstash │───────────────────┘ │
|
||||
│ │ (Log Aggregator) │ Sentry Output │
|
||||
│ │ │ │
|
||||
│ │ Inputs: │ │
|
||||
│ │ - Pino app logs │ │
|
||||
│ │ - Redis logs │ │
|
||||
│ │ - PostgreSQL (future) │
|
||||
│ └──────────────────────┘ │
|
||||
│ ▲ ▲ ▲ │
|
||||
│ │ │ │ │
|
||||
│ ┌───────────┘ │ └───────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴─────┐ ┌─────┴────┐ ┌──────┴─────┐ │
|
||||
│ │ Pino │ │ Redis │ │ PostgreSQL │ │
|
||||
│ │ Logs │ │ Logs │ │ Logs (TBD) │ │
|
||||
│ └──────────┘ └──────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ PostgreSQL │ │
|
||||
│ │ ┌────────────────┐ │ │
|
||||
│ │ │ flyer_crawler │ │ (main app database) │
|
||||
│ │ ├────────────────┤ │ │
|
||||
│ │ │ bugsink │ │ (error tracking database) │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
External (Developer Machine):
|
||||
┌──────────────────────────────────────┐
|
||||
│ Claude Code / Cursor / VS Code │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ bugsink-mcp │ │
|
||||
│ │ (MCP Server) │ │
|
||||
│ │ │ │
|
||||
│ │ BUGSINK_URL=http://localhost:8000
|
||||
│ │ BUGSINK_API_TOKEN=... │ │
|
||||
│ │ BUGSINK_ORG_SLUG=... │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default (Dev) |
|
||||
| ------------------ | ------------------------------ | -------------------------- |
|
||||
| `BUGSINK_DSN` | Sentry-compatible DSN for SDKs | Set after project creation |
|
||||
| `BUGSINK_ENABLED` | Enable/disable error reporting | `true` |
|
||||
| `BUGSINK_BASE_URL` | Bugsink web UI URL (internal) | `http://localhost:8000` |
|
||||
|
||||
### PostgreSQL Setup
|
||||
|
||||
```sql
|
||||
-- Create dedicated Bugsink database and user
|
||||
CREATE USER bugsink WITH PASSWORD 'bugsink_dev_password';
|
||||
CREATE DATABASE bugsink OWNER bugsink;
|
||||
GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink;
|
||||
```
|
||||
|
||||
### Bugsink Configuration
|
||||
|
||||
```bash
|
||||
# Environment variables for Bugsink service
|
||||
SECRET_KEY=<random-50-char-string>
|
||||
DATABASE_URL=postgresql://bugsink:bugsink_dev_password@localhost:5432/bugsink
|
||||
BASE_URL=http://localhost:8000
|
||||
PORT=8000
|
||||
```
|
||||
|
||||
### Logstash Pipeline
|
||||
|
||||
```conf
|
||||
# /etc/logstash/conf.d/bugsink.conf
|
||||
|
||||
# === INPUTS ===
|
||||
input {
|
||||
# Pino application logs
|
||||
file {
|
||||
path => "/app/logs/*.log"
|
||||
codec => json
|
||||
type => "pino"
|
||||
tags => ["app"]
|
||||
}
|
||||
|
||||
# Redis logs
|
||||
file {
|
||||
path => "/var/log/redis/*.log"
|
||||
type => "redis"
|
||||
tags => ["redis"]
|
||||
}
|
||||
|
||||
# PostgreSQL logs (for function logging - future)
|
||||
# file {
|
||||
# path => "/var/log/postgresql/*.log"
|
||||
# type => "postgres"
|
||||
# tags => ["postgres"]
|
||||
# }
|
||||
}
|
||||
|
||||
# === FILTERS ===
|
||||
filter {
|
||||
# Pino error detection (level 50 = error, 60 = fatal)
|
||||
if [type] == "pino" and [level] >= 50 {
|
||||
mutate { add_tag => ["error"] }
|
||||
}
|
||||
|
||||
# Redis error detection
|
||||
if [type] == "redis" {
|
||||
grok {
|
||||
match => { "message" => "%{POSINT:pid}:%{WORD:role} %{MONTHDAY} %{MONTH} %{TIME} %{WORD:loglevel} %{GREEDYDATA:redis_message}" }
|
||||
}
|
||||
if [loglevel] in ["WARNING", "ERROR"] {
|
||||
mutate { add_tag => ["error"] }
|
||||
}
|
||||
}
|
||||
|
||||
# PostgreSQL function error detection (future)
|
||||
# if [type] == "postgres" {
|
||||
# # Parse PostgreSQL log format and detect ERROR/FATAL levels
|
||||
# }
|
||||
}
|
||||
|
||||
# === OUTPUT ===
|
||||
output {
|
||||
if "error" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
# Sentry envelope format
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Update Dockerfile.dev**:
|
||||
- Install Bugsink (pip package or binary)
|
||||
- Install Logstash (Elastic APT repository)
|
||||
- Add systemd service files for both
|
||||
|
||||
2. **PostgreSQL initialization**:
|
||||
- Add Bugsink user/database creation to `sql/00-init-extensions.sql`
|
||||
|
||||
3. **Backend SDK integration**:
|
||||
- Install `@sentry/node`
|
||||
- Initialize in `server.ts` before Express app
|
||||
- Configure error handler middleware integration
|
||||
|
||||
4. **Frontend SDK integration**:
|
||||
- Install `@sentry/react`
|
||||
- Wrap `App` component with `Sentry.ErrorBoundary`
|
||||
- Configure in `src/index.tsx`
|
||||
|
||||
5. **Environment configuration**:
|
||||
- Add Bugsink variables to `src/config/env.ts`
|
||||
- Update `.env.example` and `compose.dev.yml`
|
||||
|
||||
6. **Logstash configuration**:
|
||||
- Create pipeline config for Pino → Bugsink
|
||||
- Configure Pino to write to log file in addition to stdout
|
||||
- Configure Redis log monitoring (connection errors, slow commands)
|
||||
|
||||
7. **MCP server documentation**:
|
||||
- Document `bugsink-mcp` setup in CLAUDE.md
|
||||
|
||||
8. **PostgreSQL function logging** (future):
|
||||
- Configure PostgreSQL to log function execution errors
|
||||
- Add Logstash input for PostgreSQL logs
|
||||
- Define filter rules for function-level error detection
|
||||
- _Note: Ask for implementation details when this step is reached_
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Full observability**: Aggregated view of errors, trends, and performance
|
||||
- **Self-hosted**: No external SaaS dependencies or subscription costs
|
||||
- **SDK compatibility**: Leverages mature Sentry SDKs with excellent documentation
|
||||
- **AI integration**: MCP server enables Claude Code to query and analyze errors
|
||||
- **Unified architecture**: Same setup works in dev container and production
|
||||
- **Lightweight**: Bugsink runs in a single process, unlike full Sentry (16GB+ RAM)
|
||||
|
||||
### Negative
|
||||
|
||||
- **Additional services**: Bugsink and Logstash add complexity to the container
|
||||
- **PostgreSQL overhead**: Additional database for error tracking
|
||||
- **Initial setup**: Requires configuration of multiple components
|
||||
- **Logstash learning curve**: Pipeline configuration requires Logstash knowledge
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Full Sentry self-hosted**: Rejected due to complexity (Kafka, Redis, ClickHouse, 16GB+ RAM minimum)
|
||||
2. **GlitchTip**: Considered, but Bugsink is lighter weight and easier to deploy
|
||||
3. **Sentry SaaS**: Rejected due to self-hosted requirement
|
||||
4. **Custom error aggregation**: Rejected in favor of proven Sentry SDK ecosystem
|
||||
|
||||
## References
|
||||
|
||||
- [Bugsink Documentation](https://www.bugsink.com/docs/)
|
||||
- [Bugsink Docker Install](https://www.bugsink.com/docs/docker-install/)
|
||||
- [@sentry/node Documentation](https://docs.sentry.io/platforms/javascript/guides/node/)
|
||||
- [@sentry/react Documentation](https://docs.sentry.io/platforms/javascript/guides/react/)
|
||||
- [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp)
|
||||
- [Logstash Reference](https://www.elastic.co/guide/en/logstash/current/index.html)
|
||||
272
docs/adr/0015-error-tracking-and-observability.md
Normal file
272
docs/adr/0015-error-tracking-and-observability.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# ADR-015: Error Tracking and Observability
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Updated**: 2026-01-26 (user context integration completed)
|
||||
|
||||
**Related**: [ADR-056](./0056-application-performance-monitoring.md) (Application Performance Monitoring)
|
||||
|
||||
## Context
|
||||
|
||||
While ADR-004 established structured logging with Pino, the application lacks a high-level, aggregated view of its health and errors. It's difficult to spot trends, identify recurring issues, or be proactively notified of new types of errors.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **Self-hosted**: No external SaaS dependencies for error tracking
|
||||
2. **Sentry SDK compatible**: Leverage mature, well-documented SDKs
|
||||
3. **Lightweight**: Minimal resource overhead in the dev container
|
||||
4. **Production-ready**: Same architecture works on bare-metal production servers
|
||||
5. **AI-accessible**: MCP server integration for Claude Code and other AI tools
|
||||
|
||||
**Note**: Application Performance Monitoring (APM) and distributed tracing are covered separately in [ADR-056](./0056-application-performance-monitoring.md).
|
||||
|
||||
## Decision
|
||||
|
||||
We implement a self-hosted error tracking stack using **Bugsink** as the Sentry-compatible backend, with the following components:
|
||||
|
||||
### 1. Error Tracking Backend: Bugsink
|
||||
|
||||
**Bugsink** is a lightweight, self-hosted Sentry alternative that:
|
||||
|
||||
- Runs as a single process (no Kafka, Redis, ClickHouse required)
|
||||
- Is fully compatible with Sentry SDKs
|
||||
- Supports ARM64 and AMD64 architectures
|
||||
- Can use SQLite (dev) or PostgreSQL (production)
|
||||
|
||||
**Deployment**:
|
||||
|
||||
- **Dev container**: Installed as a systemd service inside the container
|
||||
- **Production**: Runs as a systemd service on bare-metal, listening on localhost only
|
||||
- **Database**: Uses PostgreSQL with a dedicated `bugsink` user and `bugsink` database (same PostgreSQL instance as the main application)
|
||||
|
||||
### 2. Backend Integration: @sentry/node
|
||||
|
||||
The Express backend integrates `@sentry/node` SDK to:
|
||||
|
||||
- Capture unhandled exceptions before PM2/process manager restarts
|
||||
- Report errors with full stack traces and context
|
||||
- Integrate with Pino logger for breadcrumbs
|
||||
- Filter errors by severity (only 5xx errors sent by default)
|
||||
|
||||
### 3. Frontend Integration: @sentry/react
|
||||
|
||||
The React frontend integrates `@sentry/react` SDK to:
|
||||
|
||||
- Wrap the app in an Error Boundary for graceful error handling
|
||||
- Capture unhandled JavaScript errors
|
||||
- Report errors with component stack traces
|
||||
- Filter out browser extension errors
|
||||
- **Frontend Error Correlation**: The global API client intercepts 4xx/5xx responses and can attach the `x-request-id` header to Sentry scope for correlation with backend logs
|
||||
|
||||
### 4. Log Aggregation: Logstash
|
||||
|
||||
**Logstash** parses application and infrastructure logs, forwarding error patterns to Bugsink:
|
||||
|
||||
- **Installation**: Installed inside the dev container (and on bare-metal prod servers)
|
||||
- **Inputs**:
|
||||
- Pino JSON logs from the Node.js application (PM2 managed)
|
||||
- Redis logs (connection errors, memory warnings, slow commands)
|
||||
- PostgreSQL function logs (via `fn_log()` - see ADR-050)
|
||||
- NGINX access/error logs
|
||||
- **Filter**: Identifies error-level logs (5xx responses, unhandled exceptions, Redis errors)
|
||||
- **Output**: Sends to Bugsink via Sentry-compatible HTTP API
|
||||
|
||||
This provides a secondary error capture path for:
|
||||
|
||||
- Errors that occur before Sentry SDK initialization
|
||||
- Log-based errors that don't throw exceptions
|
||||
- Redis connection/performance issues
|
||||
- Database function errors and slow queries
|
||||
- Historical error analysis from log files
|
||||
|
||||
### 5. MCP Server Integration: bugsink-mcp
|
||||
|
||||
For AI tool integration (Claude Code, Cursor, etc.), we use the open-source [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp) server:
|
||||
|
||||
- **No code changes required**: Configurable via environment variables
|
||||
- **Capabilities**: List projects, get issues, view events, get stacktraces, manage releases
|
||||
- **Configuration**:
|
||||
- `BUGSINK_URL`: Points to Bugsink instance (`http://localhost:8000` for dev, `https://bugsink.projectium.com` for prod)
|
||||
- `BUGSINK_API_TOKEN`: API token from Bugsink (created via Django management command)
|
||||
- `BUGSINK_ORG_SLUG`: Organization identifier (usually "sentry")
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
+---------------------------------------------------------------------------+
|
||||
| Dev Container / Production Server |
|
||||
+---------------------------------------------------------------------------+
|
||||
| |
|
||||
| +------------------+ +------------------+ |
|
||||
| | Frontend | | Backend | |
|
||||
| | (React) | | (Express) | |
|
||||
| | @sentry/react | | @sentry/node | |
|
||||
| +--------+---------+ +--------+---------+ |
|
||||
| | | |
|
||||
| | Sentry SDK Protocol | |
|
||||
| +-----------+---------------+ |
|
||||
| | |
|
||||
| v |
|
||||
| +----------------------+ |
|
||||
| | Bugsink | |
|
||||
| | (localhost:8000) |<------------------+ |
|
||||
| | | | |
|
||||
| | PostgreSQL backend | | |
|
||||
| +----------------------+ | |
|
||||
| | |
|
||||
| +----------------------+ | |
|
||||
| | Logstash |-------------------+ |
|
||||
| | (Log Aggregator) | Sentry Output |
|
||||
| | | |
|
||||
| | Inputs: | |
|
||||
| | - PM2/Pino logs | |
|
||||
| | - Redis logs | |
|
||||
| | - PostgreSQL logs | |
|
||||
| | - NGINX logs | |
|
||||
| +----------------------+ |
|
||||
| ^ ^ ^ ^ |
|
||||
| | | | | |
|
||||
| +-----------+ | | +-----------+ |
|
||||
| | | | | |
|
||||
| +----+-----+ +-----+----+ +-----+----+ +-----+----+ |
|
||||
| | PM2 | | Redis | | PostgreSQL| | NGINX | |
|
||||
| | Logs | | Logs | | Logs | | Logs | |
|
||||
| +----------+ +----------+ +-----------+ +---------+ |
|
||||
| |
|
||||
| +----------------------+ |
|
||||
| | PostgreSQL | |
|
||||
| | +----------------+ | |
|
||||
| | | flyer_crawler | | (main app database) |
|
||||
| | +----------------+ | |
|
||||
| | | bugsink | | (error tracking database) |
|
||||
| | +----------------+ | |
|
||||
| +----------------------+ |
|
||||
| |
|
||||
+---------------------------------------------------------------------------+
|
||||
|
||||
External (Developer Machine):
|
||||
+--------------------------------------+
|
||||
| Claude Code / Cursor / VS Code |
|
||||
| +--------------------------------+ |
|
||||
| | bugsink-mcp | |
|
||||
| | (MCP Server) | |
|
||||
| | | |
|
||||
| | BUGSINK_URL=http://localhost:8000
|
||||
| | BUGSINK_API_TOKEN=... | |
|
||||
| | BUGSINK_ORG_SLUG=... | |
|
||||
| +--------------------------------+ |
|
||||
+--------------------------------------+
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Bugsink installed and configured in dev container
|
||||
- [x] PostgreSQL `bugsink` database and user created
|
||||
- [x] `@sentry/node` SDK integrated in backend (`src/services/sentry.server.ts`)
|
||||
- [x] `@sentry/react` SDK integrated in frontend (`src/services/sentry.client.ts`)
|
||||
- [x] ErrorBoundary component created (`src/components/ErrorBoundary.tsx`)
|
||||
- [x] ErrorBoundary wrapped around app (`src/providers/AppProviders.tsx`)
|
||||
- [x] Logstash pipeline configured for PM2/Pino, Redis, PostgreSQL, NGINX logs
|
||||
- [x] MCP server (`bugsink-mcp`) documented and configured
|
||||
- [x] Environment variables added to `src/config/env.ts` and frontend `src/config.ts`
|
||||
- [x] Browser extension errors filtered in `beforeSend`
|
||||
- [x] 5xx error filtering in backend error handler
|
||||
|
||||
### Recently Completed (2026-01-26)
|
||||
|
||||
- [x] **User context after authentication**: Integrated `setUser()` calls in `AuthProvider.tsx` to associate errors with authenticated users
|
||||
- Called on profile fetch from query (line 44-49)
|
||||
- Called on direct login with profile (line 94-99)
|
||||
- Called on login with profile fetch (line 124-129)
|
||||
- Cleared on logout (line 76-77)
|
||||
- Maps `user_id` → `id`, `email` → `email`, `full_name` → `username`
|
||||
|
||||
This completes the error tracking implementation - all errors are now associated with the authenticated user who encountered them, enabling user-specific error analysis and debugging.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default (Dev) |
|
||||
| -------------------- | -------------------------------- | -------------------------- |
|
||||
| `SENTRY_DSN` | Sentry-compatible DSN (backend) | Set after project creation |
|
||||
| `VITE_SENTRY_DSN` | Sentry-compatible DSN (frontend) | Set after project creation |
|
||||
| `SENTRY_ENVIRONMENT` | Environment name | `development` |
|
||||
| `SENTRY_DEBUG` | Enable debug logging | `false` |
|
||||
| `SENTRY_ENABLED` | Enable/disable error reporting | `true` |
|
||||
|
||||
### PostgreSQL Setup
|
||||
|
||||
```sql
|
||||
-- Create dedicated Bugsink database and user
|
||||
CREATE USER bugsink WITH PASSWORD 'bugsink_dev_password';
|
||||
CREATE DATABASE bugsink OWNER bugsink;
|
||||
GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink;
|
||||
```
|
||||
|
||||
### Bugsink Configuration
|
||||
|
||||
```bash
|
||||
# Environment variables for Bugsink service
|
||||
SECRET_KEY=<random-50-char-string>
|
||||
DATABASE_URL=postgresql://bugsink:bugsink_dev_password@localhost:5432/bugsink
|
||||
BASE_URL=http://localhost:8000
|
||||
PORT=8000
|
||||
```
|
||||
|
||||
### Logstash Pipeline
|
||||
|
||||
See `docker/logstash/bugsink.conf` for the full pipeline configuration.
|
||||
|
||||
Key routing:
|
||||
|
||||
| Source | Bugsink Project |
|
||||
| --------------- | --------------- |
|
||||
| Backend (Pino) | Backend API |
|
||||
| Worker (Pino) | Backend API |
|
||||
| PostgreSQL logs | Backend API |
|
||||
| Vite logs | Infrastructure |
|
||||
| Redis logs | Infrastructure |
|
||||
| NGINX logs | Infrastructure |
|
||||
| Frontend errors | Frontend |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Full observability**: Aggregated view of errors and trends
|
||||
- **Self-hosted**: No external SaaS dependencies or subscription costs
|
||||
- **SDK compatibility**: Leverages mature Sentry SDKs with excellent documentation
|
||||
- **AI integration**: MCP server enables Claude Code to query and analyze errors
|
||||
- **Unified architecture**: Same setup works in dev container and production
|
||||
- **Lightweight**: Bugsink runs in a single process, unlike full Sentry (16GB+ RAM)
|
||||
- **Error correlation**: Request IDs allow correlation between frontend errors and backend logs
|
||||
|
||||
### Negative
|
||||
|
||||
- **Additional services**: Bugsink and Logstash add complexity to the container
|
||||
- **PostgreSQL overhead**: Additional database for error tracking
|
||||
- **Initial setup**: Requires configuration of multiple components
|
||||
- **Logstash learning curve**: Pipeline configuration requires Logstash knowledge
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Full Sentry self-hosted**: Rejected due to complexity (Kafka, Redis, ClickHouse, 16GB+ RAM minimum)
|
||||
2. **GlitchTip**: Considered, but Bugsink is lighter weight and easier to deploy
|
||||
3. **Sentry SaaS**: Rejected due to self-hosted requirement
|
||||
4. **Custom error aggregation**: Rejected in favor of proven Sentry SDK ecosystem
|
||||
|
||||
## References
|
||||
|
||||
- [Bugsink Documentation](https://www.bugsink.com/docs/)
|
||||
- [Bugsink Docker Install](https://www.bugsink.com/docs/docker-install/)
|
||||
- [@sentry/node Documentation](https://docs.sentry.io/platforms/javascript/guides/node/)
|
||||
- [@sentry/react Documentation](https://docs.sentry.io/platforms/javascript/guides/react/)
|
||||
- [bugsink-mcp](https://github.com/j-shelfwood/bugsink-mcp)
|
||||
- [Logstash Reference](https://www.elastic.co/guide/en/logstash/current/index.html)
|
||||
- [ADR-050: PostgreSQL Function Observability](./0050-postgresql-function-observability.md)
|
||||
- [ADR-056: Application Performance Monitoring](./0056-application-performance-monitoring.md)
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Partially Implemented
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Implemented**: 2026-01-09 (Local auth only)
|
||||
**Implemented**: 2026-01-09 (Local auth + JWT), 2026-01-26 (OAuth enabled)
|
||||
|
||||
## Context
|
||||
|
||||
The application requires a secure authentication system that supports both traditional email/password login and social OAuth providers (Google, GitHub). The system must handle user sessions, token refresh, account security (lockout after failed attempts), and integrate seamlessly with the existing Express middleware pipeline.
|
||||
|
||||
Currently, **only local authentication is enabled**. OAuth strategies are fully implemented but commented out, pending configuration of OAuth provider credentials.
|
||||
**All authentication methods are now fully implemented**: Local authentication (email/password), JWT tokens, and OAuth (Google + GitHub). OAuth strategies use conditional registration - they activate automatically when the corresponding environment variables are configured.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a stateless JWT-based authentication system with the following components:
|
||||
|
||||
1. **Local Authentication**: Email/password login with bcrypt hashing.
|
||||
2. **OAuth Authentication**: Google and GitHub OAuth 2.0 (currently disabled).
|
||||
2. **OAuth Authentication**: Google and GitHub OAuth 2.0 (conditionally enabled via environment variables).
|
||||
3. **JWT Access Tokens**: Short-lived tokens (15 minutes) for API authentication.
|
||||
4. **Refresh Tokens**: Long-lived tokens (7 days) stored in HTTP-only cookies.
|
||||
5. **Account Security**: Lockout after 5 failed login attempts for 15 minutes.
|
||||
@@ -59,7 +59,7 @@ We will implement a stateless JWT-based authentication system with the following
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────┐ │ │ │
|
||||
│ └────────>│ OAuth │─────────────┘ │ │
|
||||
│ (disabled) │ Provider │ │ │
|
||||
│ │ Provider │ │ │
|
||||
│ └──────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌──────────┐ ┌──────────┐ │ │
|
||||
@@ -130,72 +130,139 @@ passport.use(
|
||||
- Refresh token: 7 days expiry, 64-byte random hex
|
||||
- Refresh token stored in HTTP-only cookie with `secure` flag in production
|
||||
|
||||
### OAuth Strategies (Disabled)
|
||||
### OAuth Strategies (Conditionally Enabled)
|
||||
|
||||
OAuth strategies are **fully implemented** and activate automatically when the corresponding environment variables are set. The strategies use conditional registration to gracefully handle missing credentials.
|
||||
|
||||
#### Google OAuth
|
||||
|
||||
Located in `src/routes/passport.routes.ts` (lines 167-217, commented):
|
||||
Located in `src/config/passport.ts` (lines 167-235):
|
||||
|
||||
```typescript
|
||||
// passport.use(new GoogleStrategy({
|
||||
// clientID: process.env.GOOGLE_CLIENT_ID!,
|
||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
// callbackURL: '/api/auth/google/callback',
|
||||
// scope: ['profile', 'email']
|
||||
// },
|
||||
// async (accessToken, refreshToken, profile, done) => {
|
||||
// const email = profile.emails?.[0]?.value;
|
||||
// const user = await db.findUserByEmail(email);
|
||||
// if (user) {
|
||||
// return done(null, user);
|
||||
// }
|
||||
// // Create new user with null password_hash
|
||||
// const newUser = await db.createUser(email, null, {
|
||||
// full_name: profile.displayName,
|
||||
// avatar_url: profile.photos?.[0]?.value
|
||||
// });
|
||||
// return done(null, newUser);
|
||||
// }
|
||||
// ));
|
||||
// Only register the strategy if the required environment variables are set.
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/google/callback',
|
||||
scope: ['profile', 'email'],
|
||||
},
|
||||
async (_accessToken, _refreshToken, profile, done) => {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) {
|
||||
return done(new Error('No email found in Google profile.'), false);
|
||||
}
|
||||
|
||||
const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger);
|
||||
if (existingUserProfile) {
|
||||
// User exists, log them in (strip sensitive fields)
|
||||
return done(null, cleanUserProfile);
|
||||
} else {
|
||||
// Create new user with null password_hash for OAuth users
|
||||
const newUserProfile = await db.userRepo.createUser(
|
||||
email,
|
||||
null,
|
||||
{
|
||||
full_name: profile.displayName,
|
||||
avatar_url: profile.photos?.[0]?.value,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
return done(null, newUserProfile);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
logger.info('[Passport] Google OAuth strategy registered.');
|
||||
} else {
|
||||
logger.warn('[Passport] Google OAuth strategy NOT registered: credentials not set.');
|
||||
}
|
||||
```
|
||||
|
||||
#### GitHub OAuth
|
||||
|
||||
Located in `src/routes/passport.routes.ts` (lines 219-269, commented):
|
||||
Located in `src/config/passport.ts` (lines 237-310):
|
||||
|
||||
```typescript
|
||||
// passport.use(new GitHubStrategy({
|
||||
// clientID: process.env.GITHUB_CLIENT_ID!,
|
||||
// clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
// callbackURL: '/api/auth/github/callback',
|
||||
// scope: ['user:email']
|
||||
// },
|
||||
// async (accessToken, refreshToken, profile, done) => {
|
||||
// const email = profile.emails?.[0]?.value;
|
||||
// // Similar flow to Google OAuth
|
||||
// }
|
||||
// ));
|
||||
// Only register the strategy if the required environment variables are set.
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GitHubStrategy(
|
||||
{
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/github/callback',
|
||||
scope: ['user:email'],
|
||||
},
|
||||
async (_accessToken, _refreshToken, profile, done) => {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) {
|
||||
return done(new Error('No public email found in GitHub profile.'), false);
|
||||
}
|
||||
// Same flow as Google OAuth - find or create user
|
||||
},
|
||||
),
|
||||
);
|
||||
logger.info('[Passport] GitHub OAuth strategy registered.');
|
||||
} else {
|
||||
logger.warn('[Passport] GitHub OAuth strategy NOT registered: credentials not set.');
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth Routes (Disabled)
|
||||
#### OAuth Routes (Active)
|
||||
|
||||
Located in `src/routes/auth.routes.ts` (lines 289-315, commented):
|
||||
Located in `src/routes/auth.routes.ts` (lines 587-609):
|
||||
|
||||
```typescript
|
||||
// const handleOAuthCallback = (req, res) => {
|
||||
// const user = req.user;
|
||||
// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
// const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
//
|
||||
// await db.saveRefreshToken(user.user_id, refreshToken);
|
||||
// res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true });
|
||||
// res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
|
||||
// };
|
||||
// Google OAuth routes
|
||||
router.get('/google', passport.authenticate('google', { session: false }));
|
||||
router.get(
|
||||
'/google/callback',
|
||||
passport.authenticate('google', {
|
||||
session: false,
|
||||
failureRedirect: '/?error=google_auth_failed',
|
||||
}),
|
||||
createOAuthCallbackHandler('google'),
|
||||
);
|
||||
|
||||
// router.get('/google', passport.authenticate('google', { session: false }));
|
||||
// router.get('/google/callback', passport.authenticate('google', { ... }), handleOAuthCallback);
|
||||
// router.get('/github', passport.authenticate('github', { session: false }));
|
||||
// router.get('/github/callback', passport.authenticate('github', { ... }), handleOAuthCallback);
|
||||
// GitHub OAuth routes
|
||||
router.get('/github', passport.authenticate('github', { session: false }));
|
||||
router.get(
|
||||
'/github/callback',
|
||||
passport.authenticate('github', {
|
||||
session: false,
|
||||
failureRedirect: '/?error=github_auth_failed',
|
||||
}),
|
||||
createOAuthCallbackHandler('github'),
|
||||
);
|
||||
```
|
||||
|
||||
#### OAuth Callback Handler
|
||||
|
||||
The callback handler generates tokens and redirects to the frontend:
|
||||
|
||||
```typescript
|
||||
const createOAuthCallbackHandler = (provider: 'google' | 'github') => {
|
||||
return async (req: Request, res: Response) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
|
||||
userProfile,
|
||||
req.log,
|
||||
);
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
|
||||
// Redirect to frontend with provider-specific token param
|
||||
const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken';
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`);
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
@@ -248,11 +315,13 @@ export const mockAuth = (req, res, next) => {
|
||||
};
|
||||
```
|
||||
|
||||
## Enabling OAuth
|
||||
## Configuring OAuth Providers
|
||||
|
||||
OAuth is fully implemented and activates automatically when credentials are provided. No code changes are required.
|
||||
|
||||
### Step 1: Set Environment Variables
|
||||
|
||||
Add to `.env`:
|
||||
Add to your environment (`.env.local` for development, Gitea secrets for production):
|
||||
|
||||
```bash
|
||||
# Google OAuth
|
||||
@@ -283,54 +352,29 @@ GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
- Development: `http://localhost:3001/api/auth/github/callback`
|
||||
- Production: `https://your-domain.com/api/auth/github/callback`
|
||||
|
||||
### Step 3: Uncomment Backend Code
|
||||
### Step 3: Restart the Application
|
||||
|
||||
**In `src/routes/passport.routes.ts`**:
|
||||
After setting the environment variables, restart PM2:
|
||||
|
||||
1. Uncomment import statements (lines 5-6):
|
||||
|
||||
```typescript
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||
```
|
||||
|
||||
2. Uncomment Google strategy (lines 167-217)
|
||||
3. Uncomment GitHub strategy (lines 219-269)
|
||||
|
||||
**In `src/routes/auth.routes.ts`**:
|
||||
|
||||
1. Uncomment `handleOAuthCallback` function (lines 291-309)
|
||||
2. Uncomment OAuth routes (lines 311-315)
|
||||
|
||||
### Step 4: Add Frontend OAuth Buttons
|
||||
|
||||
Create login buttons that redirect to:
|
||||
|
||||
- Google: `GET /api/auth/google`
|
||||
- GitHub: `GET /api/auth/github`
|
||||
|
||||
Handle callback at `/auth/callback?token=<accessToken>`:
|
||||
|
||||
1. Extract token from URL
|
||||
2. Store in client-side token storage
|
||||
3. Redirect to dashboard
|
||||
|
||||
### Step 5: Handle OAuth Callback Page
|
||||
|
||||
Create `src/pages/AuthCallback.tsx`:
|
||||
|
||||
```typescript
|
||||
const AuthCallback = () => {
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
if (token) {
|
||||
setToken(token);
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/login?error=auth_failed');
|
||||
}
|
||||
};
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev pm2 restart all
|
||||
```
|
||||
|
||||
The Passport configuration will automatically register the OAuth strategies when it detects the credentials. Check the logs for confirmation:
|
||||
|
||||
```text
|
||||
[Passport] Google OAuth strategy registered.
|
||||
[Passport] GitHub OAuth strategy registered.
|
||||
```
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
OAuth login buttons are implemented in `src/client/pages/AuthView.tsx`. The frontend:
|
||||
|
||||
1. Redirects users to `/api/auth/google` or `/api/auth/github`
|
||||
2. Handles the callback via the `useAppInitialization` hook which looks for `googleAuthToken` or `githubAuthToken` query parameters
|
||||
3. Stores the token and redirects to the dashboard
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No OAuth Provider ID Mapping**: Users are identified by email only. If a user has accounts with different emails on Google and GitHub, they create separate accounts.
|
||||
@@ -372,31 +416,32 @@ const AuthCallback = () => {
|
||||
- **Stateless Architecture**: No session storage required; scales horizontally.
|
||||
- **Secure by Default**: HTTP-only cookies, short token expiry, bcrypt hashing.
|
||||
- **Account Protection**: Lockout prevents brute-force attacks.
|
||||
- **Flexible OAuth**: Can enable/disable OAuth without code changes (just env vars + uncommenting).
|
||||
- **Graceful Degradation**: System works with local auth only.
|
||||
- **Flexible OAuth**: OAuth activates automatically when credentials are set - no code changes needed.
|
||||
- **Graceful Degradation**: System works with local auth only when OAuth credentials are not configured.
|
||||
- **Full Feature Set**: Both local and OAuth authentication are production-ready.
|
||||
|
||||
### Negative
|
||||
|
||||
- **OAuth Disabled by Default**: Requires manual uncommenting to enable.
|
||||
- **No Account Linking**: Multiple OAuth providers create separate accounts.
|
||||
- **Frontend Work Required**: OAuth login buttons don't exist yet.
|
||||
- **Token in URL**: OAuth callback passes token in URL (visible in browser history).
|
||||
- **No Account Linking**: Multiple OAuth providers create separate accounts if emails differ.
|
||||
- **Token in URL**: OAuth callback passes token in URL query parameter (visible in browser history).
|
||||
- **Email-Based Identity**: OAuth users are identified by email only, not provider-specific IDs.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Document OAuth enablement steps clearly (see [../architecture/AUTHENTICATION.md](../architecture/AUTHENTICATION.md)).
|
||||
- Document OAuth configuration steps clearly (see [../architecture/AUTHENTICATION.md](../architecture/AUTHENTICATION.md)).
|
||||
- Consider adding OAuth provider ID columns for future account linking.
|
||||
- Use URL fragment (`#token=`) instead of query parameter for callback.
|
||||
- Consider using URL fragment (`#token=`) instead of query parameter for callback in future enhancement.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------ | ------------------------------------------------ |
|
||||
| `src/routes/passport.routes.ts` | Passport strategies (local, JWT, OAuth) |
|
||||
| `src/config/passport.ts` | Passport strategies (local, JWT, OAuth) |
|
||||
| `src/routes/auth.routes.ts` | Auth endpoints (login, register, refresh, OAuth) |
|
||||
| `src/services/authService.ts` | Auth business logic |
|
||||
| `src/services/db/user.db.ts` | User database operations |
|
||||
| `src/config/env.ts` | Environment variable validation |
|
||||
| `src/client/pages/AuthView.tsx` | Frontend login/register UI with OAuth buttons |
|
||||
| [AUTHENTICATION.md](../architecture/AUTHENTICATION.md) | OAuth setup guide |
|
||||
| `.env.example` | Environment variable template |
|
||||
|
||||
@@ -409,11 +454,11 @@ const AuthCallback = () => {
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Enable OAuth**: Uncomment strategies and configure providers.
|
||||
2. **Add OAuth Provider Mapping Table**: Store `googleId`, `githubId` for account linking.
|
||||
3. **Implement Account Linking**: Allow users to connect multiple OAuth providers.
|
||||
4. **Add Password to OAuth Users**: Allow OAuth users to set a password.
|
||||
5. **Implement PKCE**: Add PKCE flow for enhanced OAuth security.
|
||||
6. **Token in Fragment**: Use URL fragment for OAuth callback token.
|
||||
7. **OAuth Token Storage**: Store OAuth refresh tokens for provider API access.
|
||||
8. **Magic Link Login**: Add passwordless email login option.
|
||||
1. **Add OAuth Provider Mapping Table**: Store `googleId`, `githubId` for account linking.
|
||||
2. **Implement Account Linking**: Allow users to connect multiple OAuth providers.
|
||||
3. **Add Password to OAuth Users**: Allow OAuth users to set a password for local login.
|
||||
4. **Implement PKCE**: Add PKCE flow for enhanced OAuth security.
|
||||
5. **Token in Fragment**: Use URL fragment for OAuth callback token instead of query parameter.
|
||||
6. **OAuth Token Storage**: Store OAuth refresh tokens for provider API access.
|
||||
7. **Magic Link Login**: Add passwordless email login option.
|
||||
8. **Additional OAuth Providers**: Support for Apple, Microsoft, or other providers.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Related**: [ADR-015](0015-application-performance-monitoring-and-error-tracking.md), [ADR-004](0004-standardized-application-wide-structured-logging.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Related**: [ADR-004](0004-standardized-application-wide-structured-logging.md)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -17,7 +19,9 @@ We will adopt a namespace-based debug filter pattern, similar to the `debug` npm
|
||||
|
||||
## Implementation
|
||||
|
||||
In `src/services/logger.server.ts`:
|
||||
### Core Implementation (Completed 2026-01-11)
|
||||
|
||||
Implemented in [src/services/logger.server.ts:140-150](src/services/logger.server.ts#L140-L150):
|
||||
|
||||
```typescript
|
||||
const debugModules = (process.env.DEBUG_MODULES || '').split(',').map((s) => s.trim());
|
||||
@@ -33,10 +37,100 @@ export const createScopedLogger = (moduleName: string) => {
|
||||
};
|
||||
```
|
||||
|
||||
### Adopted Services (Completed 2026-01-26)
|
||||
|
||||
Services currently using `createScopedLogger`:
|
||||
|
||||
- `ai-service` - AI/Gemini integration ([src/services/aiService.server.ts:1020](src/services/aiService.server.ts#L1020))
|
||||
- `flyer-processing-service` - Flyer upload and processing ([src/services/flyerProcessingService.server.ts:20](src/services/flyerProcessingService.server.ts#L20))
|
||||
|
||||
## Usage
|
||||
|
||||
To debug only AI and Database interactions:
|
||||
### Enable Debug Logging for Specific Modules
|
||||
|
||||
To debug only AI and flyer processing:
|
||||
|
||||
```bash
|
||||
DEBUG_MODULES=ai-service,db-repo npm run dev
|
||||
DEBUG_MODULES=ai-service,flyer-processing-service npm run dev
|
||||
```
|
||||
|
||||
### Enable All Debug Logging
|
||||
|
||||
Use wildcard to enable debug logging for all modules:
|
||||
|
||||
```bash
|
||||
DEBUG_MODULES=* npm run dev
|
||||
```
|
||||
|
||||
### Common Module Names
|
||||
|
||||
| Module Name | Purpose | File |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------- |
|
||||
| `ai-service` | AI/Gemini API interactions | `src/services/aiService.server.ts` |
|
||||
| `flyer-processing-service` | Flyer upload, validation, and processing | `src/services/flyerProcessingService.server.ts` |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Scoped Loggers for Long-Running Services**: Services with complex workflows or external API calls should use `createScopedLogger` to allow targeted debugging.
|
||||
|
||||
2. **Use Child Loggers for Contextual Data**: Even within scoped loggers, create child loggers with job/request-specific context:
|
||||
|
||||
```typescript
|
||||
const logger = createScopedLogger('my-service');
|
||||
|
||||
async function processJob(job: Job) {
|
||||
const jobLogger = logger.child({ jobId: job.id, jobName: job.name });
|
||||
jobLogger.debug('Starting job processing');
|
||||
}
|
||||
```
|
||||
|
||||
3. **Module Naming Convention**: Use kebab-case suffixed with `-service` or `-worker` (e.g., `ai-service`, `email-worker`).
|
||||
|
||||
4. **Production Usage**: `DEBUG_MODULES` can be set in production for temporary debugging, but should not be used continuously due to increased log volume.
|
||||
|
||||
## Examples
|
||||
|
||||
### Development Debugging
|
||||
|
||||
Debug AI service issues during development:
|
||||
|
||||
```bash
|
||||
# Dev container
|
||||
DEBUG_MODULES=ai-service npm run dev
|
||||
|
||||
# Or via PM2
|
||||
DEBUG_MODULES=ai-service pm2 restart flyer-crawler-api-dev
|
||||
```
|
||||
|
||||
### Production Troubleshooting
|
||||
|
||||
Temporarily enable debug logging for a specific subsystem:
|
||||
|
||||
```bash
|
||||
# SSH into production server
|
||||
ssh root@projectium.com
|
||||
|
||||
# Set environment variable and restart
|
||||
DEBUG_MODULES=ai-service pm2 restart flyer-crawler-api
|
||||
|
||||
# View logs
|
||||
pm2 logs flyer-crawler-api --lines 100
|
||||
|
||||
# Disable debug logging
|
||||
pm2 unset DEBUG_MODULES flyer-crawler-api
|
||||
pm2 restart flyer-crawler-api
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**:
|
||||
|
||||
- Developers can inspect detailed logs for specific subsystems without log flooding
|
||||
- Production debugging becomes more targeted and efficient
|
||||
- No performance impact when debug logging is disabled
|
||||
- Compatible with existing Pino logging infrastructure
|
||||
|
||||
**Negative**:
|
||||
|
||||
- Requires developers to know module names (mitigated by documentation above)
|
||||
- Not all services have adopted scoped loggers yet (gradual migration)
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Fully Implemented)
|
||||
|
||||
**Implementation Status**:
|
||||
|
||||
- ✅ BullMQ worker stall configuration (complete)
|
||||
- ✅ Basic health endpoints (/live, /ready, /redis, etc.)
|
||||
- ✅ /health/queues endpoint (complete)
|
||||
- ✅ Worker heartbeat mechanism (complete)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -60,3 +67,76 @@ The `/health/queues` endpoint will:
|
||||
**Negative**:
|
||||
|
||||
- Requires configuring external monitoring to poll the new endpoint.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Completed (2026-01-11)
|
||||
|
||||
1. **BullMQ Stall Configuration** - `src/config/workerOptions.ts`
|
||||
- All workers use `defaultWorkerOptions` with:
|
||||
- `stalledInterval: 30000` (30s)
|
||||
- `maxStalledCount: 3`
|
||||
- `lockDuration: 30000` (30s)
|
||||
- Applied to all 9 workers: flyer, email, analytics, cleanup, weekly-analytics, token-cleanup, receipt, expiry-alert, barcode
|
||||
|
||||
2. **Basic Health Endpoints** - `src/routes/health.routes.ts`
|
||||
- `/health/live` - Liveness probe
|
||||
- `/health/ready` - Readiness probe (checks DB, Redis, storage)
|
||||
- `/health/startup` - Startup probe
|
||||
- `/health/redis` - Redis connectivity
|
||||
- `/health/db-pool` - Database connection pool status
|
||||
|
||||
### Implementation Completed (2026-01-26)
|
||||
|
||||
1. **`/health/queues` Endpoint** ✅
|
||||
- Added route to `src/routes/health.routes.ts:511-674`
|
||||
- Iterates through all 9 queues from `src/services/queues.server.ts`
|
||||
- Fetches job counts using BullMQ Queue API: `getJobCounts()`
|
||||
- Returns structured response including both queue metrics and worker heartbeats:
|
||||
|
||||
```typescript
|
||||
{
|
||||
status: 'healthy' | 'unhealthy',
|
||||
timestamp: string,
|
||||
queues: {
|
||||
[queueName]: {
|
||||
waiting: number,
|
||||
active: number,
|
||||
failed: number,
|
||||
delayed: number
|
||||
}
|
||||
},
|
||||
workers: {
|
||||
[workerName]: {
|
||||
alive: boolean,
|
||||
lastSeen?: string,
|
||||
pid?: number,
|
||||
host?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Returns 200 OK if all healthy, 503 if any queue/worker unavailable
|
||||
- Full OpenAPI documentation included
|
||||
|
||||
2. **Worker Heartbeat Mechanism** ✅
|
||||
- Added `updateWorkerHeartbeat()` and `startWorkerHeartbeat()` in `src/services/workers.server.ts:100-149`
|
||||
- Key pattern: `worker:heartbeat:<worker-name>`
|
||||
- Stores: `{ timestamp: ISO8601, pid: number, host: string }`
|
||||
- Updates every 30s with 90s TTL
|
||||
- Integrated with `/health/queues` endpoint (checks if heartbeat < 60s old)
|
||||
- Heartbeat intervals properly cleaned up in `closeWorkers()` and `gracefulShutdown()`
|
||||
|
||||
3. **Comprehensive Tests** ✅
|
||||
- Added 5 test cases in `src/routes/health.routes.test.ts:623-858`
|
||||
- Tests cover: healthy state, queue failures, stale heartbeats, missing heartbeats, Redis errors
|
||||
- All tests follow existing patterns with proper mocking
|
||||
|
||||
### Future Enhancements (Not Implemented)
|
||||
|
||||
1. **Queue Depth Alerting** (Low Priority)
|
||||
- Add configurable thresholds per queue type
|
||||
- Return 500 if `waiting` count exceeds threshold for extended period
|
||||
- Consider using Redis for storing threshold breach timestamps
|
||||
- **Estimate**: 1-2 hours
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-023: Database Normalization and Referential Integrity
|
||||
# ADR-055: Database Normalization and Referential Integrity
|
||||
|
||||
**Date:** 2026-01-19
|
||||
**Status:** Accepted
|
||||
262
docs/adr/0056-application-performance-monitoring.md
Normal file
262
docs/adr/0056-application-performance-monitoring.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# ADR-056: Application Performance Monitoring (APM)
|
||||
|
||||
**Date**: 2026-01-26
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Related**: [ADR-015](./0015-error-tracking-and-observability.md) (Error Tracking and Observability)
|
||||
|
||||
## Context
|
||||
|
||||
Application Performance Monitoring (APM) provides visibility into application behavior through:
|
||||
|
||||
- **Distributed Tracing**: Track requests across services, queues, and database calls
|
||||
- **Performance Metrics**: Response times, throughput, error rates
|
||||
- **Resource Monitoring**: Memory usage, CPU, database connections
|
||||
- **Transaction Analysis**: Identify slow endpoints and bottlenecks
|
||||
|
||||
While ADR-015 covers error tracking and observability, APM is a distinct concern focused on performance rather than errors. The Sentry SDK supports APM through its tracing features, but this capability is currently **intentionally disabled** in our application.
|
||||
|
||||
### Current State
|
||||
|
||||
The Sentry SDK is installed and configured for error tracking (see ADR-015), but APM features are disabled:
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.client.ts
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment,
|
||||
// Performance monitoring - disabled for now to keep it simple
|
||||
tracesSampleRate: 0,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.server.ts
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment || config.server.nodeEnv,
|
||||
// Performance monitoring - disabled for now to keep it simple
|
||||
tracesSampleRate: 0,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Why APM is Currently Disabled
|
||||
|
||||
1. **Complexity**: APM adds overhead and complexity to debugging
|
||||
2. **Bugsink Limitations**: Bugsink's APM support is less mature than its error tracking
|
||||
3. **Resource Overhead**: Tracing adds memory and CPU overhead
|
||||
4. **Focus**: Error tracking provides more immediate value for our current scale
|
||||
5. **Cost**: High sample rates can significantly increase storage requirements
|
||||
|
||||
## Decision
|
||||
|
||||
We propose a **staged approach** to APM implementation:
|
||||
|
||||
### Phase 1: Selective Backend Tracing (Low Priority)
|
||||
|
||||
Enable tracing for specific high-value operations:
|
||||
|
||||
```typescript
|
||||
// Enable tracing for specific transactions only
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
tracesSampleRate: 0, // Keep default at 0
|
||||
|
||||
// Trace only specific high-value transactions
|
||||
tracesSampler: (samplingContext) => {
|
||||
const transactionName = samplingContext.transactionContext?.name;
|
||||
|
||||
// Always trace flyer processing jobs
|
||||
if (transactionName?.includes('flyer-processing')) {
|
||||
return 0.1; // 10% sample rate
|
||||
}
|
||||
|
||||
// Always trace AI/Gemini calls
|
||||
if (transactionName?.includes('gemini')) {
|
||||
return 0.5; // 50% sample rate
|
||||
}
|
||||
|
||||
// Trace slow endpoints (determined by custom logic)
|
||||
if (samplingContext.parentSampled) {
|
||||
return 0.1; // 10% for child transactions
|
||||
}
|
||||
|
||||
return 0; // Don't trace other transactions
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 2: Custom Performance Metrics
|
||||
|
||||
Add custom metrics without full tracing overhead:
|
||||
|
||||
```typescript
|
||||
// Custom metric for slow database queries
|
||||
import { metrics } from '@sentry/node';
|
||||
|
||||
// In repository methods
|
||||
const startTime = performance.now();
|
||||
const result = await pool.query(sql, params);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
metrics.distribution('db.query.duration', duration, {
|
||||
tags: { query_type: 'select', table: 'flyers' },
|
||||
});
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn({ duration, sql }, 'Slow query detected');
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Full APM Integration (Future)
|
||||
|
||||
When/if full APM is needed:
|
||||
|
||||
```typescript
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
tracesSampleRate: 0.1, // 10% of transactions
|
||||
profilesSampleRate: 0.1, // 10% of traced transactions get profiled
|
||||
|
||||
integrations: [
|
||||
// Database tracing
|
||||
Sentry.postgresIntegration(),
|
||||
// Redis tracing
|
||||
Sentry.redisIntegration(),
|
||||
// BullMQ job tracing
|
||||
Sentry.prismaIntegration(), // or custom BullMQ integration
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### To Enable Basic APM
|
||||
|
||||
1. **Update Sentry Configuration**:
|
||||
- Set `tracesSampleRate` > 0 in `src/services/sentry.server.ts`
|
||||
- Set `tracesSampleRate` > 0 in `src/services/sentry.client.ts`
|
||||
- Add environment variable `SENTRY_TRACES_SAMPLE_RATE` (default: 0)
|
||||
|
||||
2. **Add Instrumentation**:
|
||||
- Enable automatic Express instrumentation
|
||||
- Add manual spans for BullMQ job processing
|
||||
- Add database query instrumentation
|
||||
|
||||
3. **Frontend Tracing**:
|
||||
- Add Browser Tracing integration
|
||||
- Configure page load and navigation tracing
|
||||
|
||||
4. **Environment Variables**:
|
||||
|
||||
```bash
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10% sampling
|
||||
SENTRY_PROFILES_SAMPLE_RATE=0 # Profiling disabled
|
||||
```
|
||||
|
||||
5. **Bugsink Configuration**:
|
||||
- Verify Bugsink supports performance data ingestion
|
||||
- Configure retention policies for performance data
|
||||
|
||||
### Configuration Changes Required
|
||||
|
||||
```typescript
|
||||
// src/config/env.ts - Add new config
|
||||
sentry: {
|
||||
dsn: env.SENTRY_DSN,
|
||||
environment: env.SENTRY_ENVIRONMENT,
|
||||
debug: env.SENTRY_DEBUG === 'true',
|
||||
tracesSampleRate: parseFloat(env.SENTRY_TRACES_SAMPLE_RATE || '0'),
|
||||
profilesSampleRate: parseFloat(env.SENTRY_PROFILES_SAMPLE_RATE || '0'),
|
||||
},
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/services/sentry.server.ts - Updated init
|
||||
Sentry.init({
|
||||
dsn: config.sentry.dsn,
|
||||
environment: config.sentry.environment,
|
||||
tracesSampleRate: config.sentry.tracesSampleRate,
|
||||
profilesSampleRate: config.sentry.profilesSampleRate,
|
||||
// ... rest of config
|
||||
});
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### Enabling APM
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Identify performance bottlenecks
|
||||
- Track distributed transactions across services
|
||||
- Profile slow endpoints
|
||||
- Monitor resource utilization trends
|
||||
|
||||
**Costs**:
|
||||
|
||||
- Increased memory usage (~5-15% overhead)
|
||||
- Additional CPU for trace processing
|
||||
- Increased storage in Bugsink/Sentry
|
||||
- More complex debugging (noise in traces)
|
||||
- Potential latency from tracing overhead
|
||||
|
||||
### Keeping APM Disabled
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Simpler operation and debugging
|
||||
- Lower resource overhead
|
||||
- Focused on error tracking (higher priority)
|
||||
- No additional storage costs
|
||||
|
||||
**Costs**:
|
||||
|
||||
- No automated performance insights
|
||||
- Manual profiling required for bottleneck detection
|
||||
- Limited visibility into slow transactions
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **OpenTelemetry**: More vendor-neutral, but adds another dependency and complexity
|
||||
2. **Prometheus + Grafana**: Good for metrics, but doesn't provide distributed tracing
|
||||
3. **Jaeger/Zipkin**: Purpose-built for tracing, but requires additional infrastructure
|
||||
4. **New Relic/Datadog SaaS**: Full-featured but conflicts with self-hosted requirement
|
||||
|
||||
## Current Recommendation
|
||||
|
||||
**Keep APM disabled** (`tracesSampleRate: 0`) until:
|
||||
|
||||
1. Specific performance issues are identified that require tracing
|
||||
2. Bugsink's APM support is verified and tested
|
||||
3. Infrastructure can support the additional overhead
|
||||
4. There is a clear business need for performance visibility
|
||||
|
||||
When enabling APM becomes necessary, start with Phase 1 (selective tracing) to minimize overhead while gaining targeted insights.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive (When Implemented)
|
||||
|
||||
- Automated identification of slow endpoints
|
||||
- Distributed trace visualization across async operations
|
||||
- Correlation between errors and performance issues
|
||||
- Proactive alerting on performance degradation
|
||||
|
||||
### Negative
|
||||
|
||||
- Additional infrastructure complexity
|
||||
- Storage overhead for trace data
|
||||
- Potential performance impact from tracing itself
|
||||
- Learning curve for trace analysis
|
||||
|
||||
## References
|
||||
|
||||
- [Sentry Performance Monitoring](https://docs.sentry.io/product/performance/)
|
||||
- [@sentry/node Performance](https://docs.sentry.io/platforms/javascript/guides/node/performance/)
|
||||
- [@sentry/react Performance](https://docs.sentry.io/platforms/javascript/guides/react/performance/)
|
||||
- [OpenTelemetry](https://opentelemetry.io/) (alternative approach)
|
||||
- [ADR-015: Error Tracking and Observability](./0015-error-tracking-and-observability.md)
|
||||
@@ -15,9 +15,9 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 30 |
|
||||
| Accepted (Fully Implemented) | 40 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 16 |
|
||||
| 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 | Proposed | XL | WebSocket infrastructure |
|
||||
| [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
|
||||
|
||||
@@ -62,25 +62,31 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
| [ADR-029](./0029-secret-rotation-and-key-management.md) | Secret Rotation | Proposed | L | Infrastructure changes needed |
|
||||
| [ADR-032](./0032-rate-limiting-strategy.md) | Rate Limiting | Accepted | - | Fully implemented |
|
||||
| [ADR-033](./0033-file-upload-and-storage-strategy.md) | File Upload & Storage | Accepted | - | Fully implemented |
|
||||
| [ADR-048](./0048-authentication-strategy.md) | Authentication | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 5: Observability & Monitoring
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------------------- | --------------------------- | -------- | ------ | --------------------------------- |
|
||||
| [ADR-004](./0004-standardized-application-wide-structured-logging.md) | Structured Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-015](./0015-application-performance-monitoring-and-error-tracking.md) | APM & Error Tracking | Proposed | M | Third-party integration |
|
||||
| [ADR-050](./0050-postgresql-function-observability.md) | PostgreSQL Fn Observability | Proposed | M | Depends on ADR-015 implementation |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------------- | --------------------------- | -------- | ------ | ------------------------------------------ |
|
||||
| [ADR-004](./0004-standardized-application-wide-structured-logging.md) | Structured Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-015](./0015-error-tracking-and-observability.md) | Error Tracking | Accepted | - | Fully implemented |
|
||||
| [ADR-050](./0050-postgresql-function-observability.md) | PostgreSQL Fn Observability | Accepted | - | Fully implemented |
|
||||
| [ADR-051](./0051-asynchronous-context-propagation.md) | Context Propagation | Accepted | - | Fully implemented |
|
||||
| [ADR-052](./0052-granular-debug-logging-strategy.md) | Granular Debug Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-056](./0056-application-performance-monitoring.md) | APM (Performance) | Proposed | M | tracesSampleRate=0, intentionally disabled |
|
||||
|
||||
### Category 6: Deployment & Operations
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ----------------- | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ------------------ | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
|
||||
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
|
||||
|
||||
### Category 7: Frontend / User Interface
|
||||
|
||||
@@ -99,61 +105,79 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
| [ADR-010](./0010-testing-strategy-and-standards.md) | Testing Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-021](./0021-code-formatting-and-linting-unification.md) | Formatting & Linting | Accepted | - | Fully implemented |
|
||||
| [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) | Naming Conventions | Accepted | - | Fully implemented |
|
||||
| [ADR-040](./0040-testing-economics-and-priorities.md) | Testing Economics | Accepted | - | Fully implemented |
|
||||
| [ADR-045](./0045-test-data-factories-and-fixtures.md) | Test Data Factories | Accepted | - | Fully implemented |
|
||||
| [ADR-047](./0047-project-file-and-folder-organization.md) | Project Organization | Proposed | XL | Major reorganization |
|
||||
|
||||
### Category 9: Architecture Patterns
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------- | --------------------- | -------- | ------ | ----------------- |
|
||||
| [ADR-034](./0034-repository-pattern-standards.md) | Repository Pattern | Accepted | - | Fully implemented |
|
||||
| [ADR-035](./0035-service-layer-architecture.md) | Service Layer | Accepted | - | Fully implemented |
|
||||
| [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) | Event Bus | Accepted | - | Fully implemented |
|
||||
| [ADR-039](./0039-dependency-injection-pattern.md) | Dependency Injection | Accepted | - | Fully implemented |
|
||||
| [ADR-041](./0041-ai-gemini-integration-architecture.md) | AI/Gemini Integration | Accepted | - | Fully implemented |
|
||||
| [ADR-042](./0042-email-and-notification-architecture.md) | Email & Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-043](./0043-express-middleware-pipeline.md) | Middleware Pipeline | Accepted | - | Fully implemented |
|
||||
| [ADR-046](./0046-image-processing-pipeline.md) | Image Processing | Accepted | - | Fully implemented |
|
||||
| [ADR-049](./0049-gamification-and-achievement-system.md) | Gamification System | Accepted | - | Fully implemented |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------------- | --------------------- | -------- | ------ | ------------------------- |
|
||||
| [ADR-034](./0034-repository-pattern-standards.md) | Repository Pattern | Accepted | - | Fully implemented |
|
||||
| [ADR-035](./0035-service-layer-architecture.md) | Service Layer | Accepted | - | Fully implemented |
|
||||
| [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) | Event Bus | Accepted | - | Fully implemented |
|
||||
| [ADR-039](./0039-dependency-injection-pattern.md) | Dependency Injection | Accepted | - | Fully implemented |
|
||||
| [ADR-041](./0041-ai-gemini-integration-architecture.md) | AI/Gemini Integration | Accepted | - | Fully implemented |
|
||||
| [ADR-042](./0042-email-and-notification-architecture.md) | Email & Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-043](./0043-express-middleware-pipeline.md) | Middleware Pipeline | Accepted | - | Fully implemented |
|
||||
| [ADR-046](./0046-image-processing-pipeline.md) | Image Processing | Accepted | - | Fully implemented |
|
||||
| [ADR-049](./0049-gamification-and-achievement-system.md) | Gamification System | Accepted | - | Fully implemented |
|
||||
| [ADR-055](./0055-database-normalization-and-referential-integrity.md) | DB Normalization | Accepted | M | API uses IDs, not strings |
|
||||
|
||||
---
|
||||
|
||||
## Work Still To Be Completed (Priority Order)
|
||||
|
||||
These ADRs are proposed but not yet implemented, ordered by suggested implementation priority:
|
||||
These ADRs are proposed or partially implemented, ordered by suggested implementation priority:
|
||||
|
||||
| Priority | ADR | Title | Effort | Rationale |
|
||||
| -------- | ------- | --------------------------- | ------ | ------------------------------------------------- |
|
||||
| 1 | ADR-015 | APM & Error Tracking | M | Production visibility, debugging |
|
||||
| 1b | ADR-050 | PostgreSQL Fn Observability | M | Database function visibility (depends on ADR-015) |
|
||||
| 2 | ADR-024 | Feature Flags | M | Safer deployments, A/B testing |
|
||||
| 3 | ADR-023 | Schema Migrations v2 | L | Database evolution support |
|
||||
| 4 | ADR-029 | Secret Rotation | L | Security improvement |
|
||||
| 5 | ADR-008 | API Versioning | L | Future API evolution |
|
||||
| 6 | ADR-030 | Circuit Breaker | L | Resilience improvement |
|
||||
| 7 | ADR-022 | Real-time Notifications | XL | Major feature enhancement |
|
||||
| 8 | ADR-011 | Authorization & RBAC | XL | Advanced permission system |
|
||||
| 9 | ADR-025 | i18n & l10n | XL | Multi-language support |
|
||||
| 10 | ADR-031 | Data Retention & Privacy | XL | Compliance requirements |
|
||||
| Priority | ADR | Title | Status | Effort | Rationale |
|
||||
| -------- | ------- | ------------------------ | -------- | ------ | ------------------------------------ |
|
||||
| 1 | ADR-024 | Feature Flags | Proposed | M | Safer deployments, A/B testing |
|
||||
| 2 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
|
||||
| 3 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
|
||||
| 4 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
|
||||
| 5 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 6 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 7 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 8 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 9 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
## Recent Implementation History
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ---------------------------------------------------------------------- |
|
||||
| 2026-01-11 | ADR-050 | Created - PostgreSQL function observability with fn_log() and Logstash |
|
||||
| 2026-01-11 | ADR-018 | Implemented - OpenAPI/Swagger documentation at /docs/api-docs |
|
||||
| 2026-01-11 | ADR-049 | Created - Gamification system, achievements, and testing requirements |
|
||||
| 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 and rate limiting |
|
||||
| 2026-01-09 | ADR-042 | Created - Email and notification architecture with BullMQ queuing |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
## 3. API & Integration
|
||||
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Accepted)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Proposed)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Proposed)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 1 Complete)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Accepted)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Proposed)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
@@ -38,7 +38,11 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
## 5. Observability & Monitoring
|
||||
|
||||
**[ADR-004](./0004-standardized-application-wide-structured-logging.md)**: Standardized Application-Wide Structured Logging (Accepted)
|
||||
**[ADR-015](./0015-application-performance-monitoring-and-error-tracking.md)**: Application Performance Monitoring (APM) and Error Tracking (Proposed)
|
||||
**[ADR-015](./0015-error-tracking-and-observability.md)**: Error Tracking and Observability (Partial)
|
||||
**[ADR-050](./0050-postgresql-function-observability.md)**: PostgreSQL Function Observability (Accepted)
|
||||
**[ADR-051](./0051-asynchronous-context-propagation.md)**: Asynchronous Context Propagation (Accepted)
|
||||
**[ADR-052](./0052-granular-debug-logging-strategy.md)**: Granular Debug Logging Strategy (Accepted)
|
||||
**[ADR-056](./0056-application-performance-monitoring.md)**: Application Performance Monitoring (Proposed)
|
||||
|
||||
## 6. Deployment & Operations
|
||||
|
||||
@@ -48,13 +52,15 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-024](./0024-feature-flagging-strategy.md)**: Feature Flagging Strategy (Proposed)
|
||||
**[ADR-037](./0037-scheduled-jobs-and-cron-pattern.md)**: Scheduled Jobs and Cron Pattern (Accepted)
|
||||
**[ADR-038](./0038-graceful-shutdown-pattern.md)**: Graceful Shutdown Pattern (Accepted)
|
||||
**[ADR-053](./0053-worker-health-checks-and-monitoring.md)**: Worker Health Checks and Monitoring (Proposed)
|
||||
**[ADR-054](./0054-bugsink-gitea-issue-sync.md)**: Bugsink to Gitea Issue Synchronization (Proposed)
|
||||
|
||||
## 7. Frontend / User Interface
|
||||
|
||||
**[ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md)**: Frontend State Management and Server Cache Strategy (Accepted)
|
||||
**[ADR-012](./0012-frontend-component-library-and-design-system.md)**: Frontend Component Library and Design System (Partially Implemented)
|
||||
**[ADR-025](./0025-internationalization-and-localization-strategy.md)**: Internationalization (i18n) and Localization (l10n) Strategy (Proposed)
|
||||
**[ADR-026](./0026-standardized-client-side-structured-logging.md)**: Standardized Client-Side Structured Logging (Proposed)
|
||||
**[ADR-026](./0026-standardized-client-side-structured-logging.md)**: Standardized Client-Side Structured Logging (Accepted)
|
||||
**[ADR-044](./0044-frontend-feature-organization.md)**: Frontend Feature Organization Pattern (Accepted)
|
||||
|
||||
## 8. Development Workflow & Quality
|
||||
@@ -76,3 +82,5 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-042](./0042-email-and-notification-architecture.md)**: Email and Notification Architecture (Accepted)
|
||||
**[ADR-043](./0043-express-middleware-pipeline.md)**: Express Middleware Pipeline Architecture (Accepted)
|
||||
**[ADR-046](./0046-image-processing-pipeline.md)**: Image Processing Pipeline (Accepted)
|
||||
**[ADR-049](./0049-gamification-and-achievement-system.md)**: Gamification and Achievement System (Accepted)
|
||||
**[ADR-055](./0055-database-normalization-and-referential-integrity.md)**: Database Normalization and Referential Integrity (Accepted)
|
||||
|
||||
@@ -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
|
||||
|
||||
152
docs/development/ERROR-LOGGING-PATHS.md
Normal file
152
docs/development/ERROR-LOGGING-PATHS.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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-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,56 @@ 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
|
||||
|
||||
## 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.
|
||||
|
||||
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)
|
||||
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)
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.13",
|
||||
"version": "0.12.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.13",
|
||||
"version": "0.12.18",
|
||||
"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.13",
|
||||
"version": "0.12.18",
|
||||
"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
|
||||
101
server.ts
101
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';
|
||||
@@ -235,11 +216,11 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
logger.info('API Documentation available at /docs/api-docs');
|
||||
}
|
||||
|
||||
// --- API Routes ---
|
||||
// --- API Routes (ADR-008: API Versioning Strategy - Phase 1) ---
|
||||
|
||||
// ADR-053: Worker Health Checks
|
||||
// Expose queue metrics for monitoring.
|
||||
app.get('/api/health/queues', async (req, res) => {
|
||||
// Expose queue metrics for monitoring at versioned endpoint.
|
||||
app.get('/api/v1/health/queues', async (req, res) => {
|
||||
try {
|
||||
const statuses = await monitoringService.getQueueStatuses();
|
||||
res.json(statuses);
|
||||
@@ -249,48 +230,36 @@ app.get('/api/health/queues', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// The order of route registration is critical.
|
||||
// More specific routes should be registered before more general ones.
|
||||
// 1. Authentication routes for login, registration, etc.
|
||||
app.use('/api/auth', authRouter); // This was a duplicate, fixed.
|
||||
// 2. System routes for health checks, etc.
|
||||
app.use('/api/health', healthRouter);
|
||||
// 3. System routes for pm2 status, etc.
|
||||
app.use('/api/system', systemRouter);
|
||||
// 3. General authenticated user routes.
|
||||
app.use('/api/users', userRouter);
|
||||
// 4. AI routes, some of which use optional authentication.
|
||||
app.use('/api/ai', aiRouter);
|
||||
// 5. Admin routes, which are all protected by admin-level checks.
|
||||
app.use('/api/admin', adminRouter); // This seems to be missing from the original file list, but is required.
|
||||
// 6. Budgeting and spending analysis routes.
|
||||
app.use('/api/budgets', budgetRouter);
|
||||
// 7. Gamification routes for achievements.
|
||||
app.use('/api/achievements', gamificationRouter);
|
||||
// 8. Public flyer routes.
|
||||
app.use('/api/flyers', flyerRouter);
|
||||
// 8. Public recipe routes.
|
||||
app.use('/api/recipes', recipeRouter);
|
||||
// 9. Public personalization data routes (master items, etc.).
|
||||
app.use('/api/personalization', personalizationRouter);
|
||||
// 9.5. Price history routes.
|
||||
app.use('/api/price-history', priceRouter);
|
||||
// 10. Public statistics routes.
|
||||
app.use('/api/stats', statsRouter);
|
||||
// 11. UPC barcode scanning routes.
|
||||
app.use('/api/upc', upcRouter);
|
||||
// 12. Inventory and expiry tracking routes.
|
||||
app.use('/api/inventory', inventoryRouter);
|
||||
// 13. Receipt scanning routes.
|
||||
app.use('/api/receipts', receiptRouter);
|
||||
// 14. Deals and best prices routes.
|
||||
app.use('/api/deals', dealsRouter);
|
||||
// 15. Reactions/social features routes.
|
||||
app.use('/api/reactions', reactionsRouter);
|
||||
// 16. Store management routes.
|
||||
app.use('/api/stores', storeRouter);
|
||||
// 17. Category discovery routes (ADR-023: Database Normalization)
|
||||
app.use('/api/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) => {
|
||||
// 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);
|
||||
}
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -112,6 +112,15 @@ const googleSchema = z.object({
|
||||
clientSecret: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GitHub OAuth configuration schema.
|
||||
* Used for GitHub social login functionality.
|
||||
*/
|
||||
const githubSchema = z.object({
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Worker concurrency configuration schema.
|
||||
*/
|
||||
@@ -157,6 +166,7 @@ const envSchema = z.object({
|
||||
ai: aiSchema,
|
||||
upc: upcSchema,
|
||||
google: googleSchema,
|
||||
github: githubSchema,
|
||||
worker: workerSchema,
|
||||
server: serverSchema,
|
||||
sentry: sentrySchema,
|
||||
@@ -209,6 +219,10 @@ function loadEnvVars(): unknown {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
worker: {
|
||||
concurrency: process.env.WORKER_CONCURRENCY,
|
||||
lockDuration: process.env.WORKER_LOCK_DURATION,
|
||||
@@ -367,3 +381,13 @@ export const isUpcItemDbConfigured = !!config.upc.upcItemDbApiKey;
|
||||
* Returns true if Barcode Lookup API is configured.
|
||||
*/
|
||||
export const isBarcodeLookupConfigured = !!config.upc.barcodeLookupApiKey;
|
||||
|
||||
/**
|
||||
* Returns true if Google OAuth is configured (both client ID and secret present).
|
||||
*/
|
||||
export const isGoogleOAuthConfigured = !!config.google.clientId && !!config.google.clientSecret;
|
||||
|
||||
/**
|
||||
* Returns true if GitHub OAuth is configured (both client ID and secret present).
|
||||
*/
|
||||
export const isGithubOAuthConfigured = !!config.github.clientId && !!config.github.clientSecret;
|
||||
|
||||
@@ -172,7 +172,7 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/google/callback',
|
||||
callbackURL: '/api/v1/auth/google/callback',
|
||||
scope: ['profile', 'email'],
|
||||
},
|
||||
async (
|
||||
@@ -242,7 +242,7 @@ if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
{
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/github/callback',
|
||||
callbackURL: '/api/v1/auth/github/callback',
|
||||
scope: ['user:email'],
|
||||
},
|
||||
async (
|
||||
|
||||
@@ -79,10 +79,10 @@ describe('swagger configuration', () => {
|
||||
expect(spec.servers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have /api as the server URL', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api');
|
||||
it('should have /api/v1 as the server URL (ADR-008)', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api/v1');
|
||||
expect(apiServer).toBeDefined();
|
||||
expect(apiServer?.description).toBe('API server');
|
||||
expect(apiServer?.description).toBe('API server (v1)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ const options: swaggerJsdoc.Options = {
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API server',
|
||||
url: '/api/v1',
|
||||
description: 'API server (v1)',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import * as apiClient from '../services/apiClient';
|
||||
import { useAuthProfileQuery, AUTH_PROFILE_QUERY_KEY } from '../hooks/queries/useAuthProfileQuery';
|
||||
import { getToken, setToken, removeToken } from '../services/tokenStorage';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { setUser as setSentryUser } from '../services/sentry.client';
|
||||
|
||||
/**
|
||||
* AuthProvider component that manages authentication state.
|
||||
@@ -40,6 +41,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
logger.info('[AuthProvider] Profile received from query, setting state to AUTHENTICATED.');
|
||||
setUserProfile(fetchedProfile);
|
||||
setAuthStatus('AUTHENTICATED');
|
||||
// Set Sentry user context for error tracking (ADR-015)
|
||||
setSentryUser({
|
||||
id: fetchedProfile.user.user_id,
|
||||
email: fetchedProfile.user.email,
|
||||
username: fetchedProfile.full_name || fetchedProfile.user.email,
|
||||
});
|
||||
} else if (token && isError) {
|
||||
logger.warn('[AuthProvider] Token was present but validation failed. Signing out.');
|
||||
removeToken();
|
||||
@@ -66,6 +73,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setAuthStatus('SIGNED_OUT');
|
||||
// Clear the auth profile cache on logout
|
||||
queryClient.removeQueries({ queryKey: AUTH_PROFILE_QUERY_KEY });
|
||||
// Clear Sentry user context (ADR-015)
|
||||
setSentryUser(null);
|
||||
}, [queryClient]);
|
||||
|
||||
const login = useCallback(
|
||||
@@ -82,6 +91,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setAuthStatus('AUTHENTICATED');
|
||||
// Update the query cache with the provided profile
|
||||
queryClient.setQueryData(AUTH_PROFILE_QUERY_KEY, profileData);
|
||||
// Set Sentry user context for error tracking (ADR-015)
|
||||
setSentryUser({
|
||||
id: profileData.user.user_id,
|
||||
email: profileData.user.email,
|
||||
username: profileData.full_name || profileData.user.email,
|
||||
});
|
||||
logger.info('[AuthProvider-Login] Login successful. State set to AUTHENTICATED.', {
|
||||
user: profileData.user,
|
||||
});
|
||||
@@ -106,6 +121,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setAuthStatus('AUTHENTICATED');
|
||||
// Update the query cache with the fetched profile
|
||||
queryClient.setQueryData(AUTH_PROFILE_QUERY_KEY, fetchedProfileData);
|
||||
// Set Sentry user context for error tracking (ADR-015)
|
||||
setSentryUser({
|
||||
id: fetchedProfileData.user.user_id,
|
||||
email: fetchedProfileData.user.email,
|
||||
username: fetchedProfileData.full_name || fetchedProfileData.user.email,
|
||||
});
|
||||
logger.info('[AuthProvider-Login] Profile fetch successful. State set to AUTHENTICATED.');
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -153,7 +153,7 @@ vi.mock('../config/passport', () => ({
|
||||
// Import the router AFTER all mocks are defined.
|
||||
import adminRouter from './admin.routes';
|
||||
|
||||
describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
describe('Admin Content Management Routes (/api/v1/admin)', () => {
|
||||
const adminUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
|
||||
@@ -161,7 +161,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
// Create a single app instance with an admin user for all tests in this suite.
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
createMockSuggestedCorrection({ suggested_correction_id: 1 }),
|
||||
];
|
||||
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockResolvedValue(mockCorrections);
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
const response = await supertest(app).get('/api/v1/admin/corrections');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockCorrections);
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockRejectedValue(
|
||||
new Error('DB Error'),
|
||||
);
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
const response = await supertest(app).get('/api/v1/admin/corrections');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -212,7 +212,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('POST /corrections/:id/approve should approve a correction', async () => {
|
||||
const correctionId = 123;
|
||||
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ message: 'Correction approved successfully.' });
|
||||
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
|
||||
@@ -224,14 +224,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
|
||||
const correctionId = 123;
|
||||
vi.mocked(mockedDb.adminRepo.approveCorrection).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/reject should reject a correction', async () => {
|
||||
const correctionId = 789;
|
||||
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ message: 'Correction rejected successfully.' });
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
|
||||
const correctionId = 789;
|
||||
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
mockUpdatedCorrection,
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/corrections/${correctionId}`)
|
||||
.put(`/api/v1/admin/corrections/${correctionId}`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUpdatedCorrection);
|
||||
@@ -262,7 +262,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
|
||||
it('PUT /corrections/:id should return 400 for invalid data', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/corrections/101')
|
||||
.put('/api/v1/admin/corrections/101')
|
||||
.send({ suggested_value: '' }); // Send empty value
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -272,7 +272,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
new NotFoundError('Correction with ID 999 not found'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/corrections/999')
|
||||
.put('/api/v1/admin/corrections/999')
|
||||
.send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Correction with ID 999 not found');
|
||||
@@ -283,7 +283,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
new Error('Generic DB Error'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/corrections/101')
|
||||
.put('/api/v1/admin/corrections/101')
|
||||
.send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Generic DB Error');
|
||||
@@ -297,7 +297,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
createMockFlyer({ flyer_id: 2, status: 'needs_review' }),
|
||||
];
|
||||
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockResolvedValue(mockFlyers);
|
||||
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||
const response = await supertest(app).get('/api/v1/admin/review/flyers');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockFlyers);
|
||||
expect(vi.mocked(mockedDb.adminRepo.getFlyersForReview)).toHaveBeenCalledWith(
|
||||
@@ -307,7 +307,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
|
||||
it('GET /review/flyers should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||
const response = await supertest(app).get('/api/v1/admin/review/flyers');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -317,7 +317,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
// This test covers the error path for GET /stats
|
||||
it('GET /stats should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
const response = await supertest(app).get('/api/v1/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -327,14 +327,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('GET /brands should return a list of all brands', async () => {
|
||||
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })];
|
||||
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockResolvedValue(mockBrands);
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
const response = await supertest(app).get('/api/v1/admin/brands');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockBrands);
|
||||
});
|
||||
|
||||
it('GET /brands should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
const response = await supertest(app).get('/api/v1/admin/brands');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -344,7 +344,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const mockLogoUrl = '/flyer-images/brand-logos/test-logo.png';
|
||||
vi.mocked(mockedBrandService.updateBrandLogo).mockResolvedValue(mockLogoUrl);
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toBe('Brand logo updated successfully.');
|
||||
@@ -359,13 +359,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const brandId = 55;
|
||||
vi.mocked(mockedBrandService.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
|
||||
const response = await supertest(app).post('/api/admin/brands/55/logo');
|
||||
const response = await supertest(app).post('/api/v1/admin/brands/55/logo');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toMatch(
|
||||
/Logo image file is required|The request data is invalid|Logo image file is missing./,
|
||||
@@ -378,7 +378,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedBrandService.updateBrandLogo).mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -391,7 +391,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('POST /brands/:id/logo should return 400 if a non-image file is uploaded', async () => {
|
||||
const brandId = 55;
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
|
||||
expect(response.status).toBe(400);
|
||||
// This message comes from the handleMulterError middleware for the imageFileFilter
|
||||
@@ -400,7 +400,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
|
||||
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/admin/brands/abc/logo')
|
||||
.post('/api/v1/admin/brands/abc/logo')
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -411,7 +411,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const recipeId = 300;
|
||||
vi.mocked(mockedDb.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/recipes/${recipeId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(vi.mocked(mockedDb.recipeRepo.deleteRecipe)).toHaveBeenCalledWith(
|
||||
recipeId,
|
||||
@@ -422,14 +422,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 400 for invalid ID', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/recipes/abc');
|
||||
const response = await supertest(app).delete('/api/v1/admin/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 500 on DB error', async () => {
|
||||
const recipeId = 300;
|
||||
vi.mocked(mockedDb.recipeRepo.deleteRecipe).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/recipes/${recipeId}`);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
@@ -439,7 +439,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.put(`/api/v1/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUpdatedRecipe);
|
||||
@@ -449,7 +449,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const recipeId = 201;
|
||||
const requestBody = { status: 'invalid_status' };
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.put(`/api/v1/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -459,7 +459,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const requestBody = { status: 'public' as const };
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.put(`/api/v1/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -473,7 +473,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
}); // This was a duplicate, fixed.
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/comments/${commentId}/status`)
|
||||
.put(`/api/v1/admin/comments/${commentId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUpdatedComment);
|
||||
@@ -483,7 +483,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const commentId = 301;
|
||||
const requestBody = { status: 'invalid_status' };
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/comments/${commentId}/status`)
|
||||
.put(`/api/v1/admin/comments/${commentId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -495,7 +495,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
new Error('DB Error'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/comments/${commentId}/status`)
|
||||
.put(`/api/v1/admin/comments/${commentId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -511,14 +511,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
}),
|
||||
];
|
||||
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
const response = await supertest(app).get('/api/v1/admin/unmatched-items');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUnmatchedItems);
|
||||
});
|
||||
|
||||
it('GET /unmatched-items should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
const response = await supertest(app).get('/api/v1/admin/unmatched-items');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -528,7 +528,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const flyerId = 42;
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(vi.mocked(mockedDb.flyerRepo.deleteFlyer)).toHaveBeenCalledWith(
|
||||
flyerId,
|
||||
@@ -541,7 +541,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
|
||||
new NotFoundError('Flyer with ID 999 not found.'),
|
||||
);
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Flyer with ID 999 not found.');
|
||||
});
|
||||
@@ -549,13 +549,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
|
||||
const flyerId = 42;
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(new Error('Generic DB Error'));
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Generic DB Error');
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||
const response = await supertest(app).delete('/api/v1/admin/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ vi.mock('../config/passport', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
describe('Admin Job Trigger Routes (/api/v1/admin/trigger)', () => {
|
||||
const adminUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
|
||||
@@ -107,7 +107,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
// Create a single app instance with an admin user for all tests in this suite.
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
describe('POST /trigger/daily-deal-check', () => {
|
||||
it('should trigger the daily deal check job and return 202 Accepted', async () => {
|
||||
// Use the instance method mock
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/daily-deal-check');
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.data.message).toContain('Daily deal check job has been triggered');
|
||||
expect(backgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
|
||||
@@ -128,7 +128,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(backgroundJobService.runDailyDealCheck).mockImplementation(() => {
|
||||
throw new Error('Job runner failed');
|
||||
});
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/daily-deal-check');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toContain('Job runner failed');
|
||||
});
|
||||
@@ -138,7 +138,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
it('should enqueue a job designed to fail and return 202 Accepted', async () => {
|
||||
const mockJob = { id: 'failing-job-id-456' } as Job;
|
||||
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/failing-job');
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.data.message).toContain('Failing test job has been enqueued');
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
|
||||
@@ -148,7 +148,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
|
||||
it('should return 500 if enqueuing the job fails', async () => {
|
||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/failing-job');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Queue is down');
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
'manual-report-job-123',
|
||||
);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/analytics-report');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.data.message).toContain(
|
||||
@@ -173,7 +173,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(
|
||||
new Error('Queue error'),
|
||||
);
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/analytics-report');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
'manual-weekly-report-job-123',
|
||||
);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/weekly-analytics');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.data.message).toContain('Successfully enqueued weekly analytics job');
|
||||
@@ -195,7 +195,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(
|
||||
new Error('Queue error'),
|
||||
);
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
const response = await supertest(app).post('/api/v1/admin/trigger/weekly-analytics');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const flyerId = 789;
|
||||
const mockJob = { id: `cleanup-job-${flyerId}` } as Job;
|
||||
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob);
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.data.message).toBe(
|
||||
`File cleanup job for flyer ID ${flyerId} has been enqueued.`,
|
||||
@@ -216,13 +216,13 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
it('should return 500 if enqueuing the cleanup job fails', async () => {
|
||||
const flyerId = 789;
|
||||
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Queue is down');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyerId', async () => {
|
||||
const response = await supertest(app).post('/api/admin/flyers/abc/cleanup');
|
||||
const response = await supertest(app).post('/api/v1/admin/flyers/abc/cleanup');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
|
||||
});
|
||||
@@ -237,7 +237,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(monitoringService.retryFailedJob).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -252,7 +252,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if the queue name is invalid', async () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/invalid-queue/${jobId}/retry`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/jobs/invalid-queue/${jobId}/retry`);
|
||||
// Zod validation fails because queue name is an enum
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -266,7 +266,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`),
|
||||
);
|
||||
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
@@ -280,7 +280,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
new NotFoundError("Job with ID 'not-found-job' not found in queue 'flyer-processing'."),
|
||||
);
|
||||
const response = await supertest(app).post(
|
||||
`/api/admin/jobs/${queueName}/not-found-job/retry`,
|
||||
`/api/v1/admin/jobs/${queueName}/not-found-job/retry`,
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toContain('not found in queue');
|
||||
@@ -292,7 +292,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
new ValidationError([], "Job is not in a 'failed' state. Current state: completed."),
|
||||
);
|
||||
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
@@ -304,7 +304,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
// Mock monitoringService.retryFailedJob to throw a generic error
|
||||
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(new Error('Cannot retry job'));
|
||||
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
const response = await supertest(app).post(`/api/v1/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toContain('Cannot retry job');
|
||||
@@ -312,7 +312,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
|
||||
it('should return 400 for an invalid queueName or jobId', async () => {
|
||||
// This tests the Zod schema validation for the route params.
|
||||
const response = await supertest(app).post('/api/admin/jobs/ / /retry');
|
||||
const response = await supertest(app).post('/api/v1/admin/jobs/ / /retry');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ vi.mock('../config/passport', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
describe('Admin Monitoring Routes (/api/v1/admin)', () => {
|
||||
const adminUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
|
||||
@@ -119,7 +119,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
// Create a single app instance with an admin user for all tests in this suite.
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
const mockLogs = [createMockActivityLogItem({ action: 'flyer_processed' })];
|
||||
vi.mocked(adminRepo.getActivityLog).mockResolvedValue(mockLogs);
|
||||
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
const response = await supertest(app).get('/api/v1/admin/activity-log');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockLogs);
|
||||
@@ -142,13 +142,13 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
it('should use limit and offset query parameters when provided', async () => {
|
||||
vi.mocked(adminRepo.getActivityLog).mockResolvedValue([]);
|
||||
|
||||
await supertest(app).get('/api/admin/activity-log?limit=10&offset=20');
|
||||
await supertest(app).get('/api/v1/admin/activity-log?limit=10&offset=20');
|
||||
|
||||
expect(adminRepo.getActivityLog).toHaveBeenCalledWith(10, 20, expect.anything());
|
||||
});
|
||||
|
||||
it('should return 400 for invalid limit and offset query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/admin/activity-log?limit=abc&offset=-1');
|
||||
const response = await supertest(app).get('/api/v1/admin/activity-log?limit=abc&offset=-1');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2); // Both limit and offset are invalid
|
||||
@@ -156,7 +156,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
|
||||
it('should return 500 if fetching activity log fails', async () => {
|
||||
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
const response = await supertest(app).get('/api/v1/admin/activity-log');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
vi.mocked(monitoringService.getWorkerStatuses).mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
const response = await supertest(app).get('/api/v1/admin/workers/status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -190,7 +190,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
|
||||
it('should return 500 if fetching worker statuses fails', async () => {
|
||||
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
const response = await supertest(app).get('/api/v1/admin/workers/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Worker Error');
|
||||
});
|
||||
@@ -224,7 +224,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
const response = await supertest(app).get('/api/v1/admin/queues/status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -255,7 +255,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
it('should return 500 if fetching queue counts fails', async () => {
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(new Error('Redis is down'));
|
||||
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
const response = await supertest(app).get('/api/v1/admin/queues/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Redis is down');
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ import { cacheService } from '../services/cacheService.server';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
describe('Admin Routes Rate Limiting', () => {
|
||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin' });
|
||||
const app = createTestApp({ router: adminRouter, basePath: '/api/v1/admin' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -109,13 +109,13 @@ describe('Admin Routes Rate Limiting', () => {
|
||||
// Make requests up to the limit
|
||||
for (let i = 0; i < limit; i++) {
|
||||
await supertest(app)
|
||||
.post('/api/admin/trigger/daily-deal-check')
|
||||
.post('/api/v1/admin/trigger/daily-deal-check')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
}
|
||||
|
||||
// The next request should be blocked
|
||||
const response = await supertest(app)
|
||||
.post('/api/admin/trigger/daily-deal-check')
|
||||
.post('/api/v1/admin/trigger/daily-deal-check')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
@@ -132,12 +132,12 @@ describe('Admin Routes Rate Limiting', () => {
|
||||
// Note: We don't need to attach a file to test the rate limiter, as it runs before multer
|
||||
for (let i = 0; i < limit; i++) {
|
||||
await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
}
|
||||
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.post(`/api/v1/admin/brands/${brandId}/logo`)
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
@@ -151,7 +151,7 @@ describe('Admin Routes Rate Limiting', () => {
|
||||
vi.mocked(cacheService.invalidateBrands).mockResolvedValue(3);
|
||||
vi.mocked(cacheService.invalidateStats).mockResolvedValue(2);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/system/clear-cache');
|
||||
const response = await supertest(app).post('/api/v1/admin/system/clear-cache');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -168,7 +168,7 @@ describe('Admin Routes Rate Limiting', () => {
|
||||
const cacheError = new Error('Redis connection failed');
|
||||
vi.mocked(cacheService.invalidateFlyers).mockRejectedValue(cacheError);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/system/clear-cache');
|
||||
const response = await supertest(app).post('/api/v1/admin/system/clear-cache');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
|
||||
@@ -97,7 +97,7 @@ const brandLogoUpload = createUploadMiddleware({
|
||||
|
||||
// --- Bull Board (Job Queue UI) Setup ---
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/api/admin/jobs'); // Set the base path for the UI
|
||||
serverAdapter.setBasePath('/api/v1/admin/jobs'); // Set the base path for the UI
|
||||
|
||||
createBullBoard({
|
||||
queues: [
|
||||
|
||||
@@ -86,12 +86,12 @@ vi.mock('../config/passport', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
describe('Admin Stats Routes (/api/v1/admin/stats)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
||||
// Create a single app instance with an admin user for all tests in this suite.
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -110,14 +110,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
recipeCount: 50,
|
||||
};
|
||||
vi.mocked(adminRepo.getApplicationStats).mockResolvedValue(mockStats);
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
const response = await supertest(app).get('/api/v1/admin/stats');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
const response = await supertest(app).get('/api/v1/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -130,14 +130,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
|
||||
];
|
||||
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats);
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
const response = await supertest(app).get('/api/v1/admin/stats/daily');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockDailyStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
const response = await supertest(app).get('/api/v1/admin/stats/daily');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
@@ -90,14 +90,14 @@ vi.mock('../config/passport', () => ({
|
||||
isAdmin: (req: Request, res: Response, next: NextFunction) => next(),
|
||||
}));
|
||||
|
||||
describe('Admin System Routes (/api/admin/system)', () => {
|
||||
describe('Admin System Routes (/api/v1/admin/system)', () => {
|
||||
const adminUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
|
||||
});
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -108,14 +108,14 @@ describe('Admin System Routes (/api/admin/system)', () => {
|
||||
describe('POST /system/clear-geocode-cache', () => {
|
||||
it('should return 200 on successful cache clear', async () => {
|
||||
vi.mocked(geocodingService.clearGeocodeCache).mockResolvedValue(10);
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
const response = await supertest(app).post('/api/v1/admin/system/clear-geocode-cache');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toContain('10 keys were removed');
|
||||
});
|
||||
|
||||
it('should return 500 if clearing the cache fails', async () => {
|
||||
vi.mocked(geocodingService.clearGeocodeCache).mockRejectedValue(new Error('Redis is down'));
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
const response = await supertest(app).post('/api/v1/admin/system/clear-geocode-cache');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toContain('Redis is down');
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock('../config/passport', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
describe('Admin User Management Routes (/api/v1/admin/users)', () => {
|
||||
const adminId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const userId = '123e4567-e89b-12d3-a456-426614174001';
|
||||
const adminUser = createMockUserProfile({
|
||||
@@ -107,7 +107,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
// Create a single app instance with an admin user for all tests in this suite.
|
||||
const app = createTestApp({
|
||||
router: adminRouter,
|
||||
basePath: '/api/admin',
|
||||
basePath: '/api/v1/admin',
|
||||
authenticatedUser: adminUser,
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
|
||||
];
|
||||
vi.mocked(adminRepo.getAllUsers).mockResolvedValue({ users: mockUsers, total: 2 });
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
const response = await supertest(app).get('/api/v1/admin/users');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ users: mockUsers, total: 2 });
|
||||
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
|
||||
@@ -132,7 +132,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(adminRepo.getAllUsers).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
const response = await supertest(app).get('/api/v1/admin/users');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
it('should fetch a single user successfully', async () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: userId, email: 'user@test.com' } });
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUser);
|
||||
const response = await supertest(app).get(`/api/admin/users/${userId}`);
|
||||
const response = await supertest(app).get(`/api/v1/admin/users/${userId}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUser);
|
||||
expect(userRepo.findUserProfileById).toHaveBeenCalledWith(userId, expect.any(Object));
|
||||
@@ -152,7 +152,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(
|
||||
new NotFoundError('User not found.'),
|
||||
);
|
||||
const response = await supertest(app).get(`/api/admin/users/${missingId}`);
|
||||
const response = await supertest(app).get(`/api/v1/admin/users/${missingId}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('User not found.');
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get(`/api/admin/users/${userId}`);
|
||||
const response = await supertest(app).get(`/api/v1/admin/users/${userId}`);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -178,7 +178,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
};
|
||||
vi.mocked(adminRepo.updateUserRole).mockResolvedValue(updatedUser);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/users/${userId}`)
|
||||
.put(`/api/v1/admin/users/${userId}`)
|
||||
.send({ role: 'admin' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(updatedUser);
|
||||
@@ -191,7 +191,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
new NotFoundError(`User with ID ${missingId} not found.`),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/users/${missingId}`)
|
||||
.put(`/api/v1/admin/users/${missingId}`)
|
||||
.send({ role: 'user' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(`User with ID ${missingId} not found.`);
|
||||
@@ -201,7 +201,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/users/${userId}`)
|
||||
.put(`/api/v1/admin/users/${userId}`)
|
||||
.send({ role: 'admin' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
@@ -209,7 +209,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
|
||||
it('should return 400 for an invalid role', async () => {
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/users/${userId}`)
|
||||
.put(`/api/v1/admin/users/${userId}`)
|
||||
.send({ role: 'super-admin' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
const targetId = '123e4567-e89b-12d3-a456-426614174999';
|
||||
vi.mocked(userRepo.deleteUserById).mockResolvedValue(undefined);
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/users/${targetId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(
|
||||
adminId,
|
||||
@@ -232,7 +232,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
it('should prevent an admin from deleting their own account', async () => {
|
||||
const validationError = new ValidationError([], 'Admins cannot delete their own account.');
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(validationError);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${adminId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/users/${adminId}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toMatch(/Admins cannot delete their own account/);
|
||||
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
|
||||
@@ -248,7 +248,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError);
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
|
||||
const response = await supertest(app).delete(`/api/v1/admin/users/${targetId}`);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ vi.mock('../config/passport', () => ({
|
||||
isAdmin: vi.fn((req, res, next) => next()),
|
||||
}));
|
||||
|
||||
describe('AI Routes (/api/ai)', () => {
|
||||
describe('AI Routes (/api/v1/ai)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset logger implementation to no-op to prevent "Logging failed" leaks from previous tests
|
||||
@@ -123,7 +123,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
new NotFoundError('Job not found.'),
|
||||
);
|
||||
});
|
||||
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
||||
const app = createTestApp({ router: aiRouter, basePath: '/api/v1/ai' });
|
||||
|
||||
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
|
||||
describe('Diagnostic Middleware Error Handling', () => {
|
||||
@@ -134,7 +134,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
// Make any request to trigger the middleware
|
||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||
const response = await supertest(app).get('/api/v1/ai/jobs/job-123/status');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: mockErrorObject.message }, // errMsg should extract the message
|
||||
@@ -152,7 +152,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
// Make any request to trigger the middleware
|
||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||
const response = await supertest(app).get('/api/v1/ai/jobs/job-123/status');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: mockErrorString }, // errMsg should convert to string
|
||||
@@ -166,7 +166,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw null; // Simulate throwing null
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||
const response = await supertest(app).get('/api/v1/ai/jobs/job-123/status');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: 'An unknown error occurred.' }, // errMsg should handle null/undefined
|
||||
@@ -187,7 +187,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
} as unknown as Job);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if no file is provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('checksum', validChecksum);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -208,7 +208,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if checksum is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -224,7 +224,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
basePath: '/api/v1/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
@@ -292,7 +292,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
basePath: '/api/v1/ai',
|
||||
authenticatedUser: mockUserWithAddress,
|
||||
});
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
||||
.field('checksum', validChecksum)
|
||||
.attach('flyerFile', imagePath);
|
||||
@@ -319,7 +319,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.attach('flyerFile', imagePath); // No checksum field, will cause validation to throw
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -338,7 +338,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
new NotFoundError('Job not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
|
||||
const response = await supertest(app).get('/api/v1/ai/jobs/non-existent-job/status');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Job not found.');
|
||||
@@ -353,7 +353,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
};
|
||||
vi.mocked(mockedMonitoringService.getFlyerJobStatus).mockResolvedValue(mockJobStatus);
|
||||
|
||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||
const response = await supertest(app).get('/api/v1/ai/jobs/job-123/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.state).toBe('completed');
|
||||
@@ -371,7 +371,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
// This route requires authentication, so we create an app instance with a user.
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
basePath: '/api/v1/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.post('/api/v1/ai/upload-legacy')
|
||||
.field('some_legacy_field', 'value') // simulate some body data
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if no flyer file is uploaded', async () => {
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.post('/api/v1/ai/upload-legacy')
|
||||
.field('some_legacy_field', 'value');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -412,7 +412,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.post('/api/v1/ai/upload-legacy')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
@@ -429,7 +429,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.post('/api/v1/ai/upload-legacy')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -457,7 +457,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(mockDataPayload))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if no flyer image is provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(mockDataPayload));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -485,7 +485,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(mockDataPayload))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -514,7 +514,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(partialPayload))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -534,7 +534,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(payloadNoStore))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -548,7 +548,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(mockDataPayload))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -571,7 +571,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should handle payload where "data" field is an object, not stringified JSON', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(mockDataPayload)) // Supertest stringifies this, but Express JSON parser will make it an object
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -587,7 +587,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
};
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(payloadWithNullExtractedData))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -603,7 +603,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
};
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(payloadWithStringExtractedData))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -614,7 +614,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
it('should handle payload where extractedData is at the root of the body', async () => {
|
||||
// This simulates a client sending multipart fields for each property of extractedData
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('checksum', 'root-checksum')
|
||||
.field('originalFileName', 'flyer.jpg')
|
||||
.field('store_name', 'Root Store')
|
||||
@@ -636,7 +636,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
};
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(payloadMissingQuantity))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -658,7 +658,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', malformedDataString)
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
.post('/api/v1/ai/flyers/process')
|
||||
.field('data', JSON.stringify(payloadWithoutChecksum))
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
@@ -700,12 +700,12 @@ describe('AI Routes (/api/ai)', () => {
|
||||
describe('POST /check-flyer', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
it('should return 400 if no image is provided', async () => {
|
||||
const response = await supertest(app).post('/api/ai/check-flyer');
|
||||
const response = await supertest(app).post('/api/v1/ai/check-flyer');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 200 with a stubbed response on success', async () => {
|
||||
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
||||
const response = await supertest(app).post('/api/v1/ai/check-flyer').attach('image', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.is_flyer).toBe(true);
|
||||
});
|
||||
@@ -717,7 +717,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Logging failed');
|
||||
});
|
||||
// Attach a valid file to get past the `if (!req.file)` check.
|
||||
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
||||
const response = await supertest(app).post('/api/v1/ai/check-flyer').attach('image', imagePath);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -726,7 +726,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
it('should return 400 if image file is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.post('/api/v1/ai/rescan-area')
|
||||
.field('cropArea', JSON.stringify({ x: 0, y: 0, width: 10, height: 10 }))
|
||||
.field('extractionType', 'store_name');
|
||||
expect(response.status).toBe(400);
|
||||
@@ -734,7 +734,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if cropArea or extractionType is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.post('/api/v1/ai/rescan-area')
|
||||
.attach('image', imagePath)
|
||||
.field('extractionType', 'store_name'); // Missing cropArea
|
||||
expect(response.status).toBe(400);
|
||||
@@ -745,7 +745,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 400 if cropArea is malformed JSON', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.post('/api/v1/ai/rescan-area')
|
||||
.attach('image', imagePath)
|
||||
.field('cropArea', '{ "x": 0, "y": 0, "width": 10, "height": 10'); // Malformed
|
||||
expect(response.status).toBe(400);
|
||||
@@ -755,13 +755,13 @@ describe('AI Routes (/api/ai)', () => {
|
||||
describe('POST /extract-address', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
it('should return 400 if no image is provided', async () => {
|
||||
const response = await supertest(app).post('/api/ai/extract-address');
|
||||
const response = await supertest(app).post('/api/v1/ai/extract-address');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 200 with a stubbed response on success', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/extract-address')
|
||||
.post('/api/v1/ai/extract-address')
|
||||
.attach('image', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.address).toBe('not identified');
|
||||
@@ -774,7 +774,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/extract-address')
|
||||
.post('/api/v1/ai/extract-address')
|
||||
.attach('image', imagePath);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -783,13 +783,13 @@ describe('AI Routes (/api/ai)', () => {
|
||||
describe('POST /extract-logo', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
it('should return 400 if no images are provided', async () => {
|
||||
const response = await supertest(app).post('/api/ai/extract-logo');
|
||||
const response = await supertest(app).post('/api/v1/ai/extract-logo');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 200 with a stubbed response on success', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/extract-logo')
|
||||
.post('/api/v1/ai/extract-logo')
|
||||
.attach('images', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.store_logo_base_64).toBeNull();
|
||||
@@ -802,7 +802,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/extract-logo')
|
||||
.post('/api/v1/ai/extract-logo')
|
||||
.attach('images', imagePath);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -816,7 +816,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
basePath: '/api/v1/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -833,7 +833,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
vi.mocked(aiService.aiService.extractTextFromImageArea).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.post('/api/v1/ai/rescan-area')
|
||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||
.field('extractionType', 'item_details')
|
||||
.attach('image', imagePath);
|
||||
@@ -849,7 +849,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/rescan-area')
|
||||
.post('/api/v1/ai/rescan-area')
|
||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||
.field('extractionType', 'item_details')
|
||||
.attach('image', imagePath);
|
||||
@@ -865,7 +865,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('POST /quick-insights should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -874,7 +874,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.send({ items: [{ item: 'test item' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -886,35 +886,35 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('POST /deep-dive should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/deep-dive')
|
||||
.post('/api/v1/ai/deep-dive')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.text).toContain('server-generated deep dive');
|
||||
});
|
||||
|
||||
it('POST /generate-image should return 501 Not Implemented', async () => {
|
||||
const response = await supertest(app).post('/api/ai/generate-image').send({ prompt: 'test' });
|
||||
const response = await supertest(app).post('/api/v1/ai/generate-image').send({ prompt: 'test' });
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.body.error.message).toBe('Image generation is not yet implemented.');
|
||||
});
|
||||
|
||||
it('POST /generate-speech should return 501 Not Implemented', async () => {
|
||||
const response = await supertest(app).post('/api/ai/generate-speech').send({ text: 'test' });
|
||||
const response = await supertest(app).post('/api/v1/ai/generate-speech').send({ text: 'test' });
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.body.error.message).toBe('Speech generation is not yet implemented.');
|
||||
});
|
||||
|
||||
it('POST /search-web should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/search-web')
|
||||
.post('/api/v1/ai/search-web')
|
||||
.send({ query: 'test query' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -923,7 +923,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('POST /compare-prices should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/compare-prices')
|
||||
.post('/api/v1/ai/compare-prices')
|
||||
.send({ items: [{ name: 'Milk' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -935,7 +935,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
vi.mocked(aiService.aiService.planTripWithMaps).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/plan-trip')
|
||||
.post('/api/v1/ai/plan-trip')
|
||||
.send({
|
||||
items: [],
|
||||
store: { name: 'Test Store' },
|
||||
@@ -952,7 +952,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/plan-trip')
|
||||
.post('/api/v1/ai/plan-trip')
|
||||
.send({
|
||||
items: [],
|
||||
store: { name: 'Test Store' },
|
||||
@@ -968,7 +968,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Deep dive logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/deep-dive')
|
||||
.post('/api/v1/ai/deep-dive')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Deep dive logging failed');
|
||||
@@ -979,7 +979,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Search web logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/search-web')
|
||||
.post('/api/v1/ai/search-web')
|
||||
.send({ query: 'test query' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Search web logging failed');
|
||||
@@ -990,29 +990,29 @@ describe('AI Routes (/api/ai)', () => {
|
||||
throw new Error('Compare prices logging failed');
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/compare-prices')
|
||||
.post('/api/v1/ai/compare-prices')
|
||||
.send({ items: [{ name: 'Milk' }] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Compare prices logging failed');
|
||||
});
|
||||
|
||||
it('POST /quick-insights should return 400 if items are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/quick-insights').send({});
|
||||
const response = await supertest(app).post('/api/v1/ai/quick-insights').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /search-web should return 400 if query is missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/search-web').send({});
|
||||
const response = await supertest(app).post('/api/v1/ai/search-web').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /compare-prices should return 400 if items are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/compare-prices').send({});
|
||||
const response = await supertest(app).post('/api/v1/ai/compare-prices').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 400 if required fields are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/plan-trip').send({ items: [] });
|
||||
const response = await supertest(app).post('/api/v1/ai/plan-trip').send({ items: [] });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -99,7 +108,7 @@ import { createTestApp } from '../tests/utils/createTestApp';
|
||||
// --- 4. App Setup using createTestApp ---
|
||||
const app = createTestApp({
|
||||
router: authRouter,
|
||||
basePath: '/api/auth',
|
||||
basePath: '/api/v1/auth',
|
||||
// Inject cookieParser via the new middleware option
|
||||
middleware: [cookieParser()],
|
||||
});
|
||||
@@ -107,7 +116,7 @@ const app = createTestApp({
|
||||
const { mockLogger } = await import('../tests/utils/mockLogger');
|
||||
|
||||
// --- 5. Tests ---
|
||||
describe('Auth Routes (/api/auth)', () => {
|
||||
describe('Auth Routes (/api/v1/auth)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks(); // Restore spies on prototypes
|
||||
@@ -130,7 +139,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send({
|
||||
email: newUserEmail,
|
||||
password: strongPassword,
|
||||
full_name: 'Test User',
|
||||
@@ -162,7 +171,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send({
|
||||
email,
|
||||
password: strongPassword,
|
||||
full_name: 'Avatar User',
|
||||
@@ -191,7 +200,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send({
|
||||
email,
|
||||
password: strongPassword,
|
||||
full_name: '', // Send an empty string
|
||||
@@ -218,7 +227,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
refreshToken: 'new-refresh-token',
|
||||
});
|
||||
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send({
|
||||
email: 'cookie@test.com',
|
||||
password: 'StrongPassword123!',
|
||||
});
|
||||
@@ -231,7 +240,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
it('should reject registration with a weak password', async () => {
|
||||
const weakPassword = 'password';
|
||||
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send({
|
||||
email: 'anotheruser@test.com',
|
||||
password: weakPassword,
|
||||
});
|
||||
@@ -256,7 +265,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: newUserEmail, password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(409); // 409 Conflict
|
||||
@@ -268,7 +277,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: 'fail@test.com', password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -277,7 +286,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 400 for an invalid email format', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: 'not-an-email', password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -286,7 +295,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 400 for a password that is too short', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: newUserEmail, password: 'short' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -306,7 +315,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
||||
const response = await supertest(app).post('/api/v1/auth/login').send(loginCredentials);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -325,7 +334,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should reject login for incorrect credentials', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'wrong_password' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -334,7 +343,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should reject login for a locked account', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'locked@test.com', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -345,7 +354,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 401 if user is not found', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login') // This was a duplicate, fixed.
|
||||
.post('/api/v1/auth/login') // This was a duplicate, fixed.
|
||||
.send({ email: 'notfound@test.com', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -357,7 +366,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.handleSuccessfulLogin.mockRejectedValue(new Error('DB write failed'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
||||
const response = await supertest(app).post('/api/v1/auth/login').send(loginCredentials);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -369,7 +378,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// when the email is 'dberror@test.com'.
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'dberror@test.com', password: 'any_password' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -379,7 +388,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
it('should log a warning when passport authentication fails without a user', async () => {
|
||||
// This test specifically covers the `if (!user)` debug log line in the route.
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'notfound@test.com', password: 'any_password' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -402,7 +411,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
||||
const response = await supertest(app).post('/api/v1/auth/login').send(loginCredentials);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -412,7 +421,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 400 for an invalid email format', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'not-an-email', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -421,7 +430,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 400 if password is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'test@test.com' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -436,7 +445,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: 'test@test.com' });
|
||||
|
||||
// Assert
|
||||
@@ -449,7 +458,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.resetPassword.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: 'nouser@test.com' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -459,7 +468,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
mockedAuthService.resetPassword.mockRejectedValue(new Error('DB connection failed'));
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: 'any@test.com' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -467,7 +476,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 400 for an invalid email format', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: 'invalid-email' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -480,7 +489,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.updatePassword.mockResolvedValue(true);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -491,7 +500,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.updatePassword.mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token: 'invalid-token', newPassword: 'a-Very-Strong-Password-123!' }); // Use strong password to pass validation
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -502,14 +511,14 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// No need to mock the service here as validation runs first
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token: 'valid-token', newPassword: 'weak' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 if token is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-789!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -521,7 +530,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.updatePassword.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -537,7 +546,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-access-token' });
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=valid-refresh-token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -545,7 +554,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if no refresh token cookie is provided', async () => {
|
||||
const response = await supertest(app).post('/api/auth/refresh-token');
|
||||
const response = await supertest(app).post('/api/v1/auth/refresh-token');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error.message).toBe('Refresh token not found.');
|
||||
});
|
||||
@@ -554,7 +563,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=invalid-token');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -566,7 +575,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=any-token');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toMatch(/DB Error/);
|
||||
@@ -580,7 +589,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=some-valid-token');
|
||||
|
||||
// Assert
|
||||
@@ -607,7 +616,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=some-token');
|
||||
|
||||
// Assert
|
||||
@@ -625,7 +634,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should return 200 OK and clear the cookie even if no refresh token is provided', async () => {
|
||||
// Act: Make a request without a cookie.
|
||||
const response = await supertest(app).post('/api/auth/logout');
|
||||
const response = await supertest(app).post('/api/v1/auth/logout');
|
||||
|
||||
// Assert: The response should still be successful and attempt to clear the cookie.
|
||||
expect(response.status).toBe(200);
|
||||
@@ -643,7 +652,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make `maxRequests` successful calls with the special header
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter for this test
|
||||
.send({ email });
|
||||
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
||||
@@ -651,7 +660,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act: Make one more call, which should be blocked
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ email });
|
||||
|
||||
@@ -669,7 +678,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make more calls than the limit. They should all succeed because the limiter is skipped.
|
||||
for (let i = 0; i < overLimitRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
// NO 'X-Test-Rate-Limit-Enable' header is sent
|
||||
.send({ email });
|
||||
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
||||
@@ -692,7 +701,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make `maxRequests` calls. They should not be rate-limited.
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter
|
||||
.send({ token, newPassword });
|
||||
// The expected status is 400 because the token is invalid, but not 429.
|
||||
@@ -701,7 +710,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act: Make one more call, which should be blocked by the rate limiter.
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ token, newPassword });
|
||||
|
||||
@@ -721,7 +730,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make more calls than the limit.
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token, newPassword });
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
@@ -748,7 +757,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make maxRequests calls
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send(newUser);
|
||||
expect(response.status).not.toBe(429);
|
||||
@@ -756,7 +765,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act: Make one more call
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send(newUser);
|
||||
|
||||
@@ -780,7 +789,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app).post('/api/auth/register').send(newUser);
|
||||
const response = await supertest(app).post('/api/v1/auth/register').send(newUser);
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
@@ -800,14 +809,14 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send(credentials);
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send(credentials);
|
||||
|
||||
@@ -826,7 +835,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app).post('/api/auth/login').send(credentials);
|
||||
const response = await supertest(app).post('/api/v1/auth/login').send(credentials);
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
@@ -841,7 +850,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act: Make maxRequests calls
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=valid-token')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
expect(response.status).not.toBe(429);
|
||||
@@ -849,7 +858,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Act: Make one more call
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=valid-token')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
@@ -864,7 +873,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=valid-token');
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
@@ -880,14 +889,14 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Act
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=valid-token')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
|
||||
const blockedResponse = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=valid-token')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
@@ -902,10 +911,169 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Cookie', 'refreshToken=valid-token');
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Budget Routes (/api/budgets)', () => {
|
||||
describe('Budget Routes (/api/v1/budgets)', () => {
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
points: 100,
|
||||
@@ -71,7 +71,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
|
||||
const app = createTestApp({
|
||||
router: budgetRouter,
|
||||
basePath: '/api/budgets',
|
||||
basePath: '/api/v1/budgets',
|
||||
authenticatedUser: mockUserProfile,
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
// Mock the service function directly
|
||||
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue(mockBudgets);
|
||||
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
const response = await supertest(app).get('/api/v1/budgets');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockBudgets);
|
||||
@@ -93,7 +93,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.budgetRepo.getBudgetsForUser).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
const response = await supertest(app).get('/api/v1/budgets');
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
// Mock the service function
|
||||
vi.mocked(db.budgetRepo.createBudget).mockResolvedValue(mockCreatedBudget);
|
||||
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
const response = await supertest(app).post('/api/v1/budgets').send(newBudgetData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data).toEqual(mockCreatedBudget);
|
||||
@@ -131,7 +131,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(
|
||||
new ForeignKeyConstraintError('User not found'),
|
||||
);
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
const response = await supertest(app).post('/api/v1/budgets').send(newBudgetData);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
start_date: '2024-01-01',
|
||||
};
|
||||
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
const response = await supertest(app).post('/api/v1/budgets').send(newBudgetData);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
start_date: 'not-a-date', // invalid date
|
||||
};
|
||||
|
||||
const response = await supertest(app).post('/api/budgets').send(invalidData);
|
||||
const response = await supertest(app).post('/api/v1/budgets').send(invalidData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toHaveLength(4);
|
||||
@@ -166,7 +166,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
it('should return 400 if required fields are missing', async () => {
|
||||
// This test covers the `val ?? ''` part of the `requiredString` helper
|
||||
const response = await supertest(app)
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe('Budget name is required.');
|
||||
@@ -184,7 +184,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
// Mock the service function
|
||||
vi.mocked(db.budgetRepo.updateBudget).mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
||||
const response = await supertest(app).put('/api/v1/budgets/1').send(budgetUpdates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUpdatedBudget);
|
||||
@@ -194,7 +194,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(
|
||||
new NotFoundError('Budget not found'),
|
||||
);
|
||||
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
|
||||
const response = await supertest(app).put('/api/v1/budgets/999').send({ amount_cents: 1 });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Budget not found');
|
||||
});
|
||||
@@ -202,13 +202,13 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
it('should return 500 if a generic database error occurs', async () => {
|
||||
const budgetUpdates = { amount_cents: 60000 };
|
||||
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
||||
const response = await supertest(app).put('/api/v1/budgets/1').send(budgetUpdates);
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/1').send({});
|
||||
const response = await supertest(app).put('/api/v1/budgets/1').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'At least one field to update must be provided.',
|
||||
@@ -216,7 +216,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 5000 });
|
||||
const response = await supertest(app).put('/api/v1/budgets/abc').send({ amount_cents: 5000 });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
@@ -227,7 +227,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
// Mock the service function to resolve (void)
|
||||
vi.mocked(db.budgetRepo.deleteBudget).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete('/api/budgets/1');
|
||||
const response = await supertest(app).delete('/api/v1/budgets/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(
|
||||
@@ -241,20 +241,20 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(
|
||||
new NotFoundError('Budget not found'),
|
||||
);
|
||||
const response = await supertest(app).delete('/api/budgets/999');
|
||||
const response = await supertest(app).delete('/api/v1/budgets/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs', async () => {
|
||||
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).delete('/api/budgets/1');
|
||||
const response = await supertest(app).delete('/api/v1/budgets/1');
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).delete('/api/budgets/abc');
|
||||
const response = await supertest(app).delete('/api/v1/budgets/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
@@ -269,7 +269,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue(mockSpendingData);
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
||||
'/api/v1/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -281,7 +281,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
||||
'/api/v1/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -290,14 +290,14 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
|
||||
it('should return 400 for invalid date formats', async () => {
|
||||
const response = await supertest(app).get(
|
||||
'/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
|
||||
'/api/v1/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return 400 if required query parameters are missing', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis');
|
||||
const response = await supertest(app).get('/api/v1/budgets/spending-analysis');
|
||||
expect(response.status).toBe(400);
|
||||
// Expect errors for both startDate and endDate
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
|
||||
@@ -52,9 +52,9 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Deals Routes (/api/users/deals)', () => {
|
||||
describe('Deals Routes (/api/v1/users/deals)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' } });
|
||||
const basePath = '/api/users/deals';
|
||||
const basePath = '/api/v1/users/deals';
|
||||
const authenticatedApp = createTestApp({
|
||||
router: dealsRouter,
|
||||
basePath,
|
||||
@@ -69,7 +69,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
describe('GET /best-watched-prices', () => {
|
||||
it('should return 401 Unauthorized if user is not authenticated', async () => {
|
||||
const response = await supertest(unauthenticatedApp).get(
|
||||
'/api/users/deals/best-watched-prices',
|
||||
'/api/v1/users/deals/best-watched-prices',
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
|
||||
|
||||
const response = await supertest(authenticatedApp).get(
|
||||
'/api/users/deals/best-watched-prices',
|
||||
'/api/v1/users/deals/best-watched-prices',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -99,7 +99,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(authenticatedApp).get(
|
||||
'/api/users/deals/best-watched-prices',
|
||||
'/api/v1/users/deals/best-watched-prices',
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
@@ -115,7 +115,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue([]);
|
||||
|
||||
const response = await supertest(authenticatedApp)
|
||||
.get('/api/users/deals/best-watched-prices')
|
||||
.get('/api/v1/users/deals/best-watched-prices')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -34,19 +34,19 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Flyer Routes (/api/flyers)', () => {
|
||||
describe('Flyer Routes (/api/v1/flyers)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
|
||||
const app = createTestApp({ router: flyerRouter, basePath: '/api/v1/flyers' });
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should return a list of flyers on success', async () => {
|
||||
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue(mockFlyers);
|
||||
|
||||
const response = await supertest(app).get('/api/flyers');
|
||||
const response = await supertest(app).get('/api/v1/flyers');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockFlyers);
|
||||
@@ -56,36 +56,33 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should pass limit and offset query parameters to the db function', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/flyers?limit=15&offset=30');
|
||||
await supertest(app).get('/api/v1/flyers?limit=15&offset=30');
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
|
||||
});
|
||||
|
||||
it('should use default for offset when only limit is provided', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/flyers?limit=5');
|
||||
await supertest(app).get('/api/v1/flyers?limit=5');
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 5, 0);
|
||||
});
|
||||
|
||||
it('should use default for limit when only offset is provided', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/flyers?offset=10');
|
||||
await supertest(app).get('/api/v1/flyers?offset=10');
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 10);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/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/flyers:',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error in /api/v1/flyers:');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/flyers?limit=abc&offset=-5');
|
||||
const response = await supertest(app).get('/api/v1/flyers?limit=abc&offset=-5');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2);
|
||||
@@ -97,7 +94,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const mockFlyer = createMockFlyer({ flyer_id: 123 });
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
|
||||
|
||||
const response = await supertest(app).get('/api/flyers/123');
|
||||
const response = await supertest(app).get('/api/v1/flyers/123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockFlyer);
|
||||
@@ -111,14 +108,14 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(
|
||||
new NotFoundError(`Flyer with ID 999 not found.`),
|
||||
);
|
||||
const response = await supertest(app).get('/api/flyers/999');
|
||||
const response = await supertest(app).get('/api/v1/flyers/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).get('/api/flyers/abc');
|
||||
const response = await supertest(app).get('/api/v1/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Zod coercion results in NaN for "abc", which triggers a type error before our custom message
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
@@ -129,7 +126,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123');
|
||||
const response = await supertest(app).get('/api/v1/flyers/123');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
@@ -144,14 +141,14 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123 })];
|
||||
vi.mocked(db.flyerRepo.getFlyerItems).mockResolvedValue(mockFlyerItems);
|
||||
|
||||
const response = await supertest(app).get('/api/flyers/123/items');
|
||||
const response = await supertest(app).get('/api/v1/flyers/123/items');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockFlyerItems);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).get('/api/flyers/abc/items');
|
||||
const response = await supertest(app).get('/api/v1/flyers/abc/items');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
/Invalid flyer ID provided|expected number, received NaN/,
|
||||
@@ -161,12 +158,12 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123/items');
|
||||
const response = await supertest(app).get('/api/v1/flyers/123/items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError, flyerId: 123 },
|
||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||
'Error in /api/v1/flyers/123/items:',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -177,7 +174,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.post('/api/v1/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: [1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -186,7 +183,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should return 400 if flyerIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.post('/api/v1/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/expected array/);
|
||||
@@ -194,7 +191,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should return 400 if flyerIds is an empty array, as per schema validation', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.post('/api/v1/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: [] });
|
||||
expect(response.status).toBe(400);
|
||||
// Check for the specific Zod error message.
|
||||
@@ -204,7 +201,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.post('/api/v1/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
@@ -216,7 +213,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(42);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.post('/api/v1/flyers/items/batch-count')
|
||||
.send({ flyerIds: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -225,7 +222,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should return 400 if flyerIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.post('/api/v1/flyers/items/batch-count')
|
||||
.send({ flyerIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -234,7 +231,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(0);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.post('/api/v1/flyers/items/batch-count')
|
||||
.send({ flyerIds: [] });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ count: 0 });
|
||||
@@ -243,7 +240,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.post('/api/v1/flyers/items/batch-count')
|
||||
.send({ flyerIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
@@ -253,7 +250,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
describe('POST /items/:itemId/track', () => {
|
||||
it('should return 202 Accepted and call the tracking function for "click"', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/99/track')
|
||||
.post('/api/v1/flyers/items/99/track')
|
||||
.send({ type: 'click' });
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
@@ -266,7 +263,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should return 202 Accepted and call the tracking function for "view"', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/101/track')
|
||||
.post('/api/v1/flyers/items/101/track')
|
||||
.send({ type: 'view' });
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
@@ -279,14 +276,14 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
it('should return 400 for an invalid item ID', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/abc/track')
|
||||
.post('/api/v1/flyers/items/abc/track')
|
||||
.send({ type: 'click' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid interaction type', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/99/track')
|
||||
.post('/api/v1/flyers/items/99/track')
|
||||
.send({ type: 'invalid' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -296,7 +293,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockRejectedValue(trackingError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/99/track')
|
||||
.post('/api/v1/flyers/items/99/track')
|
||||
.send({ type: 'click' });
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
@@ -317,7 +314,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/99/track')
|
||||
.post('/api/v1/flyers/items/99/track')
|
||||
.send({ type: 'click' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -328,7 +325,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should apply publicReadLimiter to GET /', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
const response = await supertest(app)
|
||||
.get('/api/flyers')
|
||||
.get('/api/v1/flyers')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -339,7 +336,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue([]);
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.post('/api/v1/flyers/items/batch-fetch')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ flyerIds: [1] });
|
||||
|
||||
@@ -351,7 +348,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should apply batchLimiter to POST /items/batch-count', async () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(0);
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.post('/api/v1/flyers/items/batch-count')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ flyerIds: [1] });
|
||||
|
||||
@@ -365,7 +362,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/1/track')
|
||||
.post('/api/v1/flyers/items/1/track')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ type: 'view' });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Gamification Routes (/api/achievements)', () => {
|
||||
describe('Gamification Routes (/api/v1/achievements)', () => {
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'user@test.com' },
|
||||
points: 100,
|
||||
@@ -75,7 +75,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const basePath = '/api/achievements';
|
||||
const basePath = '/api/v1/achievements';
|
||||
const unauthenticatedApp = createTestApp({ router: gamificationRouter, basePath });
|
||||
const authenticatedApp = createTestApp({
|
||||
router: gamificationRouter,
|
||||
@@ -96,7 +96,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
];
|
||||
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue(mockAchievements);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements');
|
||||
const response = await supertest(unauthenticatedApp).get('/api/v1/achievements');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockAchievements);
|
||||
expect(db.gamificationRepo.getAllAchievements).toHaveBeenCalledWith(expectLogger);
|
||||
@@ -106,7 +106,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.gamificationRepo.getAllAchievements).mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements');
|
||||
const response = await supertest(unauthenticatedApp).get('/api/v1/achievements');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Connection Failed');
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send({ userId: 'non-existent', achievementName: 'Test Award' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
@@ -131,7 +131,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
describe('GET /me', () => {
|
||||
it('should return 401 Unauthorized when user is not authenticated', async () => {
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/me');
|
||||
const response = await supertest(unauthenticatedApp).get('/api/v1/achievements/me');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
];
|
||||
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue(mockUserAchievements);
|
||||
|
||||
const response = await supertest(authenticatedApp).get('/api/achievements/me');
|
||||
const response = await supertest(authenticatedApp).get('/api/v1/achievements/me');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUserAchievements);
|
||||
@@ -165,7 +165,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
});
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.gamificationRepo.getUserAchievements).mockRejectedValue(dbError);
|
||||
const response = await supertest(authenticatedApp).get('/api/achievements/me');
|
||||
const response = await supertest(authenticatedApp).get('/api/v1/achievements/me');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -176,7 +176,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
it('should return 401 Unauthorized if user is not authenticated', async () => {
|
||||
const response = await supertest(unauthenticatedApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send(awardPayload);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
// Let the default isAdmin mock (set in beforeEach) run, which denies access
|
||||
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send(awardPayload);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next()); // Grant admin access
|
||||
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
|
||||
const response = await supertest(adminApp).post('/api/v1/achievements/award').send(awardPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toContain('Successfully awarded');
|
||||
@@ -224,7 +224,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
|
||||
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
|
||||
const response = await supertest(adminApp).post('/api/v1/achievements/award').send(awardPayload);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
@@ -237,7 +237,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
|
||||
|
||||
const response = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send({ userId: '', achievementName: '' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
@@ -251,13 +251,13 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
|
||||
|
||||
const response1 = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send({ achievementName: 'Test Award' });
|
||||
expect(response1.status).toBe(400);
|
||||
expect(response1.body.error.details[0].message).toBe('userId is required.');
|
||||
|
||||
const response2 = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send({ userId: 'user-789' });
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body.error.details[0].message).toBe('achievementName is required.');
|
||||
@@ -274,7 +274,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.send({ userId: 'non-existent', achievementName: 'Test Award' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
@@ -294,7 +294,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get(
|
||||
'/api/achievements/leaderboard?limit=5',
|
||||
'/api/v1/achievements/leaderboard?limit=5',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -313,7 +313,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
];
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
const response = await supertest(unauthenticatedApp).get('/api/v1/achievements/leaderboard');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockLeaderboard);
|
||||
@@ -322,14 +322,14 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
const response = await supertest(unauthenticatedApp).get('/api/v1/achievements/leaderboard');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid limit parameter', async () => {
|
||||
const response = await supertest(unauthenticatedApp).get(
|
||||
'/api/achievements/leaderboard?limit=100',
|
||||
'/api/v1/achievements/leaderboard?limit=100',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
@@ -341,7 +341,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
it('should apply publicReadLimiter to GET /', async () => {
|
||||
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue([]);
|
||||
const response = await supertest(unauthenticatedApp)
|
||||
.get('/api/achievements')
|
||||
.get('/api/v1/achievements')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -356,7 +356,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
});
|
||||
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue([]);
|
||||
const response = await supertest(authenticatedApp)
|
||||
.get('/api/achievements/me')
|
||||
.get('/api/v1/achievements/me')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -373,7 +373,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.post('/api/v1/achievements/award')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ userId: 'some-user', achievementName: 'some-achievement' });
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
@@ -36,6 +39,13 @@ import * as dbConnection from '../services/db/connection.db';
|
||||
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.
|
||||
@@ -46,9 +56,9 @@ const mockedFs = fs as Mocked<typeof fs>;
|
||||
const { logger } = await import('../services/logger.server');
|
||||
|
||||
// 2. Create a minimal Express app to host the router for testing.
|
||||
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
||||
const app = createTestApp({ router: healthRouter, basePath: '/api/v1/health' });
|
||||
|
||||
describe('Health Routes (/api/health)', () => {
|
||||
describe('Health Routes (/api/v1/health)', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock history before each test to ensure isolation.
|
||||
vi.clearAllMocks();
|
||||
@@ -61,7 +71,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
describe('GET /ping', () => {
|
||||
it('should return 200 OK with "pong"', async () => {
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/ping');
|
||||
const response = await supertest(app).get('/api/v1/health/ping');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -75,7 +85,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
|
||||
// Act: Make a request to the endpoint.
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
// Assert: Check for the correct status and response body.
|
||||
expect(response.status).toBe(200);
|
||||
@@ -89,7 +99,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -101,7 +111,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('OK'); // Not 'PONG'
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -117,7 +127,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
vi.setSystemTime(fakeDate);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/time');
|
||||
const response = await supertest(app).get('/api/v1/health/time');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -133,7 +143,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
const response = await supertest(app).get('/api/v1/health/db-schema');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -145,7 +155,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Arrange: Mock the service function to return missing table names
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table_1', 'missing_table_2']);
|
||||
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
const response = await supertest(app).get('/api/v1/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toContain(
|
||||
@@ -159,7 +169,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const dbError = new Error('DB connection failed');
|
||||
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
const response = await supertest(app).get('/api/v1/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
@@ -181,7 +191,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const dbError = { message: 'DB connection failed' };
|
||||
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
const response = await supertest(app).get('/api/v1/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
@@ -201,7 +211,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/storage');
|
||||
const response = await supertest(app).get('/api/v1/health/storage');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -215,7 +225,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedFs.access.mockRejectedValue(accessError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/storage');
|
||||
const response = await supertest(app).get('/api/v1/health/storage');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -234,7 +244,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedFs.access.mockRejectedValue(accessError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/storage');
|
||||
const response = await supertest(app).get('/api/v1/health/storage');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -258,7 +268,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
const response = await supertest(app).get('/api/v1/health/db-pool');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -275,7 +285,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
const response = await supertest(app).get('/api/v1/health/db-pool');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -295,7 +305,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
throw poolError;
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
const response = await supertest(app).get('/api/v1/health/db-pool');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
@@ -315,7 +325,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
throw poolError;
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
const response = await supertest(app).get('/api/v1/health/db-pool');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
@@ -334,7 +344,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const redisError = new Error('Connection timed out');
|
||||
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
||||
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Connection timed out');
|
||||
@@ -354,7 +364,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
it('should return 500 if Redis ping returns an unexpected response', async () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('OK'); // Not 'PONG'
|
||||
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toContain('Unexpected Redis ping response: OK');
|
||||
@@ -373,7 +383,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const redisError = { message: 'Non-error rejection' };
|
||||
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
||||
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
const response = await supertest(app).get('/api/v1/health/redis');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('Non-error rejection');
|
||||
@@ -386,7 +396,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
describe('GET /live', () => {
|
||||
it('should return 200 OK with status ok', async () => {
|
||||
const response = await supertest(app).get('/api/health/live');
|
||||
const response = await supertest(app).get('/api/v1/health/live');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -408,7 +418,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -432,7 +442,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -447,7 +457,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -468,7 +478,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockRejectedValue(new Error('Redis connection refused'));
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -489,7 +499,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('UNEXPECTED');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error.details.services.redis.status).toBe('unhealthy');
|
||||
@@ -510,7 +520,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
// Storage is not a critical service, so it should still return 200
|
||||
// but overall status should reflect storage issue
|
||||
@@ -525,7 +535,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error.details.services.database.status).toBe('unhealthy');
|
||||
@@ -546,7 +556,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
mockedRedisConnection.ping.mockRejectedValue('String error');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).get('/api/health/ready');
|
||||
const response = await supertest(app).get('/api/v1/health/ready');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error.details.services.redis.status).toBe('unhealthy');
|
||||
@@ -565,7 +575,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/health/startup');
|
||||
const response = await supertest(app).get('/api/v1/health/startup');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -579,7 +589,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('Database not ready')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
const response = await supertest(app).get('/api/health/startup');
|
||||
const response = await supertest(app).get('/api/v1/health/startup');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -599,7 +609,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
waitingCount: 5, // > 3 triggers degraded
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/health/startup');
|
||||
const response = await supertest(app).get('/api/v1/health/startup');
|
||||
|
||||
// Degraded is not unhealthy, so startup should succeed
|
||||
expect(response.status).toBe(200);
|
||||
@@ -612,11 +622,419 @@ describe('Health Routes (/api/health)', () => {
|
||||
const mockPool = { query: vi.fn().mockRejectedValue({ code: 'ECONNREFUSED' }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
const response = await supertest(app).get('/api/health/startup');
|
||||
const response = await supertest(app).get('/api/v1/health/startup');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error.details.database.status).toBe('unhealthy');
|
||||
expect(response.body.error.details.database.message).toBe('Database connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// =============================================================================
|
||||
|
||||
describe('GET /queues', () => {
|
||||
// Mock the queues module
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
// Re-import after mocks are set up
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Mock Redis heartbeat responses (all healthy, last seen < 60s ago)
|
||||
const recentTimestamp = new Date(Date.now() - 10000).toISOString(); // 10 seconds ago
|
||||
const heartbeatValue = JSON.stringify({
|
||||
timestamp: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
|
||||
mockedRedisConnection.get = vi.fn().mockResolvedValue(heartbeatValue);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/queues');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.status).toBe('healthy');
|
||||
expect(response.body.data.queues).toBeDefined();
|
||||
expect(response.body.data.workers).toBeDefined();
|
||||
|
||||
// Verify queue metrics structure
|
||||
expect(response.body.data.queues['flyer-processing']).toEqual({
|
||||
waiting: 5,
|
||||
active: 2,
|
||||
failed: 1,
|
||||
delayed: 0,
|
||||
});
|
||||
|
||||
// Verify worker heartbeat structure
|
||||
expect(response.body.data.workers['flyer-processing']).toEqual({
|
||||
alive: true,
|
||||
lastSeen: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
});
|
||||
|
||||
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')),
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
mockedRedisConnection.get = vi.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/queues');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error.message).toBe('One or more queues or workers unavailable');
|
||||
expect(response.body.error.details.status).toBe('unhealthy');
|
||||
expect(response.body.error.details.queues['flyer-processing']).toEqual({
|
||||
error: 'Redis connection lost',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Mock heartbeat - one worker is stale (> 60s ago)
|
||||
const staleTimestamp = new Date(Date.now() - 120000).toISOString(); // 120 seconds ago
|
||||
const staleHeartbeat = JSON.stringify({
|
||||
timestamp: staleTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
|
||||
// First call returns stale heartbeat for flyer-processing, rest return null (no heartbeat)
|
||||
let callCount = 0;
|
||||
mockedRedisConnection.get = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? staleHeartbeat : null);
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/queues');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error.details.status).toBe('unhealthy');
|
||||
expect(response.body.error.details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Mock Redis to return null (no heartbeat found)
|
||||
mockedRedisConnection.get = vi.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/v1/health/queues');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error.details.status).toBe('unhealthy');
|
||||
expect(response.body.error.details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Mock Redis get() to throw error
|
||||
mockedRedisConnection.get = vi.fn().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({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
});
|
||||
expect(response.body.error.details.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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,17 @@ import fs from 'node:fs/promises';
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
import {
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
cleanupQueue,
|
||||
tokenCleanupQueue,
|
||||
receiptQueue,
|
||||
expiryAlertQueue,
|
||||
barcodeQueue,
|
||||
} from '../services/queues.server';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -442,4 +453,224 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health/queues:
|
||||
* get:
|
||||
* summary: Queue health and metrics with worker heartbeats
|
||||
* description: |
|
||||
* Returns job counts for all BullMQ queues and worker heartbeat status.
|
||||
* Use this endpoint to monitor queue depths and detect stuck/frozen workers.
|
||||
* Implements ADR-053: Worker Health Checks and Stalled Job Monitoring.
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Queue metrics and worker heartbeats retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [healthy, unhealthy]
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* queues:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: object
|
||||
* properties:
|
||||
* waiting:
|
||||
* type: integer
|
||||
* active:
|
||||
* type: integer
|
||||
* failed:
|
||||
* type: integer
|
||||
* delayed:
|
||||
* type: integer
|
||||
* workers:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: object
|
||||
* properties:
|
||||
* alive:
|
||||
* type: boolean
|
||||
* lastSeen:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* pid:
|
||||
* type: integer
|
||||
* host:
|
||||
* type: string
|
||||
* 503:
|
||||
* description: Redis unavailable or workers not responding
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/queues',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Define all queues to monitor
|
||||
const queues = [
|
||||
{ name: 'flyer-processing', queue: flyerQueue },
|
||||
{ name: 'email-sending', queue: emailQueue },
|
||||
{ name: 'analytics-reporting', queue: analyticsQueue },
|
||||
{ name: 'weekly-analytics-reporting', queue: weeklyAnalyticsQueue },
|
||||
{ name: 'file-cleanup', queue: cleanupQueue },
|
||||
{ name: 'token-cleanup', queue: tokenCleanupQueue },
|
||||
{ name: 'receipt-processing', queue: receiptQueue },
|
||||
{ name: 'expiry-alerts', queue: expiryAlertQueue },
|
||||
{ name: 'barcode-detection', queue: barcodeQueue },
|
||||
];
|
||||
|
||||
// Fetch job counts for all queues in parallel
|
||||
const queueMetrics = await Promise.all(
|
||||
queues.map(async ({ name, queue }) => {
|
||||
try {
|
||||
const counts = await queue.getJobCounts();
|
||||
return {
|
||||
name,
|
||||
counts: {
|
||||
waiting: counts.waiting || 0,
|
||||
active: counts.active || 0,
|
||||
failed: counts.failed || 0,
|
||||
delayed: counts.delayed || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If individual queue fails, return error state
|
||||
return {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch worker heartbeats in parallel
|
||||
const workerNames = queues.map((q) => q.name);
|
||||
const workerHeartbeats = await Promise.all(
|
||||
workerNames.map(async (name) => {
|
||||
try {
|
||||
const key = `worker:heartbeat:${name}`;
|
||||
const value = await redisConnection.get(key);
|
||||
|
||||
if (!value) {
|
||||
return { name, alive: false };
|
||||
}
|
||||
|
||||
const heartbeat = JSON.parse(value) as {
|
||||
timestamp: string;
|
||||
pid: number;
|
||||
host: string;
|
||||
};
|
||||
const lastSeenMs = new Date(heartbeat.timestamp).getTime();
|
||||
const nowMs = Date.now();
|
||||
const ageSeconds = (nowMs - lastSeenMs) / 1000;
|
||||
|
||||
// Consider alive if last heartbeat < 60 seconds ago
|
||||
const alive = ageSeconds < 60;
|
||||
|
||||
return {
|
||||
name,
|
||||
alive,
|
||||
lastSeen: heartbeat.timestamp,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
} catch (error) {
|
||||
// If heartbeat check fails, mark as unknown
|
||||
return {
|
||||
name,
|
||||
alive: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Build response objects
|
||||
const queuesData: Record<
|
||||
string,
|
||||
{ waiting: number; active: number; failed: number; delayed: number } | { error: string }
|
||||
> = {};
|
||||
const workersData: Record<
|
||||
string,
|
||||
| { alive: boolean; lastSeen?: string; pid?: number; host?: string }
|
||||
| { alive: boolean; error: string }
|
||||
> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
for (const metric of queueMetrics) {
|
||||
if ('error' in metric && metric.error) {
|
||||
queuesData[metric.name] = { error: metric.error };
|
||||
hasErrors = true;
|
||||
} else if ('counts' in metric && metric.counts) {
|
||||
queuesData[metric.name] = metric.counts;
|
||||
}
|
||||
}
|
||||
|
||||
for (const heartbeat of workerHeartbeats) {
|
||||
if ('error' in heartbeat) {
|
||||
workersData[heartbeat.name] = { alive: false, error: heartbeat.error };
|
||||
} else if (!heartbeat.alive) {
|
||||
workersData[heartbeat.name] = { alive: false };
|
||||
hasErrors = true;
|
||||
} else {
|
||||
workersData[heartbeat.name] = {
|
||||
alive: heartbeat.alive,
|
||||
lastSeen: heartbeat.lastSeen,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: hasErrors ? ('unhealthy' as const) : ('healthy' as const),
|
||||
timestamp: new Date().toISOString(),
|
||||
queues: queuesData,
|
||||
workers: workersData,
|
||||
};
|
||||
|
||||
if (hasErrors) {
|
||||
return sendError(
|
||||
res,
|
||||
ErrorCode.SERVICE_UNAVAILABLE,
|
||||
'One or more queues or workers unavailable',
|
||||
503,
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
return sendSuccess(res, response);
|
||||
} catch (error: unknown) {
|
||||
// Redis connection error or other unexpected failure
|
||||
if (error instanceof Error) {
|
||||
return next(error);
|
||||
}
|
||||
const message =
|
||||
(error as { message?: string })?.message || 'Failed to retrieve queue metrics';
|
||||
return next(new Error(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -82,7 +82,7 @@ function createMockInventoryItem(overrides: Partial<UserInventoryItem> = {}): Us
|
||||
};
|
||||
}
|
||||
|
||||
describe('Inventory Routes (/api/inventory)', () => {
|
||||
describe('Inventory Routes (/api/v1/inventory)', () => {
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
|
||||
const app = createTestApp({
|
||||
router: inventoryRouter,
|
||||
basePath: '/api/inventory',
|
||||
basePath: '/api/v1/inventory',
|
||||
authenticatedUser: mockUserProfile,
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/inventory');
|
||||
const response = await supertest(app).get('/api/v1/inventory');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
@@ -124,7 +124,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should support filtering by location', async () => {
|
||||
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get('/api/inventory?location=fridge');
|
||||
const response = await supertest(app).get('/api/v1/inventory?location=fridge');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(expiryService.getInventory).toHaveBeenCalledWith(
|
||||
@@ -136,7 +136,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should support filtering by expiring_within_days', async () => {
|
||||
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get('/api/inventory?expiring_within_days=7');
|
||||
const response = await supertest(app).get('/api/v1/inventory?expiring_within_days=7');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(expiryService.getInventory).toHaveBeenCalledWith(
|
||||
@@ -148,7 +148,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should support search filter', async () => {
|
||||
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get('/api/inventory?search=milk');
|
||||
const response = await supertest(app).get('/api/v1/inventory?search=milk');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(expiryService.getInventory).toHaveBeenCalledWith(
|
||||
@@ -161,7 +161,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/inventory?sort_by=expiry_date&sort_order=asc',
|
||||
'/api/v1/inventory?sort_by=expiry_date&sort_order=asc',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -175,7 +175,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid location', async () => {
|
||||
const response = await supertest(app).get('/api/inventory?location=invalid');
|
||||
const response = await supertest(app).get('/api/v1/inventory?location=invalid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.getInventory).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get('/api/inventory');
|
||||
const response = await supertest(app).get('/api/v1/inventory');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -194,7 +194,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
const mockItem = createMockInventoryItem();
|
||||
vi.mocked(expiryService.addInventoryItem).mockResolvedValue(mockItem);
|
||||
|
||||
const response = await supertest(app).post('/api/inventory').send({
|
||||
const response = await supertest(app).post('/api/v1/inventory').send({
|
||||
item_name: 'Milk',
|
||||
source: 'manual',
|
||||
quantity: 1,
|
||||
@@ -215,7 +215,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if item_name is missing', async () => {
|
||||
const response = await supertest(app).post('/api/inventory').send({
|
||||
const response = await supertest(app).post('/api/v1/inventory').send({
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid source', async () => {
|
||||
const response = await supertest(app).post('/api/inventory').send({
|
||||
const response = await supertest(app).post('/api/v1/inventory').send({
|
||||
item_name: 'Milk',
|
||||
source: 'invalid_source',
|
||||
});
|
||||
@@ -234,7 +234,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid expiry_date format', async () => {
|
||||
const response = await supertest(app).post('/api/inventory').send({
|
||||
const response = await supertest(app).post('/api/v1/inventory').send({
|
||||
item_name: 'Milk',
|
||||
source: 'manual',
|
||||
expiry_date: '01-10-2024',
|
||||
@@ -247,7 +247,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.addInventoryItem).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).post('/api/inventory').send({
|
||||
const response = await supertest(app).post('/api/v1/inventory').send({
|
||||
item_name: 'Milk',
|
||||
source: 'manual',
|
||||
});
|
||||
@@ -261,7 +261,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
const mockItem = createMockInventoryItem();
|
||||
vi.mocked(expiryService.getInventoryItemById).mockResolvedValue(mockItem);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/1');
|
||||
const response = await supertest(app).get('/api/v1/inventory/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.inventory_id).toBe(1);
|
||||
@@ -277,13 +277,13 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
new NotFoundError('Item not found'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/999');
|
||||
const response = await supertest(app).get('/api/v1/inventory/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid inventory ID', async () => {
|
||||
const response = await supertest(app).get('/api/inventory/abc');
|
||||
const response = await supertest(app).get('/api/v1/inventory/abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -294,7 +294,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
const mockItem = createMockInventoryItem({ quantity: 2 });
|
||||
vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem);
|
||||
|
||||
const response = await supertest(app).put('/api/inventory/1').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/1').send({
|
||||
quantity: 2,
|
||||
});
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
const mockItem = createMockInventoryItem({ expiry_date: '2024-03-01' });
|
||||
vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem);
|
||||
|
||||
const response = await supertest(app).put('/api/inventory/1').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/1').send({
|
||||
expiry_date: '2024-03-01',
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields provided', async () => {
|
||||
const response = await supertest(app).put('/api/inventory/1').send({});
|
||||
const response = await supertest(app).put('/api/v1/inventory/1').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/At least one field/);
|
||||
@@ -331,7 +331,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
new NotFoundError('Item not found'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).put('/api/inventory/999').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/999').send({
|
||||
quantity: 2,
|
||||
});
|
||||
|
||||
@@ -343,7 +343,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should delete an inventory item', async () => {
|
||||
vi.mocked(expiryService.deleteInventoryItem).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete('/api/inventory/1');
|
||||
const response = await supertest(app).delete('/api/v1/inventory/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(expiryService.deleteInventoryItem).toHaveBeenCalledWith(
|
||||
@@ -358,7 +358,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
new NotFoundError('Item not found'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).delete('/api/inventory/999');
|
||||
const response = await supertest(app).delete('/api/v1/inventory/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -368,7 +368,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should mark item as consumed', async () => {
|
||||
vi.mocked(expiryService.markItemConsumed).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).post('/api/inventory/1/consume');
|
||||
const response = await supertest(app).post('/api/v1/inventory/1/consume');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(expiryService.markItemConsumed).toHaveBeenCalledWith(
|
||||
@@ -383,7 +383,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
new NotFoundError('Item not found'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).post('/api/inventory/999/consume');
|
||||
const response = await supertest(app).post('/api/v1/inventory/999/consume');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -411,7 +411,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
|
||||
vi.mocked(expiryService.getExpiringItemsGrouped).mockResolvedValue(mockSummary);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expiring/summary');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expiring/summary');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.counts.total).toBe(4);
|
||||
@@ -420,7 +420,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.getExpiringItemsGrouped).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expiring/summary');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expiring/summary');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -431,7 +431,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
const mockItems = [createMockInventoryItem({ days_until_expiry: 5 })];
|
||||
vi.mocked(expiryService.getExpiringItems).mockResolvedValue(mockItems);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expiring');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expiring');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
@@ -445,7 +445,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should accept custom days parameter', async () => {
|
||||
vi.mocked(expiryService.getExpiringItems).mockResolvedValue([]);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expiring?days=14');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expiring?days=14');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(expiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
@@ -456,7 +456,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid days parameter', async () => {
|
||||
const response = await supertest(app).get('/api/inventory/expiring?days=100');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expiring?days=100');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -469,7 +469,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
];
|
||||
vi.mocked(expiryService.getExpiredItems).mockResolvedValue(mockItems);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expired');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expired');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
@@ -482,7 +482,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.getExpiredItems).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/expired');
|
||||
const response = await supertest(app).get('/api/v1/inventory/expired');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -509,7 +509,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
|
||||
vi.mocked(expiryService.getAlertSettings).mockResolvedValue(mockSettings);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/alerts');
|
||||
const response = await supertest(app).get('/api/v1/inventory/alerts');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
@@ -519,7 +519,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.getAlertSettings).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/alerts');
|
||||
const response = await supertest(app).get('/api/v1/inventory/alerts');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -540,7 +540,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
|
||||
vi.mocked(expiryService.updateAlertSettings).mockResolvedValue(mockSettings);
|
||||
|
||||
const response = await supertest(app).put('/api/inventory/alerts/email').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/alerts/email').send({
|
||||
days_before_expiry: 5,
|
||||
is_enabled: true,
|
||||
});
|
||||
@@ -556,7 +556,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid alert method', async () => {
|
||||
const response = await supertest(app).put('/api/inventory/alerts/sms').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/alerts/sms').send({
|
||||
is_enabled: true,
|
||||
});
|
||||
|
||||
@@ -564,7 +564,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid days_before_expiry', async () => {
|
||||
const response = await supertest(app).put('/api/inventory/alerts/email').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/alerts/email').send({
|
||||
days_before_expiry: 0,
|
||||
});
|
||||
|
||||
@@ -572,7 +572,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if days_before_expiry exceeds maximum', async () => {
|
||||
const response = await supertest(app).put('/api/inventory/alerts/email').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/alerts/email').send({
|
||||
days_before_expiry: 31,
|
||||
});
|
||||
|
||||
@@ -582,7 +582,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
it('should return 500 if service fails', async () => {
|
||||
vi.mocked(expiryService.updateAlertSettings).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).put('/api/inventory/alerts/email').send({
|
||||
const response = await supertest(app).put('/api/v1/inventory/alerts/email').send({
|
||||
is_enabled: false,
|
||||
});
|
||||
|
||||
@@ -619,7 +619,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
mockResult as any,
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/recipes/suggestions');
|
||||
const response = await supertest(app).get('/api/v1/inventory/recipes/suggestions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.recipes).toHaveLength(1);
|
||||
@@ -634,7 +634,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/inventory/recipes/suggestions?days=14&limit=5&offset=10',
|
||||
'/api/v1/inventory/recipes/suggestions?days=14&limit=5&offset=10',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -647,7 +647,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid days parameter', async () => {
|
||||
const response = await supertest(app).get('/api/inventory/recipes/suggestions?days=100');
|
||||
const response = await supertest(app).get('/api/v1/inventory/recipes/suggestions?days=100');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -657,7 +657,7 @@ describe('Inventory Routes (/api/inventory)', () => {
|
||||
new Error('DB Error'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/inventory/recipes/suggestions');
|
||||
const response = await supertest(app).get('/api/v1/inventory/recipes/suggestions');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
@@ -28,8 +28,8 @@ vi.mock('../services/logger.server', async () => ({
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
describe('Personalization Routes (/api/personalization)', () => {
|
||||
const app = createTestApp({ router: personalizationRouter, basePath: '/api/personalization' });
|
||||
describe('Personalization Routes (/api/v1/personalization)', () => {
|
||||
const app = createTestApp({ router: personalizationRouter, basePath: '/api/v1/personalization' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -44,7 +44,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
});
|
||||
|
||||
const response = await supertest(app)
|
||||
.get('/api/personalization/master-items')
|
||||
.get('/api/v1/personalization/master-items')
|
||||
.set('x-test-rate-limit-enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -55,13 +55,13 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.get('/api/personalization/master-items')
|
||||
.get('/api/v1/personalization/master-items')
|
||||
.set('x-test-rate-limit-enable', 'true');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching master items in /api/personalization/master-items:',
|
||||
'Error fetching master items in /api/v1/personalization/master-items:',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const mockRestrictions = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockResolvedValue(mockRestrictions);
|
||||
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
const response = await supertest(app).get('/api/v1/personalization/dietary-restrictions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockRestrictions);
|
||||
@@ -80,12 +80,12 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
const response = await supertest(app).get('/api/v1/personalization/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
|
||||
'Error fetching dietary restrictions in /api/v1/personalization/dietary-restrictions:',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const mockAppliances = [createMockAppliance({ name: 'Air Fryer' })];
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockResolvedValue(mockAppliances);
|
||||
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
const response = await supertest(app).get('/api/v1/personalization/appliances');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockAppliances);
|
||||
@@ -104,12 +104,12 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
const response = await supertest(app).get('/api/v1/personalization/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching appliances in /api/personalization/appliances:',
|
||||
'Error fetching appliances in /api/v1/personalization/appliances:',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -121,7 +121,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
total: 0,
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.get('/api/personalization/master-items')
|
||||
.get('/api/v1/personalization/master-items')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,11 +37,11 @@ vi.mock('../config/passport', () => ({
|
||||
import priceRouter from './price.routes';
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
|
||||
describe('Price Routes (/api/price-history)', () => {
|
||||
describe('Price Routes (/api/v1/price-history)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
|
||||
const app = createTestApp({
|
||||
router: priceRouter,
|
||||
basePath: '/api/price-history',
|
||||
basePath: '/api/v1/price-history',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
beforeEach(() => {
|
||||
@@ -57,7 +57,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue(mockHistory);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -68,7 +68,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
it('should pass limit and offset from the body to the repository', async () => {
|
||||
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
|
||||
await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3], limit: 50, offset: 10 });
|
||||
|
||||
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2, 3], expect.any(Object), 50, 10);
|
||||
@@ -77,7 +77,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
it('should log the request info', async () => {
|
||||
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
|
||||
await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3], limit: 25, offset: 5 });
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
@@ -91,7 +91,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
vi.mocked(priceRepo.getPriceHistory).mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -99,7 +99,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds is an empty array', async () => {
|
||||
const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] });
|
||||
const response = await supertest(app).post('/api/v1/price-history').send({ masterItemIds: [] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
@@ -109,7 +109,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
it('should return 400 if masterItemIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: 'not-an-array' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -121,7 +121,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
it('should return 400 if masterItemIds contains non-positive integers', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1, -2, 3] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -129,7 +129,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds is missing', async () => {
|
||||
const response = await supertest(app).post('/api/price-history').send({});
|
||||
const response = await supertest(app).post('/api/v1/price-history').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
// The actual message is "Invalid input: expected array, received undefined"
|
||||
@@ -140,7 +140,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
it('should return 400 for invalid limit and offset', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.send({ masterItemIds: [1], limit: -1, offset: 'abc' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -157,7 +157,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
it('should apply priceHistoryLimiter to POST /', async () => {
|
||||
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ masterItemIds: [1, 2] });
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Reaction Routes (/api/reactions)', () => {
|
||||
describe('Reaction Routes (/api/v1/reactions)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/v1/reactions' });
|
||||
|
||||
it('should return a list of reactions', async () => {
|
||||
const mockReactions = [
|
||||
@@ -56,7 +56,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
] as unknown as UserReaction[];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions);
|
||||
|
||||
const response = await supertest(app).get('/api/reactions');
|
||||
const response = await supertest(app).get('/api/v1/reactions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockReactions);
|
||||
@@ -72,7 +72,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
|
||||
|
||||
const response = await supertest(app).get('/api/reactions').query(query);
|
||||
const response = await supertest(app).get('/api/v1/reactions').query(query);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
@@ -85,7 +85,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const error = new Error('DB Error');
|
||||
vi.mocked(reactionRepo.getReactions).mockRejectedValue(error);
|
||||
|
||||
const response = await supertest(app).get('/api/reactions');
|
||||
const response = await supertest(app).get('/api/v1/reactions');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error fetching user reactions');
|
||||
@@ -93,7 +93,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
});
|
||||
|
||||
describe('GET /summary', () => {
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/v1/reactions' });
|
||||
|
||||
it('should return reaction summary for an entity', async () => {
|
||||
const mockSummary = [
|
||||
@@ -103,7 +103,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary);
|
||||
|
||||
const response = await supertest(app)
|
||||
.get('/api/reactions/summary')
|
||||
.get('/api/v1/reactions/summary')
|
||||
.query({ entityType: 'recipe', entityId: '123' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -112,7 +112,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if required parameters are missing', async () => {
|
||||
const response = await supertest(app).get('/api/reactions/summary');
|
||||
const response = await supertest(app).get('/api/v1/reactions/summary');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('required');
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
vi.mocked(reactionRepo.getReactionSummary).mockRejectedValue(error);
|
||||
|
||||
const response = await supertest(app)
|
||||
.get('/api/reactions/summary')
|
||||
.get('/api/v1/reactions/summary')
|
||||
.query({ entityType: 'recipe', entityId: '123' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -134,7 +134,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
|
||||
const app = createTestApp({
|
||||
router: reactionsRouter,
|
||||
basePath: '/api/reactions',
|
||||
basePath: '/api/v1/reactions',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
} as unknown as UserReaction;
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult);
|
||||
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
const response = await supertest(app).post('/api/v1/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data).toEqual({ message: 'Reaction added.', reaction: mockResult });
|
||||
@@ -166,7 +166,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
// Returning null/false from toggleReaction implies the reaction was removed
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
const response = await supertest(app).post('/api/v1/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ message: 'Reaction removed.' });
|
||||
@@ -174,7 +174,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
|
||||
it('should return 400 if body is invalid', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.send({ entity_type: 'recipe' }); // Missing other required fields
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -182,8 +182,8 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
const response = await supertest(unauthApp).post('/api/reactions/toggle').send(validBody);
|
||||
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/v1/reactions' });
|
||||
const response = await supertest(unauthApp).post('/api/v1/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const error = new Error('DB Error');
|
||||
vi.mocked(reactionRepo.toggleReaction).mockRejectedValue(error);
|
||||
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
const response = await supertest(app).post('/api/v1/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
@@ -204,10 +204,10 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should apply publicReadLimiter to GET /', async () => {
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/v1/reactions' });
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue([]);
|
||||
const response = await supertest(app)
|
||||
.get('/api/reactions')
|
||||
.get('/api/v1/reactions')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -218,13 +218,13 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
|
||||
const app = createTestApp({
|
||||
router: reactionsRouter,
|
||||
basePath: '/api/reactions',
|
||||
basePath: '/api/v1/reactions',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ entity_type: 'recipe', entity_id: '1', reaction_type: 'like' });
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Recipe Routes (/api/recipes)', () => {
|
||||
const app = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
|
||||
describe('Recipe Routes (/api/v1/recipes)', () => {
|
||||
const app = createTestApp({ router: recipeRouter, basePath: '/api/v1/recipes' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -70,7 +70,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
|
||||
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
|
||||
const response = await supertest(app).get('/api/v1/recipes/by-sale-percentage?minPercentage=75');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockRecipes);
|
||||
@@ -79,25 +79,25 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
|
||||
it('should use the default minPercentage of 50 when none is provided', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
await supertest(app).get('/api/v1/recipes/by-sale-percentage');
|
||||
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(50, expectLogger);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
const response = await supertest(app).get('/api/v1/recipes/by-sale-percentage');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-sale-percentage:',
|
||||
'Error fetching recipes in /api/v1/recipes/by-sale-percentage:',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minPercentage', async () => {
|
||||
const response = await supertest(app).get(
|
||||
'/api/recipes/by-sale-percentage?minPercentage=101',
|
||||
'/api/v1/recipes/by-sale-percentage?minPercentage=101',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('Too big');
|
||||
@@ -107,32 +107,32 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
describe('GET /by-sale-ingredients', () => {
|
||||
it('should return recipes with default minIngredients', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
const response = await supertest(app).get('/api/v1/recipes/by-sale-ingredients');
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(3, expectLogger);
|
||||
});
|
||||
|
||||
it('should use provided minIngredients query parameter', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=5');
|
||||
await supertest(app).get('/api/v1/recipes/by-sale-ingredients?minIngredients=5');
|
||||
expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(5, expectLogger);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
const response = await supertest(app).get('/api/v1/recipes/by-sale-ingredients');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-sale-ingredients:',
|
||||
'Error fetching recipes in /api/v1/recipes/by-sale-ingredients:',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minIngredients', async () => {
|
||||
const response = await supertest(app).get(
|
||||
'/api/recipes/by-sale-ingredients?minIngredients=abc',
|
||||
'/api/v1/recipes/by-sale-ingredients?minIngredients=abc',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
@@ -145,7 +145,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
|
||||
'/api/v1/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -156,19 +156,19 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get(
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
|
||||
'/api/v1/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-ingredient-and-tag:',
|
||||
'Error fetching recipes in /api/v1/recipes/by-ingredient-and-tag:',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if required query parameters are missing', async () => {
|
||||
const response = await supertest(app).get(
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=chicken',
|
||||
'/api/v1/recipes/by-ingredient-and-tag?ingredient=chicken',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe('Query parameter "tag" is required.');
|
||||
@@ -180,7 +180,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })];
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue(mockComments);
|
||||
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
const response = await supertest(app).get('/api/v1/recipes/1/comments');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockComments);
|
||||
@@ -189,14 +189,14 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
|
||||
it('should return an empty array if recipe has no comments', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
|
||||
const response = await supertest(app).get('/api/recipes/2/comments');
|
||||
const response = await supertest(app).get('/api/v1/recipes/2/comments');
|
||||
expect(response.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
const response = await supertest(app).get('/api/v1/recipes/1/comments');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
@@ -206,7 +206,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/abc/comments');
|
||||
const response = await supertest(app).get('/api/v1/recipes/abc/comments');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
@@ -217,7 +217,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockRecipe = createMockRecipe({ recipe_id: 456, name: 'Specific Recipe' });
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(mockRecipe);
|
||||
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
const response = await supertest(app).get('/api/v1/recipes/456');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockRecipe);
|
||||
@@ -227,7 +227,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
it('should return 404 if the recipe is not found', async () => {
|
||||
const notFoundError = new NotFoundError('Recipe not found');
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(notFoundError);
|
||||
const response = await supertest(app).get('/api/recipes/999');
|
||||
const response = await supertest(app).get('/api/v1/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toContain('not found');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
@@ -239,7 +239,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
const response = await supertest(app).get('/api/v1/recipes/456');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
@@ -249,7 +249,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/abc');
|
||||
const response = await supertest(app).get('/api/v1/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
@@ -259,7 +259,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
|
||||
const authApp = createTestApp({
|
||||
router: recipeRouter,
|
||||
basePath: '/api/recipes',
|
||||
basePath: '/api/v1/recipes',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockSuggestion = 'Chicken and Rice Casserole...';
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
|
||||
|
||||
const response = await supertest(authApp).post('/api/recipes/suggest').send({ ingredients });
|
||||
const response = await supertest(authApp).post('/api/v1/recipes/suggest').send({ ingredients });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
|
||||
@@ -279,7 +279,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.send({ ingredients: ['water'] });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
@@ -288,7 +288,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
|
||||
it('should return 400 if ingredients list is empty', async () => {
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.send({ ingredients: [] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -298,9 +298,9 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
|
||||
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/v1/recipes' });
|
||||
const response = await supertest(unauthApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.send({ ingredients: ['chicken'] });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -311,7 +311,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockRejectedValue(error);
|
||||
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.send({ ingredients: ['chicken'] });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -326,7 +326,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-user' } });
|
||||
const authApp = createTestApp({
|
||||
router: recipeRouter,
|
||||
basePath: '/api/recipes',
|
||||
basePath: '/api/v1/recipes',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
@@ -339,7 +339,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
// Act: Make maxRequests calls
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ ingredients });
|
||||
expect(response.status).not.toBe(429);
|
||||
@@ -347,7 +347,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
|
||||
// Act: Make one more call
|
||||
const blockedResponse = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ ingredients });
|
||||
|
||||
@@ -363,7 +363,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.send({ ingredients });
|
||||
expect(response.status).not.toBe(429);
|
||||
}
|
||||
@@ -374,7 +374,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
it('should apply publicReadLimiter to GET /:recipeId', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(createMockRecipe({}));
|
||||
const response = await supertest(app)
|
||||
.get('/api/recipes/1')
|
||||
.get('/api/v1/recipes/1')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,8 +26,8 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Stats Routes (/api/stats)', () => {
|
||||
const app = createTestApp({ router: statsRouter, basePath: '/api/stats' });
|
||||
describe('Stats Routes (/api/v1/stats)', () => {
|
||||
const app = createTestApp({ router: statsRouter, basePath: '/api/v1/stats' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -36,31 +36,31 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
describe('GET /most-frequent-sales', () => {
|
||||
it('should return most frequent sale items with default parameters', async () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockResolvedValue([]);
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales');
|
||||
const response = await supertest(app).get('/api/v1/stats/most-frequent-sales');
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.adminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(30, 10, expectLogger);
|
||||
});
|
||||
|
||||
it('should use provided query parameters', async () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/stats/most-frequent-sales?days=90&limit=5');
|
||||
await supertest(app).get('/api/v1/stats/most-frequent-sales?days=90&limit=5');
|
||||
expect(db.adminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(90, 5, expectLogger);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales');
|
||||
const response = await supertest(app).get('/api/v1/stats/most-frequent-sales');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
|
||||
'Error fetching most frequent sale items in /api/v1/stats/most-frequent-sales:',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales?days=0&limit=abc');
|
||||
const response = await supertest(app).get('/api/v1/stats/most-frequent-sales?days=0&limit=abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2);
|
||||
@@ -71,7 +71,7 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
it('should apply publicReadLimiter to GET /most-frequent-sales', async () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockResolvedValue([]);
|
||||
const response = await supertest(app)
|
||||
.get('/api/stats/most-frequent-sales')
|
||||
.get('/api/v1/stats/most-frequent-sales')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -112,12 +112,12 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('Store Routes (/api/stores)', () => {
|
||||
describe('Store Routes (/api/v1/stores)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const app = createTestApp({ router: storeRouter, basePath: '/api/stores' });
|
||||
const app = createTestApp({ router: storeRouter, basePath: '/api/v1/stores' });
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should return all stores without locations by default', async () => {
|
||||
@@ -142,7 +142,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
|
||||
mockStoreRepoMethods.getAllStores.mockResolvedValue(mockStores);
|
||||
|
||||
const response = await supertest(app).get('/api/stores');
|
||||
const response = await supertest(app).get('/api/v1/stores');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockStores);
|
||||
@@ -167,7 +167,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
mockStoresWithLocations,
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/stores?includeLocations=true');
|
||||
const response = await supertest(app).get('/api/v1/stores?includeLocations=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockStoresWithLocations);
|
||||
@@ -181,7 +181,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockStoreRepoMethods.getAllStores.mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(app).get('/api/stores');
|
||||
const response = await supertest(app).get('/api/v1/stores');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
@@ -223,7 +223,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
|
||||
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||
|
||||
const response = await supertest(app).get('/api/stores/1');
|
||||
const response = await supertest(app).get('/api/v1/stores/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockStore);
|
||||
@@ -238,13 +238,13 @@ describe('Store Routes (/api/stores)', () => {
|
||||
new NotFoundError('Store with ID 999 not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).get('/api/stores/999');
|
||||
const response = await supertest(app).get('/api/v1/stores/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid store ID', async () => {
|
||||
const response = await supertest(app).get('/api/stores/invalid');
|
||||
const response = await supertest(app).get('/api/v1/stores/invalid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -262,7 +262,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
|
||||
const response = await supertest(app).post('/api/stores').send({
|
||||
const response = await supertest(app).post('/api/v1/stores').send({
|
||||
name: 'New Store',
|
||||
logo_url: 'https://example.com/logo.png',
|
||||
});
|
||||
@@ -288,7 +288,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/stores')
|
||||
.post('/api/v1/stores')
|
||||
.send({
|
||||
name: 'New Store',
|
||||
address: {
|
||||
@@ -316,7 +316,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
|
||||
mockStoreRepoMethods.createStore.mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).post('/api/stores').send({
|
||||
const response = await supertest(app).post('/api/v1/stores').send({
|
||||
name: 'New Store',
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid request body', async () => {
|
||||
const response = await supertest(app).post('/api/stores').send({});
|
||||
const response = await supertest(app).post('/api/v1/stores').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -336,7 +336,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
it('should update a store', async () => {
|
||||
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).put('/api/stores/1').send({
|
||||
const response = await supertest(app).put('/api/v1/stores/1').send({
|
||||
name: 'Updated Store Name',
|
||||
});
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
new NotFoundError('Store with ID 999 not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).put('/api/stores/999').send({
|
||||
const response = await supertest(app).put('/api/v1/stores/999').send({
|
||||
name: 'Updated Name',
|
||||
});
|
||||
|
||||
@@ -362,7 +362,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
|
||||
it('should return 400 for invalid request body', async () => {
|
||||
// Send invalid data: logo_url must be a valid URL
|
||||
const response = await supertest(app).put('/api/stores/1').send({
|
||||
const response = await supertest(app).put('/api/v1/stores/1').send({
|
||||
logo_url: 'not-a-valid-url',
|
||||
});
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
it('should delete a store', async () => {
|
||||
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete('/api/stores/1');
|
||||
const response = await supertest(app).delete('/api/v1/stores/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockStoreRepoMethods.deleteStore).toHaveBeenCalledWith(1, expectLogger);
|
||||
@@ -385,7 +385,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
new NotFoundError('Store with ID 999 not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).delete('/api/stores/999');
|
||||
const response = await supertest(app).delete('/api/v1/stores/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -404,7 +404,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
mockAddressRepoMethods.upsertAddress.mockResolvedValue(1);
|
||||
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
|
||||
|
||||
const response = await supertest(app).post('/api/stores/1/locations').send({
|
||||
const response = await supertest(app).post('/api/v1/stores/1/locations').send({
|
||||
address_line_1: '456 New St',
|
||||
city: 'Vancouver',
|
||||
province_state: 'BC',
|
||||
@@ -417,7 +417,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid request body', async () => {
|
||||
const response = await supertest(app).post('/api/stores/1/locations').send({});
|
||||
const response = await supertest(app).post('/api/v1/stores/1/locations').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -427,7 +427,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
it('should delete a store location', async () => {
|
||||
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete('/api/stores/1/locations/1');
|
||||
const response = await supertest(app).delete('/api/v1/stores/1/locations/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockStoreLocationRepoMethods.deleteStoreLocation).toHaveBeenCalledWith(
|
||||
@@ -441,7 +441,7 @@ describe('Store Routes (/api/stores)', () => {
|
||||
new NotFoundError('Store location with ID 999 not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app).delete('/api/stores/1/locations/999');
|
||||
const response = await supertest(app).delete('/api/v1/stores/1/locations/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -33,8 +33,8 @@ import { systemService } from '../services/systemService';
|
||||
import systemRouter from './system.routes';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||
describe('System Routes (/api/v1/system)', () => {
|
||||
const app = createTestApp({ router: systemRouter, basePath: '/api/v1/system' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -49,7 +49,7 @@ describe('System Routes (/api/system)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
const response = await supertest(app).get('/api/v1/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -65,7 +65,7 @@ describe('System Routes (/api/system)', () => {
|
||||
message: 'Application process exists but is not online.',
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
const response = await supertest(app).get('/api/v1/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -80,7 +80,7 @@ describe('System Routes (/api/system)', () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
const response = await supertest(app).get('/api/v1/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -97,7 +97,7 @@ describe('System Routes (/api/system)', () => {
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
const response = await supertest(app).get('/api/v1/system/pm2-status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe(serviceError.message);
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe('System Routes (/api/system)', () => {
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
const response = await supertest(app).get('/api/v1/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
@@ -123,7 +123,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.post('/api/v1/system/geocode')
|
||||
.send({ address: 'Victoria, BC' });
|
||||
|
||||
// Assert
|
||||
@@ -134,7 +134,7 @@ describe('System Routes (/api/system)', () => {
|
||||
it('should return 404 if the address cannot be geocoded', async () => {
|
||||
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(null);
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.post('/api/v1/system/geocode')
|
||||
.send({ address: 'Invalid Address' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Could not geocode the provided address.');
|
||||
@@ -144,14 +144,14 @@ describe('System Routes (/api/system)', () => {
|
||||
const geocodeError = new Error('Geocoding service unavailable');
|
||||
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError);
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.post('/api/v1/system/geocode')
|
||||
.send({ address: 'Any Address' });
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 400 if the address is missing from the body', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.post('/api/v1/system/geocode')
|
||||
.send({ not_address: 'Victoria, BC' });
|
||||
expect(response.status).toBe(400);
|
||||
// Zod validation error message can vary slightly depending on configuration or version
|
||||
@@ -170,7 +170,7 @@ describe('System Routes (/api/system)', () => {
|
||||
// We only need to verify it blocks eventually.
|
||||
// Instead of running 100 requests, we check for the headers which confirm the middleware is active.
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.post('/api/v1/system/geocode')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ address });
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const expectLogger = expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('UPC Routes (/api/upc)', () => {
|
||||
describe('UPC Routes (/api/v1/upc)', () => {
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
});
|
||||
@@ -89,13 +89,13 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
const app = createTestApp({
|
||||
router: upcRouter,
|
||||
basePath: '/api/upc',
|
||||
basePath: '/api/v1/upc',
|
||||
authenticatedUser: mockUserProfile,
|
||||
});
|
||||
|
||||
const adminApp = createTestApp({
|
||||
router: upcRouter,
|
||||
basePath: '/api/upc',
|
||||
basePath: '/api/v1/upc',
|
||||
authenticatedUser: mockAdminProfile,
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.scanUpc).mockResolvedValue(mockScanResult);
|
||||
|
||||
const response = await supertest(app).post('/api/upc/scan').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/scan').send({
|
||||
upc_code: '012345678905',
|
||||
scan_source: 'manual_entry',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.scanUpc).mockResolvedValue(mockScanResult);
|
||||
|
||||
const response = await supertest(app).post('/api/upc/scan').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/scan').send({
|
||||
image_base64: 'SGVsbG8gV29ybGQ=',
|
||||
scan_source: 'image_upload',
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when neither upc_code nor image_base64 is provided', async () => {
|
||||
const response = await supertest(app).post('/api/upc/scan').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/scan').send({
|
||||
scan_source: 'manual_entry',
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid scan_source', async () => {
|
||||
const response = await supertest(app).post('/api/upc/scan').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/scan').send({
|
||||
upc_code: '012345678905',
|
||||
scan_source: 'invalid_source',
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 500 if the scan service fails', async () => {
|
||||
vi.mocked(upcService.scanUpc).mockRejectedValue(new Error('Scan service error'));
|
||||
|
||||
const response = await supertest(app).post('/api/upc/scan').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/scan').send({
|
||||
upc_code: '012345678905',
|
||||
scan_source: 'manual_entry',
|
||||
});
|
||||
@@ -224,7 +224,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.lookupUpc).mockResolvedValue(mockLookupResult);
|
||||
|
||||
const response = await supertest(app).get('/api/upc/lookup?upc_code=012345678905');
|
||||
const response = await supertest(app).get('/api/v1/upc/lookup?upc_code=012345678905');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.upc_code).toBe('012345678905');
|
||||
@@ -250,7 +250,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
vi.mocked(upcService.lookupUpc).mockResolvedValue(mockLookupResult);
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/upc/lookup?upc_code=012345678905&include_external=true&force_refresh=true',
|
||||
'/api/v1/upc/lookup?upc_code=012345678905&include_external=true&force_refresh=true',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -264,14 +264,14 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid UPC code format', async () => {
|
||||
const response = await supertest(app).get('/api/upc/lookup?upc_code=123');
|
||||
const response = await supertest(app).get('/api/v1/upc/lookup?upc_code=123');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/8-14 digits/);
|
||||
});
|
||||
|
||||
it('should return 400 when upc_code is missing', async () => {
|
||||
const response = await supertest(app).get('/api/upc/lookup');
|
||||
const response = await supertest(app).get('/api/v1/upc/lookup');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 500 if the lookup service fails', async () => {
|
||||
vi.mocked(upcService.lookupUpc).mockRejectedValue(new Error('Lookup error'));
|
||||
|
||||
const response = await supertest(app).get('/api/upc/lookup?upc_code=012345678905');
|
||||
const response = await supertest(app).get('/api/v1/upc/lookup?upc_code=012345678905');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -307,7 +307,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.getScanHistory).mockResolvedValue(mockHistory);
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history?limit=10&offset=0');
|
||||
const response = await supertest(app).get('/api/v1/upc/history?limit=10&offset=0');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.scans).toHaveLength(1);
|
||||
@@ -325,7 +325,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should support filtering by lookup_successful', async () => {
|
||||
vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history?lookup_successful=true');
|
||||
const response = await supertest(app).get('/api/v1/upc/history?lookup_successful=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(upcService.getScanHistory).toHaveBeenCalledWith(
|
||||
@@ -339,7 +339,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should support filtering by scan_source', async () => {
|
||||
vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history?scan_source=image_upload');
|
||||
const response = await supertest(app).get('/api/v1/upc/history?scan_source=image_upload');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(upcService.getScanHistory).toHaveBeenCalledWith(
|
||||
@@ -354,7 +354,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 });
|
||||
|
||||
const response = await supertest(app).get(
|
||||
'/api/upc/history?from_date=2024-01-01&to_date=2024-01-31',
|
||||
'/api/v1/upc/history?from_date=2024-01-01&to_date=2024-01-31',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -368,7 +368,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid date format', async () => {
|
||||
const response = await supertest(app).get('/api/upc/history?from_date=01-01-2024');
|
||||
const response = await supertest(app).get('/api/v1/upc/history?from_date=01-01-2024');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -376,7 +376,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 500 if the history service fails', async () => {
|
||||
vi.mocked(upcService.getScanHistory).mockRejectedValue(new Error('History error'));
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history');
|
||||
const response = await supertest(app).get('/api/v1/upc/history');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -399,7 +399,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.getScanById).mockResolvedValue(mockScan);
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history/1');
|
||||
const response = await supertest(app).get('/api/v1/upc/history/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.scan_id).toBe(1);
|
||||
@@ -413,14 +413,14 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 404 when scan not found', async () => {
|
||||
vi.mocked(upcService.getScanById).mockRejectedValue(new NotFoundError('Scan not found'));
|
||||
|
||||
const response = await supertest(app).get('/api/upc/history/999');
|
||||
const response = await supertest(app).get('/api/v1/upc/history/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Scan not found');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid scan ID', async () => {
|
||||
const response = await supertest(app).get('/api/upc/history/abc');
|
||||
const response = await supertest(app).get('/api/v1/upc/history/abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
||||
@@ -439,7 +439,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
|
||||
vi.mocked(upcService.getScanStats).mockResolvedValue(mockStats);
|
||||
|
||||
const response = await supertest(app).get('/api/upc/stats');
|
||||
const response = await supertest(app).get('/api/v1/upc/stats');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.total_scans).toBe(100);
|
||||
@@ -453,7 +453,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 500 if the stats service fails', async () => {
|
||||
vi.mocked(upcService.getScanStats).mockRejectedValue(new Error('Stats error'));
|
||||
|
||||
const response = await supertest(app).get('/api/upc/stats');
|
||||
const response = await supertest(app).get('/api/v1/upc/stats');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -463,7 +463,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should link UPC to product (admin only)', async () => {
|
||||
vi.mocked(upcService.linkUpcToProduct).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(adminApp).post('/api/upc/link').send({
|
||||
const response = await supertest(adminApp).post('/api/v1/upc/link').send({
|
||||
upc_code: '012345678905',
|
||||
product_id: 1,
|
||||
});
|
||||
@@ -473,7 +473,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 403 for non-admin users', async () => {
|
||||
const response = await supertest(app).post('/api/upc/link').send({
|
||||
const response = await supertest(app).post('/api/v1/upc/link').send({
|
||||
upc_code: '012345678905',
|
||||
product_id: 1,
|
||||
});
|
||||
@@ -483,7 +483,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid UPC code format', async () => {
|
||||
const response = await supertest(adminApp).post('/api/upc/link').send({
|
||||
const response = await supertest(adminApp).post('/api/v1/upc/link').send({
|
||||
upc_code: '123',
|
||||
product_id: 1,
|
||||
});
|
||||
@@ -493,7 +493,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid product_id', async () => {
|
||||
const response = await supertest(adminApp).post('/api/upc/link').send({
|
||||
const response = await supertest(adminApp).post('/api/v1/upc/link').send({
|
||||
upc_code: '012345678905',
|
||||
product_id: -1,
|
||||
});
|
||||
@@ -506,7 +506,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
new NotFoundError('Product not found'),
|
||||
);
|
||||
|
||||
const response = await supertest(adminApp).post('/api/upc/link').send({
|
||||
const response = await supertest(adminApp).post('/api/v1/upc/link').send({
|
||||
upc_code: '012345678905',
|
||||
product_id: 999,
|
||||
});
|
||||
@@ -518,7 +518,7 @@ describe('UPC Routes (/api/upc)', () => {
|
||||
it('should return 500 if the link service fails', async () => {
|
||||
vi.mocked(upcService.linkUpcToProduct).mockRejectedValue(new Error('Link error'));
|
||||
|
||||
const response = await supertest(adminApp).post('/api/upc/link').send({
|
||||
const response = await supertest(adminApp).post('/api/v1/upc/link').send({
|
||||
upc_code: '012345678905',
|
||||
product_id: 1,
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ const expectLogger = expect.objectContaining({
|
||||
info: expect.any(Function),
|
||||
error: expect.any(Function),
|
||||
});
|
||||
describe('User Routes (/api/users)', () => {
|
||||
describe('User Routes (/api/v1/users)', () => {
|
||||
// This test needs to be separate because the code it tests runs on module load.
|
||||
describe('Avatar Upload Directory Creation', () => {
|
||||
it('should log an error if avatar directory creation fails', async () => {
|
||||
@@ -107,12 +107,12 @@ describe('User Routes (/api/users)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const basePath = '/api/users';
|
||||
const basePath = '/api/v1/users';
|
||||
|
||||
describe('when user is not authenticated', () => {
|
||||
it('GET /profile should return 401', async () => {
|
||||
const app = createTestApp({ router: userRouter, basePath }); // No user injected
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
const response = await supertest(app).get('/api/v1/users/profile');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -149,7 +149,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('GET /profile', () => {
|
||||
it('should return the full user profile', async () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
const response = await supertest(app).get('/api/v1/users/profile');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUserProfile);
|
||||
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(
|
||||
@@ -162,7 +162,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(
|
||||
new NotFoundError('Profile not found for this user.'),
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
const response = await supertest(app).get('/api/v1/users/profile');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toContain('Profile not found');
|
||||
});
|
||||
@@ -170,11 +170,11 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
const response = await supertest(app).get('/api/v1/users/profile');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] GET /api/users/profile - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/profile - ERROR`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -185,7 +185,7 @@ describe('User Routes (/api/users)', () => {
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' }),
|
||||
];
|
||||
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
const response = await supertest(app).get('/api/v1/users/watched-items');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockItems);
|
||||
});
|
||||
@@ -193,11 +193,11 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
const response = await supertest(app).get('/api/v1/users/watched-items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] GET /api/users/watched-items - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/watched-items - ERROR`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@ describe('User Routes (/api/users)', () => {
|
||||
category_name: 'Produce',
|
||||
});
|
||||
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
|
||||
const response = await supertest(app).post('/api/users/watched-items').send(newItem);
|
||||
const response = await supertest(app).post('/api/v1/users/watched-items').send(newItem);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data).toEqual(mockAddedItem);
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.send({ itemName: 'Test', category_id: 5 });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
@@ -230,7 +230,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('POST /watched-items (Validation)', () => {
|
||||
it('should return 400 if itemName is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.send({ category_id: 5 });
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
@@ -239,7 +239,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
it('should return 400 if category_id is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.send({ itemName: 'Apples' });
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
@@ -252,7 +252,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ForeignKeyConstraintError('Category not found'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.send({ itemName: 'Test', category_id: 999 });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -260,7 +260,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('DELETE /watched-items/:masterItemId', () => {
|
||||
it('should remove an item from the watchlist', async () => {
|
||||
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
||||
const response = await supertest(app).delete(`/api/v1/users/watched-items/99`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
@@ -272,11 +272,11 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
||||
const response = await supertest(app).delete(`/api/v1/users/watched-items/99`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`,
|
||||
`[ROUTE] DELETE /api/v1/users/watched-items/:masterItemId - ERROR`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -287,7 +287,7 @@ describe('User Routes (/api/users)', () => {
|
||||
createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id }),
|
||||
];
|
||||
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists');
|
||||
const response = await supertest(app).get('/api/v1/users/shopping-lists');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockLists);
|
||||
});
|
||||
@@ -295,11 +295,11 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.getShoppingLists).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists');
|
||||
const response = await supertest(app).get('/api/v1/users/shopping-lists');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] GET /api/users/shopping-lists - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/shopping-lists - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.send({ name: 'Party Supplies' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -319,7 +319,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists').send({});
|
||||
const response = await supertest(app).post('/api/v1/users/shopping-lists').send({});
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toBe("Field 'name' is required.");
|
||||
@@ -330,7 +330,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ForeignKeyConstraintError('User not found'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.send({ name: 'Failing List' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
@@ -340,7 +340,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.send({ name: 'Failing List' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error.message).toBe('DB Connection Failed');
|
||||
@@ -348,7 +348,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId on DELETE', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
@@ -357,7 +357,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('DELETE /shopping-lists/:listId', () => {
|
||||
it('should delete a list', async () => {
|
||||
vi.mocked(db.shoppingRepo.deleteShoppingList).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/1');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/1');
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
@@ -366,20 +366,20 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(
|
||||
new NotFoundError('not found'),
|
||||
);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/999');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/999');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/1');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
@@ -388,7 +388,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('Shopping List Item Routes', () => {
|
||||
describe('POST /shopping-lists/:listId/items (Validation)', () => {
|
||||
it('should return 400 if neither masterItemId nor customItemName are provided', async () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists/1/items').send({});
|
||||
const response = await supertest(app).post('/api/v1/users/shopping-lists/1/items').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'Either masterItemId or customItemName must be provided.',
|
||||
@@ -400,7 +400,7 @@ describe('User Routes (/api/users)', () => {
|
||||
createMockShoppingListItem({}),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.post('/api/v1/users/shopping-lists/1/items')
|
||||
.send({ masterItemId: 123 });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
@@ -410,7 +410,7 @@ describe('User Routes (/api/users)', () => {
|
||||
createMockShoppingListItem({}),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.post('/api/v1/users/shopping-lists/1/items')
|
||||
.send({ customItemName: 'Custom Item' });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
@@ -420,7 +420,7 @@ describe('User Routes (/api/users)', () => {
|
||||
createMockShoppingListItem({}),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.post('/api/v1/users/shopping-lists/1/items')
|
||||
.send({ masterItemId: 123, customItemName: 'Custom Item' });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
@@ -435,7 +435,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(mockAddedItem);
|
||||
const response = await supertest(app)
|
||||
.post(`/api/users/shopping-lists/${listId}/items`)
|
||||
.post(`/api/v1/users/shopping-lists/${listId}/items`)
|
||||
.send(itemData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -453,7 +453,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ForeignKeyConstraintError('List not found'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/999/items')
|
||||
.post('/api/v1/users/shopping-lists/999/items')
|
||||
.send({ customItemName: 'Test' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -462,7 +462,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.post('/api/v1/users/shopping-lists/1/items')
|
||||
.send({ customItemName: 'Test' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
@@ -478,7 +478,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
|
||||
const response = await supertest(app)
|
||||
.put(`/api/users/shopping-lists/items/${itemId}`)
|
||||
.put(`/api/v1/users/shopping-lists/items/${itemId}`)
|
||||
.send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -496,7 +496,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new NotFoundError('not found'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/shopping-lists/items/999')
|
||||
.put('/api/v1/users/shopping-lists/items/999')
|
||||
.send({ is_purchased: true });
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -505,14 +505,14 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/shopping-lists/items/101')
|
||||
.put('/api/v1/users/shopping-lists/items/101')
|
||||
.send({ is_purchased: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields are provided for an item', async () => {
|
||||
const response = await supertest(app).put(`/api/users/shopping-lists/items/101`).send({});
|
||||
const response = await supertest(app).put(`/api/v1/users/shopping-lists/items/101`).send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain(
|
||||
'At least one field (quantity, is_purchased) must be provided.',
|
||||
@@ -522,7 +522,7 @@ describe('User Routes (/api/users)', () => {
|
||||
describe('DELETE /shopping-lists/items/:itemId', () => {
|
||||
it('should delete an item', async () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/items/101');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.shoppingRepo.removeShoppingListItem).toHaveBeenCalledWith(
|
||||
101,
|
||||
@@ -535,14 +535,14 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(
|
||||
new NotFoundError('not found'),
|
||||
);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/999');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/items/999');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
|
||||
const response = await supertest(app).delete('/api/v1/users/shopping-lists/items/101');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
@@ -554,7 +554,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const profileUpdates = { full_name: 'New Name' };
|
||||
const updatedProfile = createMockUserProfile({ ...mockUserProfile, ...profileUpdates });
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
|
||||
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
|
||||
const response = await supertest(app).put('/api/v1/users/profile').send(profileUpdates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(updatedProfile);
|
||||
@@ -568,7 +568,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
|
||||
const response = await supertest(app).put('/api/v1/users/profile').send(profileUpdates);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
@@ -585,17 +585,17 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile')
|
||||
.put('/api/v1/users/profile')
|
||||
.send({ full_name: 'New Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] PUT /api/users/profile - ERROR`,
|
||||
`[ROUTE] PUT /api/v1/users/profile - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if the body is empty', async () => {
|
||||
const response = await supertest(app).put('/api/users/profile').send({});
|
||||
const response = await supertest(app).put('/api/v1/users/profile').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'At least one field to update must be provided.',
|
||||
@@ -607,7 +607,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should update the password successfully with a strong password', async () => {
|
||||
vi.mocked(userService.updateUserPassword).mockResolvedValue(undefined);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.put('/api/v1/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toBe('Password updated successfully.');
|
||||
@@ -617,18 +617,18 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(userService.updateUserPassword).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.put('/api/v1/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] PUT /api/users/profile/password - ERROR`,
|
||||
`[ROUTE] PUT /api/v1/users/profile/password - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for a weak password', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.put('/api/v1/users/profile/password')
|
||||
.send({ newPassword: 'password123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -640,7 +640,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should delete the account with the correct password', async () => {
|
||||
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.send({ password: 'correct-password' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toBe('Account deleted successfully.');
|
||||
@@ -656,7 +656,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ValidationError([], 'Incorrect password.'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.send({ password: 'wrong-password' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -669,7 +669,7 @@ describe('User Routes (/api/users)', () => {
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.send({ password: 'any-password' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -681,12 +681,12 @@ describe('User Routes (/api/users)', () => {
|
||||
new Error('DB Connection Failed'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.send({ password: 'correct-password' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: new Error('DB Connection Failed') },
|
||||
`[ROUTE] DELETE /api/users/account - ERROR`,
|
||||
`[ROUTE] DELETE /api/v1/users/account - ERROR`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -701,7 +701,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
vi.mocked(db.userRepo.updateUserPreferences).mockResolvedValue(updatedProfile);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/preferences')
|
||||
.put('/api/v1/users/profile/preferences')
|
||||
.send(preferencesUpdate);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(updatedProfile);
|
||||
@@ -711,18 +711,18 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.userRepo.updateUserPreferences).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/preferences')
|
||||
.put('/api/v1/users/profile/preferences')
|
||||
.send({ darkMode: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] PUT /api/users/profile/preferences - ERROR`,
|
||||
`[ROUTE] PUT /api/v1/users/profile/preferences - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if the request body is not a valid object', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/preferences')
|
||||
.put('/api/v1/users/profile/preferences')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('"not-an-object"');
|
||||
expect(response.status).toBe(400);
|
||||
@@ -739,7 +739,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(
|
||||
mockRestrictions,
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
const response = await supertest(app).get('/api/v1/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockRestrictions);
|
||||
});
|
||||
@@ -747,16 +747,16 @@ describe('User Routes (/api/users)', () => {
|
||||
it('GET should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
const response = await supertest(app).get('/api/v1/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/me/dietary-restrictions - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid masterItemId', async () => {
|
||||
const response = await supertest(app).delete('/api/users/watched-items/abc');
|
||||
const response = await supertest(app).delete('/api/v1/users/watched-items/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
@@ -766,7 +766,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
|
||||
const restrictionIds = [1, 3, 5];
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.put('/api/v1/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
@@ -776,7 +776,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ForeignKeyConstraintError('Invalid restriction ID'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.put('/api/v1/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: [999] }); // Invalid ID
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -785,7 +785,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.put('/api/v1/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
@@ -793,7 +793,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
it('PUT should return 400 if restrictionIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.put('/api/v1/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -803,7 +803,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('GET should return a list of appliance IDs', async () => {
|
||||
const mockAppliances: Appliance[] = [createMockAppliance({ name: 'Air Fryer' })];
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
const response = await supertest(app).get('/api/v1/users/me/appliances');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockAppliances);
|
||||
});
|
||||
@@ -811,11 +811,11 @@ describe('User Routes (/api/users)', () => {
|
||||
it('GET should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
const response = await supertest(app).get('/api/v1/users/me/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`[ROUTE] GET /api/users/me/appliances - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/me/appliances - ERROR`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -823,7 +823,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
|
||||
const applianceIds = [2, 4, 6];
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.put('/api/v1/users/me/appliances')
|
||||
.send({ applianceIds });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
@@ -833,7 +833,7 @@ describe('User Routes (/api/users)', () => {
|
||||
new ForeignKeyConstraintError('Invalid appliance ID'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.put('/api/v1/users/me/appliances')
|
||||
.send({ applianceIds: [999] }); // Invalid ID
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('Invalid appliance ID');
|
||||
@@ -843,7 +843,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.put('/api/v1/users/me/appliances')
|
||||
.send({ applianceIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
@@ -851,7 +851,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
it('PUT should return 400 if applianceIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.put('/api/v1/users/me/appliances')
|
||||
.send({ applianceIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -865,7 +865,7 @@ describe('User Routes (/api/users)', () => {
|
||||
];
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
|
||||
|
||||
const response = await supertest(app).get('/api/users/notifications?limit=10');
|
||||
const response = await supertest(app).get('/api/v1/users/notifications?limit=10');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockNotifications);
|
||||
@@ -885,7 +885,7 @@ describe('User Routes (/api/users)', () => {
|
||||
];
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
|
||||
|
||||
const response = await supertest(app).get('/api/users/notifications?includeRead=true');
|
||||
const response = await supertest(app).get('/api/v1/users/notifications?includeRead=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockNotifications);
|
||||
@@ -901,13 +901,13 @@ describe('User Routes (/api/users)', () => {
|
||||
it('GET /notifications should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/notifications');
|
||||
const response = await supertest(app).get('/api/v1/users/notifications');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('POST /notifications/mark-all-read should return 204', async () => {
|
||||
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
|
||||
const response = await supertest(app).post('/api/v1/users/notifications/mark-all-read');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.notificationRepo.markAllNotificationsAsRead).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
@@ -918,7 +918,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('POST /notifications/mark-all-read should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
|
||||
const response = await supertest(app).post('/api/v1/users/notifications/mark-all-read');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
@@ -927,7 +927,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(
|
||||
createMockNotification({ notification_id: 1, user_id: 'user-123' }),
|
||||
);
|
||||
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
|
||||
const response = await supertest(app).post('/api/v1/users/notifications/1/mark-read');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(
|
||||
1,
|
||||
@@ -939,13 +939,13 @@ describe('User Routes (/api/users)', () => {
|
||||
it('POST /notifications/:notificationId/mark-read should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.notificationRepo.markNotificationAsRead).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
|
||||
const response = await supertest(app).post('/api/v1/users/notifications/1/mark-read');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid notificationId', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/notifications/abc/mark-read')
|
||||
.post('/api/v1/users/notifications/abc/mark-read')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
@@ -961,7 +961,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
const mockAddress = createMockAddress({ address_id: 1, address_line_1: '123 Main St' });
|
||||
vi.mocked(userService.getUserAddress).mockResolvedValue(mockAddress);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
const response = await supertest(appWithUser).get('/api/v1/users/addresses/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockAddress);
|
||||
});
|
||||
@@ -973,13 +973,13 @@ describe('User Routes (/api/users)', () => {
|
||||
authenticatedUser: { ...mockUserProfile, address_id: 1 },
|
||||
});
|
||||
vi.mocked(userService.getUserAddress).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
const response = await supertest(appWithUser).get('/api/v1/users/addresses/1');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
describe('GET /addresses/:addressId', () => {
|
||||
it('should return 400 for a non-numeric address ID', async () => {
|
||||
const response = await supertest(app).get('/api/users/addresses/abc'); // This was a duplicate, fixed.
|
||||
const response = await supertest(app).get('/api/v1/users/addresses/abc'); // This was a duplicate, fixed.
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -988,7 +988,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(userService.getUserAddress).mockRejectedValue(
|
||||
new ValidationError([], 'Forbidden'),
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/addresses/2'); // Requesting address 2
|
||||
const response = await supertest(app).get('/api/v1/users/addresses/2'); // Requesting address 2
|
||||
expect(response.status).toBe(400); // ValidationError maps to 400 by default in the test error handler
|
||||
expect(response.body.error.message).toBe('Forbidden');
|
||||
});
|
||||
@@ -1002,7 +1002,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(userService.getUserAddress).mockRejectedValue(
|
||||
new NotFoundError('Address not found.'),
|
||||
);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
const response = await supertest(appWithUser).get('/api/v1/users/addresses/1');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Address not found.');
|
||||
});
|
||||
@@ -1011,7 +1011,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const addressData = { address_line_1: '123 New St' };
|
||||
vi.mocked(userService.upsertUserAddress).mockResolvedValue(5);
|
||||
|
||||
const response = await supertest(app).put('/api/users/profile/address').send(addressData);
|
||||
const response = await supertest(app).put('/api/v1/users/profile/address').send(addressData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(userService.upsertUserAddress).toHaveBeenCalledWith(
|
||||
@@ -1025,13 +1025,13 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(userService.upsertUserAddress).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/address')
|
||||
.put('/api/v1/users/profile/address')
|
||||
.send({ address_line_1: '123 New St' });
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 400 if the address body is empty', async () => {
|
||||
const response = await supertest(app).put('/api/users/profile/address').send({});
|
||||
const response = await supertest(app).put('/api/v1/users/profile/address').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain(
|
||||
'At least one address field must be provided',
|
||||
@@ -1051,7 +1051,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dummyImagePath = 'test-avatar.png';
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -1068,7 +1068,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(userService.updateUserAvatar).mockRejectedValue(dbError);
|
||||
const dummyImagePath = 'test-avatar.png';
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -1077,7 +1077,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dummyTextPath = 'document.txt';
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.attach('avatar', Buffer.from('this is not an image'), dummyTextPath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -1090,7 +1090,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dummyImagePath = 'large-avatar.png';
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.attach('avatar', largeFile, dummyImagePath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -1099,7 +1099,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if no file is uploaded', async () => {
|
||||
const response = await supertest(app).post('/api/users/profile/avatar'); // No .attach() call
|
||||
const response = await supertest(app).post('/api/v1/users/profile/avatar'); // No .attach() call
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe('No avatar file uploaded.');
|
||||
@@ -1114,7 +1114,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dummyImagePath = 'test-avatar.png';
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -1127,7 +1127,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for a non-numeric address ID', async () => {
|
||||
const response = await supertest(app).get('/api/users/addresses/abc');
|
||||
const response = await supertest(app).get('/api/v1/users/addresses/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
@@ -1143,7 +1143,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const mockCreatedRecipe = createMockRecipe({ recipe_id: 1, ...recipeData });
|
||||
vi.mocked(db.recipeRepo.createRecipe).mockResolvedValue(mockCreatedRecipe);
|
||||
|
||||
const response = await supertest(app).post('/api/users/recipes').send(recipeData);
|
||||
const response = await supertest(app).post('/api/v1/users/recipes').send(recipeData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data).toEqual(mockCreatedRecipe);
|
||||
@@ -1163,7 +1163,7 @@ describe('User Routes (/api/users)', () => {
|
||||
description: 'A delicious test recipe',
|
||||
instructions: 'Mix everything together',
|
||||
};
|
||||
const response = await supertest(app).post('/api/users/recipes').send(recipeData);
|
||||
const response = await supertest(app).post('/api/v1/users/recipes').send(recipeData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
@@ -1171,7 +1171,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
it("DELETE /recipes/:recipeId should delete a user's own recipe", async () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/users/recipes/1');
|
||||
const response = await supertest(app).delete('/api/v1/users/recipes/1');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(
|
||||
1,
|
||||
@@ -1184,7 +1184,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('DELETE /recipes/:recipeId should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/recipes/1');
|
||||
const response = await supertest(app).delete('/api/v1/users/recipes/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1193,13 +1193,13 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(
|
||||
new NotFoundError('Recipe not found'),
|
||||
);
|
||||
const response = await supertest(app).delete('/api/users/recipes/999');
|
||||
const response = await supertest(app).delete('/api/v1/users/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Recipe not found');
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
const response = await supertest(app).delete('/api/users/recipes/abc');
|
||||
const response = await supertest(app).delete('/api/v1/users/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
@@ -1209,7 +1209,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockResolvedValue(mockUpdatedRecipe);
|
||||
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send(updates);
|
||||
const response = await supertest(app).put('/api/v1/users/recipes/1').send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockUpdatedRecipe);
|
||||
@@ -1224,7 +1224,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new NotFoundError('not found'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/recipes/999')
|
||||
.put('/api/v1/users/recipes/999')
|
||||
.send({ name: 'New Name' });
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -1233,21 +1233,21 @@ describe('User Routes (/api/users)', () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(dbError);
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/recipes/1')
|
||||
.put('/api/v1/users/recipes/1')
|
||||
.send({ name: 'New Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send({});
|
||||
const response = await supertest(app).put('/api/v1/users/recipes/1').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toBe('No fields provided to update.');
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/recipes/abc')
|
||||
.put('/api/v1/users/recipes/abc')
|
||||
.send({ name: 'New Name' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
@@ -1257,7 +1257,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(
|
||||
new NotFoundError('Shopping list not found'),
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/999');
|
||||
const response = await supertest(app).get('/api/v1/users/shopping-lists/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe('Shopping list not found');
|
||||
});
|
||||
@@ -1268,7 +1268,7 @@ describe('User Routes (/api/users)', () => {
|
||||
user_id: mockUserProfile.user.user_id,
|
||||
});
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
const response = await supertest(app).get('/api/v1/users/shopping-lists/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual(mockList);
|
||||
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(
|
||||
@@ -1281,7 +1281,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
const response = await supertest(app).get('/api/v1/users/shopping-lists/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1305,7 +1305,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUserProfile);
|
||||
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile')
|
||||
.put('/api/v1/users/profile')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ full_name: 'Rate Limit Test' });
|
||||
|
||||
@@ -1321,7 +1321,7 @@ describe('User Routes (/api/users)', () => {
|
||||
// Consume the limit
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.put('/api/v1/users/profile/password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ newPassword: 'StrongPassword123!' });
|
||||
expect(response.status).toBe(200);
|
||||
@@ -1329,7 +1329,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
// Next request should be blocked
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.put('/api/v1/users/profile/password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ newPassword: 'StrongPassword123!' });
|
||||
|
||||
@@ -1342,7 +1342,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const dummyImagePath = 'test-avatar.png';
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/profile/avatar')
|
||||
.post('/api/v1/users/profile/avatar')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||
|
||||
@@ -1361,7 +1361,7 @@ describe('User Routes (/api/users)', () => {
|
||||
// Consume the limit
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ password: 'correct-password' });
|
||||
expect(response.status).toBe(200);
|
||||
@@ -1369,7 +1369,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
// Next request should be blocked
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ password: 'correct-password' });
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ router.delete(
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get('/profile', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/profile - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/profile - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
req.log.debug(
|
||||
@@ -437,7 +437,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
|
||||
);
|
||||
sendSuccess(res, fullUserProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] GET /api/v1/users/profile - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -483,7 +483,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(updateProfileSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/profile - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdateProfileRequest;
|
||||
@@ -495,7 +495,7 @@ router.put(
|
||||
);
|
||||
sendSuccess(res, updatedProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/v1/users/profile - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -541,7 +541,7 @@ router.put(
|
||||
userSensitiveUpdateLimiter,
|
||||
validateRequest(updatePasswordSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/profile/password - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdatePasswordRequest;
|
||||
@@ -550,7 +550,7 @@ router.put(
|
||||
await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, req.log);
|
||||
sendSuccess(res, { message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/v1/users/profile/password - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -593,7 +593,7 @@ router.delete(
|
||||
userSensitiveUpdateLimiter,
|
||||
validateRequest(deleteAccountSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
||||
req.log.debug(`[ROUTE] DELETE /api/v1/users/account - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as DeleteAccountRequest;
|
||||
@@ -602,7 +602,7 @@ router.delete(
|
||||
await userService.deleteUserAccount(userProfile.user.user_id, body.password, req.log);
|
||||
sendSuccess(res, { message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/v1/users/account - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -628,13 +628,13 @@ router.delete(
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get('/watched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/watched-items - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] GET /api/v1/users/watched-items - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -682,7 +682,7 @@ router.post(
|
||||
userUpdateLimiter,
|
||||
validateRequest(addWatchedItemSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
||||
req.log.debug(`[ROUTE] POST /api/v1/users/watched-items - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as AddWatchedItemRequest;
|
||||
@@ -735,7 +735,7 @@ router.delete(
|
||||
userUpdateLimiter,
|
||||
validateRequest(watchedItemIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
||||
req.log.debug(`[ROUTE] DELETE /api/v1/users/watched-items/:masterItemId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteWatchedItemRequest;
|
||||
@@ -747,7 +747,7 @@ router.delete(
|
||||
);
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/v1/users/watched-items/:masterItemId - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -776,13 +776,13 @@ router.get(
|
||||
'/shopping-lists',
|
||||
validateRequest(emptySchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/shopping-lists - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log);
|
||||
sendSuccess(res, lists);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] GET /api/v1/users/shopping-lists - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -822,7 +822,7 @@ router.get(
|
||||
'/shopping-lists/:listId',
|
||||
validateRequest(shoppingListIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/shopping-lists/:listId - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/shopping-lists/:listId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
try {
|
||||
@@ -835,7 +835,7 @@ router.get(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, listId: params.listId },
|
||||
`[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`,
|
||||
`[ROUTE] GET /api/v1/users/shopping-lists/:listId - ERROR`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -881,7 +881,7 @@ router.post(
|
||||
userUpdateLimiter,
|
||||
validateRequest(createShoppingListSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
||||
req.log.debug(`[ROUTE] POST /api/v1/users/shopping-lists - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as CreateShoppingListRequest;
|
||||
@@ -931,7 +931,7 @@ router.delete(
|
||||
userUpdateLimiter,
|
||||
validateRequest(shoppingListIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
||||
req.log.debug(`[ROUTE] DELETE /api/v1/users/shopping-lists/:listId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
@@ -942,7 +942,7 @@ router.delete(
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
req.log.error(
|
||||
{ errorMessage, params: req.params },
|
||||
`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`,
|
||||
`[ROUTE] DELETE /api/v1/users/shopping-lists/:listId - ERROR`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -1012,7 +1012,7 @@ router.post(
|
||||
userUpdateLimiter,
|
||||
validateRequest(addShoppingListItemSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
|
||||
req.log.debug(`[ROUTE] POST /api/v1/users/shopping-lists/:listId/items - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as AddShoppingListItemRequest;
|
||||
@@ -1097,7 +1097,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(updateShoppingListItemSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as UpdateShoppingListItemRequest;
|
||||
@@ -1112,7 +1112,7 @@ router.put(
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, params: req.params, body: req.body },
|
||||
`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`,
|
||||
`[ROUTE] PUT /api/v1/users/shopping-lists/items/:itemId - ERROR`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -1150,7 +1150,7 @@ router.delete(
|
||||
userUpdateLimiter,
|
||||
validateRequest(shoppingListItemIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
req.log.debug(`[ROUTE] DELETE /api/v1/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteShoppingListItemRequest;
|
||||
@@ -1164,7 +1164,7 @@ router.delete(
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, params: req.params },
|
||||
`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`,
|
||||
`[ROUTE] DELETE /api/v1/users/shopping-lists/items/:itemId - ERROR`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -1207,7 +1207,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(updatePreferencesSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/profile/preferences - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdatePreferencesRequest;
|
||||
@@ -1219,7 +1219,7 @@ router.put(
|
||||
);
|
||||
sendSuccess(res, updatedProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/v1/users/profile/preferences - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -1248,7 +1248,7 @@ router.get(
|
||||
'/me/dietary-restrictions',
|
||||
validateRequest(emptySchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/me/dietary-restrictions - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(
|
||||
@@ -1257,7 +1257,7 @@ router.get(
|
||||
);
|
||||
sendSuccess(res, restrictions);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] GET /api/v1/users/me/dietary-restrictions - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -1303,7 +1303,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(setUserRestrictionsSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/me/dietary-restrictions - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserRestrictionsRequest;
|
||||
@@ -1344,7 +1344,7 @@ router.put(
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
|
||||
req.log.debug(`[ROUTE] GET /api/v1/users/me/appliances - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const appliances = await db.personalizationRepo.getUserAppliances(
|
||||
@@ -1353,7 +1353,7 @@ router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next
|
||||
);
|
||||
sendSuccess(res, appliances);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
||||
req.log.error({ error }, `[ROUTE] GET /api/v1/users/me/appliances - ERROR`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -1398,7 +1398,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(setUserAppliancesSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/me/appliances - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserAppliancesRequest;
|
||||
@@ -1654,7 +1654,7 @@ router.delete(
|
||||
userUpdateLimiter,
|
||||
validateRequest(recipeIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
||||
req.log.debug(`[ROUTE] DELETE /api/v1/users/recipes/:recipeId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteRecipeRequest;
|
||||
@@ -1664,7 +1664,7 @@ router.delete(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, params: req.params },
|
||||
`[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`,
|
||||
`[ROUTE] DELETE /api/v1/users/recipes/:recipeId - ERROR`,
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
@@ -1749,7 +1749,7 @@ router.put(
|
||||
userUpdateLimiter,
|
||||
validateRequest(updateRecipeSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
req.log.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
||||
req.log.debug(`[ROUTE] PUT /api/v1/users/recipes/:recipeId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as UpdateRecipeRequest;
|
||||
@@ -1765,7 +1765,7 @@ router.put(
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, params: req.params, body: req.body },
|
||||
`[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`,
|
||||
`[ROUTE] PUT /api/v1/users/recipes/:recipeId - ERROR`,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('API Client', () => {
|
||||
const mockFile = new File(['logo-content'], 'store-logo.png', { type: 'image/png' });
|
||||
await apiClient.uploadLogoAndUpdateStore(1, mockFile);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/stores/1/logo');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/stores/1/logo');
|
||||
expect(capturedBody).toBeInstanceOf(FormData);
|
||||
const uploadedFile = (capturedBody as FormData).get('logoImage') as File;
|
||||
expect(uploadedFile.name).toBe('store-logo.png');
|
||||
@@ -297,7 +297,7 @@ describe('API Client', () => {
|
||||
});
|
||||
await apiClient.uploadBrandLogo(2, mockFile);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/brands/2/logo');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/brands/2/logo');
|
||||
expect(capturedBody).toBeInstanceOf(FormData);
|
||||
const uploadedFile = (capturedBody as FormData).get('logoImage') as File;
|
||||
expect(uploadedFile.name).toBe('brand-logo.svg');
|
||||
@@ -311,25 +311,25 @@ describe('API Client', () => {
|
||||
|
||||
it('getAuthenticatedUserProfile should call the correct endpoint', async () => {
|
||||
await apiClient.getAuthenticatedUserProfile();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile');
|
||||
});
|
||||
|
||||
it('fetchWatchedItems should call the correct endpoint', async () => {
|
||||
await apiClient.fetchWatchedItems();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/watched-items');
|
||||
});
|
||||
|
||||
it('addWatchedItem should send a POST request with the correct body', async () => {
|
||||
const watchedItemData = { itemName: 'Apples', category_id: 5 };
|
||||
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category_id);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/watched-items');
|
||||
expect(capturedBody).toEqual(watchedItemData);
|
||||
});
|
||||
|
||||
it('removeWatchedItem should send a DELETE request to the correct URL', async () => {
|
||||
await apiClient.removeWatchedItem(99);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/watched-items/99');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/watched-items/99');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +337,7 @@ describe('API Client', () => {
|
||||
it('getBudgets should call the correct endpoint', async () => {
|
||||
server.use(http.get('http://localhost/api/budgets', () => HttpResponse.json([])));
|
||||
await apiClient.getBudgets();
|
||||
expect(capturedUrl?.pathname).toBe('/api/budgets');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/budgets');
|
||||
});
|
||||
|
||||
it('createBudget should send a POST request with budget data', async () => {
|
||||
@@ -349,7 +349,7 @@ describe('API Client', () => {
|
||||
});
|
||||
await apiClient.createBudget(budgetData);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/budgets');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/budgets');
|
||||
expect(capturedBody).toEqual(budgetData);
|
||||
});
|
||||
|
||||
@@ -357,13 +357,13 @@ describe('API Client', () => {
|
||||
const budgetUpdates = { amount_cents: 60000 };
|
||||
await apiClient.updateBudget(123, budgetUpdates);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/budgets/123');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/budgets/123');
|
||||
expect(capturedBody).toEqual(budgetUpdates);
|
||||
});
|
||||
|
||||
it('deleteBudget should send a DELETE request to the correct URL', async () => {
|
||||
await apiClient.deleteBudget(456);
|
||||
expect(capturedUrl?.pathname).toBe('/api/budgets/456');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/budgets/456');
|
||||
});
|
||||
|
||||
it('getSpendingAnalysis should send a GET request with correct query params', async () => {
|
||||
@@ -378,7 +378,7 @@ describe('API Client', () => {
|
||||
localStorage.setItem('authToken', 'gamify-token');
|
||||
await apiClient.getUserAchievements();
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/achievements/me');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/achievements/me');
|
||||
expect(capturedHeaders).not.toBeNull();
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer gamify-token');
|
||||
});
|
||||
@@ -387,14 +387,14 @@ describe('API Client', () => {
|
||||
await apiClient.fetchLeaderboard(5);
|
||||
|
||||
expect(capturedUrl).not.toBeNull(); // This assertion ensures capturedUrl is not null for the next line
|
||||
expect(capturedUrl!.pathname).toBe('/api/achievements/leaderboard');
|
||||
expect(capturedUrl!.pathname).toBe('/api/v1/achievements/leaderboard');
|
||||
expect(capturedUrl!.searchParams.get('limit')).toBe('5');
|
||||
});
|
||||
|
||||
it('getAchievements should call the public endpoint', async () => {
|
||||
// This is a public endpoint, so no token is needed.
|
||||
await apiClient.getAchievements();
|
||||
expect(capturedUrl?.pathname).toBe('/api/achievements');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/achievements');
|
||||
});
|
||||
|
||||
it('uploadAvatar should send FormData with the avatar file', async () => {
|
||||
@@ -415,20 +415,20 @@ describe('API Client', () => {
|
||||
await apiClient.getNotifications(10, 20);
|
||||
|
||||
expect(capturedUrl).not.toBeNull();
|
||||
expect(capturedUrl!.pathname).toBe('/api/users/notifications');
|
||||
expect(capturedUrl!.pathname).toBe('/api/v1/users/notifications');
|
||||
expect(capturedUrl!.searchParams.get('limit')).toBe('10');
|
||||
expect(capturedUrl!.searchParams.get('offset')).toBe('20');
|
||||
});
|
||||
|
||||
it('markAllNotificationsAsRead should send a POST request', async () => {
|
||||
await apiClient.markAllNotificationsAsRead();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/notifications/mark-all-read');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/notifications/mark-all-read');
|
||||
});
|
||||
|
||||
it('markNotificationAsRead should send a POST request to the correct URL', async () => {
|
||||
const notificationId = 123;
|
||||
await apiClient.markNotificationAsRead(notificationId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/notifications/${notificationId}/mark-read`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/notifications/${notificationId}/mark-read`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,31 +436,31 @@ describe('API Client', () => {
|
||||
// The beforeEach was testing fetchShoppingLists, so we move that into its own test.
|
||||
it('fetchShoppingLists should call the correct endpoint', async () => {
|
||||
await apiClient.fetchShoppingLists();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/shopping-lists');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/shopping-lists');
|
||||
});
|
||||
|
||||
it('fetchShoppingListById should call the correct endpoint', async () => {
|
||||
const listId = 5;
|
||||
await apiClient.fetchShoppingListById(listId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/${listId}`);
|
||||
});
|
||||
|
||||
it('createShoppingList should send a POST request with the list name', async () => {
|
||||
await apiClient.createShoppingList('Weekly Groceries');
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/shopping-lists');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/shopping-lists');
|
||||
expect(capturedBody).toEqual({ name: 'Weekly Groceries' });
|
||||
});
|
||||
|
||||
it('deleteShoppingList should send a DELETE request to the correct URL', async () => {
|
||||
const listId = 42;
|
||||
server.use(
|
||||
http.delete(`http://localhost/api/users/shopping-lists/${listId}`, () => {
|
||||
http.delete(`http://localhost/api/v1/users/shopping-lists/${listId}`, () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
await apiClient.deleteShoppingList(listId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/${listId}`);
|
||||
});
|
||||
|
||||
it('addShoppingListItem should send a POST request with item data', async () => {
|
||||
@@ -468,7 +468,7 @@ describe('API Client', () => {
|
||||
const itemData = createMockShoppingListItemPayload({ customItemName: 'Paper Towels' });
|
||||
await apiClient.addShoppingListItem(listId, itemData);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/${listId}/items`);
|
||||
expect(capturedBody).toEqual(itemData);
|
||||
});
|
||||
|
||||
@@ -477,14 +477,14 @@ describe('API Client', () => {
|
||||
const updates = { is_purchased: true };
|
||||
await apiClient.updateShoppingListItem(itemId, updates);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/items/${itemId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/items/${itemId}`);
|
||||
expect(capturedBody).toEqual(updates);
|
||||
});
|
||||
|
||||
it('removeShoppingListItem should send a DELETE request to the correct URL', async () => {
|
||||
const itemId = 101;
|
||||
await apiClient.removeShoppingListItem(itemId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/items/${itemId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/items/${itemId}`);
|
||||
});
|
||||
|
||||
it('completeShoppingList should send a POST request with total spent', async () => {
|
||||
@@ -492,7 +492,7 @@ describe('API Client', () => {
|
||||
const totalSpentCents = 12345;
|
||||
await apiClient.completeShoppingList(listId, totalSpentCents);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/complete`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/shopping-lists/${listId}/complete`);
|
||||
expect(capturedBody).toEqual({ totalSpentCents });
|
||||
});
|
||||
});
|
||||
@@ -505,48 +505,48 @@ describe('API Client', () => {
|
||||
|
||||
it('getCompatibleRecipes should call the correct endpoint', async () => {
|
||||
await apiClient.getCompatibleRecipes();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/me/compatible-recipes');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/me/compatible-recipes');
|
||||
});
|
||||
|
||||
it('forkRecipe should send a POST request to the correct URL', async () => {
|
||||
const recipeId = 99;
|
||||
server.use(
|
||||
http.post(`http://localhost/api/recipes/${recipeId}/fork`, () => {
|
||||
http.post(`http://localhost/api/v1/recipes/${recipeId}/fork`, () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
await apiClient.forkRecipe(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/fork`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/recipes/${recipeId}/fork`);
|
||||
});
|
||||
|
||||
it('getUserFavoriteRecipes should call the correct endpoint', async () => {
|
||||
await apiClient.getUserFavoriteRecipes();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/me/favorite-recipes');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/me/favorite-recipes');
|
||||
});
|
||||
|
||||
it('addFavoriteRecipe should send a POST request with the recipeId', async () => {
|
||||
const recipeId = 123;
|
||||
await apiClient.addFavoriteRecipe(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/me/favorite-recipes');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/me/favorite-recipes');
|
||||
expect(capturedBody).toEqual({ recipeId });
|
||||
});
|
||||
|
||||
it('removeFavoriteRecipe should send a DELETE request to the correct URL', async () => {
|
||||
const recipeId = 123;
|
||||
await apiClient.removeFavoriteRecipe(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/me/favorite-recipes/${recipeId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/me/favorite-recipes/${recipeId}`);
|
||||
});
|
||||
|
||||
it('getRecipeComments should call the public endpoint', async () => {
|
||||
const recipeId = 456;
|
||||
await apiClient.getRecipeComments(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/recipes/${recipeId}/comments`);
|
||||
});
|
||||
|
||||
it('getRecipeById should call the correct public endpoint', async () => {
|
||||
const recipeId = 789;
|
||||
await apiClient.getRecipeById(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/recipes/${recipeId}`);
|
||||
});
|
||||
|
||||
it('addRecipeComment should send a POST request with content and optional parentId', async () => {
|
||||
@@ -556,20 +556,20 @@ describe('API Client', () => {
|
||||
parentCommentId: 789,
|
||||
});
|
||||
await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/recipes/${recipeId}/comments`);
|
||||
expect(capturedBody).toEqual(commentData);
|
||||
});
|
||||
|
||||
it('deleteRecipe should send a DELETE request to the correct URL', async () => {
|
||||
const recipeId = 101;
|
||||
await apiClient.deleteRecipe(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/recipes/${recipeId}`);
|
||||
});
|
||||
|
||||
it('suggestRecipe should send a POST request with ingredients', async () => {
|
||||
const ingredients = ['chicken', 'rice'];
|
||||
await apiClient.suggestRecipe(ingredients);
|
||||
expect(capturedUrl?.pathname).toBe('/api/recipes/suggest');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/recipes/suggest');
|
||||
expect(capturedBody).toEqual({ ingredients });
|
||||
});
|
||||
});
|
||||
@@ -579,7 +579,7 @@ describe('API Client', () => {
|
||||
localStorage.setItem('authToken', 'user-settings-token');
|
||||
const profileData = createMockProfileUpdatePayload({ full_name: 'John Doe' });
|
||||
await apiClient.updateUserProfile(profileData, { tokenOverride: 'override-token' });
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile');
|
||||
expect(capturedBody).toEqual(profileData);
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer override-token');
|
||||
});
|
||||
@@ -587,7 +587,7 @@ describe('API Client', () => {
|
||||
it('updateUserPreferences should send a PUT request with preferences data', async () => {
|
||||
const preferences = { darkMode: true };
|
||||
await apiClient.updateUserPreferences(preferences);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile/preferences');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile/preferences');
|
||||
expect(capturedBody).toEqual(preferences);
|
||||
});
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('API Client', () => {
|
||||
await apiClient.updateUserPassword(passwordData.newPassword, {
|
||||
tokenOverride: 'pw-override-token',
|
||||
});
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile/password');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile/password');
|
||||
expect(capturedBody).toEqual(passwordData);
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer pw-override-token');
|
||||
});
|
||||
@@ -604,18 +604,18 @@ describe('API Client', () => {
|
||||
it('updateUserPassword should send a PUT request with the new password', async () => {
|
||||
const newPassword = 'new-secure-password';
|
||||
await apiClient.updateUserPassword(newPassword);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile/password');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile/password');
|
||||
expect(capturedBody).toEqual({ newPassword });
|
||||
});
|
||||
|
||||
it('exportUserData should call the correct endpoint', async () => {
|
||||
await apiClient.exportUserData();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/data-export');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/data-export');
|
||||
});
|
||||
|
||||
it('getUserFeed should call the correct endpoint with query params', async () => {
|
||||
await apiClient.getUserFeed(10, 5);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/feed');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/feed');
|
||||
expect(capturedUrl!.searchParams.get('limit')).toBe('10');
|
||||
expect(capturedUrl!.searchParams.get('offset')).toBe('5');
|
||||
});
|
||||
@@ -623,13 +623,13 @@ describe('API Client', () => {
|
||||
it('followUser should send a POST request to the correct URL', async () => {
|
||||
const userIdToFollow = 'user-to-follow';
|
||||
await apiClient.followUser(userIdToFollow);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/${userIdToFollow}/follow`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/${userIdToFollow}/follow`);
|
||||
});
|
||||
|
||||
it('unfollowUser should send a DELETE request to the correct URL', async () => {
|
||||
const userIdToUnfollow = 'user-to-unfollow';
|
||||
await apiClient.unfollowUser(userIdToUnfollow);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/${userIdToUnfollow}/follow`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/${userIdToUnfollow}/follow`);
|
||||
});
|
||||
|
||||
it('registerUser should send a POST request with user data', async () => {
|
||||
@@ -639,21 +639,21 @@ describe('API Client', () => {
|
||||
full_name: 'Test User',
|
||||
});
|
||||
await apiClient.registerUser(userData.email, userData.password, userData.full_name);
|
||||
expect(capturedUrl?.pathname).toBe('/api/auth/register');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/auth/register');
|
||||
expect(capturedBody).toEqual(userData);
|
||||
});
|
||||
|
||||
it('deleteUserAccount should send a DELETE request with the confirmation password', async () => {
|
||||
const passwordData = { password: 'current-password-for-confirmation' };
|
||||
await apiClient.deleteUserAccount(passwordData.password);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/account');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/account');
|
||||
expect(capturedBody).toEqual(passwordData);
|
||||
});
|
||||
|
||||
it('setUserDietaryRestrictions should send a PUT request with restriction IDs', async () => {
|
||||
const restrictionData = { restrictionIds: [1, 5] };
|
||||
await apiClient.setUserDietaryRestrictions(restrictionData.restrictionIds);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/me/dietary-restrictions');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/me/dietary-restrictions');
|
||||
expect(capturedBody).toEqual(restrictionData);
|
||||
});
|
||||
|
||||
@@ -662,7 +662,7 @@ describe('API Client', () => {
|
||||
await apiClient.setUserAppliances(applianceData.applianceIds, {
|
||||
tokenOverride: 'appliance-override',
|
||||
});
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/appliances');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/appliances');
|
||||
expect(capturedBody).toEqual(applianceData);
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer appliance-override');
|
||||
});
|
||||
@@ -673,52 +673,52 @@ describe('API Client', () => {
|
||||
city: 'Anytown',
|
||||
});
|
||||
await apiClient.updateUserAddress(addressData);
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/profile/address');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/profile/address');
|
||||
expect(capturedBody).toEqual(addressData);
|
||||
});
|
||||
|
||||
it('geocodeAddress should send a POST request with address data', async () => {
|
||||
const address = '1600 Amphitheatre Parkway, Mountain View, CA';
|
||||
await apiClient.geocodeAddress(address);
|
||||
expect(capturedUrl?.pathname).toBe('/api/system/geocode');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/system/geocode');
|
||||
expect(capturedBody).toEqual({ address });
|
||||
});
|
||||
|
||||
it('getUserAddress should call the correct endpoint', async () => {
|
||||
const addressId = 99;
|
||||
await apiClient.getUserAddress(addressId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/users/addresses/${addressId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/users/addresses/${addressId}`);
|
||||
});
|
||||
|
||||
it('getPantryLocations should call the correct endpoint', async () => {
|
||||
await apiClient.getPantryLocations();
|
||||
expect(capturedUrl?.pathname).toBe('/api/pantry/locations');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/pantry/locations');
|
||||
});
|
||||
|
||||
it('createPantryLocation should send a POST request with the location name', async () => {
|
||||
await apiClient.createPantryLocation('Fridge');
|
||||
expect(capturedUrl?.pathname).toBe('/api/pantry/locations');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/pantry/locations');
|
||||
expect(capturedBody).toEqual({ name: 'Fridge' });
|
||||
});
|
||||
|
||||
it('getShoppingTripHistory should call the correct endpoint', async () => {
|
||||
localStorage.setItem('authToken', 'user-settings-token');
|
||||
await apiClient.getShoppingTripHistory();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/shopping-history');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/shopping-history');
|
||||
expect(capturedHeaders!.get('Authorization')).toBe('Bearer user-settings-token');
|
||||
});
|
||||
|
||||
it('requestPasswordReset should send a POST request with email', async () => {
|
||||
const email = 'forgot@example.com';
|
||||
await apiClient.requestPasswordReset(email);
|
||||
expect(capturedUrl?.pathname).toBe('/api/auth/forgot-password');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/auth/forgot-password');
|
||||
expect(capturedBody).toEqual({ email });
|
||||
});
|
||||
|
||||
it('resetPassword should send a POST request with token and new password', async () => {
|
||||
const data = { token: 'reset-token', newPassword: 'new-password' };
|
||||
await apiClient.resetPassword(data.token, data.newPassword);
|
||||
expect(capturedUrl?.pathname).toBe('/api/auth/reset-password');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/auth/reset-password');
|
||||
expect(capturedBody).toEqual(data);
|
||||
});
|
||||
});
|
||||
@@ -726,7 +726,7 @@ describe('API Client', () => {
|
||||
describe('Public API Functions', () => {
|
||||
it('pingBackend should call the correct health check endpoint', async () => {
|
||||
await apiClient.pingBackend();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/ping');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/ping');
|
||||
});
|
||||
|
||||
it('checkDbSchema should call the correct health check endpoint', async () => {
|
||||
@@ -736,7 +736,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.checkDbSchema();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/db-schema');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/db-schema');
|
||||
});
|
||||
|
||||
it('checkStorage should call the correct health check endpoint', async () => {
|
||||
@@ -746,7 +746,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.checkStorage();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/storage');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/storage');
|
||||
});
|
||||
|
||||
it('checkDbPoolHealth should call the correct health check endpoint', async () => {
|
||||
@@ -756,7 +756,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.checkDbPoolHealth();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/db-pool');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/db-pool');
|
||||
});
|
||||
|
||||
it('checkRedisHealth should call the correct health check endpoint', async () => {
|
||||
@@ -766,7 +766,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.checkRedisHealth();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/redis');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/redis');
|
||||
});
|
||||
|
||||
it('getQueueHealth should call the correct health check endpoint', async () => {
|
||||
@@ -776,7 +776,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.getQueueHealth();
|
||||
expect(capturedUrl?.pathname).toBe('/api/health/queues');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/health/queues');
|
||||
});
|
||||
|
||||
it('checkPm2Status should call the correct system endpoint', async () => {
|
||||
@@ -786,7 +786,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.checkPm2Status();
|
||||
expect(capturedUrl?.pathname).toBe('/api/system/pm2-status');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/system/pm2-status');
|
||||
});
|
||||
|
||||
it('fetchFlyers should call the correct public endpoint', async () => {
|
||||
@@ -796,7 +796,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.fetchFlyers();
|
||||
expect(capturedUrl?.pathname).toBe('/api/flyers');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/flyers');
|
||||
});
|
||||
|
||||
it('fetchMasterItems should call the correct public endpoint', async () => {
|
||||
@@ -806,7 +806,7 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.fetchMasterItems();
|
||||
expect(capturedUrl?.pathname).toBe('/api/personalization/master-items');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/personalization/master-items');
|
||||
});
|
||||
|
||||
it('fetchCategories should call the correct public endpoint', async () => {
|
||||
@@ -816,30 +816,30 @@ describe('API Client', () => {
|
||||
}),
|
||||
);
|
||||
await apiClient.fetchCategories();
|
||||
expect(capturedUrl?.pathname).toBe('/api/categories');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/categories');
|
||||
});
|
||||
|
||||
it('fetchFlyerItems should call the correct public endpoint for a specific flyer', async () => {
|
||||
const flyerId = 123;
|
||||
server.use(
|
||||
http.get(`http://localhost/api/flyers/${flyerId}/items`, () => {
|
||||
http.get(`http://localhost/api/v1/flyers/${flyerId}/items`, () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
await apiClient.fetchFlyerItems(flyerId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/flyers/${flyerId}/items`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/flyers/${flyerId}/items`);
|
||||
});
|
||||
|
||||
it('fetchFlyerById should call the correct public endpoint for a specific flyer', async () => {
|
||||
const flyerId = 456;
|
||||
await apiClient.fetchFlyerById(flyerId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/flyers/${flyerId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/flyers/${flyerId}`);
|
||||
});
|
||||
|
||||
it('fetchFlyerItemsForFlyers should send a POST request with flyer IDs', async () => {
|
||||
const flyerIds = [1, 2, 3];
|
||||
await apiClient.fetchFlyerItemsForFlyers(flyerIds);
|
||||
expect(capturedUrl?.pathname).toBe('/api/flyers/items/batch-fetch');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/flyers/items/batch-fetch');
|
||||
expect(capturedBody).toEqual({ flyerIds });
|
||||
});
|
||||
|
||||
@@ -858,14 +858,14 @@ describe('API Client', () => {
|
||||
it('countFlyerItemsForFlyers should send a POST request with flyer IDs', async () => {
|
||||
const flyerIds = [1, 2, 3];
|
||||
await apiClient.countFlyerItemsForFlyers(flyerIds);
|
||||
expect(capturedUrl?.pathname).toBe('/api/flyers/items/batch-count');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/flyers/items/batch-count');
|
||||
expect(capturedBody).toEqual({ flyerIds });
|
||||
});
|
||||
|
||||
it('fetchHistoricalPriceData should send a POST request with master item IDs', async () => {
|
||||
const masterItemIds = [10, 20];
|
||||
await apiClient.fetchHistoricalPriceData(masterItemIds);
|
||||
expect(capturedUrl?.pathname).toBe('/api/price-history');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/price-history');
|
||||
expect(capturedBody).toEqual({ masterItemIds });
|
||||
});
|
||||
|
||||
@@ -880,88 +880,88 @@ describe('API Client', () => {
|
||||
it('approveCorrection should send a POST request to the correct URL', async () => {
|
||||
const correctionId = 45;
|
||||
await apiClient.approveCorrection(correctionId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}/approve`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/corrections/${correctionId}/approve`);
|
||||
});
|
||||
|
||||
it('updateRecipeStatus should send a PUT request with the correct body', async () => {
|
||||
const recipeId = 78;
|
||||
const statusUpdate = { status: 'public' as const };
|
||||
await apiClient.updateRecipeStatus(recipeId, 'public');
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/recipes/${recipeId}/status`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/recipes/${recipeId}/status`);
|
||||
expect(capturedBody).toEqual(statusUpdate);
|
||||
});
|
||||
|
||||
it('cleanupFlyerFiles should send a POST request to the correct URL', async () => {
|
||||
const flyerId = 99;
|
||||
await apiClient.cleanupFlyerFiles(flyerId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/flyers/${flyerId}/cleanup`);
|
||||
});
|
||||
|
||||
it('triggerFailingJob should send a POST request to the correct URL', async () => {
|
||||
await apiClient.triggerFailingJob();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/trigger/failing-job');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/trigger/failing-job');
|
||||
});
|
||||
|
||||
it('clearGeocodeCache should send a POST request to the correct URL', async () => {
|
||||
await apiClient.clearGeocodeCache();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/system/clear-geocode-cache');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/system/clear-geocode-cache');
|
||||
});
|
||||
|
||||
it('getApplicationStats should call the correct endpoint', async () => {
|
||||
await apiClient.getApplicationStats();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/stats');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/stats');
|
||||
});
|
||||
|
||||
it('getSuggestedCorrections should call the correct endpoint', async () => {
|
||||
await apiClient.getSuggestedCorrections();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/corrections');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/corrections');
|
||||
});
|
||||
|
||||
it('getFlyersForReview should call the correct endpoint', async () => {
|
||||
await apiClient.getFlyersForReview();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/review/flyers');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/review/flyers');
|
||||
});
|
||||
|
||||
it('rejectCorrection should send a POST request to the correct URL', async () => {
|
||||
const correctionId = 46;
|
||||
await apiClient.rejectCorrection(correctionId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}/reject`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/corrections/${correctionId}/reject`);
|
||||
});
|
||||
|
||||
it('updateSuggestedCorrection should send a PUT request with the new value', async () => {
|
||||
const correctionId = 47;
|
||||
const newValue = 'new value';
|
||||
await apiClient.updateSuggestedCorrection(correctionId, newValue);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/corrections/${correctionId}`);
|
||||
expect(capturedBody).toEqual({ suggested_value: newValue });
|
||||
});
|
||||
|
||||
it('getUnmatchedFlyerItems should call the correct endpoint', async () => {
|
||||
await apiClient.getUnmatchedFlyerItems();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/unmatched-items');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/unmatched-items');
|
||||
});
|
||||
|
||||
it('updateRecipeCommentStatus should send a PUT request with the status', async () => {
|
||||
const commentId = 88;
|
||||
await apiClient.updateRecipeCommentStatus(commentId, 'hidden');
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/comments/${commentId}/status`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/comments/${commentId}/status`);
|
||||
expect(capturedBody).toEqual({ status: 'hidden' });
|
||||
});
|
||||
|
||||
it('fetchAllBrands should call the correct endpoint', async () => {
|
||||
await apiClient.fetchAllBrands();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/brands');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/brands');
|
||||
});
|
||||
|
||||
it('getDailyStats should call the correct endpoint', async () => {
|
||||
await apiClient.getDailyStats();
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/stats/daily');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/stats/daily');
|
||||
});
|
||||
|
||||
it('updateUserRole should send a PUT request with the new role', async () => {
|
||||
const userId = 'user-to-promote';
|
||||
await apiClient.updateUserRole(userId, 'admin');
|
||||
expect(capturedUrl?.pathname).toBe(`/api/admin/users/${userId}/role`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/admin/users/${userId}/role`);
|
||||
expect(capturedBody).toEqual({ role: 'admin' });
|
||||
});
|
||||
});
|
||||
@@ -969,7 +969,7 @@ describe('API Client', () => {
|
||||
describe('Analytics API Functions', () => {
|
||||
it('trackFlyerItemInteraction should send a POST request with interaction type', async () => {
|
||||
await apiClient.trackFlyerItemInteraction(123, 'click');
|
||||
expect(capturedUrl?.pathname).toBe('/api/flyer-items/123/track');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/flyer-items/123/track');
|
||||
expect(capturedBody).toEqual({ type: 'click' });
|
||||
});
|
||||
|
||||
@@ -980,7 +980,7 @@ describe('API Client', () => {
|
||||
was_successful: true,
|
||||
});
|
||||
await apiClient.logSearchQuery(queryData as any);
|
||||
expect(capturedUrl?.pathname).toBe('/api/search/log');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/search/log');
|
||||
expect(capturedBody).toEqual(queryData);
|
||||
});
|
||||
|
||||
@@ -1025,7 +1025,7 @@ describe('API Client', () => {
|
||||
rememberMe: true,
|
||||
});
|
||||
await apiClient.loginUser(loginData.email, loginData.password, loginData.rememberMe);
|
||||
expect(capturedUrl?.pathname).toBe('/api/auth/login');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/auth/login');
|
||||
expect(capturedBody).toEqual(loginData);
|
||||
});
|
||||
});
|
||||
@@ -1033,7 +1033,7 @@ describe('API Client', () => {
|
||||
describe('Admin Activity Log', () => {
|
||||
it('fetchActivityLog should call the correct endpoint with query params', async () => {
|
||||
await apiClient.fetchActivityLog(50, 10);
|
||||
expect(capturedUrl?.pathname).toBe('/api/admin/activity-log');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/admin/activity-log');
|
||||
expect(capturedUrl!.searchParams.get('limit')).toBe('50');
|
||||
expect(capturedUrl!.searchParams.get('offset')).toBe('10');
|
||||
});
|
||||
@@ -1045,7 +1045,7 @@ describe('API Client', () => {
|
||||
const checksum = 'checksum-abc-123';
|
||||
await apiClient.uploadAndProcessFlyer(mockFile, checksum);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/ai/upload-and-process');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/ai/upload-and-process');
|
||||
expect(capturedBody).toBeInstanceOf(FormData);
|
||||
const uploadedFile = (capturedBody as FormData).get('flyerFile') as File;
|
||||
const sentChecksum = (capturedBody as FormData).get('checksum');
|
||||
@@ -1056,7 +1056,7 @@ describe('API Client', () => {
|
||||
it('getJobStatus should call the correct endpoint', async () => {
|
||||
const jobId = 'job-xyz-789';
|
||||
await apiClient.getJobStatus(jobId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/ai/jobs/${jobId}/status`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/ai/jobs/${jobId}/status`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1065,7 +1065,7 @@ describe('API Client', () => {
|
||||
const mockFile = new File(['receipt-content'], 'receipt.jpg', { type: 'image/jpeg' });
|
||||
await apiClient.uploadReceipt(mockFile);
|
||||
|
||||
expect(capturedUrl?.pathname).toBe('/api/receipts/upload');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/receipts/upload');
|
||||
expect(capturedBody).toBeInstanceOf(FormData);
|
||||
const uploadedFile = (capturedBody as FormData).get('receiptImage') as File;
|
||||
expect(uploadedFile.name).toBe('receipt.jpg');
|
||||
@@ -1074,29 +1074,29 @@ describe('API Client', () => {
|
||||
it('getDealsForReceipt should call the correct endpoint', async () => {
|
||||
const receiptId = 55;
|
||||
await apiClient.getDealsForReceipt(receiptId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/receipts/${receiptId}/deals`);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/v1/receipts/${receiptId}/deals`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Personalization API Functions', () => {
|
||||
it('getDietaryRestrictions should call the correct endpoint', async () => {
|
||||
await apiClient.getDietaryRestrictions();
|
||||
expect(capturedUrl?.pathname).toBe('/api/personalization/dietary-restrictions');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/personalization/dietary-restrictions');
|
||||
});
|
||||
|
||||
it('getAppliances should call the correct endpoint', async () => {
|
||||
await apiClient.getAppliances();
|
||||
expect(capturedUrl?.pathname).toBe('/api/personalization/appliances');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/personalization/appliances');
|
||||
});
|
||||
|
||||
it('getUserDietaryRestrictions should call the correct endpoint', async () => {
|
||||
await apiClient.getUserDietaryRestrictions();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/me/dietary-restrictions');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/me/dietary-restrictions');
|
||||
});
|
||||
|
||||
it('getUserAppliances should call the correct endpoint', async () => {
|
||||
await apiClient.getUserAppliances();
|
||||
expect(capturedUrl?.pathname).toBe('/api/users/appliances');
|
||||
expect(capturedUrl?.pathname).toBe('/api/v1/users/appliances');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,11 @@ try {
|
||||
|
||||
// This constant should point to your backend API.
|
||||
// It's often a good practice to store this in an environment variable.
|
||||
// Using a relative path '/api' is the most robust method for production.
|
||||
// Using a relative path '/api/v1' is the most robust method for production.
|
||||
// It makes API calls to the same host that served the frontend files,
|
||||
// which is then handled by the Nginx reverse proxy.
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
// ADR-008: API Versioning Strategy - Phase 1 migrates all routes to /api/v1.
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
|
||||
export interface ApiOptions {
|
||||
tokenOverride?: string;
|
||||
|
||||
@@ -330,9 +330,9 @@ describe('sentry.server', () => {
|
||||
const breadcrumb = {
|
||||
message: 'API call',
|
||||
category: 'http',
|
||||
data: { url: '/api/test', method: 'GET' },
|
||||
data: { url: '/api/v1/test', method: 'GET' },
|
||||
};
|
||||
expect(breadcrumb.data).toEqual({ url: '/api/test', method: 'GET' });
|
||||
expect(breadcrumb.data).toEqual({ url: '/api/v1/test', method: 'GET' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Worker, Job } from 'bullmq';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
|
||||
import { logger } from './logger.server';
|
||||
import { connection } from './redis.server';
|
||||
@@ -91,6 +92,45 @@ const createWorkerProcessor = <T, R>(processor: (job: Job<T>) => Promise<R>) =>
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the worker heartbeat in Redis.
|
||||
* Stores timestamp, PID, and hostname to detect frozen/hung workers.
|
||||
* TTL is 90s, so if heartbeat isn't updated for 90s, the key expires.
|
||||
* Implements ADR-053: Worker Health Checks.
|
||||
*/
|
||||
const updateWorkerHeartbeat = async (workerName: string) => {
|
||||
const key = `worker:heartbeat:${workerName}`;
|
||||
const value = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
host: os.hostname(),
|
||||
});
|
||||
|
||||
try {
|
||||
await connection.set(key, value, 'EX', 90);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, workerName }, `Failed to update heartbeat for worker ${workerName}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts periodic heartbeat updates for a worker.
|
||||
* Updates every 30 seconds with 90s TTL.
|
||||
*/
|
||||
const startWorkerHeartbeat = (worker: Worker) => {
|
||||
// Initial heartbeat
|
||||
updateWorkerHeartbeat(worker.name);
|
||||
|
||||
// Periodic heartbeat updates
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
updateWorkerHeartbeat(worker.name);
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
// Store interval on worker for cleanup
|
||||
(worker as unknown as { heartbeatInterval?: NodeJS.Timeout }).heartbeatInterval =
|
||||
heartbeatInterval;
|
||||
};
|
||||
|
||||
const attachWorkerEventListeners = (worker: Worker) => {
|
||||
worker.on('completed', (job: Job, returnValue: unknown) => {
|
||||
logger.info({ returnValue }, `[${worker.name}] Job ${job.id} completed successfully.`);
|
||||
@@ -102,6 +142,9 @@ const attachWorkerEventListeners = (worker: Worker) => {
|
||||
`[${worker.name}] Job ${job?.id} has ultimately failed after all attempts.`,
|
||||
);
|
||||
});
|
||||
|
||||
// Start heartbeat monitoring for this worker
|
||||
startWorkerHeartbeat(worker);
|
||||
};
|
||||
|
||||
export const flyerWorker = new Worker<FlyerJobData>(
|
||||
@@ -219,17 +262,28 @@ const SHUTDOWN_TIMEOUT = 30000; // 30 seconds
|
||||
* without exiting the process.
|
||||
*/
|
||||
export const closeWorkers = async () => {
|
||||
await Promise.all([
|
||||
flyerWorker.close(),
|
||||
emailWorker.close(),
|
||||
analyticsWorker.close(),
|
||||
cleanupWorker.close(),
|
||||
weeklyAnalyticsWorker.close(),
|
||||
tokenCleanupWorker.close(),
|
||||
receiptWorker.close(),
|
||||
expiryAlertWorker.close(),
|
||||
barcodeWorker.close(),
|
||||
]);
|
||||
// Clear heartbeat intervals
|
||||
const workers = [
|
||||
flyerWorker,
|
||||
emailWorker,
|
||||
analyticsWorker,
|
||||
cleanupWorker,
|
||||
weeklyAnalyticsWorker,
|
||||
tokenCleanupWorker,
|
||||
receiptWorker,
|
||||
expiryAlertWorker,
|
||||
barcodeWorker,
|
||||
];
|
||||
|
||||
workers.forEach((worker) => {
|
||||
const interval = (worker as unknown as { heartbeatInterval?: NodeJS.Timeout })
|
||||
.heartbeatInterval;
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers.map((w) => w.close()));
|
||||
};
|
||||
|
||||
export const gracefulShutdown = async (signal: string) => {
|
||||
|
||||
@@ -38,27 +38,27 @@ describe('Admin Route Authorization', () => {
|
||||
const adminEndpoints = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/stats',
|
||||
path: '/api/v1/admin/stats',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/users',
|
||||
path: '/api/v1/admin/users',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/corrections',
|
||||
path: '/api/v1/admin/corrections',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/admin/corrections/1/approve',
|
||||
path: '/api/v1/admin/corrections/1/approve',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/admin/trigger/daily-deal-check',
|
||||
path: '/api/v1/admin/trigger/daily-deal-check',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/queues/status',
|
||||
path: '/api/v1/admin/queues/status',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
it('should allow an admin to log in and access dashboard features', async () => {
|
||||
// 1. Register a new user (initially a regular user)
|
||||
const registerResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: adminEmail, password: adminPassword, full_name: 'E2E Admin User' });
|
||||
|
||||
expect(registerResponse.status).toBe(201);
|
||||
@@ -51,7 +51,7 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const loginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: adminEmail, password: adminPassword, rememberMe: false });
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
@@ -62,7 +62,7 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
|
||||
// 4. Fetch System Stats (Protected Admin Route)
|
||||
const statsResponse = await getRequest()
|
||||
.get('/api/admin/stats')
|
||||
.get('/api/v1/admin/stats')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(statsResponse.status).toBe(200);
|
||||
@@ -71,7 +71,7 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
|
||||
// 5. Fetch User List (Protected Admin Route)
|
||||
const usersResponse = await getRequest()
|
||||
.get('/api/admin/users')
|
||||
.get('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(usersResponse.status).toBe(200);
|
||||
@@ -84,7 +84,7 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
|
||||
// 6. Check Queue Status (Protected Admin Route)
|
||||
const queueResponse = await getRequest()
|
||||
.get('/api/admin/queues/status')
|
||||
.get('/api/v1/admin/queues/status')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(queueResponse.status).toBe(200);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: fullName });
|
||||
|
||||
// Assert
|
||||
@@ -68,7 +68,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: weakPassword, full_name: 'Weak Pass User' });
|
||||
|
||||
// Assert
|
||||
@@ -83,14 +83,14 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act 1: Register the user successfully
|
||||
const firstResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
|
||||
expect(firstResponse.status).toBe(201);
|
||||
createdUserIds.push(firstResponse.body.data.userprofile.user.user_id);
|
||||
|
||||
// Act 2: Attempt to register the same user again
|
||||
const secondResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
|
||||
|
||||
// Assert
|
||||
@@ -105,7 +105,7 @@ describe('Authentication E2E Flow', () => {
|
||||
it('should successfully log in a registered user', async () => {
|
||||
// Act: Attempt to log in with the user created in beforeAll
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
|
||||
|
||||
// Assert
|
||||
@@ -118,7 +118,7 @@ describe('Authentication E2E Flow', () => {
|
||||
it('should fail to log in with an incorrect password', async () => {
|
||||
// Act: Attempt to log in with the wrong password
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUser.user.email, password: 'wrong-password', rememberMe: false });
|
||||
|
||||
// Assert
|
||||
@@ -128,7 +128,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
it('should fail to log in with a non-existent email', async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'no-one-here@example.com', password: TEST_PASSWORD, rememberMe: false });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -142,7 +142,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act: Use the token to access a protected route
|
||||
const response = await getRequest()
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Assert
|
||||
@@ -165,7 +165,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act: Call the update endpoint
|
||||
const updateResponse = await getRequest()
|
||||
.put('/api/users/profile')
|
||||
.put('/api/v1/users/profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(profileUpdates);
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act 2: Fetch the profile again to verify persistence
|
||||
const verifyResponse = await getRequest()
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Assert 2: Check the fetched data
|
||||
@@ -190,7 +190,7 @@ describe('Authentication E2E Flow', () => {
|
||||
// Arrange: Create a user to reset the password for
|
||||
const email = `e2e-reset-pass-${Date.now()}@example.com`;
|
||||
const registerResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: 'Reset Pass User' });
|
||||
expect(registerResponse.status).toBe(201);
|
||||
createdUserIds.push(registerResponse.body.data.userprofile.user.user_id);
|
||||
@@ -200,7 +200,7 @@ describe('Authentication E2E Flow', () => {
|
||||
let loginResponse;
|
||||
while (loginAttempts < 10) {
|
||||
loginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email, password: TEST_PASSWORD, rememberMe: false });
|
||||
if (loginResponse.status === 200) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -209,7 +209,7 @@ describe('Authentication E2E Flow', () => {
|
||||
expect(loginResponse?.status).toBe(200);
|
||||
|
||||
// Request password reset (do not poll, as this endpoint is rate-limited)
|
||||
const forgotResponse = await getRequest().post('/api/auth/forgot-password').send({ email });
|
||||
const forgotResponse = await getRequest().post('/api/v1/auth/forgot-password').send({ email });
|
||||
expect(forgotResponse.status).toBe(200);
|
||||
const resetToken = forgotResponse.body.data.token;
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('Authentication E2E Flow', () => {
|
||||
// Act 2: Use the token to set a new password.
|
||||
const newPassword = 'my-new-e2e-password-!@#$';
|
||||
const resetResponse = await getRequest()
|
||||
.post('/api/auth/reset-password')
|
||||
.post('/api/v1/auth/reset-password')
|
||||
.send({ token: resetToken, newPassword });
|
||||
|
||||
// Assert 2: Check for a successful password reset message.
|
||||
@@ -232,7 +232,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// Act 3: Log in with the NEW password
|
||||
const newLoginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email, password: newPassword, rememberMe: false });
|
||||
|
||||
expect(newLoginResponse.status).toBe(200);
|
||||
@@ -246,7 +246,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: nonExistentEmail });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -261,7 +261,7 @@ describe('Authentication E2E Flow', () => {
|
||||
it('should allow an authenticated user to refresh their access token and use it', async () => {
|
||||
// 1. Log in to get the refresh token cookie and an initial access token.
|
||||
const loginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
|
||||
expect(loginResponse.status).toBe(200);
|
||||
const initialAccessToken = loginResponse.body.data.token;
|
||||
@@ -284,7 +284,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// 3. Call the refresh token endpoint, passing the cookie.
|
||||
const refreshResponse = await getRequest()
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', refreshTokenCookie!);
|
||||
|
||||
// 4. Assert the refresh was successful and we got a new token.
|
||||
@@ -295,7 +295,7 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
// 5. Use the new access token to access a protected route.
|
||||
const profileResponse = await getRequest()
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', `Bearer ${newAccessToken}`);
|
||||
expect(profileResponse.status).toBe(200);
|
||||
expect(profileResponse.body.data.user.user_id).toBe(testUser.user.user_id);
|
||||
@@ -303,12 +303,12 @@ describe('Authentication E2E Flow', () => {
|
||||
|
||||
it('should fail to refresh with an invalid or missing token', async () => {
|
||||
// Case 1: No cookie provided
|
||||
const noCookieResponse = await getRequest().post('/api/auth/refresh-token');
|
||||
const noCookieResponse = await getRequest().post('/api/v1/auth/refresh-token');
|
||||
expect(noCookieResponse.status).toBe(401);
|
||||
|
||||
// Case 2: Invalid cookie provided
|
||||
const invalidCookieResponse = await getRequest()
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=invalid-garbage-token');
|
||||
expect(invalidCookieResponse.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
it('should complete budget journey: Register -> Create Budget -> Track Spending -> Update -> Delete', async () => {
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await getRequest().post('/api/auth/register').send({
|
||||
const registerResponse = await getRequest().post('/api/v1/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'Budget E2E User',
|
||||
@@ -75,7 +75,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -95,7 +95,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
const formatDate = (d: Date) => d.toISOString().split('T')[0];
|
||||
|
||||
const createBudgetResponse = await getRequest()
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Monthly Groceries',
|
||||
@@ -113,7 +113,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 4: Create a weekly budget
|
||||
const weeklyBudgetResponse = await getRequest()
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Weekly Dining Out',
|
||||
@@ -128,7 +128,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 5: View all budgets
|
||||
const listBudgetsResponse = await getRequest()
|
||||
.get('/api/budgets')
|
||||
.get('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(listBudgetsResponse.status).toBe(200);
|
||||
@@ -203,7 +203,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 9: Test budget validation - try to create invalid budget
|
||||
const invalidBudgetResponse = await getRequest()
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Invalid Budget',
|
||||
@@ -216,7 +216,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 10: Test budget validation - missing required fields
|
||||
const missingFieldsResponse = await getRequest()
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Incomplete Budget',
|
||||
@@ -236,13 +236,13 @@ describe('E2E Budget Management Journey', () => {
|
||||
// Step 12: Verify another user cannot access our budgets
|
||||
const otherUserEmail = `other-budget-e2e-${uniqueId}@example.com`;
|
||||
await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Budget User' });
|
||||
|
||||
const { responseBody: otherLoginData } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -256,7 +256,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Other user should not see our budgets
|
||||
const otherBudgetsResponse = await getRequest()
|
||||
.get('/api/budgets')
|
||||
.get('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(otherBudgetsResponse.status).toBe(200);
|
||||
@@ -297,7 +297,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 14: Verify deletion
|
||||
const verifyDeleteResponse = await getRequest()
|
||||
.get('/api/budgets')
|
||||
.get('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(verifyDeleteResponse.status).toBe(200);
|
||||
@@ -310,7 +310,7 @@ describe('E2E Budget Management Journey', () => {
|
||||
|
||||
// Step 15: Delete account
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
|
||||
@@ -79,14 +79,14 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
// will support both category names and IDs in the watched items API.
|
||||
|
||||
// Get all available categories
|
||||
const categoriesResponse = await getRequest().get('/api/categories');
|
||||
const categoriesResponse = await getRequest().get('/api/v1/categories');
|
||||
expect(categoriesResponse.status).toBe(200);
|
||||
expect(categoriesResponse.body.success).toBe(true);
|
||||
expect(categoriesResponse.body.data.length).toBeGreaterThan(0);
|
||||
|
||||
// Find "Dairy & Eggs" category by name using the lookup endpoint
|
||||
const categoryLookupResponse = await getRequest().get(
|
||||
'/api/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
|
||||
'/api/v1/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
|
||||
);
|
||||
expect(categoryLookupResponse.status).toBe(200);
|
||||
expect(categoryLookupResponse.body.success).toBe(true);
|
||||
@@ -104,20 +104,20 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Look up other category IDs we'll need
|
||||
const bakeryResponse = await getRequest().get(
|
||||
'/api/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
|
||||
'/api/v1/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
|
||||
);
|
||||
const bakeryCategoryId = bakeryResponse.body.data.category_id;
|
||||
|
||||
const beveragesResponse = await getRequest().get('/api/categories/lookup?name=Beverages');
|
||||
const beveragesResponse = await getRequest().get('/api/v1/categories/lookup?name=Beverages');
|
||||
const beveragesCategoryId = beveragesResponse.body.data.category_id;
|
||||
|
||||
const produceResponse = await getRequest().get(
|
||||
'/api/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
|
||||
'/api/v1/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
|
||||
);
|
||||
const produceCategoryId = produceResponse.body.data.category_id;
|
||||
|
||||
const meatResponse = await getRequest().get(
|
||||
'/api/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
|
||||
'/api/v1/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
|
||||
);
|
||||
const meatCategoryId = meatResponse.body.data.category_id;
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
// to look up category IDs before creating watched items.
|
||||
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await getRequest().post('/api/auth/register').send({
|
||||
const registerResponse = await getRequest().post('/api/v1/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'Deals E2E User',
|
||||
@@ -137,7 +137,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -245,7 +245,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Step 4: Add items to watch list (using category_id from lookups above)
|
||||
const watchItem1Response = await getRequest()
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
itemName: 'E2E Milk 2%',
|
||||
@@ -263,7 +263,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
for (const item of itemsToWatch) {
|
||||
const response = await getRequest()
|
||||
.post('/api/users/watched-items')
|
||||
.post('/api/v1/users/watched-items')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(item);
|
||||
expect(response.status).toBe(201);
|
||||
@@ -271,7 +271,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Step 5: View all watched items
|
||||
const watchedListResponse = await getRequest()
|
||||
.get('/api/users/watched-items')
|
||||
.get('/api/v1/users/watched-items')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(watchedListResponse.status).toBe(200);
|
||||
@@ -286,7 +286,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Step 6: Get best prices for watched items
|
||||
const bestPricesResponse = await getRequest()
|
||||
.get('/api/deals/best-watched-prices')
|
||||
.get('/api/v1/deals/best-watched-prices')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(bestPricesResponse.status).toBe(200);
|
||||
@@ -321,7 +321,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Step 9: Verify item was removed
|
||||
const updatedWatchedListResponse = await getRequest()
|
||||
.get('/api/users/watched-items')
|
||||
.get('/api/v1/users/watched-items')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(updatedWatchedListResponse.status).toBe(200);
|
||||
@@ -334,13 +334,13 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
// Step 10: Verify another user cannot see our watched items
|
||||
const otherUserEmail = `other-deals-e2e-${uniqueId}@example.com`;
|
||||
await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Deals User' });
|
||||
|
||||
const { responseBody: otherLoginData } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -354,7 +354,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Other user's watched items should be empty
|
||||
const otherWatchedResponse = await getRequest()
|
||||
.get('/api/users/watched-items')
|
||||
.get('/api/v1/users/watched-items')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(otherWatchedResponse.status).toBe(200);
|
||||
@@ -362,7 +362,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Other user's deals should be empty
|
||||
const otherDealsResponse = await getRequest()
|
||||
.get('/api/deals/best-watched-prices')
|
||||
.get('/api/v1/deals/best-watched-prices')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(otherDealsResponse.status).toBe(200);
|
||||
@@ -373,7 +373,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
|
||||
|
||||
// Step 11: Delete account
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
|
||||
@@ -45,21 +45,21 @@ describe('Error Reporting E2E', () => {
|
||||
app.use(express.json());
|
||||
|
||||
// Test route that throws a 500 error
|
||||
app.get('/api/test/error-500', (_req, res, next) => {
|
||||
app.get('/api/v1/test/error-500', (_req, res, next) => {
|
||||
const error = new Error('Test 500 error for Sentry');
|
||||
(error as Error & { statusCode: number }).statusCode = 500;
|
||||
next(error);
|
||||
});
|
||||
|
||||
// Test route that throws a 400 error (should NOT be sent to Sentry)
|
||||
app.get('/api/test/error-400', (_req, res, next) => {
|
||||
app.get('/api/v1/test/error-400', (_req, res, next) => {
|
||||
const error = new Error('Test 400 error');
|
||||
(error as Error & { statusCode: number }).statusCode = 400;
|
||||
next(error);
|
||||
});
|
||||
|
||||
// Test route that succeeds
|
||||
app.get('/api/test/success', (_req, res) => {
|
||||
app.get('/api/v1/test/success', (_req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('Error Reporting E2E', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const response = await request(app).get('/api/test/error-500');
|
||||
const response = await request(app).get('/api/v1/test/error-500');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Test 500 error for Sentry' });
|
||||
@@ -102,14 +102,14 @@ describe('Error Reporting E2E', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const response = await request(app).get('/api/test/error-400');
|
||||
const response = await request(app).get('/api/v1/test/error-400');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Test 400 error' });
|
||||
});
|
||||
|
||||
it('should have a success endpoint that returns 200', async () => {
|
||||
const response = await request(app).get('/api/test/success');
|
||||
const response = await request(app).get('/api/v1/test/success');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ success: true });
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
|
||||
it('should allow a user to upload a flyer and wait for processing to complete', async () => {
|
||||
// 1. Register a new user
|
||||
const registerResponse = await getRequest().post('/api/auth/register').send({
|
||||
const registerResponse = await getRequest().post('/api/v1/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'E2E Flyer Uploader',
|
||||
@@ -46,7 +46,7 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
|
||||
// 2. Login to get the access token
|
||||
const loginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
expect(loginResponse.status).toBe(200);
|
||||
authToken = loginResponse.body.data.token;
|
||||
@@ -79,7 +79,7 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
|
||||
// 4. Upload the flyer
|
||||
const uploadResponse = await getRequest()
|
||||
.post('/api/flyers/upload')
|
||||
.post('/api/v1/flyers/upload')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('flyer', fileBuffer, fileName)
|
||||
.field('checksum', checksum);
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
it('should complete inventory journey: Register -> Add Items -> Track Expiry -> Consume -> Configure Alerts', async () => {
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await getRequest().post('/api/auth/register').send({
|
||||
const registerResponse = await getRequest().post('/api/v1/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'Inventory E2E User',
|
||||
@@ -68,7 +68,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -156,7 +156,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
for (const item of items) {
|
||||
const addResponse = await getRequest()
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(item);
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 4: View all inventory
|
||||
const listResponse = await getRequest()
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
@@ -208,7 +208,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 5: Filter by location
|
||||
const fridgeResponse = await getRequest()
|
||||
.get('/api/inventory?location=fridge')
|
||||
.get('/api/v1/inventory?location=fridge')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(fridgeResponse.status).toBe(200);
|
||||
@@ -219,7 +219,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 6: View expiring items
|
||||
const expiringResponse = await getRequest()
|
||||
.get('/api/inventory/expiring?days=3')
|
||||
.get('/api/v1/inventory/expiring?days=3')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(expiringResponse.status).toBe(200);
|
||||
@@ -228,7 +228,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 7: View expired items
|
||||
const expiredResponse = await getRequest()
|
||||
.get('/api/inventory/expired')
|
||||
.get('/api/v1/inventory/expired')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(expiredResponse.status).toBe(200);
|
||||
@@ -276,7 +276,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
// Step 11: Configure alert settings for email
|
||||
// The API uses PUT /inventory/alerts/:alertMethod with days_before_expiry and is_enabled
|
||||
const alertSettingsResponse = await getRequest()
|
||||
.put('/api/inventory/alerts/email')
|
||||
.put('/api/v1/inventory/alerts/email')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
is_enabled: true,
|
||||
@@ -289,7 +289,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 12: Verify alert settings were saved
|
||||
const getSettingsResponse = await getRequest()
|
||||
.get('/api/inventory/alerts')
|
||||
.get('/api/v1/inventory/alerts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getSettingsResponse.status).toBe(200);
|
||||
@@ -301,7 +301,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 13: Get recipe suggestions based on expiring items
|
||||
const suggestionsResponse = await getRequest()
|
||||
.get('/api/inventory/recipes/suggestions')
|
||||
.get('/api/v1/inventory/recipes/suggestions')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(suggestionsResponse.status).toBe(200);
|
||||
@@ -346,13 +346,13 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
// Step 17: Verify another user cannot access our inventory
|
||||
const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`;
|
||||
await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Inventory User' });
|
||||
|
||||
const { responseBody: otherLoginData } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -373,7 +373,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Other user's inventory should be empty
|
||||
const otherListResponse = await getRequest()
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(otherListResponse.status).toBe(200);
|
||||
@@ -398,7 +398,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 19: Final inventory check
|
||||
const finalListResponse = await getRequest()
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(finalListResponse.status).toBe(200);
|
||||
@@ -408,7 +408,7 @@ describe('E2E Inventory/Expiry Management Journey', () => {
|
||||
|
||||
// Step 20: Delete account
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
|
||||
it('should complete receipt journey: Register -> Upload -> View -> Manage Items -> Add to Inventory', async () => {
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await getRequest().post('/api/auth/register').send({
|
||||
const registerResponse = await getRequest().post('/api/v1/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'Receipt E2E User',
|
||||
@@ -79,7 +79,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -133,7 +133,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
|
||||
// Step 4: View receipt list
|
||||
const listResponse = await getRequest()
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
@@ -226,7 +226,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
|
||||
// Step 10: Verify items in inventory
|
||||
const inventoryResponse = await getRequest()
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(inventoryResponse.status).toBe(200);
|
||||
@@ -240,13 +240,13 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
// Step 13: Verify another user cannot access our receipt
|
||||
const otherUserEmail = `other-receipt-e2e-${uniqueId}@example.com`;
|
||||
await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Receipt User' });
|
||||
|
||||
const { responseBody: otherLoginData } = await poll(
|
||||
async () => {
|
||||
const response = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
|
||||
const responseBody = response.status === 200 ? response.body : {};
|
||||
return { response, responseBody };
|
||||
@@ -280,7 +280,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
|
||||
// Step 15: Test filtering by status
|
||||
const completedResponse = await getRequest()
|
||||
.get('/api/receipts?status=completed')
|
||||
.get('/api/v1/receipts?status=completed')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(completedResponse.status).toBe(200);
|
||||
@@ -318,7 +318,7 @@ describe('E2E Receipt Processing Journey', () => {
|
||||
|
||||
// Step 19: Delete account
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user