# 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) |