Compare commits

..

6 Commits

Author SHA1 Message Date
Gitea Actions
1fcb9fd5c7 ci: Bump version to 0.12.6 [skip ci] 2026-01-22 03:41:25 +05:00
8bd4e081ea e2e fixin, frontend + home page work
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m0s
2026-01-21 14:40:19 -08:00
Gitea Actions
6e13570deb ci: Bump version to 0.12.5 [skip ci] 2026-01-22 01:36:01 +05:00
2eba66fb71 make e2e actually e2e - sigh
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m9s
2026-01-21 12:34:46 -08:00
Gitea Actions
10cdd78e22 ci: Bump version to 0.12.4 [skip ci] 2026-01-22 00:47:30 +05:00
521943bec0 make e2e actually e2e - sigh
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m55s
2026-01-21 11:43:39 -08:00
26 changed files with 1477 additions and 931 deletions

154
CLAUDE.md
View File

@@ -424,11 +424,24 @@ psql -d "flyer-crawler-test" -c "\dn+ public"
The dev container runs its own **local Bugsink instance** - it does NOT connect to the production Bugsink server:
- **Local Bugsink**: Runs at `http://localhost:8000` inside the container
- **Pre-configured DSNs**: Set in `compose.dev.yml`, pointing to local instance
- **Local Bugsink UI**: Accessible at `https://localhost:8443` (proxied from `http://localhost:8000` by nginx)
- **Admin credentials**: `admin@localhost` / `admin`
- **Bugsink Projects**: Backend (Dev) - Project ID 1, Frontend (Dev) - Project ID 2
- **Configuration Files**:
- `compose.dev.yml` - Sets default DSNs using `127.0.0.1:8000` protocol (for initial container setup)
- `.env.local` - **OVERRIDES** compose.dev.yml with `localhost:8000` protocol (this is what the app actually uses)
- **CRITICAL**: `.env.local` takes precedence over `compose.dev.yml` environment variables
- **DSN Configuration**:
- **Backend DSN** (Node.js/Express): Configured in `.env.local` as `SENTRY_DSN=http://<key>@localhost:8000/1`
- **Frontend DSN** (React/Browser): Configured in `.env.local` as `VITE_SENTRY_DSN=http://<key>@localhost:8000/2`
- **Why localhost instead of 127.0.0.1?** The `.env.local` file was created separately and uses `localhost` which works fine in practice
- **HTTPS Setup**: Self-signed certificates auto-generated with mkcert on container startup (for UI access only, not for Sentry SDK)
- **CSRF Protection**: Django configured with `SECURE_PROXY_SSL_HEADER` to trust `X-Forwarded-Proto` from nginx
- **Isolated**: Dev errors stay local, don't pollute production/test dashboards
- **No Gitea secrets needed**: Everything is self-contained in the container
- **Accessing Errors**:
- **Via Browser**: Open `https://localhost:8443` and login to view issues
- **Via MCP**: Configure a second Bugsink MCP server pointing to `http://localhost:8000` (see MCP Servers section below)
---
@@ -436,64 +449,105 @@ The dev container runs its own **local Bugsink instance** - it does NOT connect
The following MCP servers are configured for this project:
| Server | Purpose |
| --------------------- | ------------------------------------------- |
| gitea-projectium | Gitea API for gitea.projectium.com |
| gitea-torbonium | Gitea API for gitea.torbonium.com |
| podman | Container management |
| filesystem | File system access |
| fetch | Web fetching |
| markitdown | Convert documents to markdown |
| sequential-thinking | Step-by-step reasoning |
| memory | Knowledge graph persistence |
| postgres | Direct database queries (localhost:5432) |
| playwright | Browser automation and testing |
| redis | Redis cache inspection (localhost:6379) |
| sentry-selfhosted-mcp | Error tracking via Bugsink (localhost:8000) |
| Server | Purpose |
| ------------------- | ---------------------------------------------------------------------------- |
| gitea-projectium | Gitea API for gitea.projectium.com |
| gitea-torbonium | Gitea API for gitea.torbonium.com |
| podman | Container management |
| filesystem | File system access |
| fetch | Web fetching |
| markitdown | Convert documents to markdown |
| sequential-thinking | Step-by-step reasoning |
| memory | Knowledge graph persistence |
| postgres | Direct database queries (localhost:5432) |
| playwright | Browser automation and testing |
| redis | Redis cache inspection (localhost:6379) |
| bugsink | Error tracking - production Bugsink (bugsink.projectium.com) - **PROD/TEST** |
| bugsink-dev | Error tracking - dev container Bugsink (localhost:8000) - **DEV CONTAINER** |
**Note:** MCP servers work in both **Claude CLI** and **Claude Code VS Code extension** (as of January 2026).
### Sentry/Bugsink MCP Server Setup (ADR-015)
**CRITICAL**: There are **TWO separate Bugsink MCP servers**:
To enable Claude Code to query and analyze application errors from Bugsink:
- **bugsink**: Connects to production Bugsink at `https://bugsink.projectium.com` for production and test server errors
- **bugsink-dev**: Connects to local dev container Bugsink at `http://localhost:8000` for local development errors
1. **Install the MCP server**:
### Bugsink MCP Server Setup (ADR-015)
```bash
# Clone the sentry-selfhosted-mcp repository
git clone https://github.com/ddfourtwo/sentry-selfhosted-mcp.git
cd sentry-selfhosted-mcp
npm install
```
**IMPORTANT**: You need to configure **TWO separate MCP servers** - one for production/test, one for local dev.
2. **Configure Claude Code** (add to `.claude/mcp.json`):
#### Installation (shared for both servers)
```json
{
"sentry-selfhosted-mcp": {
"command": "node",
"args": ["/path/to/sentry-selfhosted-mcp/dist/index.js"],
"env": {
"SENTRY_URL": "http://localhost:8000",
"SENTRY_AUTH_TOKEN": "<get-from-bugsink-ui>",
"SENTRY_ORG_SLUG": "flyer-crawler"
}
}
}
```
```bash
# Clone the bugsink-mcp repository (NOT sentry-selfhosted-mcp)
git clone https://github.com/j-shelfwood/bugsink-mcp.git
cd bugsink-mcp
npm install
npm run build
```
3. **Get the auth token**:
- Navigate to Bugsink UI at `http://localhost:8000`
- Log in with admin credentials
- Go to Settings > API Keys
- Create a new API key with read access
#### Production/Test Bugsink MCP (bugsink)
4. **Available capabilities**:
- List projects and issues
- View detailed error events
- Search by error message or stack trace
- Update issue status (resolve, ignore)
- Add comments to issues
Add to `.claude/mcp.json`:
```json
{
"bugsink": {
"command": "node",
"args": ["d:\\gitea\\bugsink-mcp\\dist\\index.js"],
"env": {
"BUGSINK_URL": "https://bugsink.projectium.com",
"BUGSINK_API_TOKEN": "<get-from-production-bugsink>",
"BUGSINK_ORG_SLUG": "sentry"
}
}
}
```
**Get the auth token**:
- Navigate to https://bugsink.projectium.com
- Log in with production credentials
- Go to Settings > API Keys
- Create a new API key with read access
#### Dev Container Bugsink MCP (bugsink-dev)
Add to `.claude/mcp.json`:
```json
{
"bugsink-dev": {
"command": "node",
"args": ["d:\\gitea\\bugsink-mcp\\dist\\index.js"],
"env": {
"BUGSINK_URL": "http://localhost:8000",
"BUGSINK_API_TOKEN": "<get-from-local-bugsink>",
"BUGSINK_ORG_SLUG": "sentry"
}
}
}
```
**Get the auth token**:
- Navigate to http://localhost:8000 (or https://localhost:8443)
- Log in with `admin@localhost` / `admin`
- Go to Settings > API Keys
- Create a new API key with read access
#### MCP Tool Usage
When using Bugsink MCP tools, remember:
- `mcp__bugsink__*` tools connect to **production/test** Bugsink
- `mcp__bugsink-dev__*` tools connect to **dev container** Bugsink
- Available capabilities for both:
- List projects and issues
- View detailed error events and stacktraces
- Search by error message or stack trace
- Update issue status (resolve, ignore)
- Create releases
### SSH Server Access

View File

@@ -147,6 +147,9 @@ ALLOWED_HOSTS = deduce_allowed_hosts(BUGSINK["BASE_URL"])\n\
\n\
# Console email backend for dev\n\
EMAIL_BACKEND = "bugsink.email_backends.QuietConsoleEmailBackend"\n\
\n\
# HTTPS proxy support (nginx reverse proxy on port 8443)\n\
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")\n\
' > /opt/bugsink/conf/bugsink_conf.py
# Create Bugsink startup script

View File

@@ -59,7 +59,11 @@ See [INSTALL.md](INSTALL.md) for detailed setup instructions.
## Environment Variables
This project uses environment variables for configuration (no `.env` files). Key variables:
**Production/Test**: Uses Gitea CI/CD secrets injected during deployment (no local `.env` files)
**Dev Container**: Uses `.env.local` file which **overrides** the default DSNs in `compose.dev.yml`
Key variables:
| Variable | Description |
| -------------------------------------------- | -------------------------------- |

View File

@@ -50,7 +50,8 @@ services:
- '80:80' # HTTP redirect to HTTPS (matches production)
- '443:443' # Frontend HTTPS (nginx proxies Vite 5173 → 443)
- '3001:3001' # Backend API
- '8000:8000' # Bugsink error tracking (ADR-015)
- '8000:8000' # Bugsink error tracking HTTP (ADR-015)
- '8443:8443' # Bugsink error tracking HTTPS (ADR-015)
environment:
# Core settings
- NODE_ENV=development
@@ -77,13 +78,16 @@ services:
- BUGSINK_DB_USER=bugsink
- BUGSINK_DB_PASSWORD=bugsink_dev_password
- BUGSINK_PORT=8000
- BUGSINK_BASE_URL=http://localhost:8000
- BUGSINK_BASE_URL=https://localhost:8443
- BUGSINK_ADMIN_EMAIL=admin@localhost
- BUGSINK_ADMIN_PASSWORD=admin
- BUGSINK_SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security
# Sentry SDK configuration (points to local Bugsink)
- SENTRY_DSN=http://59a58583-e869-7697-f94a-cfa0337676a8@localhost:8000/1
- VITE_SENTRY_DSN=http://d5fc5221-4266-ff2f-9af8-5689696072f3@localhost:8000/2
# Sentry SDK configuration (points to local Bugsink HTTP)
# Note: Using HTTP with 127.0.0.1 instead of localhost because Sentry SDK
# doesn't accept 'localhost' as a valid hostname in DSN validation
# The browser accesses Bugsink at http://localhost:8000 (nginx proxies to HTTPS for the app)
- SENTRY_DSN=http://cea01396-c562-46ad-b587-8fa5ee6b1d22@127.0.0.1:8000/1
- VITE_SENTRY_DSN=http://d92663cb-73cf-4145-b677-b84029e4b762@127.0.0.1:8000/2
- SENTRY_ENVIRONMENT=development
- VITE_SENTRY_ENVIRONMENT=development
- SENTRY_ENABLED=true

View File

