Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e13570deb | ||
| 2eba66fb71 | |||
|
|
10cdd78e22 | ||
| 521943bec0 | |||
|
|
810c0eb61b | ||
| 3314063e25 |
@@ -45,7 +45,7 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
run: npm ci
|
||||
|
||||
- name: Bump Minor Version and Push
|
||||
run: |
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
# If dependencies are not found in cache, it will run 'npm ci' automatically.
|
||||
# If they are found, it restores them. This is the standard, reliable way.
|
||||
- name: Install Dependencies
|
||||
run: npm ci --legacy-peer-deps # 'ci' is faster and safer for CI/CD than 'install'.
|
||||
run: npm ci # 'ci' is faster and safer for CI/CD than 'install'.
|
||||
|
||||
- name: Bump Version and Push
|
||||
run: |
|
||||
|
||||
@@ -340,8 +340,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies for development)
|
||||
# Use --legacy-peer-deps due to react-joyride peer dependency conflict with React 19
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN npm install
|
||||
|
||||
# ============================================================================
|
||||
# Environment Configuration
|
||||
|
||||
@@ -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
|
||||
@@ -81,9 +82,9 @@ services:
|
||||
- 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 HTTPS)
|
||||
- SENTRY_DSN=https://cea01396-c562-46ad-b587-8fa5ee6b1d22@localhost:8443/1
|
||||
- VITE_SENTRY_DSN=https://d92663cb-73cf-4145-b677-b84029e4b762@localhost:8443/2
|
||||
- SENTRY_ENVIRONMENT=development
|
||||
- VITE_SENTRY_ENVIRONMENT=development
|
||||
- SENTRY_ENABLED=true
|
||||
|
||||
534
docs/TESTING_SESSION_2026-01-21.md
Normal file
534
docs/TESTING_SESSION_2026-01-21.md
Normal 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
|
||||
@@ -135,7 +135,7 @@ New users saw "Welcome to Flyer Crawler!" with no explanation of features or how
|
||||
|
||||
### Solution
|
||||
|
||||
Implemented interactive guided tour using `react-joyride`:
|
||||
Implemented interactive guided tour using `driver.js` (framework-agnostic, React 19 compatible):
|
||||
|
||||
**Tour Steps** (6 total):
|
||||
|
||||
@@ -148,24 +148,27 @@ Implemented interactive guided tour using `react-joyride`:
|
||||
|
||||
**Features**:
|
||||
|
||||
- Auto-starts for first-time users
|
||||
- Auto-starts for first-time users (500ms delay for DOM readiness)
|
||||
- Persists completion in localStorage (`flyer_crawler_onboarding_completed`)
|
||||
- Skip button for experienced users
|
||||
- Progress indicator showing current step
|
||||
- Styled with brand colors (#14b8a6)
|
||||
- Custom styled with pastel colors, sharp borders (design system)
|
||||
- Dark mode compatible
|
||||
- Zero React peer dependencies (compatible with React 19)
|
||||
|
||||
### Deliverables
|
||||
|
||||
- **Created**: `src/hooks/useOnboardingTour.ts` (custom hook)
|
||||
- **Created**: `src/hooks/useOnboardingTour.ts` (custom hook with Driver.js)
|
||||
- **Modified**: Added `data-tour` attributes to 6 components:
|
||||
- `src/features/flyer/FlyerUploader.tsx`
|
||||
- `src/features/flyer/ExtractedDataTable.tsx`
|
||||
- `src/features/shopping/WatchedItemsList.tsx`
|
||||
- `src/features/charts/PriceChart.tsx`
|
||||
- `src/features/shopping/ShoppingList.tsx`
|
||||
- **Modified**: `src/layouts/MainLayout.tsx` - Integrated Joyride component
|
||||
- **Installed**: `react-joyride@2.9.3`, `@types/react-joyride@2.0.2`
|
||||
- **Modified**: `src/layouts/MainLayout.tsx` - Integrated tour via hook
|
||||
- **Installed**: `driver.js@^1.3.1`
|
||||
|
||||
**Migration Note (2026-01-21)**: Originally implemented with `react-joyride@2.9.3`, but migrated to `driver.js` for React 19 compatibility.
|
||||
|
||||
### User Flow
|
||||
|
||||
@@ -360,13 +363,13 @@ npm test -- --run src/components/Button.test.tsx
|
||||
|
||||
1. `tailwind.config.js` - Brand colors
|
||||
2. `src/App.tsx` - New routes, MobileTabBar
|
||||
3. `src/layouts/MainLayout.tsx` - Joyride, responsive layout
|
||||
3. `src/layouts/MainLayout.tsx` - Tour integration, responsive layout
|
||||
4. `src/features/flyer/FlyerUploader.tsx` - Button, data-tour
|
||||
5. `src/features/flyer/ExtractedDataTable.tsx` - data-tour
|
||||
6. `src/features/shopping/WatchedItemsList.tsx` - Button, data-tour
|
||||
7. `src/features/shopping/ShoppingList.tsx` - Button, data-tour
|
||||
8. `src/features/charts/PriceChart.tsx` - data-tour
|
||||
9. `package.json` - Dependencies (react-joyride)
|
||||
9. `package.json` - Dependencies (driver.js)
|
||||
10. `package-lock.json` - Dependency lock
|
||||
|
||||
### Statistics
|
||||
@@ -383,7 +386,7 @@ npm test -- --run src/components/Button.test.tsx
|
||||
|
||||
### Bundle Size Impact
|
||||
|
||||
- `react-joyride`: ~30KB gzipped
|
||||
- `driver.js`: ~10KB gzipped (lightweight, zero dependencies)
|
||||
- `Button` component: <5KB (reduces duplication)
|
||||
- Brand colors: 0KB (CSS utilities, tree-shaken)
|
||||
- **Total increase**: ~25KB gzipped
|
||||
@@ -461,8 +464,9 @@ If issues arise:
|
||||
|
||||
1. Revert commit containing `src/components/MobileTabBar.tsx`
|
||||
2. Remove new routes from `src/App.tsx`
|
||||
3. Restore previous `MainLayout.tsx` (remove Joyride)
|
||||
3. Restore previous `MainLayout.tsx` (remove tour integration)
|
||||
4. Keep Button component and brand colors (safe changes)
|
||||
5. Remove `driver.js` and restore localStorage keys if needed
|
||||
|
||||
---
|
||||
|
||||
|
||||
186
package-lock.json
generated
186
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.5",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
@@ -15,12 +15,12 @@
|
||||
"@sentry/react": "^10.32.1",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@types/connect-timeout": "^1.9.0",
|
||||
"@types/react-joyride": "^2.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.65.1",
|
||||
"connect-timeout": "^1.9.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"driver.js": "^1.3.1",
|
||||
"exif-parser": "^0.1.12",
|
||||
"express": "^5.1.0",
|
||||
"express-list-endpoints": "^7.1.1",
|
||||
@@ -45,7 +45,6 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -2144,12 +2143,6 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gilbarbara/deep-equal": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
|
||||
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.34.0.tgz",
|
||||
@@ -6603,6 +6596,7 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -6618,15 +6612,6 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-joyride": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-joyride/-/react-joyride-2.0.2.tgz",
|
||||
"integrity": "sha512-RbixI8KE4K4B4bVzigT765oiQMCbWqlb9vj5qz1pFvkOvynkiAGurGVVf+nGszGGa89WrQhUnAwd0t1tqxeoDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -9354,13 +9339,6 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-diff": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
|
||||
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -9368,15 +9346,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-require-extensions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
|
||||
@@ -9625,6 +9594,12 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/driver.js": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
|
||||
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -12186,12 +12161,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-lite": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
|
||||
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-map": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||
@@ -12731,6 +12700,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -13629,6 +13599,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -14792,6 +14763,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -15420,17 +15398,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/popper.js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -15630,6 +15597,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -15641,6 +15609,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
@@ -15856,45 +15825,6 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-floater": {
|
||||
"version": "0.7.9",
|
||||
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
|
||||
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"is-lite": "^0.8.2",
|
||||
"popper.js": "^1.16.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"tree-changes": "^0.9.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "15 - 18",
|
||||
"react-dom": "15 - 18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-floater/node_modules/@gilbarbara/deep-equal": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
|
||||
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-floater/node_modules/is-lite": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
|
||||
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-floater/node_modules/tree-changes": {
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
|
||||
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gilbarbara/deep-equal": "^0.1.1",
|
||||
"is-lite": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
@@ -15912,64 +15842,12 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-innertext": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
|
||||
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=0.0.0 <=99",
|
||||
"react": ">=0.0.0 <=99"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-joyride": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
|
||||
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"deep-diff": "^1.0.2",
|
||||
"deepmerge": "^4.3.1",
|
||||
"is-lite": "^1.2.1",
|
||||
"react-floater": "^0.7.9",
|
||||
"react-innertext": "^1.1.5",
|
||||
"react-is": "^16.13.1",
|
||||
"scroll": "^3.0.1",
|
||||
"scrollparent": "^2.1.0",
|
||||
"tree-changes": "^0.11.2",
|
||||
"type-fest": "^4.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "15 - 18",
|
||||
"react-dom": "15 - 18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-joyride/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-joyride/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
@@ -16603,18 +16481,6 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scroll": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
|
||||
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scrollparent": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -18065,16 +17931,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-changes": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
|
||||
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"is-lite": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
@@ -36,7 +36,6 @@
|
||||
"@sentry/react": "^10.32.1",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@types/connect-timeout": "^1.9.0",
|
||||
"@types/react-joyride": "^2.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.65.1",
|
||||
"connect-timeout": "^1.9.1",
|
||||
@@ -66,7 +65,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
"driver.js": "^1.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -17,9 +17,44 @@ 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
|
||||
|
||||
# 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)..."
|
||||
nginx &
|
||||
fi
|
||||
|
||||
@@ -41,8 +76,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)
|
||||
|
||||
@@ -1,87 +1,286 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Step, CallBackProps } from 'react-joyride';
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { driver, Driver, DriveStep } from 'driver.js';
|
||||
import 'driver.js/dist/driver.css';
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = 'flyer_crawler_onboarding_completed';
|
||||
|
||||
export const useOnboardingTour = () => {
|
||||
const [runTour, setRunTour] = useState(false);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
// Custom CSS to match design system: pastel colors, sharp borders, minimalist
|
||||
const DRIVER_CSS = `
|
||||
.driver-popover {
|
||||
background-color: #f0fdfa !important;
|
||||
border: 2px solid #0d9488 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
|
||||
max-width: 320px !important;
|
||||
}
|
||||
|
||||
.driver-popover-title {
|
||||
color: #134e4a !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.driver-popover-description {
|
||||
color: #1f2937 !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.driver-popover-progress-text {
|
||||
color: #0d9488 !important;
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.driver-popover-navigation-btns {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.driver-popover-prev-btn,
|
||||
.driver-popover-next-btn {
|
||||
background-color: #14b8a6 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #0d9488 !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 500 !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
}
|
||||
|
||||
.driver-popover-prev-btn:hover,
|
||||
.driver-popover-next-btn:hover {
|
||||
background-color: #115e59 !important;
|
||||
}
|
||||
|
||||
.driver-popover-prev-btn {
|
||||
background-color: #ccfbf1 !important;
|
||||
color: #134e4a !important;
|
||||
}
|
||||
|
||||
.driver-popover-prev-btn:hover {
|
||||
background-color: #99f6e4 !important;
|
||||
}
|
||||
|
||||
.driver-popover-close-btn {
|
||||
color: #0d9488 !important;
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.driver-popover-close-btn:hover {
|
||||
color: #115e59 !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-left,
|
||||
.driver-popover-arrow-side-right,
|
||||
.driver-popover-arrow-side-top,
|
||||
.driver-popover-arrow-side-bottom {
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-left::after,
|
||||
.driver-popover-arrow-side-right::after,
|
||||
.driver-popover-arrow-side-top::after,
|
||||
.driver-popover-arrow-side-bottom::after {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-left::before {
|
||||
border-right-color: #f0fdfa !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-right::before {
|
||||
border-left-color: #f0fdfa !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-top::before {
|
||||
border-bottom-color: #f0fdfa !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-bottom::before {
|
||||
border-top-color: #f0fdfa !important;
|
||||
}
|
||||
|
||||
.driver-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.driver-active-element {
|
||||
box-shadow: 0 0 0 4px #14b8a6 !important;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.driver-popover {
|
||||
background-color: #1f2937 !important;
|
||||
border-color: #14b8a6 !important;
|
||||
}
|
||||
|
||||
.driver-popover-title {
|
||||
color: #ccfbf1 !important;
|
||||
}
|
||||
|
||||
.driver-popover-description {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-left::before {
|
||||
border-right-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-right::before {
|
||||
border-left-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-top::before {
|
||||
border-bottom-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-bottom::before {
|
||||
border-top-color: #1f2937 !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const tourSteps: DriveStep[] = [
|
||||
{
|
||||
element: '[data-tour="flyer-uploader"]',
|
||||
popover: {
|
||||
title: 'Upload Flyers',
|
||||
description:
|
||||
'Upload a grocery flyer here by clicking or dragging a PDF/image file. Our AI will extract prices and items automatically.',
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="extracted-data-table"]',
|
||||
popover: {
|
||||
title: 'Extracted Items',
|
||||
description:
|
||||
'View all extracted items from your flyers here. You can watch items to track price changes and deals.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="watch-button"]',
|
||||
popover: {
|
||||
title: 'Watch Items',
|
||||
description:
|
||||
'Click the eye icon to watch items and get notified when prices drop or deals appear.',
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="watched-items"]',
|
||||
popover: {
|
||||
title: 'Watched Items',
|
||||
description:
|
||||
'Your watched items appear here. Track prices across different stores and get deal alerts.',
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="price-chart"]',
|
||||
popover: {
|
||||
title: 'Active Deals',
|
||||
description:
|
||||
'Active deals show here with price comparisons. See which store has the best price!',
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="shopping-list"]',
|
||||
popover: {
|
||||
title: 'Shopping Lists',
|
||||
description:
|
||||
'Create shopping lists from your watched items and get the best prices automatically.',
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Inject custom styles into the document head
|
||||
const injectStyles = () => {
|
||||
const styleId = 'driver-js-custom-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = DRIVER_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
export const useOnboardingTour = () => {
|
||||
const driverRef = useRef<Driver | null>(null);
|
||||
|
||||
const markTourComplete = useCallback(() => {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, 'true');
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
injectStyles();
|
||||
|
||||
if (driverRef.current) {
|
||||
driverRef.current.destroy();
|
||||
}
|
||||
|
||||
driverRef.current = driver({
|
||||
showProgress: true,
|
||||
steps: tourSteps,
|
||||
nextBtnText: 'Next',
|
||||
prevBtnText: 'Previous',
|
||||
doneBtnText: 'Done',
|
||||
progressText: 'Step {{current}} of {{total}}',
|
||||
onDestroyed: () => {
|
||||
markTourComplete();
|
||||
},
|
||||
});
|
||||
|
||||
driverRef.current.drive();
|
||||
}, [markTourComplete]);
|
||||
|
||||
const skipTour = useCallback(() => {
|
||||
if (driverRef.current) {
|
||||
driverRef.current.destroy();
|
||||
}
|
||||
markTourComplete();
|
||||
}, [markTourComplete]);
|
||||
|
||||
const replayTour = useCallback(() => {
|
||||
startTour();
|
||||
}, [startTour]);
|
||||
|
||||
// Auto-start tour on mount if not completed
|
||||
useEffect(() => {
|
||||
const hasCompletedOnboarding = localStorage.getItem(ONBOARDING_STORAGE_KEY);
|
||||
if (!hasCompletedOnboarding) {
|
||||
setRunTour(true);
|
||||
// Small delay to ensure DOM elements are mounted
|
||||
const timer = setTimeout(() => {
|
||||
startTour();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
}, [startTour]);
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="flyer-uploader"]',
|
||||
content:
|
||||
'Upload a grocery flyer here by clicking or dragging a PDF/image file. Our AI will extract prices and items automatically.',
|
||||
disableBeacon: true,
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="extracted-data-table"]',
|
||||
content:
|
||||
'View all extracted items from your flyers here. You can watch items to track price changes and deals.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="watch-button"]',
|
||||
content:
|
||||
'Click the eye icon to watch items and get notified when prices drop or deals appear.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="watched-items"]',
|
||||
content:
|
||||
'Your watched items appear here. Track prices across different stores and get deal alerts.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="price-chart"]',
|
||||
content: 'Active deals show here with price comparisons. See which store has the best price!',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="shopping-list"]',
|
||||
content:
|
||||
'Create shopping lists from your watched items and get the best prices automatically.',
|
||||
placement: 'left',
|
||||
},
|
||||
];
|
||||
|
||||
const handleJoyrideCallback = useCallback((data: CallBackProps) => {
|
||||
const { status, index } = data;
|
||||
|
||||
if (status === 'finished' || status === 'skipped') {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, 'true');
|
||||
setRunTour(false);
|
||||
setStepIndex(0);
|
||||
} else if (data.action === 'next' || data.action === 'prev') {
|
||||
setStepIndex(index + (data.action === 'next' ? 1 : 0));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const skipTour = useCallback(() => {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, 'true');
|
||||
setRunTour(false);
|
||||
setStepIndex(0);
|
||||
}, []);
|
||||
|
||||
const replayTour = useCallback(() => {
|
||||
setStepIndex(0);
|
||||
setRunTour(true);
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (driverRef.current) {
|
||||
driverRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runTour,
|
||||
steps,
|
||||
stepIndex,
|
||||
handleJoyrideCallback,
|
||||
skipTour,
|
||||
replayTour,
|
||||
startTour,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/layouts/MainLayout.tsx
|
||||
import React, { useCallback } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Joyride from 'react-joyride';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useOnboardingTour } from '../hooks/useOnboardingTour';
|
||||
import { useFlyers } from '../hooks/useFlyers';
|
||||
@@ -34,7 +33,8 @@ export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
}) => {
|
||||
const { userProfile, authStatus } = useAuth();
|
||||
const user = userProfile?.user ?? null;
|
||||
const { runTour, steps, stepIndex, handleJoyrideCallback } = useOnboardingTour();
|
||||
// Driver.js tour is initialized and managed imperatively inside the hook
|
||||
useOnboardingTour();
|
||||
const { flyers, refetchFlyers, flyersError } = useFlyers();
|
||||
const { masterItems, error: masterItemsError } = useMasterItems();
|
||||
const {
|
||||
@@ -99,22 +99,6 @@ export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
|
||||
<Joyride
|
||||
steps={steps}
|
||||
run={runTour}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleJoyrideCallback}
|
||||
continuous
|
||||
showProgress
|
||||
showSkipButton
|
||||
styles={{
|
||||
options: {
|
||||
primaryColor: '#14b8a6',
|
||||
textColor: '#1f2937',
|
||||
zIndex: 10000,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{shouldShowBanner && (
|
||||
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
|
||||
<AnonymousUserBanner onOpenProfile={onOpenProfile} />
|
||||
|
||||
@@ -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;
|
||||
@@ -34,47 +39,41 @@ describe('Admin Route Authorization', () => {
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/stats',
|
||||
action: (token: string) => apiClient.getApplicationStats(token),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/users',
|
||||
action: (token: string) => apiClient.authedGet('/admin/users', { tokenOverride: token }),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/corrections',
|
||||
action: (token: string) => apiClient.getSuggestedCorrections(token),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/admin/corrections/1/approve',
|
||||
action: (token: string) => apiClient.approveCorrection(1, token),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/admin/trigger/daily-deal-check',
|
||||
action: (token: string) =>
|
||||
apiClient.authedPostEmpty('/admin/trigger/daily-deal-check', { tokenOverride: token }),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/admin/queues/status',
|
||||
action: (token: string) =>
|
||||
apiClient.authedGet('/admin/queues/status', { tokenOverride: token }),
|
||||
},
|
||||
];
|
||||
|
||||
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.');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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('/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('/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('/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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/user/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
userId = null;
|
||||
|
||||
@@ -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/user/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
userId = null;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/user/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
userId = null;
|
||||
|
||||
@@ -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/user/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
userId = null;
|
||||
|
||||
@@ -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/user/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user