Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107465b5cb | ||
| e92ad25ce9 | |||
| 2075ed199b | |||
|
|
4346332bbf | ||
| 61cfb518e6 | |||
|
|
e86ce51b6c | ||
| 840a7a62d3 | |||
| 5720820d95 | |||
|
|
e5cdb54308 | ||
| a3f212ff81 | |||
|
|
de263f74b0 | ||
| a71e41302b | |||
|
|
3575803252 | ||
| d03900cefe | |||
|
|
6d49639845 | ||
| d4543cf4b9 | |||
|
|
4f08238698 | ||
| 38b35f87aa |
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,128 +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"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"localerrors",
|
||||
"devdb",
|
||||
"gitea-projectium"
|
||||
]
|
||||
}
|
||||
17
.env.example
17
.env.example
@@ -94,11 +94,18 @@ WORKER_LOCK_DURATION=120000
|
||||
# Error Tracking (ADR-015)
|
||||
# ===================
|
||||
# Sentry-compatible error tracking via Bugsink (self-hosted)
|
||||
# DSNs are created in Bugsink UI at http://localhost:8000 (dev) or your production URL
|
||||
# Backend DSN - for Express/Node.js errors
|
||||
SENTRY_DSN=
|
||||
# Frontend DSN - for React/browser errors (uses VITE_ prefix)
|
||||
VITE_SENTRY_DSN=
|
||||
# DSNs are created in Bugsink UI at https://localhost:8443 (dev) or your production URL
|
||||
#
|
||||
# Dev container projects:
|
||||
# - Project 1: Backend API (Dev) - receives Pino, PostgreSQL errors
|
||||
# - Project 2: Frontend (Dev) - receives browser errors via Sentry SDK
|
||||
# - Project 4: Infrastructure (Dev) - receives Redis, NGINX, Vite errors
|
||||
#
|
||||
# Backend DSN - for Express/Node.js errors (internal container URL)
|
||||
SENTRY_DSN=http://<key>@localhost:8000/1
|
||||
# Frontend DSN - for React/browser errors (uses nginx proxy for browser access)
|
||||
# Note: Browsers cannot reach localhost:8000 directly, so we use nginx proxy at /bugsink-api/
|
||||
VITE_SENTRY_DSN=https://<key>@localhost/bugsink-api/2
|
||||
# Environment name for error grouping (defaults to NODE_ENV)
|
||||
SENTRY_ENVIRONMENT=development
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,6 @@ test-output.txt
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
nul
|
||||
tmpclaude*
|
||||
|
||||
53
CLAUDE.md
53
CLAUDE.md
@@ -123,23 +123,30 @@ The dev container now matches production by using PM2 for process management.
|
||||
|
||||
### Log Aggregation (ADR-050)
|
||||
|
||||
All logs flow to Bugsink via Logstash:
|
||||
All logs flow to Bugsink via Logstash with 3-project routing:
|
||||
|
||||
| Source | Log Location | Status |
|
||||
| ----------------- | --------------------------------- | ------ |
|
||||
| Backend (Pino) | `/var/log/pm2/api-*.log` | Active |
|
||||
| Worker (Pino) | `/var/log/pm2/worker-*.log` | Active |
|
||||
| Vite | `/var/log/pm2/vite-*.log` | Active |
|
||||
| PostgreSQL | `/var/log/postgresql/*.log` | Active |
|
||||
| Redis | `/var/log/redis/redis-server.log` | Active |
|
||||
| NGINX | `/var/log/nginx/*.log` | Active |
|
||||
| Frontend (Sentry) | Browser -> Bugsink SDK | Active |
|
||||
| Source | Log Location | Bugsink Project |
|
||||
| ----------------- | --------------------------------- | ------------------ |
|
||||
| Backend (Pino) | `/var/log/pm2/api-*.log` | Backend API (1) |
|
||||
| Worker (Pino) | `/var/log/pm2/worker-*.log` | Backend API (1) |
|
||||
| PostgreSQL | `/var/log/postgresql/*.log` | Backend API (1) |
|
||||
| Vite | `/var/log/pm2/vite-*.log` | Infrastructure (4) |
|
||||
| Redis | `/var/log/redis/redis-server.log` | Infrastructure (4) |
|
||||
| NGINX | `/var/log/nginx/*.log` | Infrastructure (4) |
|
||||
| Frontend (Sentry) | Browser -> nginx proxy | Frontend (2) |
|
||||
|
||||
**Bugsink Projects (Dev Container)**:
|
||||
|
||||
- Project 1: Backend API (Dev) - Application errors
|
||||
- Project 2: Frontend (Dev) - Browser errors via nginx proxy
|
||||
- Project 4: Infrastructure (Dev) - Redis, NGINX, Vite errors
|
||||
|
||||
**Key Files**:
|
||||
|
||||
- `ecosystem.dev.config.cjs` - PM2 development configuration
|
||||
- `scripts/dev-entrypoint.sh` - Container startup script
|
||||
- `docker/logstash/bugsink.conf` - Logstash pipeline configuration
|
||||
- `docker/nginx/dev.conf` - NGINX config with Bugsink API proxy
|
||||
|
||||
**Full Dev Container Guide**: See [docs/development/DEV-CONTAINER.md](docs/development/DEV-CONTAINER.md)
|
||||
|
||||
@@ -215,6 +222,7 @@ Common issues with solutions:
|
||||
4. **Filename collisions** - Multer predictable names → Use `${Date.now()}-${Math.round(Math.random() * 1e9)}`
|
||||
5. **Response format mismatches** - API format changes → Log response bodies, update assertions
|
||||
6. **External service failures** - PM2/Redis unavailable → try/catch with graceful degradation
|
||||
7. **TZ environment variable breaks async hooks** - `TZ=America/Los_Angeles` causes `RangeError: Invalid triggerAsyncId value: NaN` → Tests now explicitly set `TZ=` (empty) in package.json scripts
|
||||
|
||||
**Full Details**: See test issues section at end of this document or [docs/development/TESTING.md](docs/development/TESTING.md)
|
||||
|
||||
@@ -370,3 +378,28 @@ API formats change: `data.jobId` vs `data.job.id`, nested vs flat, string vs num
|
||||
PM2/Redis health checks fail when unavailable.
|
||||
|
||||
**Solution**: try/catch with graceful degradation or mock
|
||||
|
||||
### 7. TZ Environment Variable Breaking Async Hooks
|
||||
|
||||
**Problem**: When `TZ=America/Los_Angeles` (or other timezone values) is set in the environment, Node.js async_hooks module can produce `RangeError: Invalid triggerAsyncId value: NaN`. This breaks React Testing Library's `render()` function which uses async hooks internally.
|
||||
|
||||
**Root Cause**: Setting `TZ` to certain timezone values interferes with Node.js's internal async tracking mechanism, causing invalid async IDs to be generated.
|
||||
|
||||
**Symptoms**:
|
||||
|
||||
```text
|
||||
RangeError: Invalid triggerAsyncId value: NaN
|
||||
❯ process.env.NODE_ENV.queueSeveralMicrotasks node_modules/react/cjs/react.development.js:751:15
|
||||
❯ process.env.NODE_ENV.exports.act node_modules/react/cjs/react.development.js:886:11
|
||||
❯ node_modules/@testing-library/react/dist/act-compat.js:46:25
|
||||
❯ renderRoot node_modules/@testing-library/react/dist/pure.js:189:26
|
||||
```
|
||||
|
||||
**Solution**: Explicitly unset `TZ` in all test scripts by adding `TZ=` (empty value) to cross-env:
|
||||
|
||||
```json
|
||||
"test:unit": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
"test:integration": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
```
|
||||
|
||||
**Context**: This issue was introduced in commit `d03900c` which added `TZ: 'America/Los_Angeles'` to PM2 ecosystem configs for consistent log timestamps in production/dev environments. Tests must explicitly override this to prevent the async hooks error.
|
||||
|
||||
@@ -174,6 +174,21 @@ BUGSINK = {\n\
|
||||
}\n\
|
||||
\n\
|
||||
ALLOWED_HOSTS = deduce_allowed_hosts(BUGSINK["BASE_URL"])\n\
|
||||
# Also allow 127.0.0.1 access (both localhost and 127.0.0.1 should work)\n\
|
||||
if "127.0.0.1" not in ALLOWED_HOSTS:\n\
|
||||
ALLOWED_HOSTS.append("127.0.0.1")\n\
|
||||
if "localhost" not in ALLOWED_HOSTS:\n\
|
||||
ALLOWED_HOSTS.append("localhost")\n\
|
||||
\n\
|
||||
# CSRF Trusted Origins (Django 4.0+ requires full origin for HTTPS POST requests)\n\
|
||||
# This fixes "CSRF verification failed" errors when accessing Bugsink via HTTPS\n\
|
||||
# Both localhost and 127.0.0.1 must be trusted to support different access patterns\n\
|
||||
CSRF_TRUSTED_ORIGINS = [\n\
|
||||
"https://localhost:8443",\n\
|
||||
"https://127.0.0.1:8443",\n\
|
||||
"http://localhost:8000",\n\
|
||||
"http://127.0.0.1:8000",\n\
|
||||
]\n\
|
||||
\n\
|
||||
# Console email backend for dev\n\
|
||||
EMAIL_BACKEND = "bugsink.email_backends.QuietConsoleEmailBackend"\n\
|
||||
|
||||
@@ -57,6 +57,8 @@ services:
|
||||
- '8000:8000' # Bugsink error tracking HTTP (ADR-015)
|
||||
- '8443:8443' # Bugsink error tracking HTTPS (ADR-015)
|
||||
environment:
|
||||
# Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
- TZ=America/Los_Angeles
|
||||
# Core settings
|
||||
- NODE_ENV=development
|
||||
# Database - use service name for Docker networking
|
||||
@@ -122,6 +124,10 @@ services:
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
# Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
TZ: America/Los_Angeles
|
||||
# PostgreSQL timezone setting (used by log_timezone and timezone parameters)
|
||||
PGTZ: America/Los_Angeles
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: flyer_crawler_dev
|
||||
@@ -142,6 +148,8 @@ services:
|
||||
postgres
|
||||
-c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
-c hba_file=/var/lib/postgresql/data/pg_hba.conf
|
||||
-c timezone=America/Los_Angeles
|
||||
-c log_timezone=America/Los_Angeles
|
||||
-c log_min_messages=notice
|
||||
-c client_min_messages=notice
|
||||
-c logging_collector=on
|
||||
@@ -175,6 +183,9 @@ services:
|
||||
user: root
|
||||
ports:
|
||||
- '6379:6379'
|
||||
environment:
|
||||
# Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
TZ: America/Los_Angeles
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
# Create log volume for Logstash access (ADR-050)
|
||||
|
||||
@@ -12,9 +12,18 @@
|
||||
# - NGINX logs (/var/log/nginx/*.log) - Access and error logs
|
||||
# - Redis logs (/var/log/redis/*.log) - Via shared volume (ADR-050)
|
||||
#
|
||||
# Bugsink Projects:
|
||||
# - Project 1: Backend API (Dev) - Pino errors, PostgreSQL errors
|
||||
# Bugsink Projects (3-project architecture):
|
||||
# - Project 1: Backend API (Dev) - Pino/PM2 app errors, PostgreSQL errors
|
||||
# DSN Key: cea01396c56246adb5878fa5ee6b1d22
|
||||
# - Project 2: Frontend (Dev) - Configured via Sentry SDK in browser
|
||||
# DSN Key: d92663cb73cf4145b677b84029e4b762
|
||||
# - Project 4: Infrastructure (Dev) - Redis, NGINX, PM2 operational logs
|
||||
# DSN Key: 14e8791da3d347fa98073261b596cab9
|
||||
#
|
||||
# Routing Logic:
|
||||
# - Backend logs (type: pm2_api, pm2_worker, pino, postgres) -> Project 1
|
||||
# - Infrastructure logs (type: redis, nginx_error, nginx_5xx) -> Project 4
|
||||
# - Vite errors (type: pm2_vite with errors) -> Project 4 (build tooling)
|
||||
#
|
||||
# Related Documentation:
|
||||
# - docs/adr/0050-postgresql-function-observability.md
|
||||
@@ -112,7 +121,8 @@ input {
|
||||
# ============================================================================
|
||||
# Captures PostgreSQL log output including fn_log() structured JSON messages.
|
||||
# PostgreSQL is configured to write logs to /var/log/postgresql/ (shared volume).
|
||||
# Log format: "2026-01-22 00:00:00 UTC [5724] postgres@flyer_crawler_dev LOG: message"
|
||||
# Log format: "2026-01-22 14:30:00 PST [5724] postgres@flyer_crawler_dev LOG: message"
|
||||
# Note: Timestamps are in PST (America/Los_Angeles) timezone as configured in compose.dev.yml
|
||||
file {
|
||||
path => "/var/log/postgresql/*.log"
|
||||
type => "postgres"
|
||||
@@ -217,10 +227,11 @@ filter {
|
||||
# PostgreSQL Log Processing (ADR-050)
|
||||
# ============================================================================
|
||||
# PostgreSQL log format in dev container:
|
||||
# "2026-01-22 00:00:00 UTC [5724] postgres@flyer_crawler_dev LOG: message"
|
||||
# "2026-01-22 07:06:03 UTC [19851] postgres@flyer_crawler_dev ERROR: column "id" does not exist"
|
||||
# "2026-01-22 14:30:00 PST [5724] postgres@flyer_crawler_dev LOG: message"
|
||||
# "2026-01-22 15:06:03 PST [19851] postgres@flyer_crawler_dev ERROR: column "id" does not exist"
|
||||
# Note: Timestamps are in PST (America/Los_Angeles) timezone
|
||||
if [type] == "postgres" {
|
||||
# Parse PostgreSQL log prefix with UTC timezone
|
||||
# Parse PostgreSQL log prefix with timezone (PST in dev, may vary in prod)
|
||||
grok {
|
||||
match => { "message" => "%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME} %{WORD:pg_timezone} \[%{POSINT:pg_pid}\] %{DATA:pg_user}@%{DATA:pg_database} %{WORD:pg_level}: ?%{GREEDYDATA:pg_message}" }
|
||||
tag_on_failure => ["_postgres_grok_failure"]
|
||||
@@ -344,26 +355,56 @@ filter {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Generate Sentry Event ID for all errors
|
||||
# Generate Sentry Event ID and Ensure Required Fields for all errors
|
||||
# ============================================================================
|
||||
# CRITICAL: sentry_level MUST be set for all errors before output.
|
||||
# Bugsink's PostgreSQL schema limits level to varchar(7), so valid values are:
|
||||
# fatal, error, warning, info, debug (all <= 7 chars)
|
||||
# If sentry_level is not set, the literal "%{sentry_level}" (16 chars) is sent,
|
||||
# causing PostgreSQL insertion failures.
|
||||
# ============================================================================
|
||||
if "error" in [tags] {
|
||||
# Use Ruby for robust field handling - handles all edge cases
|
||||
ruby {
|
||||
code => '
|
||||
require "securerandom"
|
||||
event.set("sentry_event_id", SecureRandom.hex(16))
|
||||
'
|
||||
}
|
||||
|
||||
# Ensure error_message has a fallback value
|
||||
if ![error_message] {
|
||||
mutate { add_field => { "error_message" => "%{message}" } }
|
||||
# Generate unique event ID for Sentry
|
||||
event.set("sentry_event_id", SecureRandom.hex(16))
|
||||
|
||||
# =====================================================================
|
||||
# CRITICAL: Validate and set sentry_level
|
||||
# =====================================================================
|
||||
# Valid Sentry levels (max 7 chars for Bugsink PostgreSQL schema):
|
||||
# fatal, error, warning, info, debug
|
||||
# Default to "error" if missing, empty, or invalid.
|
||||
# =====================================================================
|
||||
valid_levels = ["fatal", "error", "warning", "info", "debug"]
|
||||
current_level = event.get("sentry_level")
|
||||
|
||||
if current_level.nil? || current_level.to_s.strip.empty? || !valid_levels.include?(current_level.to_s.downcase)
|
||||
event.set("sentry_level", "error")
|
||||
else
|
||||
# Normalize to lowercase
|
||||
event.set("sentry_level", current_level.to_s.downcase)
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# Ensure error_message has a fallback value
|
||||
# =====================================================================
|
||||
error_msg = event.get("error_message")
|
||||
if error_msg.nil? || error_msg.to_s.strip.empty?
|
||||
fallback_msg = event.get("message") || event.get("msg") || "Unknown error"
|
||||
event.set("error_message", fallback_msg.to_s)
|
||||
end
|
||||
'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
# ============================================================================
|
||||
# Forward Errors to Bugsink (Backend API Project)
|
||||
# Forward Errors to Bugsink (Project Routing)
|
||||
# ============================================================================
|
||||
# Bugsink uses Sentry-compatible API. Events must include:
|
||||
# - event_id: 32 hex characters (UUID without dashes)
|
||||
@@ -373,9 +414,50 @@ output {
|
||||
# - platform: "node" for backend, "javascript" for frontend
|
||||
#
|
||||
# Authentication via X-Sentry-Auth header with project's public key.
|
||||
# Dev container DSN: http://cea01396c56246adb5878fa5ee6b1d22@localhost:8000/1
|
||||
#
|
||||
# Project Routing:
|
||||
# - Project 1 (Backend): Pino app logs, PostgreSQL errors
|
||||
# - Project 4 (Infrastructure): Redis, NGINX, Vite build errors
|
||||
# ============================================================================
|
||||
if "error" in [tags] {
|
||||
|
||||
# ============================================================================
|
||||
# Infrastructure Errors -> Project 4
|
||||
# ============================================================================
|
||||
# Redis warnings/errors, NGINX errors, and Vite build errors go to
|
||||
# the Infrastructure project for separation from application code errors.
|
||||
if "error" in [tags] and ([type] == "redis" or [type] == "nginx_error" or [type] == "nginx_access" or [type] == "pm2_vite") {
|
||||
http {
|
||||
url => "http://localhost:8000/api/4/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
headers => {
|
||||
"X-Sentry-Auth" => "Sentry sentry_key=14e8791da3d347fa98073261b596cab9, sentry_version=7"
|
||||
"Content-Type" => "application/json"
|
||||
}
|
||||
mapping => {
|
||||
"event_id" => "%{sentry_event_id}"
|
||||
"timestamp" => "%{@timestamp}"
|
||||
"level" => "%{sentry_level}"
|
||||
"platform" => "other"
|
||||
"logger" => "%{type}"
|
||||
"message" => "%{error_message}"
|
||||
"extra" => {
|
||||
"hostname" => "%{[host][name]}"
|
||||
"source_type" => "%{type}"
|
||||
"tags" => "%{tags}"
|
||||
"original_message" => "%{message}"
|
||||
"project" => "infrastructure"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Backend Application Errors -> Project 1
|
||||
# ============================================================================
|
||||
# Pino application logs (API, Worker), PostgreSQL function errors, and
|
||||
# native PostgreSQL errors go to the Backend API project.
|
||||
else if "error" in [tags] and ([type] in ["pm2_api", "pm2_worker", "pino", "postgres"]) {
|
||||
http {
|
||||
url => "http://localhost:8000/api/1/store/"
|
||||
http_method => "post"
|
||||
@@ -384,7 +466,6 @@ output {
|
||||
"X-Sentry-Auth" => "Sentry sentry_key=cea01396c56246adb5878fa5ee6b1d22, sentry_version=7"
|
||||
"Content-Type" => "application/json"
|
||||
}
|
||||
# Transform event to Sentry format using regular fields (not @metadata)
|
||||
mapping => {
|
||||
"event_id" => "%{sentry_event_id}"
|
||||
"timestamp" => "%{@timestamp}"
|
||||
@@ -397,6 +478,38 @@ output {
|
||||
"source_type" => "%{type}"
|
||||
"tags" => "%{tags}"
|
||||
"original_message" => "%{message}"
|
||||
"project" => "backend"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Fallback: Any other errors -> Project 1
|
||||
# ============================================================================
|
||||
# Catch-all for any errors that don't match specific routing rules.
|
||||
else if "error" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/1/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
headers => {
|
||||
"X-Sentry-Auth" => "Sentry sentry_key=cea01396c56246adb5878fa5ee6b1d22, sentry_version=7"
|
||||
"Content-Type" => "application/json"
|
||||
}
|
||||
mapping => {
|
||||
"event_id" => "%{sentry_event_id}"
|
||||
"timestamp" => "%{@timestamp}"
|
||||
"level" => "%{sentry_level}"
|
||||
"platform" => "node"
|
||||
"logger" => "%{type}"
|
||||
"message" => "%{error_message}"
|
||||
"extra" => {
|
||||
"hostname" => "%{[host][name]}"
|
||||
"source_type" => "%{type}"
|
||||
"tags" => "%{tags}"
|
||||
"original_message" => "%{message}"
|
||||
"project" => "backend-fallback"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,37 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Bugsink Sentry API Proxy (for frontend error reporting)
|
||||
# ============================================================================
|
||||
# The frontend Sentry SDK cannot reach localhost:8000 directly from the browser
|
||||
# because port 8000 is only accessible within the container network.
|
||||
# This proxy allows the browser to send errors to https://localhost/bugsink-api/
|
||||
# which NGINX forwards to the Bugsink container on port 8000.
|
||||
#
|
||||
# Frontend DSN format: https://localhost/bugsink-api/<project_id>
|
||||
# Example: https://localhost/bugsink-api/2 for Frontend (Dev) project
|
||||
#
|
||||
# The Sentry SDK sends POST requests to /bugsink-api/<project>/store/
|
||||
# This proxy strips /bugsink-api and forwards to http://localhost:8000/api/
|
||||
# ============================================================================
|
||||
location /bugsink-api/ {
|
||||
proxy_pass http://localhost:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Allow large error payloads with stack traces
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Timeouts for error reporting (should be fast)
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections for real-time notifications
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3001;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# This file is mounted into the PostgreSQL container to enable structured logging
|
||||
# from database functions via fn_log()
|
||||
|
||||
# Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
timezone = 'America/Los_Angeles'
|
||||
log_timezone = 'America/Los_Angeles'
|
||||
|
||||
# Enable logging to files for Logstash pickup
|
||||
logging_collector = on
|
||||
log_destination = 'stderr'
|
||||
|
||||
@@ -28,9 +28,28 @@ The `.env.local` file uses `localhost` while `compose.dev.yml` uses `127.0.0.1`.
|
||||
## HTTPS Setup
|
||||
|
||||
- Self-signed certificates auto-generated with mkcert on container startup
|
||||
- CSRF Protection: Django configured with `SECURE_PROXY_SSL_HEADER` to trust `X-Forwarded-Proto` from nginx
|
||||
- CSRF Protection: Django configured with `CSRF_TRUSTED_ORIGINS` for both `localhost` and `127.0.0.1` (see below)
|
||||
- HTTPS proxy: nginx on port 8443 proxies to Bugsink on port 8000
|
||||
- HTTPS is for UI access only - Sentry SDK uses HTTP directly
|
||||
|
||||
### CSRF Configuration
|
||||
|
||||
Django 4.0+ requires `CSRF_TRUSTED_ORIGINS` for HTTPS POST requests. The Bugsink configuration (`Dockerfile.dev`) includes:
|
||||
|
||||
```python
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://localhost:8443",
|
||||
"https://127.0.0.1:8443",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
]
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
```
|
||||
|
||||
**Both hostnames are required** because browsers treat `localhost` and `127.0.0.1` as different origins.
|
||||
|
||||
If you get "CSRF verification failed" errors, see [BUGSINK-SETUP.md](tools/BUGSINK-SETUP.md#csrf-verification-failed) for troubleshooting.
|
||||
|
||||
## Isolation Benefits
|
||||
|
||||
- Dev errors stay local, don't pollute production/test dashboards
|
||||
|
||||
@@ -2,17 +2,337 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Phase 1 Complete)
|
||||
|
||||
**Updated**: 2026-01-26
|
||||
|
||||
## 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 (Future)
|
||||
|
||||
- [ ] Create version router factory
|
||||
- [ ] Implement deprecation header middleware
|
||||
- [ ] Add version detection to request context
|
||||
- [ ] Document versioning patterns for developers
|
||||
|
||||
### 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) | 39 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 16 |
|
||||
| Proposed (Not Started) | 15 |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +49,7 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
| [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-022](./0022-real-time-notification-system.md) | Real-time Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Implemented | L | Completed (routes, middleware, tests) |
|
||||
|
||||
### 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,78 @@ 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-008 | API Versioning | Proposed | L | Future API evolution |
|
||||
| 6 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 7 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 8 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 9 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 10 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
## 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-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)
|
||||
|
||||
@@ -175,29 +175,30 @@ npm run dev:pm2:logs
|
||||
|
||||
### Log Flow Architecture (ADR-050)
|
||||
|
||||
All application logs flow through Logstash to Bugsink:
|
||||
All application logs flow through Logstash to Bugsink using a 3-project architecture:
|
||||
|
||||
```
|
||||
```text
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| PM2 Logs | | PostgreSQL | | Redis Logs |
|
||||
| PM2 Logs | | PostgreSQL | | Redis/NGINX |
|
||||
| /var/log/pm2/ | | /var/log/ | | /var/log/redis/ |
|
||||
+--------+---------+ | postgresql/ | +--------+---------+
|
||||
| +--------+---------+ |
|
||||
| (API + Worker) | | postgresql/ | | /var/log/nginx/ |
|
||||
+--------+---------+ +--------+---------+ +--------+---------+
|
||||
| | |
|
||||
v v v
|
||||
+------------------------------------------------------------------------+
|
||||
| LOGSTASH |
|
||||
| /etc/logstash/conf.d/bugsink.conf |
|
||||
| (Routes by log type) |
|
||||
+------------------------------------------------------------------------+
|
||||
| | |
|
||||
| +---------+---------+ |
|
||||
| | | |
|
||||
v v v v
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| Errors -> | | Operational -> | | NGINX Logs -> |
|
||||
| Bugsink API | | /var/log/ | | /var/log/ |
|
||||
| (Project 1) | | logstash/*.log | | logstash/*.log |
|
||||
+------------------+ +------------------+ +------------------+
|
||||
v v v
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| Backend API | | Frontend (Dev) | | Infrastructure |
|
||||
| (Project 1) | | (Project 2) | | (Project 4) |
|
||||
| - Pino errors | | - Browser SDK | | - Redis warnings |
|
||||
| - PostgreSQL | | (not Logstash) | | - NGINX errors |
|
||||
+------------------+ +------------------+ | - Vite errors |
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### Log Sources
|
||||
@@ -231,8 +232,11 @@ podman exec flyer-crawler-dev curl -s localhost:9600/_node/stats/pipelines?prett
|
||||
- **URL**: `https://localhost:8443`
|
||||
- **Login**: `admin@localhost` / `admin`
|
||||
- **Projects**:
|
||||
- Project 1: Backend API (errors from Pino, PostgreSQL, Redis)
|
||||
- Project 2: Frontend (errors from Sentry SDK in browser)
|
||||
- Project 1: Backend API (Dev) - Pino app errors, PostgreSQL errors
|
||||
- Project 2: Frontend (Dev) - Browser errors via Sentry SDK
|
||||
- Project 4: Infrastructure (Dev) - Redis warnings, NGINX errors, Vite build errors
|
||||
|
||||
**Note**: Frontend DSN uses nginx proxy (`/bugsink-api/`) because browsers cannot reach `localhost:8000` directly. See [BUGSINK-SETUP.md](../tools/BUGSINK-SETUP.md#frontend-nginx-proxy) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -268,14 +272,41 @@ podman-compose -f compose.dev.yml down
|
||||
|
||||
Key environment variables are set in `compose.dev.yml`:
|
||||
|
||||
| Variable | Value | Purpose |
|
||||
| ----------------- | ----------------------------- | -------------------- |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `DB_HOST` | `postgres` | PostgreSQL hostname |
|
||||
| `REDIS_URL` | `redis://redis:6379` | Redis connection URL |
|
||||
| `FRONTEND_URL` | `https://localhost` | CORS origin |
|
||||
| `SENTRY_DSN` | `http://...@127.0.0.1:8000/1` | Backend Bugsink DSN |
|
||||
| `VITE_SENTRY_DSN` | `http://...@127.0.0.1:8000/2` | Frontend Bugsink DSN |
|
||||
| Variable | Value | Purpose |
|
||||
| ----------------- | ----------------------------- | --------------------------- |
|
||||
| `TZ` | `America/Los_Angeles` | Timezone (PST) for all logs |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `DB_HOST` | `postgres` | PostgreSQL hostname |
|
||||
| `REDIS_URL` | `redis://redis:6379` | Redis connection URL |
|
||||
| `FRONTEND_URL` | `https://localhost` | CORS origin |
|
||||
| `SENTRY_DSN` | `http://...@127.0.0.1:8000/1` | Backend Bugsink DSN |
|
||||
| `VITE_SENTRY_DSN` | `http://...@127.0.0.1:8000/2` | Frontend Bugsink DSN |
|
||||
|
||||
### Timezone Configuration
|
||||
|
||||
All dev container services are configured to use PST (America/Los_Angeles) timezone for consistent log timestamps:
|
||||
|
||||
| Service | Configuration | Notes |
|
||||
| ---------- | ------------------------------------------------ | ------------------------------ |
|
||||
| App | `TZ=America/Los_Angeles` in compose.dev.yml | Also set via dev-entrypoint.sh |
|
||||
| PostgreSQL | `timezone` and `log_timezone` in postgres config | Logs timestamps in PST |
|
||||
| Redis | `TZ=America/Los_Angeles` in compose.dev.yml | Alpine uses TZ env var |
|
||||
| PM2 | `TZ` in ecosystem.dev.config.cjs | Pino timestamps use local time |
|
||||
|
||||
**Verifying Timezone**:
|
||||
|
||||
```bash
|
||||
# Check container timezone
|
||||
podman exec flyer-crawler-dev date
|
||||
|
||||
# Check PostgreSQL timezone
|
||||
podman exec flyer-crawler-postgres psql -U postgres -c "SHOW timezone;"
|
||||
|
||||
# Check Redis log timestamps
|
||||
MSYS_NO_PATHCONV=1 podman exec flyer-crawler-redis cat /var/log/redis/redis-server.log | head -5
|
||||
```
|
||||
|
||||
**Note**: If you need UTC timestamps for production compatibility, change `TZ=UTC` in compose.dev.yml and restart containers.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ MSYS_NO_PATHCONV=1 podman exec flyer-crawler-dev ls -la /var/log/redis/
|
||||
| NGINX logs missing | Output directory | `ls -lh /var/log/logstash/nginx-access-*.log` |
|
||||
| Redis logs missing | Shared volume | Dev: Check `redis_logs` volume mounted; Prod: Check `/var/log/redis/redis-server.log` exists |
|
||||
| High disk usage | Log rotation | Verify `/etc/logrotate.d/logstash` configured |
|
||||
| varchar(7) error | Level validation | Add Ruby filter to validate/normalize `sentry_level` before output |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ This runbook provides step-by-step diagnostics and solutions for common Logstash
|
||||
| Wrong Bugsink project | Environment detection failed | Verify `pg_database` field extraction |
|
||||
| 403 authentication error | Missing/wrong DSN key | Check `X-Sentry-Auth` header |
|
||||
| 500 error from Bugsink | Invalid event format | Verify `event_id` and required fields |
|
||||
| varchar(7) constraint | Unresolved `%{sentry_level}` | Add Ruby filter for level validation |
|
||||
|
||||
---
|
||||
|
||||
@@ -385,7 +386,88 @@ systemctl status logstash
|
||||
|
||||
---
|
||||
|
||||
### Issue 7: Log File Rotation Issues
|
||||
### Issue 7: Level Field Constraint Violation (varchar(7))
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Bugsink returns HTTP 500 errors
|
||||
- PostgreSQL errors: `value too long for type character varying(7)`
|
||||
- Events fail to insert with literal `%{sentry_level}` string (16 characters)
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
When Logstash cannot determine the log level (no error patterns matched), the `sentry_level` field remains as the unresolved placeholder `%{sentry_level}`. Bugsink's PostgreSQL schema has a `varchar(7)` constraint on the level field.
|
||||
|
||||
Valid Sentry levels (all <= 7 characters): `fatal`, `error`, `warning`, `info`, `debug`
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check for HTTP 500 responses in Logstash logs
|
||||
podman exec flyer-crawler-dev cat /var/log/logstash/logstash.log | grep "500"
|
||||
|
||||
# Check Bugsink for constraint violation errors
|
||||
# Via MCP:
|
||||
mcp__localerrors__list_issues({ project_id: 1, status: 'unresolved' })
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
Add a Ruby filter block in `docker/logstash/bugsink.conf` to validate and normalize the `sentry_level` field before sending to Bugsink:
|
||||
|
||||
```ruby
|
||||
# Add this AFTER all mutate filters that set sentry_level
|
||||
# and BEFORE the output section
|
||||
|
||||
ruby {
|
||||
code => '
|
||||
level = event.get("sentry_level")
|
||||
# Check if level is invalid (nil, empty, contains placeholder, or too long)
|
||||
if level.nil? || level.to_s.empty? || level.to_s.include?("%{") || level.to_s.length > 7
|
||||
# Default to "error" for error-tagged events, "info" otherwise
|
||||
if event.get("tags")&.include?("error")
|
||||
event.set("sentry_level", "error")
|
||||
else
|
||||
event.set("sentry_level", "info")
|
||||
end
|
||||
else
|
||||
# Normalize to lowercase and validate
|
||||
normalized = level.to_s.downcase
|
||||
valid_levels = ["fatal", "error", "warning", "info", "debug"]
|
||||
unless valid_levels.include?(normalized)
|
||||
normalized = "error"
|
||||
end
|
||||
event.set("sentry_level", normalized)
|
||||
end
|
||||
'
|
||||
}
|
||||
```
|
||||
|
||||
**Key validations performed:**
|
||||
|
||||
1. Checks for nil or empty values
|
||||
2. Detects unresolved placeholders (`%{...}`)
|
||||
3. Enforces 7-character maximum length
|
||||
4. Normalizes to lowercase
|
||||
5. Validates against allowed Sentry levels
|
||||
6. Defaults to "error" for error-tagged events, "info" otherwise
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Restart Logstash
|
||||
podman exec flyer-crawler-dev systemctl restart logstash
|
||||
|
||||
# Generate a test log that triggers the filter
|
||||
podman exec flyer-crawler-dev pm2 restart flyer-crawler-api-dev
|
||||
|
||||
# Check no new HTTP 500 errors
|
||||
podman exec flyer-crawler-dev cat /var/log/logstash/logstash.log | tail -50 | grep -E "(500|error)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 8: Log File Rotation Issues
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
|
||||
@@ -49,18 +49,24 @@ Bugsink is a lightweight, self-hosted error tracking platform that is fully comp
|
||||
| Web UI | `https://localhost:8443` (nginx proxy) |
|
||||
| Internal URL | `http://localhost:8000` (direct) |
|
||||
| Credentials | `admin@localhost` / `admin` |
|
||||
| Backend Project | Project ID 1 - `flyer-crawler-dev-backend` |
|
||||
| Frontend Project | Project ID 2 - `flyer-crawler-dev-frontend` |
|
||||
| Backend Project | Project ID 1 - `Backend API (Dev)` |
|
||||
| Frontend Project | Project ID 2 - `Frontend (Dev)` |
|
||||
| Infra Project | Project ID 4 - `Infrastructure (Dev)` |
|
||||
| Backend DSN | `http://<key>@localhost:8000/1` |
|
||||
| Frontend DSN | `http://<key>@localhost:8000/2` |
|
||||
| Frontend DSN | `https://<key>@localhost/bugsink-api/2` (via nginx proxy) |
|
||||
| Infra DSN | `http://<key>@localhost:8000/4` (Logstash only) |
|
||||
| Database | `postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink` |
|
||||
|
||||
**Important:** The Frontend DSN uses an nginx proxy (`/bugsink-api/`) because the browser cannot reach `localhost:8000` directly (container-internal port). See [Frontend Nginx Proxy](#frontend-nginx-proxy) for details.
|
||||
|
||||
**Configuration Files:**
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `compose.dev.yml` | Initial DSNs using `127.0.0.1:8000` (container startup) |
|
||||
| `.env.local` | **OVERRIDES** compose.dev.yml with `localhost:8000` (app runtime) |
|
||||
| File | Purpose |
|
||||
| ------------------------------ | ------------------------------------------------------- |
|
||||
| `compose.dev.yml` | Initial DSNs using `127.0.0.1:8000` (container startup) |
|
||||
| `.env.local` | **OVERRIDES** compose.dev.yml (app runtime) |
|
||||
| `docker/nginx/dev.conf` | Nginx proxy for Bugsink API (frontend error reporting) |
|
||||
| `docker/logstash/bugsink.conf` | Log routing to Backend/Infrastructure projects |
|
||||
|
||||
**Note:** `.env.local` takes precedence over `compose.dev.yml` environment variables.
|
||||
|
||||
@@ -360,75 +366,127 @@ const config = {
|
||||
|
||||
---
|
||||
|
||||
## Frontend Nginx Proxy
|
||||
|
||||
The frontend Sentry SDK runs in the browser, which cannot directly reach `localhost:8000` (the Bugsink container-internal port). To solve this, we use an nginx proxy.
|
||||
|
||||
### How It Works
|
||||
|
||||
```text
|
||||
Browser --HTTPS--> https://localhost/bugsink-api/2/store/
|
||||
|
|
||||
v (nginx proxy)
|
||||
http://localhost:8000/api/2/store/
|
||||
|
|
||||
v
|
||||
Bugsink (internal)
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
Location: `docker/nginx/dev.conf`
|
||||
|
||||
```nginx
|
||||
# Proxy Bugsink Sentry API for frontend error reporting
|
||||
location /bugsink-api/ {
|
||||
proxy_pass http://localhost:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Allow large error payloads with stack traces
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Timeouts for error reporting
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend DSN Format
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
# Uses nginx proxy path instead of direct port
|
||||
VITE_SENTRY_DSN=https://<key>@localhost/bugsink-api/2
|
||||
```
|
||||
|
||||
### Testing Frontend Error Reporting
|
||||
|
||||
1. Open browser console at `https://localhost`
|
||||
|
||||
2. Trigger a test error:
|
||||
|
||||
```javascript
|
||||
throw new Error('Test frontend error from browser');
|
||||
```
|
||||
|
||||
3. Check Bugsink Frontend (Dev) project for the error
|
||||
|
||||
4. Verify browser console shows Sentry SDK activity (if VITE_SENTRY_DEBUG=true)
|
||||
|
||||
---
|
||||
|
||||
## Logstash Integration
|
||||
|
||||
Logstash aggregates logs from multiple sources and forwards error patterns to Bugsink.
|
||||
|
||||
**Note:** See [ADR-015](../adr/0015-application-performance-monitoring-and-error-tracking.md) for the full architecture.
|
||||
|
||||
### 3-Project Architecture
|
||||
|
||||
Logstash routes errors to different Bugsink projects based on log source:
|
||||
|
||||
| Project | ID | Receives |
|
||||
| -------------------- | --- | --------------------------------------------- |
|
||||
| Backend API (Dev) | 1 | Pino app errors, PostgreSQL errors |
|
||||
| Frontend (Dev) | 2 | Browser errors (via Sentry SDK, not Logstash) |
|
||||
| Infrastructure (Dev) | 4 | Redis warnings, NGINX errors, Vite errors |
|
||||
|
||||
### Log Sources
|
||||
|
||||
| Source | Log Path | Error Detection |
|
||||
| ---------- | ---------------------- | ------------------------- |
|
||||
| Pino (app) | `/app/logs/*.log` | level >= 50 (error/fatal) |
|
||||
| Redis | `/var/log/redis/*.log` | WARNING/ERROR log levels |
|
||||
| PostgreSQL | (future) | ERROR/FATAL log levels |
|
||||
| Source | Log Path | Project Destination | Error Detection |
|
||||
| ---------- | --------------------------- | ------------------- | ------------------------- |
|
||||
| PM2 API | `/var/log/pm2/api-*.log` | Backend (1) | level >= 50 (error/fatal) |
|
||||
| PM2 Worker | `/var/log/pm2/worker-*.log` | Backend (1) | level >= 50 (error/fatal) |
|
||||
| PM2 Vite | `/var/log/pm2/vite-*.log` | Infrastructure (4) | error keyword patterns |
|
||||
| PostgreSQL | `/var/log/postgresql/*.log` | Backend (1) | ERROR/FATAL log levels |
|
||||
| Redis | `/var/log/redis/*.log` | Infrastructure (4) | WARNING level (`#`) |
|
||||
| NGINX | `/var/log/nginx/error.log` | Infrastructure (4) | error/crit/alert/emerg |
|
||||
|
||||
### Pipeline Configuration
|
||||
|
||||
**Location:** `/etc/logstash/conf.d/bugsink.conf`
|
||||
**Location:** `/etc/logstash/conf.d/bugsink.conf` (or `docker/logstash/bugsink.conf` in project)
|
||||
|
||||
```conf
|
||||
# === INPUTS ===
|
||||
input {
|
||||
file {
|
||||
path => "/app/logs/*.log"
|
||||
codec => json
|
||||
type => "pino"
|
||||
tags => ["app"]
|
||||
}
|
||||
The configuration:
|
||||
|
||||
file {
|
||||
path => "/var/log/redis/*.log"
|
||||
type => "redis"
|
||||
tags => ["redis"]
|
||||
}
|
||||
1. **Inputs**: Reads from PM2 logs, PostgreSQL logs, Redis logs, NGINX logs
|
||||
2. **Filters**: Detects errors and assigns tags based on log type
|
||||
3. **Outputs**: Routes to appropriate Bugsink project based on log source
|
||||
|
||||
**Key Routing Logic:**
|
||||
|
||||
```ruby
|
||||
# Infrastructure logs -> Project 4
|
||||
if "error" in [tags] and ([type] == "redis" or [type] == "nginx_error" or [type] == "pm2_vite") {
|
||||
http { url => "http://localhost:8000/api/4/store/" ... }
|
||||
}
|
||||
|
||||
# === FILTERS ===
|
||||
filter {
|
||||
if [type] == "pino" and [level] >= 50 {
|
||||
mutate { add_tag => ["error"] }
|
||||
}
|
||||
|
||||
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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# === OUTPUT ===
|
||||
output {
|
||||
if "error" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
}
|
||||
}
|
||||
# Backend logs -> Project 1
|
||||
else if "error" in [tags] and ([type] in ["pm2_api", "pm2_worker", "pino", "postgres"]) {
|
||||
http { url => "http://localhost:8000/api/1/store/" ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Secondary Capture Path**: Catches errors before SDK initialization
|
||||
2. **Log-Based Errors**: Captures errors that don't throw exceptions
|
||||
3. **Infrastructure Monitoring**: Redis connection issues, slow commands
|
||||
4. **Historical Analysis**: Process existing log files
|
||||
1. **Separation of Concerns**: Application errors separate from infrastructure issues
|
||||
2. **Secondary Capture Path**: Catches errors before SDK initialization
|
||||
3. **Log-Based Errors**: Captures errors that don't throw exceptions
|
||||
4. **Infrastructure Monitoring**: Redis, NGINX, build tooling issues
|
||||
5. **Historical Analysis**: Process existing log files
|
||||
|
||||
---
|
||||
|
||||
@@ -743,6 +801,228 @@ podman exec flyer-crawler-dev psql -U postgres -h postgres -c "\l" | grep bugsin
|
||||
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage check"
|
||||
```
|
||||
|
||||
### PostgreSQL Sequence Out of Sync (Duplicate Key Errors)
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Bugsink throws `duplicate key value violates unique constraint "projects_project_pkey"`
|
||||
- Error detail shows: `Key (id)=(1) already exists`
|
||||
- New projects or other entities fail to create
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
PostgreSQL sequences can become out of sync with actual data after:
|
||||
|
||||
- Manual data insertion or database seeding
|
||||
- Restoring from backup
|
||||
- Copying data between environments
|
||||
|
||||
The sequence generates IDs that already exist in the table.
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Dev Container - Check sequence vs max ID
|
||||
podman exec flyer-crawler-dev psql -U bugsink -h postgres -d bugsink -c "
|
||||
SELECT
|
||||
(SELECT MAX(id) FROM projects_project) as max_id,
|
||||
(SELECT last_value FROM projects_project_id_seq) as seq_last_value,
|
||||
CASE
|
||||
WHEN (SELECT MAX(id) FROM projects_project) <= (SELECT last_value FROM projects_project_id_seq)
|
||||
THEN 'OK'
|
||||
ELSE 'OUT OF SYNC - Needs reset'
|
||||
END as status;
|
||||
"
|
||||
|
||||
# Production
|
||||
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< "
|
||||
SELECT MAX(id) as max_id, (SELECT last_value FROM projects_project_id_seq) as seq_value FROM projects_project;
|
||||
"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
Reset the sequence to the maximum existing ID:
|
||||
|
||||
```bash
|
||||
# Dev Container
|
||||
podman exec flyer-crawler-dev psql -U bugsink -h postgres -d bugsink -c "
|
||||
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
|
||||
"
|
||||
|
||||
# Production
|
||||
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< "
|
||||
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
|
||||
"
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
After running the fix, verify:
|
||||
|
||||
```bash
|
||||
# Next ID should be max_id + 1
|
||||
podman exec flyer-crawler-dev psql -U bugsink -h postgres -d bugsink -c "
|
||||
SELECT nextval('projects_project_id_seq') - 1 as current_seq_value;
|
||||
"
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
|
||||
When manually inserting data or restoring backups, always reset sequences:
|
||||
|
||||
```sql
|
||||
-- Generic pattern for any table/sequence
|
||||
SELECT setval('SEQUENCE_NAME', COALESCE((SELECT MAX(id) FROM TABLE_NAME), 1), true);
|
||||
|
||||
-- Common Bugsink sequences that may need reset:
|
||||
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
|
||||
SELECT setval('teams_team_id_seq', COALESCE((SELECT MAX(id) FROM teams_team), 1), true);
|
||||
SELECT setval('releases_release_id_seq', COALESCE((SELECT MAX(id) FROM releases_release), 1), true);
|
||||
```
|
||||
|
||||
### Logstash Level Field Constraint Violation
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Bugsink errors: `value too long for type character varying(7)`
|
||||
- Errors in Backend API project from Logstash
|
||||
- Log shows `%{sentry_level}` literal string being sent
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
Logstash sends the literal placeholder `%{sentry_level}` (16 characters) to Bugsink when:
|
||||
|
||||
- No error pattern is detected in the log message
|
||||
- The `sentry_level` field is not properly initialized
|
||||
- Bugsink's `level` column has a `varchar(7)` constraint
|
||||
|
||||
Valid Sentry levels are: `fatal`, `error`, `warning`, `info`, `debug` (all <= 7 characters).
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check for recent level constraint errors in Bugsink
|
||||
# Via MCP:
|
||||
mcp__localerrors__list_issues({ project_id: 1, status: 'unresolved' })
|
||||
|
||||
# Or check Logstash logs for HTTP 500 responses
|
||||
podman exec flyer-crawler-dev cat /var/log/logstash/logstash.log | grep "500"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
The fix requires updating the Logstash configuration (`docker/logstash/bugsink.conf`) to:
|
||||
|
||||
1. Validate `sentry_level` is not nil, empty, or contains placeholder text
|
||||
2. Set a default value of "error" for any error-tagged event without a valid level
|
||||
3. Normalize levels to lowercase
|
||||
|
||||
**Key filter block (Ruby):**
|
||||
|
||||
```ruby
|
||||
ruby {
|
||||
code => '
|
||||
level = event.get("sentry_level")
|
||||
# Check if level is invalid (nil, empty, contains placeholder, or invalid value)
|
||||
if level.nil? || level.to_s.empty? || level.to_s.include?("%{") || level.to_s.length > 7
|
||||
# Default to "error" for error-tagged events, "info" otherwise
|
||||
if event.get("tags")&.include?("error")
|
||||
event.set("sentry_level", "error")
|
||||
else
|
||||
event.set("sentry_level", "info")
|
||||
end
|
||||
else
|
||||
# Normalize to lowercase and validate
|
||||
normalized = level.to_s.downcase
|
||||
valid_levels = ["fatal", "error", "warning", "info", "debug"]
|
||||
unless valid_levels.include?(normalized)
|
||||
normalized = "error"
|
||||
end
|
||||
event.set("sentry_level", normalized)
|
||||
end
|
||||
'
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
After applying the fix:
|
||||
|
||||
1. Restart Logstash: `podman exec flyer-crawler-dev systemctl restart logstash`
|
||||
2. Generate a test error and verify it appears in Bugsink without level errors
|
||||
3. Check no new "value too long" errors appear in the project
|
||||
|
||||
### CSRF Verification Failed
|
||||
|
||||
**Symptoms:** "CSRF verification failed. Request aborted." error when performing actions in Bugsink UI (resolving issues, changing settings, etc.)
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
Django 4.0+ requires `CSRF_TRUSTED_ORIGINS` to be explicitly configured for HTTPS POST requests. The error occurs because:
|
||||
|
||||
1. Bugsink is accessed via `https://localhost:8443` (nginx HTTPS proxy)
|
||||
2. Django's CSRF protection validates the `Origin` header against `CSRF_TRUSTED_ORIGINS`
|
||||
3. Without explicit configuration, Django rejects POST requests from HTTPS origins
|
||||
|
||||
**Why localhost vs 127.0.0.1 Matters:**
|
||||
|
||||
- `localhost` and `127.0.0.1` are treated as DIFFERENT origins by browsers
|
||||
- If you access Bugsink via `https://localhost:8443`, Django must trust `https://localhost:8443`
|
||||
- If you access via `https://127.0.0.1:8443`, Django must trust `https://127.0.0.1:8443`
|
||||
- The fix includes BOTH to allow either access pattern
|
||||
|
||||
**Configuration (Already Applied):**
|
||||
|
||||
The Bugsink Django configuration in `Dockerfile.dev` includes:
|
||||
|
||||
```python
|
||||
# CSRF Trusted Origins (Django 4.0+ requires full origin for HTTPS POST requests)
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://localhost:8443",
|
||||
"https://127.0.0.1:8443",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
]
|
||||
|
||||
# HTTPS proxy support (nginx reverse proxy on port 8443)
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Verify CSRF_TRUSTED_ORIGINS is configured
|
||||
podman exec flyer-crawler-dev sh -c 'cat /opt/bugsink/conf/bugsink_conf.py | grep -A 6 CSRF_TRUSTED'
|
||||
|
||||
# Expected output:
|
||||
# CSRF_TRUSTED_ORIGINS = [
|
||||
# "https://localhost:8443",
|
||||
# "https://127.0.0.1:8443",
|
||||
# "http://localhost:8000",
|
||||
# "http://127.0.0.1:8000",
|
||||
# ]
|
||||
```
|
||||
|
||||
**If Issue Persists After Fix:**
|
||||
|
||||
1. **Rebuild the container image** (configuration is baked into the image):
|
||||
|
||||
```bash
|
||||
podman-compose -f compose.dev.yml down
|
||||
podman build -f Dockerfile.dev -t localhost/flyer-crawler-dev:latest .
|
||||
podman-compose -f compose.dev.yml up -d
|
||||
```
|
||||
|
||||
2. **Clear browser cookies** for localhost:8443
|
||||
|
||||
3. **Check nginx X-Forwarded-Proto header** - the nginx config must set this header for Django to recognize HTTPS:
|
||||
|
||||
```bash
|
||||
podman exec flyer-crawler-dev cat /etc/nginx/sites-available/bugsink | grep X-Forwarded-Proto
|
||||
# Should show: proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
@@ -44,6 +44,8 @@ if (missingVars.length > 0) {
|
||||
// --- Shared Environment Variables ---
|
||||
// These come from compose.dev.yml environment section
|
||||
const sharedEnv = {
|
||||
// Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
TZ: process.env.TZ || 'America/Los_Angeles',
|
||||
NODE_ENV: 'development',
|
||||
DB_HOST: process.env.DB_HOST || 'postgres',
|
||||
DB_PORT: process.env.DB_PORT || '5432',
|
||||
@@ -160,6 +162,8 @@ module.exports = {
|
||||
min_uptime: '5s',
|
||||
// Environment
|
||||
env: {
|
||||
// Timezone: PST (America/Los_Angeles) for consistent log timestamps
|
||||
TZ: process.env.TZ || 'America/Los_Angeles',
|
||||
NODE_ENV: 'development',
|
||||
// Vite-specific env vars (VITE_ prefix)
|
||||
VITE_SENTRY_DSN: process.env.VITE_SENTRY_DSN,
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.16",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
@@ -14,12 +14,12 @@
|
||||
"start": "npm run start:prod",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
|
||||
"test-wsl": "cross-env NODE_ENV=test vitest run",
|
||||
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx ./node_modules/vitest/vitest.mjs run",
|
||||
"test-wsl": "cross-env NODE_ENV=test TZ= vitest run",
|
||||
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
|
||||
"test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
||||
"test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
||||
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts",
|
||||
"test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
||||
"test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
||||
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
|
||||
@@ -23,6 +23,26 @@ set -e
|
||||
|
||||
echo "Starting Flyer Crawler Dev Container..."
|
||||
|
||||
# ============================================================================
|
||||
# Timezone Configuration
|
||||
# ============================================================================
|
||||
# Ensure TZ is set for consistent log timestamps across all services.
|
||||
# TZ should be set via compose.dev.yml environment (default: America/Los_Angeles)
|
||||
# ============================================================================
|
||||
if [ -n "$TZ" ]; then
|
||||
echo "Timezone configured: $TZ"
|
||||
# Link timezone data if available (for date command and other tools)
|
||||
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||
echo "$TZ" > /etc/timezone
|
||||
echo "System timezone set to: $(date +%Z) ($(date))"
|
||||
else
|
||||
echo "Warning: Timezone data not found for $TZ, using TZ environment variable only"
|
||||
fi
|
||||
else
|
||||
echo "Warning: TZ environment variable not set, using container default timezone"
|
||||
fi
|
||||
|
||||
# Configure Bugsink HTTPS (ADR-015)
|
||||
echo "Configuring Bugsink HTTPS..."
|
||||
mkdir -p /etc/bugsink/ssl
|
||||
|
||||
60
server.ts
60
server.ts
@@ -235,11 +235,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);
|
||||
@@ -251,46 +251,60 @@ 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.
|
||||
// All routes are now versioned under /api/v1 as per ADR-008.
|
||||
// 1. Authentication routes for login, registration, etc.
|
||||
app.use('/api/auth', authRouter); // This was a duplicate, fixed.
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
// 2. System routes for health checks, etc.
|
||||
app.use('/api/health', healthRouter);
|
||||
app.use('/api/v1/health', healthRouter);
|
||||
// 3. System routes for pm2 status, etc.
|
||||
app.use('/api/system', systemRouter);
|
||||
app.use('/api/v1/system', systemRouter);
|
||||
// 3. General authenticated user routes.
|
||||
app.use('/api/users', userRouter);
|
||||
app.use('/api/v1/users', userRouter);
|
||||
// 4. AI routes, some of which use optional authentication.
|
||||
app.use('/api/ai', aiRouter);
|
||||
app.use('/api/v1/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.
|
||||
app.use('/api/v1/admin', adminRouter);
|
||||
// 6. Budgeting and spending analysis routes.
|
||||
app.use('/api/budgets', budgetRouter);
|
||||
app.use('/api/v1/budgets', budgetRouter);
|
||||
// 7. Gamification routes for achievements.
|
||||
app.use('/api/achievements', gamificationRouter);
|
||||
app.use('/api/v1/achievements', gamificationRouter);
|
||||
// 8. Public flyer routes.
|
||||
app.use('/api/flyers', flyerRouter);
|
||||
app.use('/api/v1/flyers', flyerRouter);
|
||||
// 8. Public recipe routes.
|
||||
app.use('/api/recipes', recipeRouter);
|
||||
app.use('/api/v1/recipes', recipeRouter);
|
||||
// 9. Public personalization data routes (master items, etc.).
|
||||
app.use('/api/personalization', personalizationRouter);
|
||||
app.use('/api/v1/personalization', personalizationRouter);
|
||||
// 9.5. Price history routes.
|
||||
app.use('/api/price-history', priceRouter);
|
||||
app.use('/api/v1/price-history', priceRouter);
|
||||
// 10. Public statistics routes.
|
||||
app.use('/api/stats', statsRouter);
|
||||
app.use('/api/v1/stats', statsRouter);
|
||||
// 11. UPC barcode scanning routes.
|
||||
app.use('/api/upc', upcRouter);
|
||||
app.use('/api/v1/upc', upcRouter);
|
||||
// 12. Inventory and expiry tracking routes.
|
||||
app.use('/api/inventory', inventoryRouter);
|
||||
app.use('/api/v1/inventory', inventoryRouter);
|
||||
// 13. Receipt scanning routes.
|
||||
app.use('/api/receipts', receiptRouter);
|
||||
app.use('/api/v1/receipts', receiptRouter);
|
||||
// 14. Deals and best prices routes.
|
||||
app.use('/api/deals', dealsRouter);
|
||||
app.use('/api/v1/deals', dealsRouter);
|
||||
// 15. Reactions/social features routes.
|
||||
app.use('/api/reactions', reactionsRouter);
|
||||
app.use('/api/v1/reactions', reactionsRouter);
|
||||
// 16. Store management routes.
|
||||
app.use('/api/stores', storeRouter);
|
||||
app.use('/api/v1/stores', storeRouter);
|
||||
// 17. Category discovery routes (ADR-023: Database Normalization)
|
||||
app.use('/api/categories', categoryRouter);
|
||||
app.use('/api/v1/categories', categoryRouter);
|
||||
|
||||
// --- Backwards Compatibility Redirect (ADR-008: API Versioning Strategy) ---
|
||||
// Redirect old /api/* paths to /api/v1/* for backwards compatibility.
|
||||
// This allows clients to gradually migrate to the versioned API.
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Only redirect if the path does NOT already start with /v1
|
||||
if (!req.path.startsWith('/v1')) {
|
||||
const newPath = `/api/v1${req.path}`;
|
||||
logger.info({ oldPath: `/api${req.path}`, newPath }, 'Redirecting to versioned API');
|
||||
return res.redirect(301, newPath);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Error Handling and Server Startup ---
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -27,9 +27,13 @@ const defaultProps = {
|
||||
};
|
||||
|
||||
const setupSuccessMocks = () => {
|
||||
// The API returns {success, data: {userprofile, token}}, and the mutation extracts .data
|
||||
const mockAuthResponse = {
|
||||
userprofile: createMockUserProfile({ user: { user_id: '123', email: 'test@example.com' } }),
|
||||
token: 'mock-token',
|
||||
success: true,
|
||||
data: {
|
||||
userprofile: createMockUserProfile({ user: { user_id: '123', email: 'test@example.com' } }),
|
||||
token: 'mock-token',
|
||||
},
|
||||
};
|
||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockAuthResponse)),
|
||||
|
||||
@@ -82,7 +82,11 @@ const defaultAuthenticatedProps = {
|
||||
};
|
||||
|
||||
const setupSuccessMocks = () => {
|
||||
const mockAuthResponse = { userprofile: authenticatedProfile, token: 'mock-token' };
|
||||
// The API returns {success, data: {userprofile, token}}, and the mutation extracts .data
|
||||
const mockAuthResponse = {
|
||||
success: true,
|
||||
data: { userprofile: authenticatedProfile, token: 'mock-token' },
|
||||
};
|
||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockAuthResponse)),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,7 +99,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 +107,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 +130,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 +162,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 +191,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 +218,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 +231,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 +256,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 +268,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 +277,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 +286,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 +306,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 +325,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 +334,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 +345,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 +357,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 +369,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 +379,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 +402,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 +412,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 +421,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 +436,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 +449,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 +459,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 +467,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 +480,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 +491,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 +502,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 +521,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 +537,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 +545,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 +554,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 +566,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 +580,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 +607,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 +625,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 +643,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 +651,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 +669,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 +692,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 +701,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 +721,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 +748,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 +756,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 +780,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 +800,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 +826,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 +841,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 +849,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 +864,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 +880,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,7 +902,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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,36 @@ 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:',
|
||||
'Error fetching flyers 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 +97,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 +111,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 +129,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 +144,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 +161,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 fetching flyer items in /api/v1/flyers/:id/items:',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -177,7 +177,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 +186,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 +194,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 +204,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 +216,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 +225,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 +234,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 +243,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 +253,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 +266,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 +279,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 +296,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 +317,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 +328,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 +339,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 +351,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 +365,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' });
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -46,9 +46,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 +61,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 +75,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 +89,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 +101,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 +117,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 +133,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 +145,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 +159,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 +181,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 +201,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 +215,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 +234,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 +258,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 +275,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 +295,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 +315,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 +334,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 +354,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 +373,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 +386,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 +408,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 +432,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 +447,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 +468,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 +489,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 +510,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 +525,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 +546,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 +565,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 +579,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 +599,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 +612,247 @@ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
queuesData[metric.name] = { error: metric.error };
|
||||
hasErrors = true;
|
||||
} else {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@ describe('API Client', () => {
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ token: 'new-refreshed-token' }),
|
||||
// The API returns {success, data: {token}} wrapper format
|
||||
json: () => Promise.resolve({ success: true, data: { token: 'new-refreshed-token' } }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -218,7 +219,7 @@ describe('API Client', () => {
|
||||
localStorage.setItem('authToken', 'expired-token');
|
||||
// Mock the global fetch to return a sequence of responses:
|
||||
// 1. 401 Unauthorized (initial API call)
|
||||
// 2. 200 OK (token refresh call)
|
||||
// 2. 200 OK (token refresh call) - uses API wrapper format {success, data: {token}}
|
||||
// 3. 200 OK (retry of the initial API call)
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -229,7 +230,8 @@ describe('API Client', () => {
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ token: 'new-refreshed-token' }),
|
||||
// The API returns {success, data: {token}} wrapper format
|
||||
json: () => Promise.resolve({ success: true, data: { token: 'new-refreshed-token' } }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -283,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');
|
||||
@@ -295,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');
|
||||
@@ -309,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,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 () => {
|
||||
@@ -347,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);
|
||||
});
|
||||
|
||||
@@ -355,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 () => {
|
||||
@@ -376,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');
|
||||
});
|
||||
@@ -385,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 () => {
|
||||
@@ -413,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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,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 () => {
|
||||
@@ -466,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);
|
||||
});
|
||||
|
||||
@@ -475,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 () => {
|
||||
@@ -490,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 });
|
||||
});
|
||||
});
|
||||
@@ -503,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 () => {
|
||||
@@ -554,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 });
|
||||
});
|
||||
});
|
||||
@@ -577,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');
|
||||
});
|
||||
@@ -585,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);
|
||||
});
|
||||
|
||||
@@ -594,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');
|
||||
});
|
||||
@@ -602,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');
|
||||
});
|
||||
@@ -621,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 () => {
|
||||
@@ -637,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);
|
||||
});
|
||||
|
||||
@@ -660,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');
|
||||
});
|
||||
@@ -671,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);
|
||||
});
|
||||
});
|
||||
@@ -724,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 () => {
|
||||
@@ -734,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 () => {
|
||||
@@ -744,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 () => {
|
||||
@@ -754,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 () => {
|
||||
@@ -764,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 () => {
|
||||
@@ -774,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 () => {
|
||||
@@ -784,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 () => {
|
||||
@@ -794,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 () => {
|
||||
@@ -804,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 () => {
|
||||
@@ -814,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 });
|
||||
});
|
||||
|
||||
@@ -856,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 });
|
||||
});
|
||||
|
||||
@@ -878,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' });
|
||||
});
|
||||
});
|
||||
@@ -967,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' });
|
||||
});
|
||||
|
||||
@@ -978,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);
|
||||
});
|
||||
|
||||
@@ -1023,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);
|
||||
});
|
||||
});
|
||||
@@ -1031,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');
|
||||
});
|
||||
@@ -1043,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');
|
||||
@@ -1054,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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1063,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');
|
||||
@@ -1072,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;
|
||||
|
||||
@@ -62,12 +62,33 @@ vi.mock('./logger.server', () => ({
|
||||
vi.mock('bullmq', () => ({
|
||||
Worker: mocks.MockWorker,
|
||||
Queue: vi.fn(function () {
|
||||
return { add: vi.fn() };
|
||||
return { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
// Add UnrecoverableError to the mock so it can be used in tests
|
||||
UnrecoverableError: class UnrecoverableError extends Error {},
|
||||
}));
|
||||
|
||||
// Mock redis.server to prevent real Redis connection attempts
|
||||
vi.mock('./redis.server', () => ({
|
||||
connection: {
|
||||
on: vi.fn(),
|
||||
quit: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock queues.server to provide mock queue instances
|
||||
vi.mock('./queues.server', () => ({
|
||||
flyerQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
emailQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
analyticsQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
cleanupQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
weeklyAnalyticsQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
tokenCleanupQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
receiptQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
expiryAlertQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
barcodeQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
// Mock flyerProcessingService.server as flyerWorker and cleanupWorker depend on it
|
||||
vi.mock('./flyerProcessingService.server', () => {
|
||||
// Mock the constructor to return an object with the mocked methods
|
||||
@@ -88,6 +109,67 @@ vi.mock('./flyerDataTransformer', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock aiService.server to prevent initialization issues
|
||||
vi.mock('./aiService.server', () => ({
|
||||
aiService: {
|
||||
extractAndValidateData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock db/index.db to prevent database connections
|
||||
vi.mock('./db/index.db', () => ({
|
||||
personalizationRepo: {},
|
||||
}));
|
||||
|
||||
// Mock flyerAiProcessor.server
|
||||
vi.mock('./flyerAiProcessor.server', () => ({
|
||||
FlyerAiProcessor: vi.fn().mockImplementation(function () {
|
||||
return { processFlyer: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock flyerPersistenceService.server
|
||||
vi.mock('./flyerPersistenceService.server', () => ({
|
||||
FlyerPersistenceService: vi.fn().mockImplementation(function () {
|
||||
return { persistFlyerData: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock db/connection.db to prevent database connections
|
||||
vi.mock('./db/connection.db', () => ({
|
||||
withTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock receiptService.server
|
||||
vi.mock('./receiptService.server', () => ({
|
||||
processReceiptJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock expiryService.server
|
||||
vi.mock('./expiryService.server', () => ({
|
||||
processExpiryAlertJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock barcodeService.server
|
||||
vi.mock('./barcodeService.server', () => ({
|
||||
processBarcodeDetectionJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock flyerFileHandler.server
|
||||
vi.mock('./flyerFileHandler.server', () => ({
|
||||
FlyerFileHandler: vi.fn().mockImplementation(function () {
|
||||
return { handleFile: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock workerOptions config
|
||||
vi.mock('../config/workerOptions', () => ({
|
||||
defaultWorkerOptions: {
|
||||
lockDuration: 30000,
|
||||
stalledInterval: 30000,
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to create a mock BullMQ Job object
|
||||
const createMockJob = <T>(data: T): Job<T> => {
|
||||
return {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
it('should complete full UPC scanning journey: Register -> Scan -> Lookup -> History -> Stats', async () => {
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: userEmail, password: userPassword, full_name: 'UPC E2E User' });
|
||||
expect(registerResponse.status).toBe(201);
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('E2E UPC Scanning 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 };
|
||||
@@ -100,7 +100,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Step 4: Scan the UPC code
|
||||
const scanResponse = await getRequest()
|
||||
.post('/api/upc/scan')
|
||||
.post('/api/v1/upc/scan')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
upc_code: testUpc,
|
||||
@@ -126,7 +126,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
// Step 6: Scan a few more items to build history
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const additionalScan = await getRequest()
|
||||
.post('/api/upc/scan')
|
||||
.post('/api/v1/upc/scan')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
upc_code: `00000000000${i}`,
|
||||
@@ -142,7 +142,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Step 7: View scan history
|
||||
const historyResponse = await getRequest()
|
||||
.get('/api/upc/history')
|
||||
.get('/api/v1/upc/history')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(historyResponse.status).toBe(200);
|
||||
@@ -161,7 +161,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Step 9: Check user scan statistics
|
||||
const statsResponse = await getRequest()
|
||||
.get('/api/upc/stats')
|
||||
.get('/api/v1/upc/stats')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(statsResponse.status).toBe(200);
|
||||
@@ -170,7 +170,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Step 10: Test history filtering by scan_source
|
||||
const filteredHistoryResponse = await getRequest()
|
||||
.get('/api/upc/history?scan_source=manual_entry')
|
||||
.get('/api/v1/upc/history?scan_source=manual_entry')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(filteredHistoryResponse.status).toBe(200);
|
||||
@@ -181,13 +181,13 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
// Step 11: Verify another user cannot see our scans
|
||||
const otherUserEmail = `other-upc-e2e-${uniqueId}@example.com`;
|
||||
await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other UPC 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 };
|
||||
@@ -208,7 +208,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Other user's history should be empty
|
||||
const otherHistoryResponse = await getRequest()
|
||||
.get('/api/upc/history')
|
||||
.get('/api/v1/upc/history')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(otherHistoryResponse.status).toBe(200);
|
||||
@@ -219,7 +219,7 @@ describe('E2E UPC Scanning Journey', () => {
|
||||
|
||||
// Step 12: Delete account (self-service)
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('E2E User Journey', () => {
|
||||
it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', async () => {
|
||||
// 1. Register a new user
|
||||
const registerResponse = await getRequest()
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: userEmail, password: userPassword, full_name: 'E2E Traveler' });
|
||||
|
||||
expect(registerResponse.status).toBe(201);
|
||||
@@ -46,7 +46,7 @@ describe('E2E User Journey', () => {
|
||||
let loginAttempts = 0;
|
||||
while (loginAttempts < 10) {
|
||||
loginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
if (loginResponse.status === 200) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -62,7 +62,7 @@ describe('E2E User Journey', () => {
|
||||
|
||||
// 3. Create a Shopping List
|
||||
const createListResponse = await getRequest()
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'E2E Party List' });
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('E2E User Journey', () => {
|
||||
|
||||
// 5. Verify the list and item exist via GET
|
||||
const getListsResponse = await getRequest()
|
||||
.get('/api/users/shopping-lists')
|
||||
.get('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getListsResponse.status).toBe(200);
|
||||
@@ -94,7 +94,7 @@ describe('E2E User Journey', () => {
|
||||
|
||||
// 6. Delete the User Account (Self-Service)
|
||||
const deleteAccountResponse = await getRequest()
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('E2E User Journey', () => {
|
||||
|
||||
// 7. Verify Login is no longer possible
|
||||
const failLoginResponse = await getRequest()
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: userEmail, password: userPassword, rememberMe: false });
|
||||
|
||||
expect(failLoginResponse.status).toBe(401);
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/stats', () => {
|
||||
it('should allow an admin to fetch application stats', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/stats')
|
||||
.get('/api/v1/admin/stats')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
const stats = response.body.data;
|
||||
// DEBUG: Log response if it fails expectation
|
||||
@@ -77,7 +77,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid a regular user from fetching application stats', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/stats')
|
||||
.get('/api/v1/admin/stats')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
expect(response.status).toBe(403);
|
||||
const errorData = response.body.error;
|
||||
@@ -88,7 +88,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/stats/daily', () => {
|
||||
it('should allow an admin to fetch daily stats', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/stats/daily')
|
||||
.get('/api/v1/admin/stats/daily')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
const dailyStats = response.body.data;
|
||||
expect(dailyStats).toBeDefined();
|
||||
@@ -102,7 +102,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid a regular user from fetching daily stats', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/stats/daily')
|
||||
.get('/api/v1/admin/stats/daily')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
expect(response.status).toBe(403);
|
||||
const errorData = response.body.error;
|
||||
@@ -115,7 +115,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
// This test just verifies access and correct response shape.
|
||||
// More detailed tests would require seeding corrections.
|
||||
const response = await request
|
||||
.get('/api/admin/corrections')
|
||||
.get('/api/v1/admin/corrections')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
const corrections = response.body.data;
|
||||
expect(corrections).toBeDefined();
|
||||
@@ -124,7 +124,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid a regular user from fetching suggested corrections', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/corrections')
|
||||
.get('/api/v1/admin/corrections')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
expect(response.status).toBe(403);
|
||||
const errorData = response.body.error;
|
||||
@@ -135,7 +135,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/brands', () => {
|
||||
it('should allow an admin to fetch all brands', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/brands')
|
||||
.get('/api/v1/admin/brands')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
const brands = response.body.data;
|
||||
expect(brands).toBeDefined();
|
||||
@@ -147,7 +147,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid a regular user from fetching all brands', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/brands')
|
||||
.get('/api/v1/admin/brands')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
expect(response.status).toBe(403);
|
||||
const errorData = response.body.error;
|
||||
@@ -335,7 +335,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/queues/status', () => {
|
||||
it('should return queue status for all queues', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/queues/status')
|
||||
.get('/api/v1/admin/queues/status')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -352,7 +352,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from viewing queue status', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/queues/status')
|
||||
.get('/api/v1/admin/queues/status')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -363,7 +363,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('POST /api/admin/trigger/analytics-report', () => {
|
||||
it('should enqueue an analytics report job', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/analytics-report')
|
||||
.post('/api/v1/admin/trigger/analytics-report')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(202); // 202 Accepted for async job enqueue
|
||||
@@ -373,7 +373,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from triggering analytics report', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/analytics-report')
|
||||
.post('/api/v1/admin/trigger/analytics-report')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -383,7 +383,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('POST /api/admin/trigger/weekly-analytics', () => {
|
||||
it('should enqueue a weekly analytics job', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/weekly-analytics')
|
||||
.post('/api/v1/admin/trigger/weekly-analytics')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(202); // 202 Accepted for async job enqueue
|
||||
@@ -393,7 +393,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from triggering weekly analytics', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/weekly-analytics')
|
||||
.post('/api/v1/admin/trigger/weekly-analytics')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -403,7 +403,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('POST /api/admin/trigger/daily-deal-check', () => {
|
||||
it('should enqueue a daily deal check job', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/daily-deal-check')
|
||||
.post('/api/v1/admin/trigger/daily-deal-check')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(202); // 202 Accepted for async job trigger
|
||||
@@ -413,7 +413,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from triggering daily deal check', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/trigger/daily-deal-check')
|
||||
.post('/api/v1/admin/trigger/daily-deal-check')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -423,7 +423,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('POST /api/admin/system/clear-cache', () => {
|
||||
it('should clear the application cache', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/system/clear-cache')
|
||||
.post('/api/v1/admin/system/clear-cache')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -433,7 +433,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from clearing cache', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/system/clear-cache')
|
||||
.post('/api/v1/admin/system/clear-cache')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -443,7 +443,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('POST /api/admin/jobs/:queue/:id/retry', () => {
|
||||
it('should return validation error for invalid queue name', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/jobs/invalid-queue-name/1/retry')
|
||||
.post('/api/v1/admin/jobs/invalid-queue-name/1/retry')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -453,7 +453,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 404 for non-existent job', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/jobs/flyer-processing/999999999/retry')
|
||||
.post('/api/v1/admin/jobs/flyer-processing/999999999/retry')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -462,7 +462,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from retrying jobs', async () => {
|
||||
const response = await request
|
||||
.post('/api/admin/jobs/flyer-processing/1/retry')
|
||||
.post('/api/v1/admin/jobs/flyer-processing/1/retry')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -473,7 +473,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/users', () => {
|
||||
it('should return all users for admin', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/users')
|
||||
.get('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -487,7 +487,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from listing all users', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/users')
|
||||
.get('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -497,7 +497,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
describe('GET /api/admin/review/flyers', () => {
|
||||
it('should return pending review flyers for admin', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/review/flyers')
|
||||
.get('/api/v1/admin/review/flyers')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -507,7 +507,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
it('should forbid regular users from viewing pending flyers', async () => {
|
||||
const response = await request
|
||||
.get('/api/admin/review/flyers')
|
||||
.get('/api/v1/admin/review/flyers')
|
||||
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/check-flyer should return a boolean', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/check-flyer')
|
||||
.post('/api/v1/ai/check-flyer')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('image', Buffer.from('content'), 'test.jpg');
|
||||
const result = response.body.data;
|
||||
@@ -75,7 +75,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/extract-address should return a stubbed address', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/extract-address')
|
||||
.post('/api/v1/ai/extract-address')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('image', Buffer.from('content'), 'test.jpg');
|
||||
const result = response.body.data;
|
||||
@@ -85,7 +85,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/extract-logo')
|
||||
.post('/api/v1/ai/extract-logo')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('images', Buffer.from('content'), 'test.jpg');
|
||||
const result = response.body.data;
|
||||
@@ -95,7 +95,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [{ item: 'test' }] });
|
||||
const result = response.body.data;
|
||||
@@ -109,7 +109,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/deep-dive')
|
||||
.post('/api/v1/ai/deep-dive')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [{ item: 'test' }] });
|
||||
const result = response.body.data;
|
||||
@@ -123,7 +123,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/ai/search-web should return a stubbed search result', async () => {
|
||||
const response = await request
|
||||
.post('/api/ai/search-web')
|
||||
.post('/api/v1/ai/search-web')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ query: 'test query' });
|
||||
const result = response.body.data;
|
||||
@@ -165,7 +165,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const response = await request
|
||||
.post('/api/ai/plan-trip')
|
||||
.post('/api/v1/ai/plan-trip')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [], store: mockStore, userLocation: mockLocation });
|
||||
// The service for this endpoint is disabled and throws an error, which results in a 500.
|
||||
@@ -182,7 +182,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
// The backend for this is not stubbed and will throw an error.
|
||||
// This test confirms that the endpoint is protected and responds as expected to a failure.
|
||||
const response = await request
|
||||
.post('/api/ai/generate-image')
|
||||
.post('/api/v1/ai/generate-image')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ prompt: 'a test prompt' });
|
||||
expect(response.status).toBe(501);
|
||||
@@ -191,7 +191,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
|
||||
// The backend for this is not stubbed and will throw an error.
|
||||
const response = await request
|
||||
.post('/api/ai/generate-speech')
|
||||
.post('/api/v1/ai/generate-speech')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ text: 'a test prompt' });
|
||||
expect(response.status).toBe(501);
|
||||
@@ -205,7 +205,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
// Send requests up to the limit
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const response = await request
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ items });
|
||||
@@ -214,7 +214,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
// The next request should be blocked
|
||||
const blockedResponse = await request
|
||||
.post('/api/ai/quick-insights')
|
||||
.post('/api/v1/ai/quick-insights')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ items });
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Authentication API Integration', () => {
|
||||
it('should successfully log in a registered user', async () => {
|
||||
// The `rememberMe` parameter is required. For a test, `false` is a safe default.
|
||||
const response = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
|
||||
const data = response.body.data;
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// The loginUser function returns a Response object. We check its status.
|
||||
const response = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: adminEmail, password: wrongPassword, rememberMe: false });
|
||||
expect(response.status).toBe(401);
|
||||
const errorData = response.body.error;
|
||||
@@ -83,7 +83,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// The loginUser function returns a Response object. We check its status.
|
||||
const response = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: nonExistentEmail, password: anyPassword, rememberMe: false });
|
||||
expect(response.status).toBe(401);
|
||||
const errorData = response.body.error;
|
||||
@@ -103,7 +103,7 @@ describe('Authentication API Integration', () => {
|
||||
};
|
||||
|
||||
// Act: Register the new user.
|
||||
const registerResponse = await request.post('/api/auth/register').send(userData);
|
||||
const registerResponse = await request.post('/api/v1/auth/register').send(userData);
|
||||
|
||||
// Assert 1: Check that the registration was successful and the returned profile is correct.
|
||||
expect(registerResponse.status).toBe(201);
|
||||
@@ -117,7 +117,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// Assert 2 (Verification): Fetch the profile using the new token to confirm the value in the DB is null.
|
||||
const profileResponse = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', `Bearer ${registeredToken}`);
|
||||
|
||||
expect(profileResponse.status).toBe(200);
|
||||
@@ -128,7 +128,7 @@ describe('Authentication API Integration', () => {
|
||||
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
|
||||
// This ensures the test is self-contained and not affected by other tests.
|
||||
const loginResponse = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: true });
|
||||
const refreshTokenCookie = loginResponse.headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// Act: Make a request to the refresh-token endpoint, including the cookie.
|
||||
const response = await request
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', refreshTokenCookie!);
|
||||
|
||||
// Assert: Check for a successful response and a new access token.
|
||||
@@ -151,7 +151,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// Act: Make a request to the refresh-token endpoint with the invalid cookie.
|
||||
const response = await request
|
||||
.post('/api/auth/refresh-token')
|
||||
.post('/api/v1/auth/refresh-token')
|
||||
.set('Cookie', invalidRefreshTokenCookie);
|
||||
|
||||
// Assert: Check for a 403 Forbidden response.
|
||||
@@ -163,13 +163,13 @@ describe('Authentication API Integration', () => {
|
||||
it('should successfully log out and clear the refresh token cookie', async () => {
|
||||
// Arrange: Log in to get a valid refresh token cookie.
|
||||
const loginResponse = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: true });
|
||||
const refreshTokenCookie = loginResponse.headers['set-cookie'][0].split(';')[0];
|
||||
expect(refreshTokenCookie).toBeDefined();
|
||||
|
||||
// Act: Make a request to the new logout endpoint, including the cookie.
|
||||
const response = await request.post('/api/auth/logout').set('Cookie', refreshTokenCookie!);
|
||||
const response = await request.post('/api/v1/auth/logout').set('Cookie', refreshTokenCookie!);
|
||||
|
||||
// Assert: Check for a successful response and a cookie-clearing header.
|
||||
expect(response.status).toBe(200);
|
||||
@@ -186,7 +186,7 @@ describe('Authentication API Integration', () => {
|
||||
// Send requests up to the limit. These should all pass.
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const response = await request
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ email });
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
// The next request (the 6th one) should be blocked.
|
||||
const blockedResponse = await request
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
.send({ email });
|
||||
|
||||
@@ -209,14 +209,14 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
describe('Token Edge Cases', () => {
|
||||
it('should reject empty Bearer token', async () => {
|
||||
const response = await request.get('/api/users/profile').set('Authorization', 'Bearer ');
|
||||
const response = await request.get('/api/v1/users/profile').set('Authorization', 'Bearer ');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject token without dots (invalid JWT structure)', async () => {
|
||||
const response = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', 'Bearer notavalidtoken');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -224,7 +224,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
it('should reject token with only 2 parts (missing signature)', async () => {
|
||||
const response = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', 'Bearer header.payload');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -233,7 +233,7 @@ describe('Authentication API Integration', () => {
|
||||
it('should reject token with invalid signature', async () => {
|
||||
// Valid structure but tampered signature
|
||||
const response = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.invalidsig');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -242,13 +242,13 @@ describe('Authentication API Integration', () => {
|
||||
it('should accept lowercase "bearer" scheme (case-insensitive)', async () => {
|
||||
// First get a valid token
|
||||
const loginResponse = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
|
||||
const token = loginResponse.body.data.token;
|
||||
|
||||
// Use lowercase "bearer"
|
||||
const response = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', `bearer ${token}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -256,14 +256,14 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
it('should reject Basic auth scheme', async () => {
|
||||
const response = await request
|
||||
.get('/api/users/profile')
|
||||
.get('/api/v1/users/profile')
|
||||
.set('Authorization', 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject missing Authorization header', async () => {
|
||||
const response = await request.get('/api/users/profile');
|
||||
const response = await request.get('/api/v1/users/profile');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -273,12 +273,12 @@ describe('Authentication API Integration', () => {
|
||||
it('should return same error for wrong password and non-existent user', async () => {
|
||||
// Wrong password for existing user
|
||||
const wrongPassResponse = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: testUserEmail, password: 'wrong-password', rememberMe: false });
|
||||
|
||||
// Non-existent user
|
||||
const nonExistentResponse = await request
|
||||
.post('/api/auth/login')
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'nonexistent@example.com', password: 'any-password', rememberMe: false });
|
||||
|
||||
// Both should return 401 with the same message
|
||||
@@ -291,12 +291,12 @@ describe('Authentication API Integration', () => {
|
||||
it('should return same response for forgot-password on existing and non-existing email', async () => {
|
||||
// Request for existing user
|
||||
const existingResponse = await request
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: testUserEmail });
|
||||
|
||||
// Request for non-existing user
|
||||
const nonExistingResponse = await request
|
||||
.post('/api/auth/forgot-password')
|
||||
.post('/api/v1/auth/forgot-password')
|
||||
.send({ email: 'nonexistent-user@example.com' });
|
||||
|
||||
// Both should return 200 with similar success message (prevents email enumeration)
|
||||
@@ -307,7 +307,7 @@ describe('Authentication API Integration', () => {
|
||||
});
|
||||
|
||||
it('should return validation error for missing login fields', async () => {
|
||||
const response = await request.post('/api/auth/login').send({ email: testUserEmail }); // Missing password
|
||||
const response = await request.post('/api/v1/auth/login').send({ email: testUserEmail }); // Missing password
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -317,7 +317,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
describe('Password Reset', () => {
|
||||
it('should reject reset with invalid token', async () => {
|
||||
const response = await request.post('/api/auth/reset-password').send({
|
||||
const response = await request.post('/api/v1/auth/reset-password').send({
|
||||
token: 'invalid-reset-token',
|
||||
newPassword: TEST_PASSWORD,
|
||||
});
|
||||
@@ -329,7 +329,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
describe('Registration Validation', () => {
|
||||
it('should reject duplicate email registration', async () => {
|
||||
const response = await request.post('/api/auth/register').send({
|
||||
const response = await request.post('/api/v1/auth/register').send({
|
||||
email: testUserEmail, // Already exists
|
||||
password: TEST_PASSWORD,
|
||||
full_name: 'Duplicate User',
|
||||
@@ -341,7 +341,7 @@ describe('Authentication API Integration', () => {
|
||||
});
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const response = await request.post('/api/auth/register').send({
|
||||
const response = await request.post('/api/v1/auth/register').send({
|
||||
email: 'not-an-email',
|
||||
password: TEST_PASSWORD,
|
||||
full_name: 'Invalid Email User',
|
||||
@@ -353,7 +353,7 @@ describe('Authentication API Integration', () => {
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
const response = await request.post('/api/auth/register').send({
|
||||
const response = await request.post('/api/v1/auth/register').send({
|
||||
email: `weak-pass-${Date.now()}@example.com`,
|
||||
password: '123456', // Too weak
|
||||
full_name: 'Weak Password User',
|
||||
@@ -366,7 +366,7 @@ describe('Authentication API Integration', () => {
|
||||
|
||||
describe('Refresh Token Edge Cases', () => {
|
||||
it('should return error when refresh token cookie is missing', async () => {
|
||||
const response = await request.post('/api/auth/refresh-token');
|
||||
const response = await request.post('/api/v1/auth/refresh-token');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error.message).toBe('Refresh token not found.');
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
describe('GET /api/budgets', () => {
|
||||
it('should fetch budgets for the authenticated user', async () => {
|
||||
const response = await request
|
||||
.get('/api/budgets')
|
||||
.get('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -79,7 +79,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request.get('/api/budgets');
|
||||
const response = await request.get('/api/v1/budgets');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(newBudgetData);
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(invalidBudgetData);
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request.post('/api/budgets').send({
|
||||
const response = await request.post('/api/v1/budgets').send({
|
||||
name: 'Unauthorized Budget',
|
||||
amount_cents: 10000,
|
||||
period: 'monthly',
|
||||
@@ -146,7 +146,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should reject period="yearly" (only weekly/monthly allowed)', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Yearly Budget',
|
||||
@@ -162,7 +162,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should reject negative amount_cents', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Negative Budget',
|
||||
@@ -177,7 +177,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should reject invalid date format', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Invalid Date Budget',
|
||||
@@ -192,7 +192,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should require name field', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
amount_cents: 10000,
|
||||
@@ -235,7 +235,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 404 when updating a non-existent budget', async () => {
|
||||
const response = await request
|
||||
.put('/api/budgets/999999')
|
||||
.put('/api/v1/budgets/999999')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Non-existent' });
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const createResponse = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(budgetToDelete);
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
// Verify it's actually deleted
|
||||
const getResponse = await request
|
||||
.get('/api/budgets')
|
||||
.get('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
const budgets: Budget[] = getResponse.body.data;
|
||||
@@ -296,7 +296,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 404 when deleting a non-existent budget', async () => {
|
||||
const response = await request
|
||||
.delete('/api/budgets/999999')
|
||||
.delete('/api/v1/budgets/999999')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -314,7 +314,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
// Note: This test verifies the endpoint works and returns the correct structure.
|
||||
// In a real scenario with seeded shopping trip data, we'd verify actual values.
|
||||
const response = await request
|
||||
.get('/api/budgets/spending-analysis')
|
||||
.get('/api/v1/budgets/spending-analysis')
|
||||
.query({ startDate: '2025-01-01', endDate: '2025-12-31' })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -332,7 +332,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 for invalid date format', async () => {
|
||||
const response = await request
|
||||
.get('/api/budgets/spending-analysis')
|
||||
.get('/api/v1/budgets/spending-analysis')
|
||||
.query({ startDate: 'invalid-date', endDate: '2025-12-31' })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 when required query params are missing', async () => {
|
||||
const response = await request
|
||||
.get('/api/budgets/spending-analysis')
|
||||
.get('/api/v1/budgets/spending-analysis')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -349,7 +349,7 @@ describe('Budget API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request
|
||||
.get('/api/budgets/spending-analysis')
|
||||
.get('/api/v1/budgets/spending-analysis')
|
||||
.query({ startDate: '2025-01-01', endDate: '2025-12-31' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
|
||||
describe('GET /api/categories', () => {
|
||||
it('should return list of all categories', async () => {
|
||||
const response = await request.get('/api/categories');
|
||||
const response = await request.get('/api/v1/categories');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -34,7 +34,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return categories in alphabetical order', async () => {
|
||||
const response = await request.get('/api/categories');
|
||||
const response = await request.get('/api/v1/categories');
|
||||
const categories = response.body.data;
|
||||
|
||||
// Verify alphabetical ordering
|
||||
@@ -46,7 +46,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should include expected categories', async () => {
|
||||
const response = await request.get('/api/categories');
|
||||
const response = await request.get('/api/v1/categories');
|
||||
const categories = response.body.data;
|
||||
const categoryNames = categories.map((c: { name: string }) => c.name);
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
describe('GET /api/categories/:id', () => {
|
||||
it('should return specific category by valid ID', async () => {
|
||||
// First get all categories to find a valid ID
|
||||
const listResponse = await request.get('/api/categories');
|
||||
const listResponse = await request.get('/api/v1/categories');
|
||||
const firstCategory = listResponse.body.data[0];
|
||||
|
||||
const response = await request.get(`/api/categories/${firstCategory.category_id}`);
|
||||
@@ -73,7 +73,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent category ID', async () => {
|
||||
const response = await request.get('/api/categories/999999');
|
||||
const response = await request.get('/api/v1/categories/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -81,7 +81,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for invalid category ID (not a number)', async () => {
|
||||
const response = await request.get('/api/categories/invalid');
|
||||
const response = await request.get('/api/v1/categories/invalid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -89,7 +89,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for negative category ID', async () => {
|
||||
const response = await request.get('/api/categories/-1');
|
||||
const response = await request.get('/api/v1/categories/-1');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -97,7 +97,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for zero category ID', async () => {
|
||||
const response = await request.get('/api/categories/0');
|
||||
const response = await request.get('/api/v1/categories/0');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -107,7 +107,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
|
||||
describe('GET /api/categories/lookup', () => {
|
||||
it('should find category by exact name', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=Dairy%20%26%20Eggs');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -116,7 +116,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should find category by case-insensitive name', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=dairy%20%26%20eggs');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=dairy%20%26%20eggs');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -124,7 +124,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should find category with mixed case', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=DaIrY%20%26%20eGgS');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=DaIrY%20%26%20eGgS');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -132,7 +132,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent category name', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=NonExistentCategory');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=NonExistentCategory');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -140,7 +140,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if name parameter is missing', async () => {
|
||||
const response = await request.get('/api/categories/lookup');
|
||||
const response = await request.get('/api/v1/categories/lookup');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -148,7 +148,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for empty name parameter', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -156,7 +156,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should return 400 for whitespace-only name parameter', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name= ');
|
||||
const response = await request.get('/api/v1/categories/lookup?name= ');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -164,7 +164,7 @@ describe('Category API Routes (Integration)', () => {
|
||||
});
|
||||
|
||||
it('should handle URL-encoded category names', async () => {
|
||||
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
|
||||
const response = await request.get('/api/v1/categories/lookup?name=Dairy%20%26%20Eggs');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Create some shopping lists
|
||||
const listResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'Cascade Test List' });
|
||||
expect(listResponse.status).toBe(201);
|
||||
@@ -65,7 +65,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Delete the user account
|
||||
const deleteResponse = await request
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ password: TEST_PASSWORD });
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
@@ -88,7 +88,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Create a budget
|
||||
const budgetResponse = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: 'Cascade Test Budget',
|
||||
@@ -108,7 +108,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Delete the user account
|
||||
const deleteResponse = await request
|
||||
.delete('/api/users/account')
|
||||
.delete('/api/v1/users/account')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ password: TEST_PASSWORD });
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
@@ -131,7 +131,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Create a shopping list
|
||||
const listResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'Item Cascade List' });
|
||||
expect(listResponse.status).toBe(201);
|
||||
@@ -194,7 +194,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Try to add item to non-existent list
|
||||
const response = await request
|
||||
.post('/api/users/shopping-lists/999999/items')
|
||||
.post('/api/v1/users/shopping-lists/999999/items')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ customItemName: 'Invalid List Item', quantity: 1 });
|
||||
|
||||
@@ -227,13 +227,13 @@ describe('Data Integrity Integration Tests', () => {
|
||||
|
||||
// Register first user
|
||||
const firstResponse = await request
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: 'First User' });
|
||||
expect(firstResponse.status).toBe(201);
|
||||
|
||||
// Try to register second user with same email
|
||||
const secondResponse = await request
|
||||
.post('/api/auth/register')
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email, password: TEST_PASSWORD, full_name: 'Second User' });
|
||||
|
||||
expect(secondResponse.status).toBe(409); // CONFLICT
|
||||
@@ -255,7 +255,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
});
|
||||
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: 'Invalid Period Budget',
|
||||
@@ -279,7 +279,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
});
|
||||
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: 'Negative Amount Budget',
|
||||
@@ -331,7 +331,7 @@ describe('Data Integrity Integration Tests', () => {
|
||||
});
|
||||
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
// name is missing - required field
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('Deals API Routes Integration Tests', () => {
|
||||
|
||||
describe('GET /api/deals/best-watched-prices', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request.get('/api/deals/best-watched-prices');
|
||||
const response = await request.get('/api/v1/deals/best-watched-prices');
|
||||
|
||||
// Passport returns 401 Unauthorized for unauthenticated requests
|
||||
expect(response.status).toBe(401);
|
||||
@@ -49,7 +49,7 @@ describe('Deals API Routes Integration Tests', () => {
|
||||
it('should return empty array for authenticated user with no watched items', async () => {
|
||||
// The test user has no watched items by default, so should get empty array
|
||||
const response = await request
|
||||
.get('/api/deals/best-watched-prices')
|
||||
.get('/api/v1/deals/best-watched-prices')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -59,7 +59,7 @@ describe('Deals API Routes Integration Tests', () => {
|
||||
|
||||
it('should reject invalid JWT token', async () => {
|
||||
const response = await request
|
||||
.get('/api/deals/best-watched-prices')
|
||||
.get('/api/v1/deals/best-watched-prices')
|
||||
.set('Authorization', 'Bearer invalid.token.here');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -67,7 +67,7 @@ describe('Deals API Routes Integration Tests', () => {
|
||||
|
||||
it('should reject missing Bearer prefix', async () => {
|
||||
const response = await request
|
||||
.get('/api/deals/best-watched-prices')
|
||||
.get('/api/v1/deals/best-watched-prices')
|
||||
.set('Authorization', authToken);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
}
|
||||
|
||||
const response = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('flyerFile', testImagePath);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
}
|
||||
|
||||
const response = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('flyerFile', testImagePath)
|
||||
.field('checksum', 'not-a-valid-hex-checksum-at-all!!!!');
|
||||
@@ -96,7 +96,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
}
|
||||
|
||||
const response = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('flyerFile', testImagePath)
|
||||
.field('checksum', 'abc123'); // Too short
|
||||
@@ -111,7 +111,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
const checksum = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const response = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.field('checksum', checksum);
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
describe('Shopping List Names', () => {
|
||||
it('should accept unicode characters and emojis', async () => {
|
||||
const response = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Grocery List 🛒 日本語 émoji' });
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it('should store XSS payloads as-is (frontend must escape)', async () => {
|
||||
const xssPayload = '<script>alert("xss")</script>';
|
||||
const response = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: xssPayload });
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it('should reject null bytes in JSON', async () => {
|
||||
// Null bytes in JSON should be rejected by the JSON parser
|
||||
const response = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"name":"test\u0000value"}');
|
||||
@@ -175,7 +175,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it("should return 404 (not 403) for accessing another user's shopping list", async () => {
|
||||
// Create a shopping list as the primary user
|
||||
const createResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Private List' });
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it("should return 404 when trying to update another user's shopping list", async () => {
|
||||
// Create a shopping list as the primary user
|
||||
const createResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Another Private List' });
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it("should return 404 when trying to delete another user's shopping list", async () => {
|
||||
// Create a shopping list as the primary user
|
||||
const createResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Delete Test List' });
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it('should safely handle SQL injection in query params', async () => {
|
||||
// Attempt SQL injection in limit param
|
||||
const response = await request
|
||||
.get('/api/personalization/master-items')
|
||||
.get('/api/v1/personalization/master-items')
|
||||
.query({ limit: '10; DROP TABLE users; --' });
|
||||
|
||||
// Should either return normal data or a validation error, not crash
|
||||
@@ -250,7 +250,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
it('should safely handle SQL injection in search params', async () => {
|
||||
// Attempt SQL injection in flyer search
|
||||
const response = await request.get('/api/flyers').query({
|
||||
const response = await request.get('/api/v1/flyers').query({
|
||||
search: "'; DROP TABLE flyers; --",
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
describe('API Error Handling', () => {
|
||||
it('should return 404 for non-existent resources with clear message', async () => {
|
||||
const response = await request
|
||||
.get('/api/flyers/99999999')
|
||||
.get('/api/v1/flyers/99999999')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -273,7 +273,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
it('should return validation error for malformed JSON body', async () => {
|
||||
const response = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{ invalid json }');
|
||||
@@ -283,7 +283,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
it('should return validation error for missing required fields', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({}); // Empty body
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
it('should return validation error for invalid data types', async () => {
|
||||
const response = await request
|
||||
.post('/api/budgets')
|
||||
.post('/api/v1/budgets')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Test Budget',
|
||||
@@ -313,7 +313,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
// Create 5 shopping lists concurrently
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request
|
||||
.post('/api/users/shopping-lists')
|
||||
.post('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: `Concurrent List ${i + 1}` }),
|
||||
);
|
||||
@@ -331,7 +331,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
|
||||
// Verify all lists were created
|
||||
const listResponse = await request
|
||||
.get('/api/users/shopping-lists')
|
||||
.get('/api/v1/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
@@ -345,7 +345,7 @@ describe('Edge Cases Integration Tests', () => {
|
||||
it('should handle concurrent reads without errors', async () => {
|
||||
// Make 10 concurrent read requests
|
||||
const promises = Array.from({ length: 10 }, () =>
|
||||
request.get('/api/personalization/master-items'),
|
||||
request.get('/api/v1/personalization/master-items'),
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
console.error('[TEST ACTION] Uploading file with baseUrl:', testBaseUrl);
|
||||
|
||||
const uploadReq = request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('checksum', checksum)
|
||||
// Pass the baseUrl directly in the form data to ensure the worker receives it,
|
||||
// bypassing issues with vi.stubEnv in multi-threaded test environments.
|
||||
@@ -426,7 +426,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// 2. Act: Upload the file and wait for processing
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.field('baseUrl', 'https://example.com')
|
||||
.field('checksum', checksum)
|
||||
@@ -552,7 +552,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// 2. Act: Upload the file and wait for processing
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.field('baseUrl', 'https://example.com')
|
||||
.field('checksum', checksum)
|
||||
@@ -688,7 +688,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('baseUrl', 'https://example.com')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
@@ -776,7 +776,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('baseUrl', 'https://example.com')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
@@ -843,7 +843,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// Act 1: Upload the file to start the background job.
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.field('baseUrl', 'https://example.com')
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
[createdFlyerId],
|
||||
);
|
||||
|
||||
const response = await request.get('/api/flyers');
|
||||
const response = await request.get('/api/v1/flyers');
|
||||
flyers = response.body.data;
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
describe('GET /api/flyers', () => {
|
||||
it('should return a list of flyers', async () => {
|
||||
// Act: Call the API endpoint using the client function.
|
||||
const response = await request.get('/api/flyers');
|
||||
const response = await request.get('/api/v1/flyers');
|
||||
const flyers: Flyer[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(flyers).toBeInstanceOf(Array);
|
||||
@@ -121,7 +121,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
expect(flyerIds.length).toBeGreaterThan(0);
|
||||
|
||||
// Act: Fetch items for all available flyers.
|
||||
const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds });
|
||||
const response = await request.post('/api/v1/flyers/items/batch-fetch').send({ flyerIds });
|
||||
const items: FlyerItem[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(items).toBeInstanceOf(Array);
|
||||
@@ -139,7 +139,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
expect(flyerIds.length).toBeGreaterThan(0);
|
||||
|
||||
// Act
|
||||
const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds });
|
||||
const response = await request.post('/api/v1/flyers/items/batch-count').send({ flyerIds });
|
||||
const result = response.body.data;
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
);
|
||||
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.field('checksum', checksum)
|
||||
.field('baseUrl', testBaseUrl)
|
||||
@@ -234,7 +234,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
createdFilePaths.push(savedImagePath);
|
||||
|
||||
// --- Act 3: Fetch the user's achievements (triggers endpoint, response not needed) ---
|
||||
await request.get('/api/achievements/me').set('Authorization', `Bearer ${authToken}`);
|
||||
await request.get('/api/v1/achievements/me').set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
// --- Assert 2: Verify the "First-Upload" achievement was awarded ---
|
||||
// The 'Welcome Aboard' achievement is awarded on user creation, so we expect at least two.
|
||||
@@ -261,7 +261,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
expect(firstUploadAchievement?.points_value).toBeGreaterThan(0);
|
||||
|
||||
// --- Act 4: Fetch the leaderboard ---
|
||||
const leaderboardResponse = await request.get('/api/achievements/leaderboard');
|
||||
const leaderboardResponse = await request.get('/api/v1/achievements/leaderboard');
|
||||
const leaderboard: LeaderboardUser[] = leaderboardResponse.body.data;
|
||||
|
||||
// --- Assert 3: Verify the user is on the leaderboard with points ---
|
||||
@@ -309,7 +309,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
// Note: This assumes a legacy endpoint exists at `/api/ai/upload-legacy`.
|
||||
// This endpoint would be responsible for calling `aiService.processLegacyFlyerUpload`.
|
||||
const response = await request
|
||||
.post('/api/ai/upload-legacy')
|
||||
.post('/api/v1/ai/upload-legacy')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.field('data', JSON.stringify(legacyPayload))
|
||||
.attach('flyerFile', imageBuffer, uniqueFileName);
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Milk 2%', // Note: API uses master_item_id to resolve name from master_grocery_items
|
||||
@@ -115,7 +115,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should add item without expiry date', async () => {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Rice',
|
||||
@@ -139,7 +139,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
.split('T')[0];
|
||||
const purchaseDate = new Date().toISOString().split('T')[0];
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Cheese',
|
||||
@@ -161,7 +161,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should reject invalid location', async () => {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Test Item',
|
||||
@@ -175,7 +175,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should reject missing item_name', async () => {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
quantity: 1,
|
||||
@@ -187,7 +187,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
});
|
||||
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
const response = await request.post('/api/inventory').send({
|
||||
const response = await request.post('/api/v1/inventory').send({
|
||||
item_name: 'Test Item',
|
||||
quantity: 1,
|
||||
location: 'fridge',
|
||||
@@ -210,7 +210,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
for (const item of items) {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: item.name,
|
||||
@@ -229,7 +229,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should return all inventory items', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -241,7 +241,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should filter by location', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.query({ location: 'fridge' })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should support pagination', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.query({ limit: 2, offset: 0 })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
// Note: expiry_status is computed server-side based on best_before_date, not a query filter
|
||||
// This test verifies that items created in this test suite with future dates have correct status
|
||||
const response = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -296,7 +296,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
createdUserIds.push(otherUser.user.user_id);
|
||||
|
||||
const response = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -313,7 +313,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Single Item Test', // Note: API resolves name from master_item_id
|
||||
@@ -343,7 +343,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory/999999')
|
||||
.get('/api/v1/inventory/999999')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -370,7 +370,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Update Test Item',
|
||||
@@ -440,7 +440,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
it('should delete an inventory item', async () => {
|
||||
// Create item to delete
|
||||
const createResponse = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Delete Test Item',
|
||||
@@ -473,7 +473,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Consume Test Item',
|
||||
@@ -512,7 +512,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
it('should return 404 for already consumed or non-existent item', async () => {
|
||||
// Create new item to test double consumption
|
||||
const createResponse = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Double Consume Test',
|
||||
@@ -565,7 +565,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
for (const item of items) {
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: item.name,
|
||||
@@ -584,7 +584,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should return items expiring within default days', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory/expiring')
|
||||
.get('/api/v1/inventory/expiring')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -595,7 +595,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
it('should respect days parameter', async () => {
|
||||
// Note: The API uses "days" not "days_ahead" parameter
|
||||
const response = await request
|
||||
.get('/api/inventory/expiring')
|
||||
.get('/api/v1/inventory/expiring')
|
||||
.query({ days: 2 })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -610,7 +610,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
// The API handles pantry_locations and item creation properly
|
||||
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const response = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Expired Item',
|
||||
@@ -629,7 +629,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should return expired items', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory/expired')
|
||||
.get('/api/v1/inventory/expired')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -647,7 +647,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
describe('GET /api/inventory/alerts', () => {
|
||||
it('should return alert settings', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory/alerts')
|
||||
.get('/api/v1/inventory/alerts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -659,7 +659,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
describe('PUT /api/inventory/alerts/:alertMethod', () => {
|
||||
it('should update alert settings for email method', async () => {
|
||||
const response = await request
|
||||
.put('/api/inventory/alerts/email')
|
||||
.put('/api/v1/inventory/alerts/email')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
is_enabled: true,
|
||||
@@ -672,7 +672,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should reject invalid days_before_expiry', async () => {
|
||||
const response = await request
|
||||
.put('/api/inventory/alerts/email')
|
||||
.put('/api/v1/inventory/alerts/email')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
days_before_expiry: 0, // Must be at least 1
|
||||
@@ -683,7 +683,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
it('should reject invalid alert method', async () => {
|
||||
const response = await request
|
||||
.put('/api/inventory/alerts/invalid_method')
|
||||
.put('/api/v1/inventory/alerts/invalid_method')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
days_before_expiry: 5,
|
||||
@@ -697,7 +697,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
describe('GET /api/inventory/recipes/suggestions - Recipe Suggestions', () => {
|
||||
it('should return recipe suggestions for expiring items', async () => {
|
||||
const response = await request
|
||||
.get('/api/inventory/recipes/suggestions')
|
||||
.get('/api/v1/inventory/recipes/suggestions')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -710,7 +710,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
it('should handle full add-track-consume workflow', async () => {
|
||||
// Step 1: Add item
|
||||
const addResponse = await request
|
||||
.post('/api/inventory')
|
||||
.post('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
item_name: 'Workflow Test Item',
|
||||
@@ -728,7 +728,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
// Step 2: Verify in list
|
||||
const listResponse = await request
|
||||
.get('/api/inventory')
|
||||
.get('/api/v1/inventory')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
const found = listResponse.body.data.items.find(
|
||||
@@ -738,7 +738,7 @@ describe('Inventory/Expiry Integration Tests (/api/inventory)', () => {
|
||||
|
||||
// Step 3: Check in expiring items (using correct param name: days)
|
||||
const expiringResponse = await request
|
||||
.get('/api/inventory/expiring')
|
||||
.get('/api/v1/inventory/expiring')
|
||||
.query({ days: 10 })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
describe('GET /api/users/notifications', () => {
|
||||
it('should fetch unread notifications for the authenticated user by default', async () => {
|
||||
const response = await request
|
||||
.get('/api/users/notifications')
|
||||
.get('/api/v1/users/notifications')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -69,7 +69,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
|
||||
it('should fetch all notifications when includeRead=true', async () => {
|
||||
const response = await request
|
||||
.get('/api/users/notifications?includeRead=true')
|
||||
.get('/api/v1/users/notifications?includeRead=true')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -80,7 +80,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
it('should respect pagination with limit and offset', async () => {
|
||||
// Fetch with limit=1, should get the latest unread notification
|
||||
const response1 = await request
|
||||
.get('/api/users/notifications?limit=1')
|
||||
.get('/api/v1/users/notifications?limit=1')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
@@ -90,7 +90,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
|
||||
// Fetch with limit=1 and offset=1, should get the older unread notification
|
||||
const response2 = await request
|
||||
.get('/api/users/notifications?limit=1&offset=1')
|
||||
.get('/api/v1/users/notifications?limit=1&offset=1')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
@@ -100,7 +100,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if user is not authenticated', async () => {
|
||||
const response = await request.get('/api/users/notifications');
|
||||
const response = await request.get('/api/v1/users/notifications');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
describe('POST /api/users/notifications/mark-all-read', () => {
|
||||
it('should mark all unread notifications as read', async () => {
|
||||
const response = await request
|
||||
.post('/api/users/notifications/mark-all-read')
|
||||
.post('/api/v1/users/notifications/mark-all-read')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
@@ -149,7 +149,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
describe('Job Status Polling', () => {
|
||||
describe('GET /api/ai/jobs/:id/status', () => {
|
||||
it('should return 404 for non-existent job', async () => {
|
||||
const response = await request.get('/api/ai/jobs/nonexistent-job-id/status');
|
||||
const response = await request.get('/api/v1/ai/jobs/nonexistent-job-id/status');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -159,7 +159,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
it('should be accessible without authentication (public endpoint)', async () => {
|
||||
// This verifies that job status can be polled without auth
|
||||
// This is important for UX where users may poll status from frontend
|
||||
const response = await request.get('/api/ai/jobs/test-job-123/status');
|
||||
const response = await request.get('/api/v1/ai/jobs/test-job-123/status');
|
||||
|
||||
// Should return 404 (job not found) rather than 401 (unauthorized)
|
||||
expect(response.status).toBe(404);
|
||||
@@ -195,7 +195,7 @@ describe('Notification API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 404 for non-existent notification', async () => {
|
||||
const response = await request
|
||||
.delete('/api/users/notifications/999999')
|
||||
.delete('/api/v1/users/notifications/999999')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
|
||||
it('should return the correct price history for a given master item ID', async () => {
|
||||
const response = await request
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ masterItemIds: [masterItemId] });
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
|
||||
it('should respect the limit parameter', async () => {
|
||||
const response = await request
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ masterItemIds: [masterItemId], limit: 2 });
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
|
||||
it('should respect the offset parameter', async () => {
|
||||
const response = await request
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ masterItemIds: [masterItemId], limit: 2, offset: 1 });
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
|
||||
it('should return price history sorted by date in ascending order', async () => {
|
||||
const response = await request
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ masterItemIds: [masterItemId] });
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
|
||||
it('should return an empty array for a master item ID with no price history', async () => {
|
||||
const response = await request
|
||||
.post('/api/price-history')
|
||||
.post('/api/v1/price-history')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ masterItemIds: [999999] });
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -109,31 +109,31 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
describe('Health Check Endpoints', () => {
|
||||
it('GET /api/health/ping should return "pong"', async () => {
|
||||
const response = await request.get('/api/health/ping');
|
||||
const response = await request.get('/api/v1/health/ping');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.message).toBe('pong');
|
||||
});
|
||||
|
||||
it('GET /api/health/db-schema should return success', async () => {
|
||||
const response = await request.get('/api/health/db-schema');
|
||||
const response = await request.get('/api/v1/health/db-schema');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/health/storage should return success', async () => {
|
||||
const response = await request.get('/api/health/storage');
|
||||
const response = await request.get('/api/v1/health/storage');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/health/db-pool should return success', async () => {
|
||||
const response = await request.get('/api/health/db-pool');
|
||||
const response = await request.get('/api/v1/health/db-pool');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/health/time should return the server time', async () => {
|
||||
const response = await request.get('/api/health/time');
|
||||
const response = await request.get('/api/v1/health/time');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toHaveProperty('currentTime');
|
||||
expect(response.body.data).toHaveProperty('year');
|
||||
@@ -143,7 +143,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
describe('Public Data Endpoints', () => {
|
||||
it('GET /api/flyers should return a list of flyers', async () => {
|
||||
const response = await request.get('/api/flyers');
|
||||
const response = await request.get('/api/v1/flyers');
|
||||
const flyers: Flyer[] = response.body.data;
|
||||
expect(flyers.length).toBeGreaterThan(0);
|
||||
const foundFlyer = flyers.find((f) => f.flyer_id === testFlyer.flyer_id);
|
||||
@@ -162,7 +162,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/flyers/items/batch-fetch should return items for multiple flyers', async () => {
|
||||
const flyerIds = [testFlyer.flyer_id];
|
||||
const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds });
|
||||
const response = await request.post('/api/v1/flyers/items/batch-fetch').send({ flyerIds });
|
||||
const items: FlyerItem[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(items).toBeInstanceOf(Array);
|
||||
@@ -171,14 +171,14 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
it('POST /api/flyers/items/batch-count should return a count for multiple flyers', async () => {
|
||||
const flyerIds = [testFlyer.flyer_id];
|
||||
const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds });
|
||||
const response = await request.post('/api/v1/flyers/items/batch-count').send({ flyerIds });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.count).toBeTypeOf('number');
|
||||
expect(response.body.data.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('GET /api/personalization/master-items should return a list of master grocery items', async () => {
|
||||
const response = await request.get('/api/personalization/master-items');
|
||||
const response = await request.get('/api/v1/personalization/master-items');
|
||||
expect(response.status).toBe(200);
|
||||
// The endpoint returns { items: [...], total: N } for pagination support
|
||||
expect(response.body.data).toHaveProperty('items');
|
||||
@@ -190,7 +190,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('GET /api/recipes/by-sale-percentage should return recipes', async () => {
|
||||
const response = await request.get('/api/recipes/by-sale-percentage?minPercentage=10');
|
||||
const response = await request.get('/api/v1/recipes/by-sale-percentage?minPercentage=10');
|
||||
const recipes: Recipe[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(recipes).toBeInstanceOf(Array);
|
||||
@@ -199,7 +199,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
it('GET /api/recipes/by-ingredient-and-tag should return recipes', async () => {
|
||||
// This test is now less brittle. It might return our created recipe or others.
|
||||
const response = await request.get(
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=Test&tag=Public',
|
||||
'/api/v1/recipes/by-ingredient-and-tag?ingredient=Test&tag=Public',
|
||||
);
|
||||
const recipes: Recipe[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
@@ -222,7 +222,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('GET /api/stats/most-frequent-sales should return frequent items', async () => {
|
||||
const response = await request.get('/api/stats/most-frequent-sales?days=365&limit=5');
|
||||
const response = await request.get('/api/v1/stats/most-frequent-sales?days=365&limit=5');
|
||||
const items = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(items).toBeInstanceOf(Array);
|
||||
@@ -230,7 +230,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
it('GET /api/personalization/dietary-restrictions should return a list of restrictions', async () => {
|
||||
// This test relies on static seed data for a lookup table, which is acceptable.
|
||||
const response = await request.get('/api/personalization/dietary-restrictions');
|
||||
const response = await request.get('/api/v1/personalization/dietary-restrictions');
|
||||
const restrictions: DietaryRestriction[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(restrictions).toBeInstanceOf(Array);
|
||||
@@ -239,7 +239,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('GET /api/personalization/appliances should return a list of appliances', async () => {
|
||||
const response = await request.get('/api/personalization/appliances');
|
||||
const response = await request.get('/api/v1/personalization/appliances');
|
||||
const appliances: Appliance[] = response.body.data;
|
||||
expect(response.status).toBe(200);
|
||||
expect(appliances).toBeInstanceOf(Array);
|
||||
@@ -256,7 +256,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
|
||||
for (let i = 0; i < maxRequests; i++) {
|
||||
const response = await request
|
||||
.get('/api/personalization/master-items')
|
||||
.get('/api/v1/personalization/master-items')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true'); // Enable rate limiter middleware
|
||||
|
||||
if (response.status === 429) {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
describe('GET /api/reactions', () => {
|
||||
it('should return reactions (public endpoint)', async () => {
|
||||
const response = await request.get('/api/reactions');
|
||||
const response = await request.get('/api/v1/reactions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -72,7 +72,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should filter reactions by entityType', async () => {
|
||||
const response = await request.get('/api/reactions').query({ entityType: 'recipe' });
|
||||
const response = await request.get('/api/v1/reactions').query({ entityType: 'recipe' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -81,7 +81,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should filter reactions by entityId', async () => {
|
||||
const response = await request
|
||||
.get('/api/reactions')
|
||||
.get('/api/v1/reactions')
|
||||
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -93,7 +93,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
describe('GET /api/reactions/summary', () => {
|
||||
it('should return reaction summary for an entity', async () => {
|
||||
const response = await request
|
||||
.get('/api/reactions/summary')
|
||||
.get('/api/v1/reactions/summary')
|
||||
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -104,7 +104,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 when entityType is missing', async () => {
|
||||
const response = await request
|
||||
.get('/api/reactions/summary')
|
||||
.get('/api/v1/reactions/summary')
|
||||
.query({ entityId: String(testRecipeId) });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -112,7 +112,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when entityId is missing', async () => {
|
||||
const response = await request.get('/api/reactions/summary').query({ entityType: 'recipe' });
|
||||
const response = await request.get('/api/v1/reactions/summary').query({ entityType: 'recipe' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
@@ -121,7 +121,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
describe('POST /api/reactions/toggle', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request.post('/api/reactions/toggle').send({
|
||||
const response = await request.post('/api/v1/reactions/toggle').send({
|
||||
entity_type: 'recipe',
|
||||
entity_id: String(testRecipeId),
|
||||
reaction_type: 'like',
|
||||
@@ -132,7 +132,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should add a reaction when none exists', async () => {
|
||||
const response = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
@@ -154,7 +154,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
it('should remove the reaction when toggled again', async () => {
|
||||
// First add the reaction
|
||||
const addResponse = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
@@ -169,7 +169,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
// Then toggle it off
|
||||
const removeResponse = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
@@ -184,7 +184,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 for missing entity_type', async () => {
|
||||
const response = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_id: String(testRecipeId),
|
||||
@@ -197,7 +197,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 for missing entity_id', async () => {
|
||||
const response = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
@@ -210,7 +210,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
|
||||
it('should return 400 for missing reaction_type', async () => {
|
||||
const response = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
@@ -224,7 +224,7 @@ describe('Reactions API Routes Integration Tests', () => {
|
||||
it('should accept entity_id as string (required format)', async () => {
|
||||
// entity_id must be a string per the Zod schema
|
||||
const response = await request
|
||||
.post('/api/reactions/toggle')
|
||||
.post('/api/v1/reactions/toggle')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
entity_type: 'recipe',
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
);
|
||||
|
||||
const response = await request
|
||||
.post('/api/receipts')
|
||||
.post('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('receipt', testImageBuffer, 'test-receipt.png')
|
||||
.field('store_location_id', testStoreLocationId.toString())
|
||||
@@ -229,7 +229,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
);
|
||||
|
||||
const response = await request
|
||||
.post('/api/receipts')
|
||||
.post('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('receipt', testImageBuffer, 'test-receipt-2.png');
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should reject request without file', async () => {
|
||||
const response = await request
|
||||
.post('/api/receipts')
|
||||
.post('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -257,7 +257,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
);
|
||||
|
||||
const response = await request
|
||||
.post('/api/receipts')
|
||||
.post('/api/v1/receipts')
|
||||
.attach('receipt', testImageBuffer, 'test-receipt.png');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -285,7 +285,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should return paginated list of receipts', async () => {
|
||||
const response = await request
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -297,7 +297,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should support status filter', async () => {
|
||||
const response = await request
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.query({ status: 'completed' })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should support pagination', async () => {
|
||||
const response = await request
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.query({ limit: 2, offset: 0 })
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
@@ -327,7 +327,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
createdUserIds.push(otherUser.user.user_id);
|
||||
|
||||
const response = await request
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${otherToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -386,7 +386,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should return 404 for non-existent receipt', async () => {
|
||||
const response = await request
|
||||
.get('/api/receipts/999999')
|
||||
.get('/api/v1/receipts/999999')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -463,7 +463,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
it('should return 404 for non-existent receipt', async () => {
|
||||
const response = await request
|
||||
.post('/api/receipts/999999/reprocess')
|
||||
.post('/api/v1/receipts/999999/reprocess')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -692,7 +692,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
);
|
||||
|
||||
const uploadResponse = await request
|
||||
.post('/api/receipts')
|
||||
.post('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('receipt', testImageBuffer, 'workflow-test.png')
|
||||
.field('transaction_date', '2024-01-20');
|
||||
@@ -711,7 +711,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
||||
|
||||
// Step 3: Check it appears in list
|
||||
const listResponse = await request
|
||||
.get('/api/receipts')
|
||||
.get('/api/v1/receipts')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent recipe ID', async () => {
|
||||
const response = await request.get('/api/recipes/999999');
|
||||
const response = await request.get('/api/v1/recipes/999999');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.post('/api/users/recipes')
|
||||
.post('/api/v1/users/recipes')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(newRecipeData);
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
it('should allow an authenticated user to delete their own recipe', async () => {
|
||||
// Create a recipe specifically for deletion
|
||||
const createRes = await request
|
||||
.post('/api/users/recipes')
|
||||
.post('/api/v1/users/recipes')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Recipe To Delete',
|
||||
@@ -294,7 +294,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
it('should return empty array for recipe with no comments', async () => {
|
||||
// Create a recipe specifically with no comments
|
||||
const createRes = await request
|
||||
.post('/api/users/recipes')
|
||||
.post('/api/v1/users/recipes')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
name: 'Recipe With No Comments',
|
||||
@@ -321,7 +321,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
|
||||
|
||||
const response = await request
|
||||
.post('/api/recipes/suggest')
|
||||
.post('/api/v1/recipes/suggest')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ ingredients });
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user