@@ -24,7 +24,29 @@ server {
# Allow large file uploads (matches production)
client_max_body_size 100M;
# Proxy all requests to Vite dev server on port 5173
# Proxy API requests to Express server on port 3001
location /api/ {
proxy_pass http://localhost:3001;
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;
}
# Proxy WebSocket connections for real-time notifications
location /ws {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
# Proxy all other requests to Vite dev server on port 5173
location / {
proxy_pass http://localhost:5173;
proxy_http_version 1.1;

View File

@@ -0,0 +1,534 @@
# Testing Session - UI/UX Improvements
**Date**: 2026-01-21
**Tester**: [Your Name]
**Session Start**: [Time]
**Environment**: Dev Container
---
## 🎯 Session Objective
Test all 4 critical UI/UX improvements:
1. Brand Colors (visual verification)
2. Button Component (functional testing)
3. Onboarding Tour (flow testing)
4. Mobile Navigation (responsive testing)
---
## ✅ Pre-Test Setup Checklist
### 1. Dev Server Status
- [ ] Dev server running at `http://localhost:5173`
- [ ] Browser open (Chrome/Edge recommended)
- [ ] DevTools open (F12)
**Command to start**:
```bash
podman exec -it flyer-crawler-dev npm run dev:container
```
**Server Status**: [ ] Running [ ] Not Running
---
### 2. Browser Setup
- [ ] Clear cache (Ctrl+Shift+Delete)
- [ ] Clear localStorage for localhost
- [ ] Enable responsive design mode (Ctrl+Shift+M)
**Browser Version**: ********\_********
---
## 🧪 Test Execution
### TEST 1: Onboarding Tour ⭐ CRITICAL
**Priority**: 🔴 Must Pass
**Time**: 5 minutes
#### Steps:
1. Open DevTools → Application → Local Storage
2. Delete key: `flyer_crawler_onboarding_completed`
3. Refresh page (F5)
4. Observe if tour appears
#### Expected:
- ✅ Tour modal appears within 2 seconds
- ✅ Shows "Step 1 of 6"
- ✅ Points to Flyer Uploader section
- ✅ Skip button visible
- ✅ Next button visible
#### Actual Result:
```
[Record what you see here]
```
**Status**: [ ] ✅ PASS [ ] ❌ FAIL [ ] ⚠️ PARTIAL
**Screenshots**: [Attach if needed]
---
### TEST 2: Tour Navigation
**Time**: 5 minutes
#### Steps:
Click "Next" button 6 times, observe each step
#### Verification Table:
| Step | Target | Visible? | Correct Text? | Notes |
| ---- | -------------- | -------- | ------------- | ----- |
| 1 | Flyer Uploader | [ ] | [ ] | |
| 2 | Data Table | [ ] | [ ] | |
| 3 | Watch Button | [ ] | [ ] | |
| 4 | Watchlist | [ ] | [ ] | |
| 5 | Price Chart | [ ] | [ ] | |
| 6 | Shopping List | [ ] | [ ] | |
#### Additional Checks:
- [ ] Progress indicator updates (1/6 → 6/6)
- [ ] Can click "Previous" button
- [ ] Tour closes after step 6
- [ ] localStorage key saved
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 3: Mobile Tab Bar ⭐ CRITICAL
**Priority**: 🔴 Must Pass
**Time**: 8 minutes
#### Part A: Mobile View (375px)
**Setup**: Toggle device toolbar → iPhone SE
#### Checks:
- [ ] Bottom tab bar visible
- [ ] 4 tabs present: Home, Deals, Lists, Profile
- [ ] Left sidebar (flyer list) HIDDEN
- [ ] Right sidebar (widgets) HIDDEN
- [ ] Main content uses full width
**Visual Check**:
```
Tab Bar Position: [ ] Bottom [ ] Other: _______
Number of Tabs: _______
Tab Bar Height: ~64px? [ ] Yes [ ] No
```
#### Part B: Tab Navigation
Click each tab and verify:
| Tab | URL | Page Loads? | Highlights? | Content Correct? |
| ------- | ---------- | ----------- | ----------- | ---------------- |
| Home | `/` | [ ] | [ ] | [ ] |
| Deals | `/deals` | [ ] | [ ] | [ ] |
| Lists | `/lists` | [ ] | [ ] | [ ] |
| Profile | `/profile` | [ ] | [ ] | [ ] |
#### Part C: Desktop View (1440px)
**Setup**: Exit device mode, maximize window
#### Checks:
- [ ] Tab bar HIDDEN (not visible)
- [ ] Left sidebar VISIBLE
- [ ] Right sidebar VISIBLE
- [ ] 3-column layout intact
- [ ] No layout regressions
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 4: Dark Mode ⭐ CRITICAL
**Priority**: 🔴 Must Pass
**Time**: 5 minutes
#### Steps:
1. Click dark mode toggle in header
2. Navigate: Home → Deals → Lists → Profile
3. Observe colors and contrast
#### Visual Verification:
**Mobile Tab Bar**:
- [ ] Dark background (#111827 or similar)
- [ ] Dark border color
- [ ] Active tab: teal (#14b8a6)
- [ ] Inactive tabs: gray
**New Pages**:
- [ ] DealsPage: dark background, light text
- [ ] ShoppingListsPage: dark cards
- [ ] FlyersPage: dark theme
- [ ] No white boxes visible
**Button Component**:
- [ ] Primary buttons: teal background
- [ ] Secondary buttons: gray background
- [ ] Danger buttons: red background
- [ ] All text readable
#### Toggle Back:
- [ ] Light mode restores correctly
- [ ] No stuck dark elements
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 5: Brand Colors Visual Check
**Time**: 3 minutes
#### Verification:
Navigate through app and check teal color consistency:
- [ ] Active tab: teal
- [ ] Primary buttons: teal
- [ ] Links on hover: teal
- [ ] Focus rings: teal
- [ ] All teal shades match (#14b8a6)
**Color Picker Check** (optional):
Use DevTools color picker on active tab:
- Expected: `#14b8a6` or `rgb(20, 184, 166)`
- Actual: ********\_\_\_********
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 6: Button Component
**Time**: 5 minutes
#### Find and Test Buttons:
**FlyerUploader Page**:
- [ ] "Upload Another Flyer" button (primary, teal)
- [ ] Button clickable
- [ ] Hover effect works
- [ ] Loading state (if applicable)
**ShoppingList Page** (navigate to /lists):
- [ ] "New List" button (secondary, gray)
- [ ] "Delete List" button (danger, red)
- [ ] Buttons functional
- [ ] Hover states work
**In Dark Mode**:
- [ ] All button variants visible
- [ ] Good contrast
- [ ] No white backgrounds
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 7: Responsive Breakpoints
**Time**: 5 minutes
#### Test at each width:
**375px (Mobile)**:
```
Tab bar: [ ] Visible [ ] Hidden
Sidebars: [ ] Visible [ ] Hidden
Layout: [ ] Single column [ ] Multi-column
```
**768px (Tablet)**:
```
Tab bar: [ ] Visible [ ] Hidden
Sidebars: [ ] Visible [ ] Hidden
Layout: [ ] Single column [ ] Multi-column
```
**1024px (Desktop)**:
```
Tab bar: [ ] Visible [ ] Hidden
Sidebars: [ ] Visible [ ] Hidden
Layout: [ ] Single column [ ] Multi-column
```
**1440px (Large Desktop)**:
```
Layout: [ ] Unchanged [ ] Broken
All elements: [ ] Visible [ ] Hidden/Cut off
```
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
### TEST 8: Admin Routes (If Admin User)
**Time**: 3 minutes
**Skip if**: [ ] Not admin user
#### Steps:
1. Log in as admin
2. Navigate to `/admin`
3. Check for tab bar
#### Checks:
- [ ] Admin dashboard loads
- [ ] Tab bar NOT visible
- [ ] Layout looks correct
- [ ] Can navigate to subpages
- [ ] Subpages work in mobile view
**Status**: [ ] ✅ PASS [ ] ❌ FAIL [ ] ⏭️ SKIPPED
---
### TEST 9: Console Errors
**Time**: 2 minutes
#### Steps:
1. Open Console tab in DevTools
2. Clear console
3. Navigate through app: Home → Deals → Lists → Profile → Home
4. Check for red error messages
#### Results:
```
Errors Found: [ ] None [ ] Some (list below)
```
**React 19 warnings are OK** (peer dependencies)
**Status**: [ ] ✅ PASS (no errors) [ ] ❌ FAIL (errors present)
---
### TEST 10: Integration Flow
**Time**: 5 minutes
#### User Journey:
1. Start on Home page (mobile view)
2. Navigate to Deals tab
3. Navigate to Lists tab
4. Navigate to Profile tab
5. Navigate back to Home
6. Toggle dark mode
7. Navigate through tabs again
#### Checks:
- [ ] All navigation smooth
- [ ] No data loss
- [ ] Active tab always correct
- [ ] Browser back button works
- [ ] Dark mode persists across routes
- [ ] No JavaScript errors
- [ ] No layout shifting
**Status**: [ ] ✅ PASS [ ] ❌ FAIL
---
## 📊 Test Results Summary
### Critical Tests Status
| Test | Status | Priority | Notes |
| ------------------- | ------ | ----------- | ----- |
| 1. Onboarding Tour | [ ] | 🔴 Critical | |
| 2. Tour Navigation | [ ] | 🟡 High | |
| 3. Mobile Tab Bar | [ ] | 🔴 Critical | |
| 4. Dark Mode | [ ] | 🔴 Critical | |
| 5. Brand Colors | [ ] | 🟡 High | |
| 6. Button Component | [ ] | 🟢 Medium | |
| 7. Responsive | [ ] | 🔴 Critical | |
| 8. Admin Routes | [ ] | 🟢 Medium | |
| 9. Console Errors | [ ] | 🔴 Critical | |
| 10. Integration | [ ] | 🟡 High | |
**Pass Rate**: **\_** / 10 tests passed
---
## 🐛 Issues Found
### Critical Issues (Blockers)
1. ***
2. ***
3. ***
### High Priority Issues
1. ***
2. ***
3. ***
### Medium/Low Priority Issues
1. ***
2. ***
3. ***
---
## 📸 Screenshots
Attach screenshots for:
- [ ] Onboarding tour (step 1)
- [ ] Mobile tab bar (375px)
- [ ] Desktop layout (1440px)
- [ ] Dark mode (tab bar)
- [ ] Any bugs/issues found
---
## 🎯 Final Decision
### Must-Pass Criteria
**Critical tests** (all must pass):
- [ ] Test 1: Onboarding Tour
- [ ] Test 3: Mobile Tab Bar
- [ ] Test 4: Dark Mode
- [ ] Test 7: Responsive
- [ ] Test 9: No Console Errors
**Result**: [ ] ALL CRITICAL PASS [ ] SOME FAIL
---
### Production Readiness
**Overall Assessment**:
[ ] ✅ READY FOR PRODUCTION
[ ] ⚠️ READY WITH MINOR ISSUES
[ ] ❌ NOT READY (critical issues)
**Blocking Issues** (must fix before deploy):
1. ***
2. ***
3. ***
**Recommended Fixes** (can deploy, fix later):
1. ***
2. ***
3. ***
---
## 🔐 Sign-Off
**Tester Name**: ****************\_\_\_****************
**Date/Time Completed**: ************\_\_\_************
**Total Testing Time**: **\_\_** minutes
**Recommended Action**:
[ ] Deploy to production
[ ] Deploy to staging first
[ ] Fix issues, re-test
[ ] Hold deployment
**Additional Notes**:
---
---
---
---
---
## 📋 Next Steps
**If PASS**:
1. [ ] Create commit with test results
2. [ ] Update CHANGELOG.md
3. [ ] Tag release (v0.12.4)
4. [ ] Deploy to staging
5. [ ] Monitor for 24 hours
6. [ ] Deploy to production
**If FAIL**:
1. [ ] Log issues in GitHub/Gitea
2. [ ] Assign to developer
3. [ ] Schedule re-test
4. [ ] Update test plan if needed
---
**Session End**: [Time]
**Session Duration**: **\_\_** minutes

4
package-lock.json generated
View File

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

View File

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

View File

@@ -17,9 +17,48 @@ set -e
echo "🚀 Starting Flyer Crawler Dev Container..."
# Configure Bugsink HTTPS (ADR-015)
echo "🔒 Configuring Bugsink HTTPS..."
mkdir -p /etc/bugsink/ssl
if [ ! -f "/etc/bugsink/ssl/localhost+2.pem" ]; then
cd /etc/bugsink/ssl && mkcert localhost 127.0.0.1 ::1 > /dev/null 2>&1
fi
# Create nginx config for Bugsink HTTPS
cat > /etc/nginx/sites-available/bugsink <<'NGINX_EOF'
server {
listen 8443 ssl http2;
listen [::]:8443 ssl http2;
server_name localhost;
ssl_certificate /etc/bugsink/ssl/localhost+2.pem;
ssl_certificate_key /etc/bugsink/ssl/localhost+2-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://127.0.0.1:8000;
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;
proxy_redirect off;
proxy_buffering off;
client_max_body_size 20M;
}
}
NGINX_EOF
ln -sf /etc/nginx/sites-available/bugsink /etc/nginx/sites-enabled/bugsink
# Copy the dev nginx config from mounted volume to nginx sites-available
echo "📋 Copying nginx dev config..."
cp /app/docker/nginx/dev.conf /etc/nginx/sites-available/default
# Start nginx in background (if installed)
if command -v nginx &> /dev/null; then
echo "🌐 Starting nginx (HTTPS proxy: Vite 5173 → port 443)..."
echo "🌐 Starting nginx (HTTPS: Vite 5173 → 443, Bugsink 8000 → 8443, API 3001 → /api/)..."
nginx &
fi
@@ -27,6 +66,22 @@ fi
echo "📊 Starting Bugsink error tracking..."
/usr/local/bin/start-bugsink.sh > /var/log/bugsink/server.log 2>&1 &
# Wait for Bugsink to initialize, then run snappea migrations
echo "⏳ Waiting for Bugsink to initialize..."
sleep 5
echo "🔧 Running Bugsink snappea database migrations..."
cd /opt/bugsink/conf && \
export DATABASE_URL="postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink" && \
export SECRET_KEY="dev-bugsink-secret-key-minimum-50-characters-for-security" && \
/opt/bugsink/bin/bugsink-manage migrate --database=snappea > /dev/null 2>&1
# Start Snappea task worker
echo "🔄 Starting Snappea task worker..."
cd /opt/bugsink/conf && \
export DATABASE_URL="postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink" && \
export SECRET_KEY="dev-bugsink-secret-key-minimum-50-characters-for-security" && \
/opt/bugsink/bin/bugsink-manage runsnappea > /var/log/bugsink/snappea.log 2>&1 &
# Start Logstash in background
echo "📝 Starting Logstash..."
/usr/share/logstash/bin/logstash -f /etc/logstash/conf.d/bugsink.conf > /var/log/logstash/logstash.log 2>&1 &
@@ -41,8 +96,8 @@ cd /app
echo "💻 Starting development server..."
echo " - Frontend: https://localhost (nginx HTTPS → Vite on 5173)"
echo " - Backend API: http://localhost:3001"
echo " - Bugsink: http://localhost:8000"
echo " - Note: Accept the self-signed certificate warning in your browser"
echo " - Bugsink: https://localhost:8443 (nginx HTTPS → Bugsink on 8000)"
echo " - Note: Accept the self-signed certificate warnings in your browser"
echo ""
# Run npm dev server (this will block and keep container alive)

View File

@@ -15,7 +15,7 @@ export const Dashboard: React.FC = () => {
<RecipeSuggester />
{/* Other Dashboard Widgets */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/80">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Your Flyers</h2>
<FlyerCountDisplay />
</div>

View File

@@ -31,7 +31,7 @@ export const Header: React.FC<HeaderProps> = ({
// The state and handlers for the old AuthModal and SignUpModal have been removed.
return (
<>
<header className="bg-white dark:bg-gray-900 shadow-md sticky top-0 z-20">
<header className="bg-white dark:bg-gray-900 shadow-md sticky top-0 z-20 border-b-2 border-brand-primary dark:border-brand-secondary">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">

View File

@@ -43,7 +43,7 @@ export const Leaderboard: React.FC = () => {
}
return (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/80">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
<Award className="w-6 h-6 mr-2 text-blue-500" />
Top Users
@@ -57,7 +57,7 @@ export const Leaderboard: React.FC = () => {
{leaderboard.map((user) => (
<li
key={user.user_id}
className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-600"
className="flex items-center space-x-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition-colors hover:bg-brand-light/30 dark:hover:bg-brand-dark/20"
>
<div className="shrink-0 w-8 text-center">{getRankIcon(user.rank)}</div>
<img

View File

@@ -48,7 +48,7 @@ export const RecipeSuggester: React.FC = () => {
);
return (
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/80">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Get a Recipe Suggestion
</h2>

View File

@@ -155,9 +155,15 @@ export const MainLayout: React.FC<MainLayoutProps> = ({
unitSystem={'imperial'} // This can be passed down or sourced from a context
user={user}
/>
<PriceHistoryChart />
<Leaderboard />
<ActivityLog userProfile={userProfile} onLogClick={handleActivityLogClick} />
{user && (
<>
<PriceHistoryChart />
<Leaderboard />
{userProfile && (
<ActivityLog userProfile={userProfile} onLogClick={handleActivityLogClick} />
)}
</>
)}
</>
</div>
</div>

View File

@@ -1,14 +1,18 @@
// src/tests/e2e/admin-authorization.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { createAndLoginUser } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('Admin Route Authorization', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
let regularUser: UserProfile;
let regularUserAuthToken: string;
@@ -17,6 +21,7 @@ describe('Admin Route Authorization', () => {
const { user, token } = await createAndLoginUser({
email: `e2e-authz-user-${Date.now()}@example.com`,
fullName: 'E2E AuthZ User',
request: getRequest(),
});
regularUser = user;
regularUserAuthToken = token;
@@ -33,48 +38,42 @@ describe('Admin Route Authorization', () => {
const adminEndpoints = [
{
method: 'GET',
path: '/admin/stats',
action: (token: string) => apiClient.getApplicationStats(token),
path: '/api/admin/stats',
},
{
method: 'GET',
path: '/admin/users',
action: (token: string) => apiClient.authedGet('/admin/users', { tokenOverride: token }),
path: '/api/admin/users',
},
{
method: 'GET',
path: '/admin/corrections',
action: (token: string) => apiClient.getSuggestedCorrections(token),
path: '/api/admin/corrections',
},
{
method: 'POST',
path: '/admin/corrections/1/approve',
action: (token: string) => apiClient.approveCorrection(1, token),
path: '/api/admin/corrections/1/approve',
},
{
method: 'POST',
path: '/admin/trigger/daily-deal-check',
action: (token: string) =>
apiClient.authedPostEmpty('/admin/trigger/daily-deal-check', { tokenOverride: token }),
path: '/api/admin/trigger/daily-deal-check',
},
{
method: 'GET',
path: '/admin/queues/status',
action: (token: string) =>
apiClient.authedGet('/admin/queues/status', { tokenOverride: token }),
path: '/api/admin/queues/status',
},
];
it.each(adminEndpoints)(
'should return 403 Forbidden for a regular user trying to access $method $path',
async ({ action }) => {
async ({ method, path }) => {
// Act: Attempt to access the admin endpoint with the regular user's token
const response = await action(regularUserAuthToken);
const requestBuilder = method === 'GET' ? getRequest().get(path) : getRequest().post(path);
const response = await requestBuilder
.set('Authorization', `Bearer ${regularUserAuthToken}`)
.send();
// Assert: The request should be forbidden
expect(response.status).toBe(403);
const responseBody = await response.json();
expect(responseBody.error.message).toBe('Forbidden: Administrator access required.');
expect(response.body.error.message).toBe('Forbidden: Administrator access required.');
},
);
});

View File

@@ -1,14 +1,18 @@
// src/tests/e2e/admin-dashboard.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Admin Dashboard Flow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
// Use a unique email for every run to avoid collisions
const uniqueId = Date.now();
const adminEmail = `e2e-admin-${uniqueId}@example.com`;
@@ -26,15 +30,12 @@ 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 apiClient.registerUser(
adminEmail,
adminPassword,
'E2E Admin User',
);
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: adminEmail, password: adminPassword, full_name: 'E2E Admin User' });
expect(registerResponse.status).toBe(201);
const registerResponseBody = await registerResponse.json();
const registeredUser = registerResponseBody.data.userprofile.user;
const registeredUser = registerResponse.body.data.userprofile.user;
adminUserId = registeredUser.user_id;
expect(adminUserId).toBeDefined();
@@ -49,49 +50,47 @@ describe('E2E Admin Dashboard Flow', () => {
// and to provide a buffer for any rate limits from previous tests.
await new Promise((resolve) => setTimeout(resolve, 2000));
const loginResponse = await apiClient.loginUser(adminEmail, adminPassword, false);
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Failed to log in as admin: ${loginResponse.status} ${errorText}`);
}
const loginResponseBody = await loginResponse.json();
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: adminEmail, password: adminPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
authToken = loginResponseBody.data.token;
authToken = loginResponse.body.data.token;
expect(authToken).toBeDefined();
// Verify the role returned in the login response is now 'admin'
expect(loginResponseBody.data.userprofile.role).toBe('admin');
expect(loginResponse.body.data.userprofile.role).toBe('admin');
// 4. Fetch System Stats (Protected Admin Route)
const statsResponse = await apiClient.getApplicationStats(authToken);
const statsResponse = await getRequest()
.get('/api/admin/stats')
.set('Authorization', `Bearer ${authToken}`);
expect(statsResponse.status).toBe(200);
const statsResponseBody = await statsResponse.json();
expect(statsResponseBody.data).toHaveProperty('userCount');
expect(statsResponseBody.data).toHaveProperty('flyerCount');
expect(statsResponse.body.data).toHaveProperty('userCount');
expect(statsResponse.body.data).toHaveProperty('flyerCount');
// 5. Fetch User List (Protected Admin Route)
const usersResponse = await apiClient.authedGet('/admin/users', { tokenOverride: authToken });
const usersResponse = await getRequest()
.get('/api/admin/users')
.set('Authorization', `Bearer ${authToken}`);
expect(usersResponse.status).toBe(200);
const usersResponseBody = await usersResponse.json();
expect(usersResponseBody.data).toHaveProperty('users');
expect(usersResponseBody.data).toHaveProperty('total');
expect(Array.isArray(usersResponseBody.data.users)).toBe(true);
expect(usersResponse.body.data).toHaveProperty('users');
expect(usersResponse.body.data).toHaveProperty('total');
expect(Array.isArray(usersResponse.body.data.users)).toBe(true);
// The list should contain the admin user we just created
const self = usersResponseBody.data.users.find((u: any) => u.user_id === adminUserId);
const self = usersResponse.body.data.users.find((u: any) => u.user_id === adminUserId);
expect(self).toBeDefined();
// 6. Check Queue Status (Protected Admin Route)
const queueResponse = await apiClient.authedGet('/admin/queues/status', {
tokenOverride: authToken,
});
const queueResponse = await getRequest()
.get('/api/admin/queues/status')
.set('Authorization', `Bearer ${authToken}`);
expect(queueResponse.status).toBe(200);
const queueResponseBody = await queueResponse.json();
expect(Array.isArray(queueResponseBody.data)).toBe(true);
expect(Array.isArray(queueResponse.body.data)).toBe(true);
// Verify that the 'flyer-processing' queue is present in the status report
const flyerQueue = queueResponseBody.data.find((q: any) => q.name === 'flyer-processing');
const flyerQueue = queueResponse.body.data.find((q: any) => q.name === 'flyer-processing');
expect(flyerQueue).toBeDefined();
expect(flyerQueue.counts).toBeDefined();
});

View File

@@ -1,16 +1,19 @@
// src/tests/e2e/auth.e2e.test.ts
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('Authentication E2E Flow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
let testUser: UserProfile;
let testUserAuthToken: string;
const createdUserIds: string[] = [];
@@ -21,6 +24,7 @@ describe('Authentication E2E Flow', () => {
const { user, token } = await createAndLoginUser({
email: `e2e-login-user-${Date.now()}@example.com`,
fullName: 'E2E Login User',
request: getRequest(),
});
testUserAuthToken = token;
testUser = user;
@@ -43,18 +47,19 @@ describe('Authentication E2E Flow', () => {
const fullName = 'E2E Register User';
// Act
const response = await apiClient.registerUser(email, TEST_PASSWORD, fullName);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: fullName });
// Assert
expect(response.status).toBe(201);
expect(responseBody.data.message).toBe('User registered successfully!');
expect(responseBody.data.userprofile).toBeDefined();
expect(responseBody.data.userprofile.user.email).toBe(email);
expect(responseBody.data.token).toBeTypeOf('string');
expect(response.body.data.message).toBe('User registered successfully!');
expect(response.body.data.userprofile).toBeDefined();
expect(response.body.data.userprofile.user.email).toBe(email);
expect(response.body.data.token).toBeTypeOf('string');
// Add to cleanup
createdUserIds.push(responseBody.data.userprofile.user.user_id);
createdUserIds.push(response.body.data.userprofile.user.user_id);
});
it('should fail to register a user with a weak password', async () => {
@@ -62,12 +67,13 @@ describe('Authentication E2E Flow', () => {
const weakPassword = '123';
// Act
const response = await apiClient.registerUser(email, weakPassword, 'Weak Pass User');
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/register')
.send({ email, password: weakPassword, full_name: 'Weak Pass User' });
// Assert
expect(response.status).toBe(400);
expect(responseBody.error.details[0].message).toContain(
expect(response.body.error.details[0].message).toContain(
'Password must be at least 8 characters long.',
);
});
@@ -76,18 +82,20 @@ describe('Authentication E2E Flow', () => {
const email = `e2e-register-duplicate-${Date.now()}@example.com`;
// Act 1: Register the user successfully
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const firstResponseBody = await firstResponse.json();
const firstResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
expect(firstResponse.status).toBe(201);
createdUserIds.push(firstResponseBody.data.userprofile.user.user_id);
createdUserIds.push(firstResponse.body.data.userprofile.user.user_id);
// Act 2: Attempt to register the same user again
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const secondResponseBody = await secondResponse.json();
const secondResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
// Assert
expect(secondResponse.status).toBe(409); // Conflict
expect(secondResponseBody.error.message).toContain(
expect(secondResponse.body.error.message).toContain(
'A user with this email address already exists.',
);
});
@@ -96,32 +104,35 @@ describe('Authentication E2E Flow', () => {
describe('Login Flow', () => {
it('should successfully log in a registered user', async () => {
// Act: Attempt to log in with the user created in beforeAll
const response = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
// Assert
expect(response.status).toBe(200);
expect(responseBody.data.userprofile).toBeDefined();
expect(responseBody.data.userprofile.user.email).toBe(testUser.user.email);
expect(responseBody.data.token).toBeTypeOf('string');
expect(response.body.data.userprofile).toBeDefined();
expect(response.body.data.userprofile.user.email).toBe(testUser.user.email);
expect(response.body.data.token).toBeTypeOf('string');
});
it('should fail to log in with an incorrect password', async () => {
// Act: Attempt to log in with the wrong password
const response = await apiClient.loginUser(testUser.user.email, 'wrong-password', false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: 'wrong-password', rememberMe: false });
// Assert
expect(response.status).toBe(401);
expect(responseBody.error.message).toBe('Incorrect email or password.');
expect(response.body.error.message).toBe('Incorrect email or password.');
});
it('should fail to log in with a non-existent email', async () => {
const response = await apiClient.loginUser('no-one-here@example.com', TEST_PASSWORD, false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: 'no-one-here@example.com', password: TEST_PASSWORD, rememberMe: false });
expect(response.status).toBe(401);
expect(responseBody.error.message).toBe('Incorrect email or password.');
expect(response.body.error.message).toBe('Incorrect email or password.');
});
it('should be able to access a protected route after logging in', async () => {
@@ -130,15 +141,16 @@ describe('Authentication E2E Flow', () => {
expect(token).toBeDefined();
// Act: Use the token to access a protected route
const profileResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: token });
const responseBody = await profileResponse.json();
const response = await getRequest()
.get('/api/users/profile')
.set('Authorization', `Bearer ${token}`);
// Assert
expect(profileResponse.status).toBe(200);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.user_id).toBe(testUser.user.user_id);
expect(responseBody.data.user.email).toBe(testUser.user.email);
expect(responseBody.data.role).toBe('user');
expect(response.status).toBe(200);
expect(response.body.data).toBeDefined();
expect(response.body.data.user.user_id).toBe(testUser.user.user_id);
expect(response.body.data.user.email).toBe(testUser.user.email);
expect(response.body.data.role).toBe('user');
});
it('should allow an authenticated user to update their profile', async () => {
@@ -152,23 +164,24 @@ describe('Authentication E2E Flow', () => {
};
// Act: Call the update endpoint
const updateResponse = await apiClient.updateUserProfile(profileUpdates, {
tokenOverride: token,
});
const updateResponseBody = await updateResponse.json();
const updateResponse = await getRequest()
.put('/api/users/profile')
.set('Authorization', `Bearer ${token}`)
.send(profileUpdates);
// Assert: Check the response from the update call
expect(updateResponse.status).toBe(200);
expect(updateResponseBody.data.full_name).toBe(profileUpdates.full_name);
expect(updateResponseBody.data.avatar_url).toBe(profileUpdates.avatar_url);
expect(updateResponse.body.data.full_name).toBe(profileUpdates.full_name);
expect(updateResponse.body.data.avatar_url).toBe(profileUpdates.avatar_url);
// Act 2: Fetch the profile again to verify persistence
const verifyResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: token });
const verifyResponseBody = await verifyResponse.json();
const verifyResponse = await getRequest()
.get('/api/users/profile')
.set('Authorization', `Bearer ${token}`);
// Assert 2: Check the fetched data
expect(verifyResponseBody.data.full_name).toBe(profileUpdates.full_name);
expect(verifyResponseBody.data.avatar_url).toBe(profileUpdates.avatar_url);
expect(verifyResponse.body.data.full_name).toBe(profileUpdates.full_name);
expect(verifyResponse.body.data.avatar_url).toBe(profileUpdates.avatar_url);
});
});
@@ -176,27 +189,29 @@ describe('Authentication E2E Flow', () => {
it('should allow a user to reset their password and log in with the new one', async () => {
// Arrange: Create a user to reset the password for
const email = `e2e-reset-pass-${Date.now()}@example.com`;
const registerResponse = await apiClient.registerUser(
email,
TEST_PASSWORD,
'Reset Pass User',
);
const registerResponseBody = await registerResponse.json();
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Reset Pass User' });
expect(registerResponse.status).toBe(201);
createdUserIds.push(registerResponseBody.data.userprofile.user.user_id);
createdUserIds.push(registerResponse.body.data.userprofile.user.user_id);
// Poll until the user can log in, confirming the record has propagated.
await poll(
() => apiClient.loginUser(email, TEST_PASSWORD, false),
(response) => response.ok,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
// Verify user can log in (confirming registration completed)
let loginAttempts = 0;
let loginResponse;
while (loginAttempts < 10) {
loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email, password: TEST_PASSWORD, rememberMe: false });
if (loginResponse.status === 200) break;
await new Promise((resolve) => setTimeout(resolve, 1000));
loginAttempts++;
}
expect(loginResponse?.status).toBe(200);
// Request password reset (do not poll, as this endpoint is rate-limited)
const forgotResponse = await apiClient.requestPasswordReset(email);
const forgotResponse = await getRequest().post('/api/auth/forgot-password').send({ email });
expect(forgotResponse.status).toBe(200);
const forgotResponseBody = await forgotResponse.json();
const resetToken = forgotResponseBody.data.token;
const resetToken = forgotResponse.body.data.token;
// Assert 1: Check that we received a token.
expect(
@@ -207,20 +222,22 @@ describe('Authentication E2E Flow', () => {
// Act 2: Use the token to set a new password.
const newPassword = 'my-new-e2e-password-!@#$';
const resetResponse = await apiClient.resetPassword(resetToken, newPassword);
const resetResponseBody = await resetResponse.json();
const resetResponse = await getRequest()
.post('/api/auth/reset-password')
.send({ token: resetToken, newPassword });
// Assert 2: Check for a successful password reset message.
expect(resetResponse.status).toBe(200);
expect(resetResponseBody.data.message).toBe('Password has been reset successfully.');
expect(resetResponse.body.data.message).toBe('Password has been reset successfully.');
// Act 3: Log in with the NEW password
const loginResponse = await apiClient.loginUser(email, newPassword, false);
const loginResponseBody = await loginResponse.json();
const newLoginResponse = await getRequest()
.post('/api/auth/login')
.send({ email, password: newPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
expect(loginResponseBody.data.userprofile).toBeDefined();
expect(loginResponseBody.data.userprofile.user.email).toBe(email);
expect(newLoginResponse.status).toBe(200);
expect(newLoginResponse.body.data.userprofile).toBeDefined();
expect(newLoginResponse.body.data.userprofile.user.email).toBe(email);
});
it('should return a generic success message for a non-existent email to prevent enumeration', async () => {
@@ -228,73 +245,71 @@ describe('Authentication E2E Flow', () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
const response = await apiClient.requestPasswordReset(nonExistentEmail);
const response = await getRequest()
.post('/api/auth/forgot-password')
.send({ email: nonExistentEmail });
// Check for rate limiting or other errors before parsing JSON to avoid SyntaxError
if (!response.ok) {
const text = await response.text();
throw new Error(`Request failed with status ${response.status}: ${text}`);
}
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody.data.message).toBe(
expect(response.body.data.message).toBe(
'If an account with that email exists, a password reset link has been sent.',
);
expect(responseBody.data.token).toBeUndefined();
expect(response.body.data.token).toBeUndefined();
});
});
describe('Token Refresh 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 apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
expect(loginResponse.status).toBe(200);
const loginResponseBody = await loginResponse.json();
const initialAccessToken = loginResponseBody.data.token;
const initialAccessToken = loginResponse.body.data.token;
// 2. Extract the refresh token from the 'set-cookie' header.
const setCookieHeader = loginResponse.headers.get('set-cookie');
const setCookieHeaders = loginResponse.headers['set-cookie'];
expect(
setCookieHeader,
setCookieHeaders,
'Set-Cookie header should be present in login response',
).toBeDefined();
// A typical Set-Cookie header might be 'refreshToken=...; Path=/; HttpOnly; Max-Age=...'. We just need the 'refreshToken=...' part.
const refreshTokenCookie = setCookieHeader!.split(';')[0];
// Find the refreshToken cookie
const refreshTokenCookie = Array.isArray(setCookieHeaders)
? setCookieHeaders.find((cookie: string) => cookie.startsWith('refreshToken='))
: setCookieHeaders;
expect(refreshTokenCookie).toBeDefined();
// Wait for >1 second to ensure the 'iat' (Issued At) claim in the new JWT changes.
// JWT timestamps have second-level precision.
await new Promise((resolve) => setTimeout(resolve, 1100));
// 3. Call the refresh token endpoint, passing the cookie.
// This assumes a new method in apiClient to handle this specific request.
const refreshResponse = await apiClient.refreshToken(refreshTokenCookie);
const refreshResponse = await getRequest()
.post('/api/auth/refresh-token')
.set('Cookie', refreshTokenCookie!);
// 4. Assert the refresh was successful and we got a new token.
expect(refreshResponse.status).toBe(200);
const refreshResponseBody = await refreshResponse.json();
const newAccessToken = refreshResponseBody.data.token;
const newAccessToken = refreshResponse.body.data.token;
expect(newAccessToken).toBeDefined();
expect(newAccessToken).not.toBe(initialAccessToken);
// 5. Use the new access token to access a protected route.
const profileResponse = await apiClient.getAuthenticatedUserProfile({
tokenOverride: newAccessToken,
});
const profileResponse = await getRequest()
.get('/api/users/profile')
.set('Authorization', `Bearer ${newAccessToken}`);
expect(profileResponse.status).toBe(200);
const profileResponseBody = await profileResponse.json();
expect(profileResponseBody.data.user.user_id).toBe(testUser.user.user_id);
expect(profileResponse.body.data.user.user_id).toBe(testUser.user.user_id);
});
it('should fail to refresh with an invalid or missing token', async () => {
// Case 1: No cookie provided. This assumes refreshToken can handle an empty string.
const noCookieResponse = await apiClient.refreshToken('');
// Case 1: No cookie provided
const noCookieResponse = await getRequest().post('/api/auth/refresh-token');
expect(noCookieResponse.status).toBe(401);
// Case 2: Invalid cookie provided
const invalidCookieResponse = await apiClient.refreshToken(
'refreshToken=invalid-garbage-token',
);
const invalidCookieResponse = await getRequest()
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=invalid-garbage-token');
expect(invalidCookieResponse.status).toBe(403);
});
});

View File

@@ -4,7 +4,7 @@
* Tests the complete flow from user registration to creating budgets, tracking spending, and managing finances.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,35 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Helper to make authenticated API calls
const authedFetch = async (
path: string,
options: RequestInit & { token?: string } = {},
): Promise<Response> => {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Budget Management Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `budget-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongBudgetPassword123!';
@@ -83,21 +64,23 @@ 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 apiClient.registerUser(
userEmail,
userPassword,
'Budget E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Budget E2E User',
});
expect(registerResponse.status).toBe(201);
// Step 2: Login to get auth token
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -111,73 +94,65 @@ describe('E2E Budget Management Journey', () => {
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const formatDate = (d: Date) => d.toISOString().split('T')[0];
const createBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const createBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Monthly Groceries',
amount_cents: 50000, // $500.00
period: 'monthly',
start_date: formatDate(startOfMonth),
}),
});
});
expect(createBudgetResponse.status).toBe(201);
const createBudgetData = await createBudgetResponse.json();
expect(createBudgetData.data.name).toBe('Monthly Groceries');
expect(createBudgetData.data.amount_cents).toBe(50000);
expect(createBudgetData.data.period).toBe('monthly');
const budgetId = createBudgetData.data.budget_id;
expect(createBudgetResponse.body.data.name).toBe('Monthly Groceries');
expect(createBudgetResponse.body.data.amount_cents).toBe(50000);
expect(createBudgetResponse.body.data.period).toBe('monthly');
const budgetId = createBudgetResponse.body.data.budget_id;
createdBudgetIds.push(budgetId);
// Step 4: Create a weekly budget
const weeklyBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const weeklyBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Weekly Dining Out',
amount_cents: 10000, // $100.00
period: 'weekly',
start_date: formatDate(today),
}),
});
});
expect(weeklyBudgetResponse.status).toBe(201);
const weeklyBudgetData = await weeklyBudgetResponse.json();
expect(weeklyBudgetData.data.period).toBe('weekly');
createdBudgetIds.push(weeklyBudgetData.data.budget_id);
expect(weeklyBudgetResponse.body.data.period).toBe('weekly');
createdBudgetIds.push(weeklyBudgetResponse.body.data.budget_id);
// Step 5: View all budgets
const listBudgetsResponse = await authedFetch('/budgets', {
method: 'GET',
token: authToken,
});
const listBudgetsResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${authToken}`);
expect(listBudgetsResponse.status).toBe(200);
const listBudgetsData = await listBudgetsResponse.json();
expect(listBudgetsData.data.length).toBe(2);
expect(listBudgetsResponse.body.data.length).toBe(2);
// Find our budgets
const monthlyBudget = listBudgetsData.data.find(
const monthlyBudget = listBudgetsResponse.body.data.find(
(b: { name: string }) => b.name === 'Monthly Groceries',
);
expect(monthlyBudget).toBeDefined();
expect(monthlyBudget.amount_cents).toBe(50000);
// Step 6: Update a budget
const updateBudgetResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const updateBudgetResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
amount_cents: 55000, // Increase to $550.00
name: 'Monthly Groceries (Updated)',
}),
});
});
expect(updateBudgetResponse.status).toBe(200);
const updateBudgetData = await updateBudgetResponse.json();
expect(updateBudgetData.data.amount_cents).toBe(55000);
expect(updateBudgetData.data.name).toBe('Monthly Groceries (Updated)');
expect(updateBudgetResponse.body.data.amount_cents).toBe(55000);
expect(updateBudgetResponse.body.data.name).toBe('Monthly Groceries (Updated)');
// Step 7: Create test spending data (receipts) to track against budget
const pool = getPool();
@@ -212,69 +187,67 @@ describe('E2E Budget Management Journey', () => {
// Step 8: Check spending analysis
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const spendingResponse = await authedFetch(
`/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
{
method: 'GET',
token: authToken,
},
);
const spendingResponse = await getRequest()
.get(
`/api/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
)
.set('Authorization', `Bearer ${authToken}`);
expect(spendingResponse.status).toBe(200);
const spendingData = await spendingResponse.json();
expect(spendingData.success).toBe(true);
expect(Array.isArray(spendingData.data)).toBe(true);
expect(spendingResponse.body.success).toBe(true);
expect(Array.isArray(spendingResponse.body.data)).toBe(true);
// Verify we have spending data
// Note: The spending might be $0 or have data depending on how the backend calculates spending
// The test is mainly verifying the endpoint works
// Step 9: Test budget validation - try to create invalid budget
const invalidBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const invalidBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Invalid Budget',
amount_cents: -100, // Negative amount should be rejected
period: 'monthly',
start_date: formatDate(today),
}),
});
});
expect(invalidBudgetResponse.status).toBe(400);
// Step 10: Test budget validation - missing required fields
const missingFieldsResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const missingFieldsResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Incomplete Budget',
// Missing amount_cents, period, start_date
}),
});
});
expect(missingFieldsResponse.status).toBe(400);
// Step 11: Test update validation - empty update
const emptyUpdateResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({}), // No fields to update
});
const emptyUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({}); // No fields to update
expect(emptyUpdateResponse.status).toBe(400);
// Step 12: Verify another user cannot access our budgets
const otherUserEmail = `other-budget-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Budget User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Budget User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -282,31 +255,27 @@ describe('E2E Budget Management Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our budgets
const otherBudgetsResponse = await authedFetch('/budgets', {
method: 'GET',
token: otherToken,
});
const otherBudgetsResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherBudgetsResponse.status).toBe(200);
const otherBudgetsData = await otherBudgetsResponse.json();
expect(otherBudgetsData.data.length).toBe(0);
expect(otherBudgetsResponse.body.data.length).toBe(0);
// Other user should not be able to update our budget
const otherUpdateResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: otherToken,
body: JSON.stringify({
const otherUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({
amount_cents: 99999,
}),
});
});
expect(otherUpdateResponse.status).toBe(404); // Should not find the budget
// Other user should not be able to delete our budget
const otherDeleteAttemptResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'DELETE',
token: otherToken,
});
const otherDeleteAttemptResponse = await getRequest()
.delete(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDeleteAttemptResponse.status).toBe(404);
@@ -314,38 +283,36 @@ describe('E2E Budget Management Journey', () => {
await cleanupDb({ userIds: [otherUserId] });
// Step 13: Delete the weekly budget
const deleteBudgetResponse = await authedFetch(`/budgets/${weeklyBudgetData.data.budget_id}`, {
method: 'DELETE',
token: authToken,
});
const deleteBudgetResponse = await getRequest()
.delete(`/api/budgets/${weeklyBudgetResponse.body.data.budget_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteBudgetResponse.status).toBe(204);
// Remove from cleanup list
const deleteIndex = createdBudgetIds.indexOf(weeklyBudgetData.data.budget_id);
const deleteIndex = createdBudgetIds.indexOf(weeklyBudgetResponse.body.data.budget_id);
if (deleteIndex > -1) {
createdBudgetIds.splice(deleteIndex, 1);
}
// Step 14: Verify deletion
const verifyDeleteResponse = await authedFetch('/budgets', {
method: 'GET',
token: authToken,
});
const verifyDeleteResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(200);
const verifyDeleteData = await verifyDeleteResponse.json();
expect(verifyDeleteData.data.length).toBe(1); // Only monthly budget remains
expect(verifyDeleteResponse.body.data.length).toBe(1); // Only monthly budget remains
const deletedBudget = verifyDeleteData.data.find(
(b: { budget_id: number }) => b.budget_id === weeklyBudgetData.data.budget_id,
const deletedBudget = verifyDeleteResponse.body.data.find(
(b: { budget_id: number }) => b.budget_id === weeklyBudgetResponse.body.data.budget_id,
);
expect(deletedBudget).toBeUndefined();
// Step 15: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,7 +4,7 @@
* Tests the complete flow from user registration to watching items and viewing best prices.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,35 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Helper to make authenticated API calls
const authedFetch = async (
path: string,
options: RequestInit & { token?: string } = {},
): Promise<Response> => {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Deals and Price Tracking Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `deals-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongDealsPassword123!';
@@ -98,87 +79,70 @@ 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 authedFetch('/categories', {
method: 'GET',
});
const categoriesResponse = await getRequest().get('/api/categories');
expect(categoriesResponse.status).toBe(200);
const categoriesData = await categoriesResponse.json();
expect(categoriesData.success).toBe(true);
expect(categoriesData.data.length).toBeGreaterThan(0);
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 authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
{
method: 'GET',
},
const categoryLookupResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
);
expect(categoryLookupResponse.status).toBe(200);
const categoryLookupData = await categoryLookupResponse.json();
expect(categoryLookupData.success).toBe(true);
expect(categoryLookupData.data.name).toBe('Dairy & Eggs');
expect(categoryLookupResponse.body.success).toBe(true);
expect(categoryLookupResponse.body.data.name).toBe('Dairy & Eggs');
const dairyEggsCategoryId = categoryLookupData.data.category_id;
const dairyEggsCategoryId = categoryLookupResponse.body.data.category_id;
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await authedFetch(`/categories/${dairyEggsCategoryId}`, {
method: 'GET',
});
const categoryByIdResponse = await getRequest().get(`/api/categories/${dairyEggsCategoryId}`);
expect(categoryByIdResponse.status).toBe(200);
const categoryByIdData = await categoryByIdResponse.json();
expect(categoryByIdData.success).toBe(true);
expect(categoryByIdData.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdData.data.name).toBe('Dairy & Eggs');
expect(categoryByIdResponse.body.success).toBe(true);
expect(categoryByIdResponse.body.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdResponse.body.data.name).toBe('Dairy & Eggs');
// Look up other category IDs we'll need
const bakeryResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
{ method: 'GET' },
const bakeryResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
);
const bakeryData = await bakeryResponse.json();
const bakeryCategoryId = bakeryData.data.category_id;
const bakeryCategoryId = bakeryResponse.body.data.category_id;
const beveragesResponse = await authedFetch('/categories/lookup?name=Beverages', {
method: 'GET',
});
const beveragesData = await beveragesResponse.json();
const beveragesCategoryId = beveragesData.data.category_id;
const beveragesResponse = await getRequest().get('/api/categories/lookup?name=Beverages');
const beveragesCategoryId = beveragesResponse.body.data.category_id;
const produceResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
{ method: 'GET' },
const produceResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
);
const produceData = await produceResponse.json();
const produceCategoryId = produceData.data.category_id;
const produceCategoryId = produceResponse.body.data.category_id;
const meatResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
{ method: 'GET' },
const meatResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
);
const meatData = await meatResponse.json();
const meatCategoryId = meatData.data.category_id;
const meatCategoryId = meatResponse.body.data.category_id;
// NOTE: The watched items API now uses category_id (number) as of Phase 3.
// Category names are no longer accepted. Use the category discovery endpoints
// to look up category IDs before creating watched items.
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
userPassword,
'Deals E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Deals E2E User',
});
expect(registerResponse.status).toBe(201);
// Step 2: Login to get auth token
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -280,18 +244,16 @@ describe('E2E Deals and Price Tracking Journey', () => {
);
// Step 4: Add items to watch list (using category_id from lookups above)
const watchItem1Response = await authedFetch('/users/watched-items', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const watchItem1Response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send({
itemName: 'E2E Milk 2%',
category_id: dairyEggsCategoryId,
}),
});
});
expect(watchItem1Response.status).toBe(201);
const watchItem1Data = await watchItem1Response.json();
expect(watchItem1Data.data.name).toBe('E2E Milk 2%');
expect(watchItem1Response.body.data.name).toBe('E2E Milk 2%');
// Add more items to watch list
const itemsToWatch = [
@@ -300,47 +262,42 @@ describe('E2E Deals and Price Tracking Journey', () => {
];
for (const item of itemsToWatch) {
const response = await authedFetch('/users/watched-items', {
method: 'POST',
token: authToken,
body: JSON.stringify(item),
});
const response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(response.status).toBe(201);
}
// Step 5: View all watched items
const watchedListResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: authToken,
});
const watchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(watchedListResponse.status).toBe(200);
const watchedListData = await watchedListResponse.json();
expect(watchedListData.data.length).toBeGreaterThanOrEqual(3);
expect(watchedListResponse.body.data.length).toBeGreaterThanOrEqual(3);
// Find our watched items
const watchedMilk = watchedListData.data.find(
const watchedMilk = watchedListResponse.body.data.find(
(item: { name: string }) => item.name === 'E2E Milk 2%',
);
expect(watchedMilk).toBeDefined();
expect(watchedMilk.category_id).toBe(dairyEggsCategoryId);
// Step 6: Get best prices for watched items
const bestPricesResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: authToken,
});
const bestPricesResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${authToken}`);
expect(bestPricesResponse.status).toBe(200);
const bestPricesData = await bestPricesResponse.json();
expect(bestPricesData.success).toBe(true);
expect(bestPricesResponse.body.success).toBe(true);
// Verify we got deals for our watched items
expect(Array.isArray(bestPricesData.data)).toBe(true);
expect(Array.isArray(bestPricesResponse.body.data)).toBe(true);
// Find the milk deal and verify it's the best price (Store 2 at $4.99)
if (bestPricesData.data.length > 0) {
const milkDeal = bestPricesData.data.find(
if (bestPricesResponse.body.data.length > 0) {
const milkDeal = bestPricesResponse.body.data.find(
(deal: { item_name: string }) => deal.item_name === 'E2E Milk 2%',
);
@@ -356,38 +313,39 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Step 8: Remove an item from watch list
const milkMasterItemId = createdMasterItemIds[0];
const removeResponse = await authedFetch(`/users/watched-items/${milkMasterItemId}`, {
method: 'DELETE',
token: authToken,
});
const removeResponse = await getRequest()
.delete(`/api/users/watched-items/${milkMasterItemId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(removeResponse.status).toBe(204);
// Step 9: Verify item was removed
const updatedWatchedListResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: authToken,
});
const updatedWatchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(updatedWatchedListResponse.status).toBe(200);
const updatedWatchedListData = await updatedWatchedListResponse.json();
const milkStillWatched = updatedWatchedListData.data.find(
const milkStillWatched = updatedWatchedListResponse.body.data.find(
(item: { item_name: string }) => item.item_name === 'E2E Milk 2%',
);
expect(milkStillWatched).toBeUndefined();
// Step 10: Verify another user cannot see our watched items
const otherUserEmail = `other-deals-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Deals User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Deals User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -395,32 +353,29 @@ describe('E2E Deals and Price Tracking Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user's watched items should be empty
const otherWatchedResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: otherToken,
});
const otherWatchedResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherWatchedResponse.status).toBe(200);
const otherWatchedData = await otherWatchedResponse.json();
expect(otherWatchedData.data.length).toBe(0);
expect(otherWatchedResponse.body.data.length).toBe(0);
// Other user's deals should be empty
const otherDealsResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: otherToken,
});
const otherDealsResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDealsResponse.status).toBe(200);
const otherDealsData = await otherDealsResponse.json();
expect(otherDealsData.data.length).toBe(0);
expect(otherDealsResponse.body.data.length).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 11: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -1,18 +1,22 @@
// src/tests/e2e/flyer-upload.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import supertest from 'supertest';
import crypto from 'crypto';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import path from 'path';
import fs from 'fs';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Flyer Upload and Processing Workflow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `e2e-uploader-${uniqueId}@example.com`;
const userPassword = 'StrongPassword123!';
@@ -33,19 +37,20 @@ 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 apiClient.registerUser(
userEmail,
userPassword,
'E2E Flyer Uploader',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'E2E Flyer Uploader',
});
expect(registerResponse.status).toBe(201);
// 2. Login to get the access token
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
const loginResponseBody = await loginResponse.json();
authToken = loginResponseBody.data.token;
userId = loginResponseBody.data.userprofile.user.user_id;
authToken = loginResponse.body.data.token;
userId = loginResponse.body.data.userprofile.user.user_id;
expect(authToken).toBeDefined();
// 3. Prepare the flyer file
@@ -69,29 +74,27 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
]);
}
// Create a File object for the apiClient
// FIX: The Node.js `Buffer` type can be incompatible with the web `File` API's
// expected `BlobPart` type in some TypeScript configurations. Explicitly creating
// a `Uint8Array` from the buffer ensures compatibility and resolves the type error.
// `Uint8Array` is a valid `BufferSource`, which is a valid `BlobPart`.
const flyerFile = new File([new Uint8Array(fileBuffer)], fileName, { type: 'image/jpeg' });
// Calculate checksum (required by the API)
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 4. Upload the flyer
const uploadResponse = await apiClient.uploadAndProcessFlyer(flyerFile, checksum, authToken);
const uploadResponse = await getRequest()
.post('/api/flyers/upload')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyer', fileBuffer, fileName)
.field('checksum', checksum);
expect(uploadResponse.status).toBe(202);
const uploadResponseBody = await uploadResponse.json();
const jobId = uploadResponseBody.data.jobId;
const jobId = uploadResponse.body.data.jobId;
expect(jobId).toBeDefined();
// 5. Poll for job completion using the new utility
const jobStatusResponse = await poll(
async () => {
const statusResponse = await apiClient.getJobStatus(jobId, authToken);
return statusResponse.json();
const statusResponse = await getRequest()
.get(`/api/jobs/${jobId}`)
.set('Authorization', `Bearer ${authToken}`);
return statusResponse.body;
},
(responseBody) =>
responseBody.data.state === 'completed' || responseBody.data.state === 'failed',

View File

@@ -4,39 +4,20 @@
* Tests the complete flow from adding inventory items to tracking expiry and alerts.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Helper to make authenticated API calls
const authedFetch = async (
path: string,
options: RequestInit & { token?: string } = {},
): Promise<Response> => {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Inventory/Expiry Management Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `inventory-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongInventoryPassword123!';
@@ -76,21 +57,23 @@ 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 apiClient.registerUser(
userEmail,
userPassword,
'Inventory E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Inventory E2E User',
});
expect(registerResponse.status).toBe(201);
// Step 2: Login to get auth token
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -172,16 +155,14 @@ describe('E2E Inventory/Expiry Management Journey', () => {
];
for (const item of items) {
const addResponse = await authedFetch('/inventory', {
method: 'POST',
token: authToken,
body: JSON.stringify(item),
});
const addResponse = await getRequest()
.post('/api/inventory')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(addResponse.status).toBe(201);
const addData = await addResponse.json();
expect(addData.data.item_name).toBe(item.item_name);
createdInventoryIds.push(addData.data.inventory_id);
expect(addResponse.body.data.item_name).toBe(item.item_name);
createdInventoryIds.push(addResponse.body.data.inventory_id);
}
// Add an expired item directly to the database for testing expired endpoint
@@ -217,159 +198,135 @@ describe('E2E Inventory/Expiry Management Journey', () => {
createdInventoryIds.push(expiredResult.rows[0].pantry_item_id);
// Step 4: View all inventory
const listResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const listResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const listData = await listResponse.json();
expect(listData.data.items.length).toBe(6); // All our items
expect(listData.data.total).toBe(6);
expect(listResponse.body.data.items.length).toBe(6); // All our items
expect(listResponse.body.data.total).toBe(6);
// Step 5: Filter by location
const fridgeResponse = await authedFetch('/inventory?location=fridge', {
method: 'GET',
token: authToken,
});
const fridgeResponse = await getRequest()
.get('/api/inventory?location=fridge')
.set('Authorization', `Bearer ${authToken}`);
expect(fridgeResponse.status).toBe(200);
const fridgeData = await fridgeResponse.json();
fridgeData.data.items.forEach((item: { location: string }) => {
fridgeResponse.body.data.items.forEach((item: { location: string }) => {
expect(item.location).toBe('fridge');
});
expect(fridgeData.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt
expect(fridgeResponse.body.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt
// Step 6: View expiring items
const expiringResponse = await authedFetch('/inventory/expiring?days=3', {
method: 'GET',
token: authToken,
});
const expiringResponse = await getRequest()
.get('/api/inventory/expiring?days=3')
.set('Authorization', `Bearer ${authToken}`);
expect(expiringResponse.status).toBe(200);
const expiringData = await expiringResponse.json();
// Should include the Milk (tomorrow)
expect(expiringData.data.items.length).toBeGreaterThanOrEqual(1);
expect(expiringResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Step 7: View expired items
const expiredResponse = await authedFetch('/inventory/expired', {
method: 'GET',
token: authToken,
});
const expiredResponse = await getRequest()
.get('/api/inventory/expired')
.set('Authorization', `Bearer ${authToken}`);
expect(expiredResponse.status).toBe(200);
const expiredData = await expiredResponse.json();
expect(expiredData.data.items.length).toBeGreaterThanOrEqual(1);
expect(expiredResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Find the expired yogurt
const expiredYogurt = expiredData.data.items.find(
const expiredYogurt = expiredResponse.body.data.items.find(
(i: { item_name: string }) => i.item_name === 'Expired Yogurt E2E',
);
expect(expiredYogurt).toBeDefined();
// Step 8: Get specific item details
const milkId = createdInventoryIds[0];
const detailResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'GET',
token: authToken,
});
const detailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
const detailData = await detailResponse.json();
expect(detailData.data.item_name).toBe('E2E Milk');
expect(detailData.data.quantity).toBe(2);
expect(detailResponse.body.data.item_name).toBe('E2E Milk');
expect(detailResponse.body.data.quantity).toBe(2);
// Step 9: Update item quantity and location
const updateResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const updateResponse = await getRequest()
.put(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
quantity: 1,
notes: 'One bottle used',
}),
});
});
expect(updateResponse.status).toBe(200);
const updateData = await updateResponse.json();
expect(updateData.data.quantity).toBe(1);
expect(updateResponse.body.data.quantity).toBe(1);
// Step 10: Consume some apples (partial consume via update, then mark fully consumed)
// First, reduce quantity via update
const applesId = createdInventoryIds[3];
const partialConsumeResponse = await authedFetch(`/inventory/${applesId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({ quantity: 4 }), // 6 - 2 = 4
});
const partialConsumeResponse = await getRequest()
.put(`/api/inventory/${applesId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ quantity: 4 }); // 6 - 2 = 4
expect(partialConsumeResponse.status).toBe(200);
const partialConsumeData = await partialConsumeResponse.json();
expect(partialConsumeData.data.quantity).toBe(4);
expect(partialConsumeResponse.body.data.quantity).toBe(4);
// Step 11: Configure alert settings for email
// The API uses PUT /inventory/alerts/:alertMethod with days_before_expiry and is_enabled
const alertSettingsResponse = await authedFetch('/inventory/alerts/email', {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const alertSettingsResponse = await getRequest()
.put('/api/inventory/alerts/email')
.set('Authorization', `Bearer ${authToken}`)
.send({
is_enabled: true,
days_before_expiry: 3,
}),
});
});
expect(alertSettingsResponse.status).toBe(200);
const alertSettingsData = await alertSettingsResponse.json();
expect(alertSettingsData.data.is_enabled).toBe(true);
expect(alertSettingsData.data.days_before_expiry).toBe(3);
expect(alertSettingsResponse.body.data.is_enabled).toBe(true);
expect(alertSettingsResponse.body.data.days_before_expiry).toBe(3);
// Step 12: Verify alert settings were saved
const getSettingsResponse = await authedFetch('/inventory/alerts', {
method: 'GET',
token: authToken,
});
const getSettingsResponse = await getRequest()
.get('/api/inventory/alerts')
.set('Authorization', `Bearer ${authToken}`);
expect(getSettingsResponse.status).toBe(200);
const getSettingsData = await getSettingsResponse.json();
// Should have email alerts enabled
const emailAlert = getSettingsData.data.find(
const emailAlert = getSettingsResponse.body.data.find(
(s: { alert_method: string }) => s.alert_method === 'email',
);
expect(emailAlert?.is_enabled).toBe(true);
// Step 13: Get recipe suggestions based on expiring items
const suggestionsResponse = await authedFetch('/inventory/recipes/suggestions', {
method: 'GET',
token: authToken,
});
const suggestionsResponse = await getRequest()
.get('/api/inventory/recipes/suggestions')
.set('Authorization', `Bearer ${authToken}`);
expect(suggestionsResponse.status).toBe(200);
const suggestionsData = await suggestionsResponse.json();
expect(Array.isArray(suggestionsData.data.recipes)).toBe(true);
expect(Array.isArray(suggestionsResponse.body.data.recipes)).toBe(true);
// Step 14: Fully consume an item (marks as consumed, returns 204)
const breadId = createdInventoryIds[2];
const fullConsumeResponse = await authedFetch(`/inventory/${breadId}/consume`, {
method: 'POST',
token: authToken,
});
const fullConsumeResponse = await getRequest()
.post(`/api/inventory/${breadId}/consume`)
.set('Authorization', `Bearer ${authToken}`);
expect(fullConsumeResponse.status).toBe(204);
// Verify the item is now marked as consumed
const consumedItemResponse = await authedFetch(`/inventory/${breadId}`, {
method: 'GET',
token: authToken,
});
const consumedItemResponse = await getRequest()
.get(`/api/inventory/${breadId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(consumedItemResponse.status).toBe(200);
const consumedItemData = await consumedItemResponse.json();
expect(consumedItemData.data.is_consumed).toBe(true);
expect(consumedItemResponse.body.data.is_consumed).toBe(true);
// Step 15: Delete an item
const riceId = createdInventoryIds[4];
const deleteResponse = await authedFetch(`/inventory/${riceId}`, {
method: 'DELETE',
token: authToken,
});
const deleteResponse = await getRequest()
.delete(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -380,24 +337,27 @@ describe('E2E Inventory/Expiry Management Journey', () => {
}
// Step 16: Verify deletion
const verifyDeleteResponse = await authedFetch(`/inventory/${riceId}`, {
method: 'GET',
token: authToken,
});
const verifyDeleteResponse = await getRequest()
.get(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
// Step 17: Verify another user cannot access our inventory
const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Inventory User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Inventory User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -405,58 +365,52 @@ describe('E2E Inventory/Expiry Management Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our inventory
const otherDetailResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'GET',
token: otherToken,
});
const otherDetailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
// Other user's inventory should be empty
const otherListResponse = await authedFetch('/inventory', {
method: 'GET',
token: otherToken,
});
const otherListResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherListResponse.status).toBe(200);
const otherListData = await otherListResponse.json();
expect(otherListData.data.total).toBe(0);
expect(otherListResponse.body.data.total).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 18: Move frozen item to fridge (simulating thawing)
const pizzaId = createdInventoryIds[1];
const moveResponse = await authedFetch(`/inventory/${pizzaId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const moveResponse = await getRequest()
.put(`/api/inventory/${pizzaId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
location: 'fridge',
expiry_date: formatDate(nextWeek), // Update expiry since thawed
notes: 'Thawed for dinner',
}),
});
});
expect(moveResponse.status).toBe(200);
const moveData = await moveResponse.json();
expect(moveData.data.location).toBe('fridge');
expect(moveResponse.body.data.location).toBe('fridge');
// Step 19: Final inventory check
const finalListResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const finalListResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(finalListResponse.status).toBe(200);
const finalListData = await finalListResponse.json();
// We should have: Milk (1), Pizza (thawed, 3), Bread (consumed), Apples (4), Expired Yogurt (1)
// Rice was deleted, Bread was consumed
expect(finalListData.data.total).toBeLessThanOrEqual(5);
expect(finalListResponse.body.data.total).toBeLessThanOrEqual(5);
// Step 20: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,7 +4,7 @@
* Tests the complete flow from user registration to uploading receipts and managing items.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,40 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import FormData from 'form-data';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Helper to make authenticated API calls
const authedFetch = async (
path: string,
options: RequestInit & { token?: string } = {},
): Promise<Response> => {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
...(fetchOptions.headers as Record<string, string>),
};
// Only add Content-Type for JSON (not for FormData)
if (!(fetchOptions.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Receipt Processing Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `receipt-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongReceiptPassword123!';
@@ -92,21 +68,23 @@ 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 apiClient.registerUser(
userEmail,
userPassword,
'Receipt E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Receipt E2E User',
});
expect(registerResponse.status).toBe(201);
// Step 2: Login to get auth token
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -154,73 +132,63 @@ describe('E2E Receipt Processing Journey', () => {
const itemIds = itemsResult.rows.map((r) => r.receipt_item_id);
// Step 4: View receipt list
const listResponse = await authedFetch('/receipts', {
method: 'GET',
token: authToken,
});
const listResponse = await getRequest()
.get('/api/receipts')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const listData = await listResponse.json();
expect(listData.success).toBe(true);
expect(listData.data.receipts.length).toBeGreaterThanOrEqual(1);
expect(listResponse.body.success).toBe(true);
expect(listResponse.body.data.receipts.length).toBeGreaterThanOrEqual(1);
// Find our receipt
const ourReceipt = listData.data.receipts.find(
const ourReceipt = listResponse.body.data.receipts.find(
(r: { receipt_id: number }) => r.receipt_id === receiptId,
);
expect(ourReceipt).toBeDefined();
expect(ourReceipt.store_location_id).toBe(storeLocationId);
// Step 5: View receipt details
const detailResponse = await authedFetch(`/receipts/${receiptId}`, {
method: 'GET',
token: authToken,
});
const detailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
const detailData = await detailResponse.json();
expect(detailData.data.receipt.receipt_id).toBe(receiptId);
expect(detailData.data.items.length).toBe(3);
expect(detailResponse.body.data.receipt.receipt_id).toBe(receiptId);
expect(detailResponse.body.data.items.length).toBe(3);
// Step 6: View receipt items
const itemsResponse = await authedFetch(`/receipts/${receiptId}/items`, {
method: 'GET',
token: authToken,
});
const itemsResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items`)
.set('Authorization', `Bearer ${authToken}`);
expect(itemsResponse.status).toBe(200);
const itemsData = await itemsResponse.json();
expect(itemsData.data.items.length).toBe(3);
expect(itemsResponse.body.data.items.length).toBe(3);
// Step 7: Update an item's status
const updateItemResponse = await authedFetch(`/receipts/${receiptId}/items/${itemIds[1]}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const updateItemResponse = await getRequest()
.put(`/api/receipts/${receiptId}/items/${itemIds[1]}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
status: 'matched',
match_confidence: 0.85,
}),
});
});
expect(updateItemResponse.status).toBe(200);
const updateItemData = await updateItemResponse.json();
expect(updateItemData.data.status).toBe('matched');
expect(updateItemResponse.body.data.status).toBe('matched');
// Step 8: View unadded items
const unaddedResponse = await authedFetch(`/receipts/${receiptId}/items/unadded`, {
method: 'GET',
token: authToken,
});
const unaddedResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items/unadded`)
.set('Authorization', `Bearer ${authToken}`);
expect(unaddedResponse.status).toBe(200);
const unaddedData = await unaddedResponse.json();
expect(unaddedData.data.items.length).toBe(3); // None added yet
expect(unaddedResponse.body.data.items.length).toBe(3); // None added yet
// Step 9: Confirm items to add to inventory
const confirmResponse = await authedFetch(`/receipts/${receiptId}/confirm`, {
method: 'POST',
token: authToken,
body: JSON.stringify({
const confirmResponse = await getRequest()
.post(`/api/receipts/${receiptId}/confirm`)
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [
{
receipt_item_id: itemIds[0],
@@ -242,16 +210,14 @@ describe('E2E Receipt Processing Journey', () => {
include: false, // Skip the eggs
},
],
}),
});
});
expect(confirmResponse.status).toBe(200);
const confirmData = await confirmResponse.json();
expect(confirmData.data.count).toBeGreaterThanOrEqual(0);
expect(confirmResponse.body.data.count).toBeGreaterThanOrEqual(0);
// Track inventory items for cleanup
if (confirmData.data.added_items) {
confirmData.data.added_items.forEach((item: { inventory_id: number }) => {
if (confirmResponse.body.data.added_items) {
confirmResponse.body.data.added_items.forEach((item: { inventory_id: number }) => {
if (item.inventory_id) {
createdInventoryIds.push(item.inventory_id);
}
@@ -259,15 +225,13 @@ describe('E2E Receipt Processing Journey', () => {
}
// Step 10: Verify items in inventory
const inventoryResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const inventoryResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(inventoryResponse.status).toBe(200);
const inventoryData = await inventoryResponse.json();
// Should have at least the items we added
expect(inventoryData.data.items.length).toBeGreaterThanOrEqual(0);
expect(inventoryResponse.body.data.items.length).toBeGreaterThanOrEqual(0);
// Step 11-12: Processing logs tests skipped - receipt_processing_logs table not implemented
// TODO: Add these steps back when the receipt_processing_logs table is added to the schema
@@ -275,15 +239,19 @@ describe('E2E Receipt Processing Journey', () => {
// Step 13: Verify another user cannot access our receipt
const otherUserEmail = `other-receipt-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Receipt User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Receipt User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -291,10 +259,9 @@ describe('E2E Receipt Processing Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our receipt
const otherDetailResponse = await authedFetch(`/receipts/${receiptId}`, {
method: 'GET',
token: otherToken,
});
const otherDetailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
@@ -312,35 +279,27 @@ describe('E2E Receipt Processing Journey', () => {
createdReceiptIds.push(receipt2Result.rows[0].receipt_id);
// Step 15: Test filtering by status
const completedResponse = await authedFetch('/receipts?status=completed', {
method: 'GET',
token: authToken,
});
const completedResponse = await getRequest()
.get('/api/receipts?status=completed')
.set('Authorization', `Bearer ${authToken}`);
expect(completedResponse.status).toBe(200);
const completedData = await completedResponse.json();
completedData.data.receipts.forEach((r: { status: string }) => {
completedResponse.body.data.receipts.forEach((r: { status: string }) => {
expect(r.status).toBe('completed');
});
// Step 16: Test reprocessing a failed receipt
const reprocessResponse = await authedFetch(
`/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`,
{
method: 'POST',
token: authToken,
},
);
const reprocessResponse = await getRequest()
.post(`/api/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`)
.set('Authorization', `Bearer ${authToken}`);
expect(reprocessResponse.status).toBe(200);
const reprocessData = await reprocessResponse.json();
expect(reprocessData.data.message).toContain('reprocessing');
expect(reprocessResponse.body.data.message).toContain('reprocessing');
// Step 17: Delete the failed receipt
const deleteResponse = await authedFetch(`/receipts/${receipt2Result.rows[0].receipt_id}`, {
method: 'DELETE',
token: authToken,
});
const deleteResponse = await getRequest()
.delete(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -351,20 +310,17 @@ describe('E2E Receipt Processing Journey', () => {
}
// Step 18: Verify deletion
const verifyDeleteResponse = await authedFetch(
`/receipts/${receipt2Result.rows[0].receipt_id}`,
{
method: 'GET',
token: authToken,
},
);
const verifyDeleteResponse = await getRequest()
.get(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
// Step 19: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,39 +4,20 @@
* Tests the complete flow from user registration to scanning UPCs and viewing history.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Helper to make authenticated API calls
const authedFetch = async (
path: string,
options: RequestInit & { token?: string } = {},
): Promise<Response> => {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E UPC Scanning Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
const uniqueId = Date.now();
const userEmail = `upc-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongUpcPassword123!';
@@ -71,17 +52,21 @@ 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 apiClient.registerUser(userEmail, userPassword, 'UPC E2E User');
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: userEmail, password: userPassword, full_name: 'UPC E2E User' });
expect(registerResponse.status).toBe(201);
// Step 2: Login to get auth token
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -114,110 +99,100 @@ describe('E2E UPC Scanning Journey', () => {
createdProductIds.push(productId);
// Step 4: Scan the UPC code
const scanResponse = await authedFetch('/upc/scan', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const scanResponse = await getRequest()
.post('/api/upc/scan')
.set('Authorization', `Bearer ${authToken}`)
.send({
upc_code: testUpc,
scan_source: 'manual_entry',
}),
});
});
expect(scanResponse.status).toBe(200);
const scanData = await scanResponse.json();
expect(scanData.success).toBe(true);
expect(scanData.data.upc_code).toBe(testUpc);
const scanId = scanData.data.scan_id;
expect(scanResponse.body.success).toBe(true);
expect(scanResponse.body.data.upc_code).toBe(testUpc);
const scanId = scanResponse.body.data.scan_id;
createdScanIds.push(scanId);
// Step 5: Lookup the product by UPC
const lookupResponse = await authedFetch(`/upc/lookup?upc_code=${testUpc}`, {
method: 'GET',
token: authToken,
});
const lookupResponse = await getRequest()
.get(`/api/upc/lookup?upc_code=${testUpc}`)
.set('Authorization', `Bearer ${authToken}`);
expect(lookupResponse.status).toBe(200);
const lookupData = await lookupResponse.json();
expect(lookupData.success).toBe(true);
expect(lookupData.data.product).toBeDefined();
expect(lookupData.data.product.name).toBe('E2E Test Product');
expect(lookupResponse.body.success).toBe(true);
expect(lookupResponse.body.data.product).toBeDefined();
expect(lookupResponse.body.data.product.name).toBe('E2E Test Product');
// Step 6: Scan a few more items to build history
for (let i = 0; i < 3; i++) {
const additionalScan = await authedFetch('/upc/scan', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const additionalScan = await getRequest()
.post('/api/upc/scan')
.set('Authorization', `Bearer ${authToken}`)
.send({
upc_code: `00000000000${i}`,
scan_source: i % 2 === 0 ? 'manual_entry' : 'image_upload',
}),
});
});
if (additionalScan.ok) {
const additionalData = await additionalScan.json();
if (additionalData.data?.scan_id) {
createdScanIds.push(additionalData.data.scan_id);
if (additionalScan.status === 200) {
if (additionalScan.body.data?.scan_id) {
createdScanIds.push(additionalScan.body.data.scan_id);
}
}
}
// Step 7: View scan history
const historyResponse = await authedFetch('/upc/history', {
method: 'GET',
token: authToken,
});
const historyResponse = await getRequest()
.get('/api/upc/history')
.set('Authorization', `Bearer ${authToken}`);
expect(historyResponse.status).toBe(200);
const historyData = await historyResponse.json();
expect(historyData.success).toBe(true);
expect(historyData.data.scans.length).toBeGreaterThanOrEqual(4); // At least our 4 scans
expect(historyData.data.total).toBeGreaterThanOrEqual(4);
expect(historyResponse.body.success).toBe(true);
expect(historyResponse.body.data.scans.length).toBeGreaterThanOrEqual(4); // At least our 4 scans
expect(historyResponse.body.data.total).toBeGreaterThanOrEqual(4);
// Step 8: View specific scan details
const scanDetailResponse = await authedFetch(`/upc/history/${scanId}`, {
method: 'GET',
token: authToken,
});
const scanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(scanDetailResponse.status).toBe(200);
const scanDetailData = await scanDetailResponse.json();
expect(scanDetailData.data.scan_id).toBe(scanId);
expect(scanDetailData.data.upc_code).toBe(testUpc);
expect(scanDetailResponse.body.data.scan_id).toBe(scanId);
expect(scanDetailResponse.body.data.upc_code).toBe(testUpc);
// Step 9: Check user scan statistics
const statsResponse = await authedFetch('/upc/stats', {
method: 'GET',
token: authToken,
});
const statsResponse = await getRequest()
.get('/api/upc/stats')
.set('Authorization', `Bearer ${authToken}`);
expect(statsResponse.status).toBe(200);
const statsData = await statsResponse.json();
expect(statsData.success).toBe(true);
expect(statsData.data.total_scans).toBeGreaterThanOrEqual(4);
expect(statsResponse.body.success).toBe(true);
expect(statsResponse.body.data.total_scans).toBeGreaterThanOrEqual(4);
// Step 10: Test history filtering by scan_source
const filteredHistoryResponse = await authedFetch('/upc/history?scan_source=manual_entry', {
method: 'GET',
token: authToken,
});
const filteredHistoryResponse = await getRequest()
.get('/api/upc/history?scan_source=manual_entry')
.set('Authorization', `Bearer ${authToken}`);
expect(filteredHistoryResponse.status).toBe(200);
const filteredData = await filteredHistoryResponse.json();
filteredData.data.scans.forEach((scan: { scan_source: string }) => {
filteredHistoryResponse.body.data.scans.forEach((scan: { scan_source: string }) => {
expect(scan.scan_source).toBe('manual_entry');
});
// Step 11: Verify another user cannot see our scans
const otherUserEmail = `other-upc-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other UPC User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other UPC User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -225,30 +200,28 @@ describe('E2E UPC Scanning Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our scan
const otherScanDetailResponse = await authedFetch(`/upc/history/${scanId}`, {
method: 'GET',
token: otherToken,
});
const otherScanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherScanDetailResponse.status).toBe(404);
// Other user's history should be empty
const otherHistoryResponse = await authedFetch('/upc/history', {
method: 'GET',
token: otherToken,
});
const otherHistoryResponse = await getRequest()
.get('/api/upc/history')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherHistoryResponse.status).toBe(200);
const otherHistoryData = await otherHistoryResponse.json();
expect(otherHistoryData.data.total).toBe(0);
expect(otherHistoryResponse.body.data.total).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 12: Delete account (self-service)
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);

View File

@@ -1,14 +1,17 @@
// src/tests/e2e/user-journey.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E User Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
// Use a unique email for every run to avoid collisions
const uniqueId = Date.now();
const userEmail = `e2e-test-${uniqueId}@example.com`;
@@ -28,58 +31,61 @@ 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 apiClient.registerUser(userEmail, userPassword, 'E2E Traveler');
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: userEmail, password: userPassword, full_name: 'E2E Traveler' });
expect(registerResponse.status).toBe(201);
const registerResponseBody = await registerResponse.json();
expect(registerResponseBody.data.message).toBe('User registered successfully!');
expect(registerResponse.body.data.message).toBe('User registered successfully!');
// 2. Login to get the access token.
// We poll here because even between two API calls (register and login),
// there can be a small delay before the newly created user record is visible
// to the transaction started by the login request. This prevents flaky test failures.
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
async () => {
const response = await apiClient.loginUser(userEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
return { response, responseBody };
},
(result) => result.response.ok,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
// to the transaction started by the login getRequest(). This prevents flaky test failures.
let loginResponse;
let loginAttempts = 0;
while (loginAttempts < 10) {
loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
if (loginResponse.status === 200) break;
await new Promise((resolve) => setTimeout(resolve, 1000));
loginAttempts++;
}
expect(loginResponse.status).toBe(200);
authToken = loginResponseBody.data.token;
userId = loginResponseBody.data.userprofile.user.user_id;
expect(loginResponse?.status).toBe(200);
authToken = loginResponse!.body.data.token;
userId = loginResponse!.body.data.userprofile.user.user_id;
expect(authToken).toBeDefined();
expect(userId).toBeDefined();
// 3. Create a Shopping List
const createListResponse = await apiClient.createShoppingList('E2E Party List', authToken);
const createListResponse = await getRequest()
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'E2E Party List' });
expect(createListResponse.status).toBe(201);
const createListResponseBody = await createListResponse.json();
shoppingListId = createListResponseBody.data.shopping_list_id;
shoppingListId = createListResponse.body.data.shopping_list_id;
expect(shoppingListId).toBeDefined();
// 4. Add an item to the list
const addItemResponse = await apiClient.addShoppingListItem(
shoppingListId,
{ customItemName: 'Chips' },
authToken,
);
const addItemResponse = await getRequest()
.post(`/api/users/shopping-lists/${shoppingListId}/items`)
.set('Authorization', `Bearer ${authToken}`)
.send({ customItemName: 'Chips' });
expect(addItemResponse.status).toBe(201);
const addItemResponseBody = await addItemResponse.json();
expect(addItemResponseBody.data.custom_item_name).toBe('Chips');
expect(addItemResponse.body.data.custom_item_name).toBe('Chips');
// 5. Verify the list and item exist via GET
const getListsResponse = await apiClient.fetchShoppingLists(authToken);
const getListsResponse = await getRequest()
.get('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
expect(getListsResponse.status).toBe(200);
const getListsResponseBody = await getListsResponse.json();
const myLists = getListsResponseBody.data;
const myLists = getListsResponse.body.data;
const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId);
expect(targetList).toBeDefined();
@@ -87,16 +93,18 @@ describe('E2E User Journey', () => {
expect(targetList.items[0].custom_item_name).toBe('Chips');
// 6. Delete the User Account (Self-Service)
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
const deleteResponseBody = await deleteAccountResponse.json();
expect(deleteResponseBody.data.message).toBe('Account deleted successfully.');
expect(deleteAccountResponse.body.data.message).toBe('Account deleted successfully.');
// 7. Verify Login is no longer possible
const failLoginResponse = await apiClient.loginUser(userEmail, userPassword, false);
const failLoginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
expect(failLoginResponse.status).toBe(401);

View File

@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import path from 'path';
import os from 'os';
import type { Server } from 'http';
import type Express from 'express';
import { logger } from '../../services/logger.server';
import { getPool } from '../../services/db/connection.db';
@@ -20,6 +21,21 @@ let server: Server;
let globalPool: ReturnType<typeof getPool> | null = null;
// Temporary directory for test file storage (to avoid modifying committed fixtures)
let tempStorageDir: string | null = null;
// Internal app variable - only used within the globalSetup process
let app: Express.Application;
/**
* Gets the base URL for the E2E test server.
* Tests should make HTTP requests to this URL instead of accessing the app directly.
*
* NOTE: Due to Vitest's architecture, globalSetup runs in a separate Node.js process
* from test files. This means the Express app instance cannot be shared directly.
* Instead, tests should connect via HTTP to the server started by globalSetup.
*/
export function getServerUrl(): string {
const port = process.env.TEST_PORT || 3098;
return `http://localhost:${port}`;
}
/**
* Cleans all BullMQ queues to ensure no stale jobs from previous test runs.
@@ -122,7 +138,7 @@ export async function setup() {
console.error(`[E2E-SETUP-DEBUG] About to import server module...`);
const appModule = await import('../../../server');
console.error(`[E2E-SETUP-DEBUG] Server module imported successfully`);
const app = appModule.default;
app = appModule.default; // Assign to exported app variable
console.error(`[E2E-SETUP-DEBUG] App object type: ${typeof app}`);
// Use a dedicated E2E test port (3098) to avoid conflicts with integration tests (3099)

View File

@@ -48,6 +48,25 @@ const e2eConfig = mergeConfig(
reportsDirectory: '.coverage/e2e',
reportOnFailure: true,
clean: true,
// Include server-side code for true e2e coverage measurement
include: [
'src/routes/**/*.{ts,tsx}',
'src/middleware/**/*.{ts,tsx}',
'src/controllers/**/*.{ts,tsx}',
'src/services/**/*.{ts,tsx}',
'src/config/**/*.{ts,tsx}',
'server.ts',
],
exclude: [
'src/tests/**',
'src/**/*.test.{ts,tsx}',
'src/**/*.d.ts',
'src/services/apiClient.ts', // Client-side wrapper, not server code
'src/services/logger.client.ts',
'src/services/sentry.client.ts',
'src/services/eventBus.ts', // Client-side only
'src/services/processingErrors.ts', // Client-side only
],
},
},
}),