docs: separate documentation and specs into initial commit
Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
This commit is contained in:
513
_bmad-output/implementation-artifacts/1-17-load-testing.story.md
Normal file
513
_bmad-output/implementation-artifacts/1-17-load-testing.story.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Story 1.17-a: Add Load Testing for NFR4 (100 Concurrent Users)
|
||||
|
||||
**Status:** Draft
|
||||
**Epic:** Epic 1 - Webflow to Payload CMS + Astro Migration
|
||||
**Priority:** P1 (High - NFR4 Validation Required)
|
||||
**Estimated Time:** 3 hours
|
||||
|
||||
## Story
|
||||
|
||||
**As a** Development Team,
|
||||
**I want** a load testing framework that validates system performance under concurrent user load,
|
||||
**So that** we can ensure NFR4 compliance (100 concurrent users) before production deployment.
|
||||
|
||||
## Context
|
||||
|
||||
This is a Sprint 1 addition story. NFR4 requires the system to support at least 100 concurrent users without performance degradation. Load testing was only implied in the original plan and needs explicit validation.
|
||||
|
||||
**Story Source:**
|
||||
- NFR4 from `docs/prd/02-requirements.md`
|
||||
- Sprint 1 adjustments in `sprint-status.yaml`
|
||||
- Task specs from `docs/prd/epic-1-stories-1.3-1.17-tasks.md`
|
||||
|
||||
**NFR4 Requirement:**
|
||||
> "The system must support at least 100 concurrent users without performance degradation."
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Part 1: Load Testing Framework
|
||||
1. **AC1 - Tool Selected**: k6 or Artillery chosen and installed
|
||||
2. **AC2 - Test Scripts Created**: Scripts for public browsing and admin operations
|
||||
|
||||
### Part 2: Test Scenarios
|
||||
3. **AC3 - Public Browsing Test**: 100 concurrent users browsing pages
|
||||
4. **AC4 - Admin Operations Test**: 20 concurrent admin users
|
||||
5. **AC5 - API Performance Test**: Payload CMS API endpoints under load
|
||||
|
||||
### Part 3: Performance Targets
|
||||
6. **AC6 - Response Time**: 95th percentile response time < 500ms
|
||||
7. **AC7 - Error Rate**: Error rate < 1%
|
||||
8. **AC8 - Cloudflare Limits**: Validated within Workers limits
|
||||
|
||||
### Part 4: Reporting
|
||||
9. **AC9 - Test Report**: Generated report with results and recommendations
|
||||
10. **AC10 - CI Integration**: Tests can be run on demand
|
||||
|
||||
## Dev Technical Guidance
|
||||
|
||||
### Task 1: Select and Install Load Testing Tool
|
||||
|
||||
**Recommended: k6** (Grafana's load testing tool)
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# Install k6
|
||||
pnpm add -D k6
|
||||
|
||||
# Or globally
|
||||
brew install k6 # macOS
|
||||
```
|
||||
|
||||
**Why k6:**
|
||||
- JavaScript-based tests (familiar to devs)
|
||||
- Good Cloudflare Workers support
|
||||
- Excellent reporting and metrics
|
||||
- Easy CI/CD integration
|
||||
|
||||
### Task 2: Create Load Test Scripts
|
||||
|
||||
**File:** `apps/frontend/tests/load/public-browsing.js`
|
||||
|
||||
```javascript
|
||||
import http from 'k6/http'
|
||||
import { check, sleep } from 'k6'
|
||||
import { Rate } from 'k6/metrics'
|
||||
|
||||
// Custom metrics
|
||||
const errorRate = new Rate('errors')
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '2m', target: 100 }, // Ramp up to 100 users (NFR4 target)
|
||||
{ duration: '2m', target: 100 }, // Stay at 100 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
|
||||
errors: ['rate<0.01'], // Error rate < 1%
|
||||
http_req_failed: ['rate<0.01'], // Failed requests < 1%
|
||||
},
|
||||
}
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:4321'
|
||||
|
||||
// Pages to test
|
||||
const pages = [
|
||||
'/',
|
||||
'/about',
|
||||
'/solutions',
|
||||
'/contact',
|
||||
'/blog',
|
||||
'/portfolio',
|
||||
]
|
||||
|
||||
export function setup() {
|
||||
// Optional: Login and get auth token for admin tests
|
||||
const loginRes = http.post(`${BASE_URL}/api/login`, JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'test123',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (loginRes.status === 200) {
|
||||
return { token: loginRes.json('token') }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export default function(data) {
|
||||
// Pick a random page
|
||||
const page = pages[Math.floor(Math.random() * pages.length)]
|
||||
|
||||
// Make request
|
||||
const res = http.get(`${BASE_URL}${page}`, {
|
||||
tags: { name: `Page: ${page}` },
|
||||
})
|
||||
|
||||
// Check response
|
||||
const success = check(res, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'response time < 500ms': (r) => r.timings.duration < 500,
|
||||
'page contains content': (r) => r.html().find('body').length > 0,
|
||||
})
|
||||
|
||||
errorRate.add(!success)
|
||||
|
||||
// Think time between requests (2-8 seconds)
|
||||
sleep(Math.random() * 6 + 2)
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
console.log('Load test completed')
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `apps/frontend/tests/load/admin-operations.js`
|
||||
|
||||
```javascript
|
||||
import http from 'k6/http'
|
||||
import { check, sleep } from 'k6'
|
||||
import { Rate } from 'k6/metrics'
|
||||
|
||||
const errorRate = new Rate('errors')
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '20s', target: 5 }, // Ramp up to 5 admin users
|
||||
{ duration: '1m', target: 10 }, // Ramp up to 10 admin users
|
||||
{ duration: '2m', target: 20 }, // Ramp up to 20 admin users
|
||||
{ duration: '1m', target: 20 }, // Stay at 20 users
|
||||
{ duration: '20s', target: 0 }, // Ramp down
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<500'],
|
||||
errors: ['rate<0.01'],
|
||||
},
|
||||
}
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'
|
||||
const API_URL = __ENV.API_URL || 'http://localhost:3000/api'
|
||||
|
||||
export function setup() {
|
||||
// Login as admin
|
||||
const loginRes = http.post(`${API_URL}/users/login`, JSON.stringify({
|
||||
email: 'admin@enchun.tw',
|
||||
password: 'admin123',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (loginRes.status !== 200) {
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
|
||||
return { token: loginRes.json('token') }
|
||||
}
|
||||
|
||||
export default function(data) {
|
||||
const headers = {
|
||||
'Authorization': `JWT ${data.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Test different admin operations
|
||||
const operations = [
|
||||
// List posts
|
||||
() => {
|
||||
const res = http.get(`${API_URL}/posts?limit=10`, { headers })
|
||||
check(res, { 'posts list status': (r) => r.status === 200 })
|
||||
},
|
||||
// List categories
|
||||
() => {
|
||||
const res = http.get(`${API_URL}/categories?limit=20`, { headers })
|
||||
check(res, { 'categories list status': (r) => r.status === 200 })
|
||||
},
|
||||
// Get globals
|
||||
() => {
|
||||
const res = http.get(`${API_URL}/globals/header`, { headers })
|
||||
check(res, { 'globals status': (r) => r.status === 200 })
|
||||
},
|
||||
// Get media
|
||||
() => {
|
||||
const res = http.get(`${API_URL}/media?limit=10`, { headers })
|
||||
check(res, { 'media list status': (r) => r.status === 200 })
|
||||
},
|
||||
]
|
||||
|
||||
// Execute random operation
|
||||
const operation = operations[Math.floor(Math.random() * operations.length)]
|
||||
const res = operation()
|
||||
|
||||
errorRate.add(!res)
|
||||
|
||||
// Think time
|
||||
sleep(Math.random() * 3 + 1)
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `apps/backend/tests/load/api-performance.js`
|
||||
|
||||
```javascript
|
||||
import http from 'k6/http'
|
||||
import { check } from 'k6'
|
||||
import { Rate } from 'k6/metrics'
|
||||
|
||||
const errorRate = new Rate('errors')
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
concurrent_readers: {
|
||||
executor: 'constant-vus',
|
||||
vus: 50,
|
||||
duration: '2m',
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
concurrent_writers: {
|
||||
executor: 'constant-vus',
|
||||
vus: 10,
|
||||
duration: '2m',
|
||||
startTime: '30s',
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<500'],
|
||||
errors: ['rate<0.01'],
|
||||
},
|
||||
}
|
||||
|
||||
const API_URL = __ENV.API_URL || 'http://localhost:3000/api'
|
||||
|
||||
export function setup() {
|
||||
// Create test user for write operations
|
||||
const loginRes = http.post(`${API_URL}/users/login`, JSON.stringify({
|
||||
email: 'test-writer@example.com',
|
||||
password: 'test123',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
return { token: loginRes.json('token') || '' }
|
||||
}
|
||||
|
||||
export default function(data) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
headers['Authorization'] = `JWT ${data.token}`
|
||||
}
|
||||
|
||||
// Mix of read operations
|
||||
const endpoints = [
|
||||
() => http.get(`${API_URL}/posts?limit=10&depth=1`, { headers }),
|
||||
() => http.get(`${API_URL}/categories`, { headers }),
|
||||
() => http.get(`${API_URL}/globals/header`, { headers }),
|
||||
() => http.get(`${API_URL}/globals/footer`, { headers }),
|
||||
]
|
||||
|
||||
const res = endpoints[Math.floor(Math.random() * endpoints.length)]()
|
||||
|
||||
check(res, {
|
||||
'status 200': (r) => r.status === 200,
|
||||
'response < 500ms': (r) => r.timings.duration < 500,
|
||||
})
|
||||
|
||||
errorRate.add(res.status !== 200)
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3: Create Test Runner Scripts
|
||||
|
||||
**File:** `package.json` scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:load": "npm-run-all -p test:load:public test:load:admin",
|
||||
"test:load:public": "k6 run apps/frontend/tests/load/public-browsing.js",
|
||||
"test:load:admin": "k6 run apps/frontend/tests/load/admin-operations.js",
|
||||
"test:load:api": "k6 run apps/backend/tests/load/api-performance.js",
|
||||
"test:load:report": "k6 run --out json=load-test-results.json apps/frontend/tests/load/public-browsing.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4: Cloudflare Workers Limits Validation
|
||||
|
||||
**Create documentation file:** `docs/load-testing-cloudflare-limits.md`
|
||||
|
||||
```markdown
|
||||
# Cloudflare Workers Limits for Load Testing
|
||||
|
||||
## Relevant Limits
|
||||
|
||||
| Resource | Limit | Notes |
|
||||
|----------|-------|-------|
|
||||
| CPU Time | 10ms (Free), 50ms (Paid) | Per request |
|
||||
| Request Timeout | 30 seconds | Wall-clock time |
|
||||
| Concurrent Requests | No limit | But fair use applies |
|
||||
| Workers Requests | 100,000/day (Free) | Paid plan has more |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Monitor CPU time per request during load tests
|
||||
2. Ensure 95th percentile < 50ms CPU time
|
||||
3. Use `wrangler dev` local mode for initial testing
|
||||
4. Test on preview deployment before production
|
||||
|
||||
## Monitoring
|
||||
|
||||
Use Cloudflare Analytics to verify:
|
||||
- Request count by endpoint
|
||||
- CPU usage percentage
|
||||
- Cache hit rate
|
||||
- Error rate
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── frontend/
|
||||
│ └── tests/
|
||||
│ └── load/
|
||||
│ ├── public-browsing.js ← CREATE
|
||||
│ └── admin-operations.js ← CREATE
|
||||
├── backend/
|
||||
│ └── tests/
|
||||
│ └── load/
|
||||
│ └── api-performance.js ← CREATE
|
||||
docs/
|
||||
└── load-testing-cloudflare-limits.md ← CREATE
|
||||
package.json ← MODIFY (add scripts)
|
||||
```
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
### Part 1: Setup
|
||||
- [ ] **Task 1.1**: Install k6
|
||||
- [ ] Add k6 as dev dependency
|
||||
- [ ] Verify installation
|
||||
- [ ] Add npm scripts
|
||||
|
||||
- [ ] **Task 1.2**: Create test directories
|
||||
- [ ] Create apps/frontend/tests/load
|
||||
- [ ] Create apps/backend/tests/load
|
||||
|
||||
### Part 2: Public Browsing Test
|
||||
- [ ] **Task 2.1**: Create public-browsing.js
|
||||
- [ ] Implement 100 user ramp-up
|
||||
- [ ] Add page navigation logic
|
||||
- [ ] Configure thresholds
|
||||
|
||||
- [ ] **Task 2.2**: Run initial test
|
||||
- [ ] Test against local dev server
|
||||
- [ ] Verify results
|
||||
- [ ] Adjust think times if needed
|
||||
|
||||
### Part 3: Admin Operations Test
|
||||
- [ ] **Task 3.1**: Create admin-operations.js
|
||||
- [ ] Implement auth flow
|
||||
- [ ] Add admin operations
|
||||
- [ ] Configure 20 user target
|
||||
|
||||
- [ ] **Task 3.2**: Run admin test
|
||||
- [ ] Verify auth works
|
||||
- [ ] Check performance metrics
|
||||
|
||||
### Part 4: API Performance Test
|
||||
- [ ] **Task 4.1**: Create api-performance.js
|
||||
- [ ] Implement concurrent readers/writers
|
||||
- [ ] Add all key endpoints
|
||||
|
||||
- [ ] **Task 4.2**: Run API test
|
||||
- [ ] Verify API performance
|
||||
- [ ] Check for bottlenecks
|
||||
|
||||
### Part 5: Cloudflare Validation
|
||||
- [ ] **Task 5.1**: Create limits documentation
|
||||
- [ ] **Task 5.2**: Test on Workers environment
|
||||
- [ ] **Task 5.3**: Verify CPU time limits
|
||||
|
||||
### Part 6: Reporting
|
||||
- [ ] **Task 6.1**: Generate test report
|
||||
- [ ] Run all tests
|
||||
- [ ] Generate HTML report
|
||||
- [ ] Document results
|
||||
|
||||
- [ ] **Task 6.2**: Create recommendations
|
||||
- [ ] Identify bottlenecks
|
||||
- [ ] Suggest optimizations
|
||||
- [ ] Document for deployment
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Performance Targets
|
||||
|
||||
| Metric | Target | Threshold |
|
||||
|--------|--------|-----------|
|
||||
| Response Time (p95) | < 500ms | NFR5 |
|
||||
| Error Rate | < 1% | Acceptable |
|
||||
| Concurrent Users | 100 | NFR4 |
|
||||
| Admin Users | 20 | Target |
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Public Browsing** (100 users):
|
||||
- Homepage
|
||||
- About page
|
||||
- Solutions page
|
||||
- Contact page
|
||||
- Blog listing
|
||||
- Portfolio listing
|
||||
|
||||
2. **Admin Operations** (20 users):
|
||||
- List posts
|
||||
- List categories
|
||||
- Get globals (Header/Footer)
|
||||
- List media
|
||||
|
||||
3. **API Performance** (50 readers + 10 writers):
|
||||
- GET /api/posts
|
||||
- GET /api/categories
|
||||
- GET /api/globals/*
|
||||
- Mixed read/write operations
|
||||
|
||||
### Manual Testing Checklist
|
||||
- [ ] k6 installed successfully
|
||||
- [ ] Public browsing test runs
|
||||
- [ ] Admin operations test runs
|
||||
- [ ] API performance test runs
|
||||
- [ ] All tests pass thresholds
|
||||
- [ ] Results documented
|
||||
- [ ] Cloudflare limits validated
|
||||
- [ ] Recommendations created
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Local testing not representative | Medium | Medium | Test on preview deployment |
|
||||
| Cloudflare limits exceeded | Low | High | Monitor CPU time, optimize |
|
||||
| Test data pollution | Medium | Low | Use test environment |
|
||||
| False failures | Low | Low | Calibrate thresholds properly |
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] k6 installed and configured
|
||||
- [ ] Public browsing test script created
|
||||
- [ ] Admin operations test script created
|
||||
- [ ] API performance test script created
|
||||
- [ ] All tests run successfully
|
||||
- [ ] Performance targets met (p95 < 500ms, errors < 1%)
|
||||
- [ ] 100 concurrent user test passed
|
||||
- [ ] Cloudflare Workers limits validated
|
||||
- [ ] Test report generated
|
||||
- [ ] Recommendations documented
|
||||
- [ ] sprint-status.yaml updated
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
*To be filled by Dev Agent*
|
||||
|
||||
### Debug Log References
|
||||
*To be filled by Dev Agent*
|
||||
|
||||
### Completion Notes
|
||||
*To be filled by Dev Agent*
|
||||
|
||||
### File List
|
||||
*To be filled by Dev Agent*
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Action | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-01-31 | Story created (Draft) | SM Agent (Bob) |
|
||||
Reference in New Issue
Block a user