Files
website-enchun-mgr/_bmad-output/implementation-artifacts/1-17-load-testing.story.md
pkupuk e9897388dc docs: separate documentation and specs into initial commit
Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
2026-02-11 11:49:49 +08:00

13 KiB

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

  1. AC3 - Public Browsing Test: 100 concurrent users browsing pages
  2. AC4 - Admin Operations Test: 20 concurrent admin users
  3. AC5 - API Performance Test: Payload CMS API endpoints under load

Part 3: Performance Targets

  1. AC6 - Response Time: 95th percentile response time < 500ms
  2. AC7 - Error Rate: Error rate < 1%
  3. AC8 - Cloudflare Limits: Validated within Workers limits

Part 4: Reporting

  1. AC9 - Test Report: Generated report with results and recommendations
  2. 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:

# 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

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

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

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

{
  "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

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