From a8e1a438b9dd19327a9cfe7e84b2ac470bc1fccb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 08:41:15 +0700 Subject: [PATCH] feat(load-tests): add K6 load testing suite for critical API paths K6 scripts for 4 critical paths: - Auth (100 VU): login, register, profile - Listings (500 VU): search with filters, detail view - Search (200 VU): full-text + geo search - Payments (50 VU): create payment, list transactions SLA thresholds: p50<200ms, p95<500ms, p99<1s, error<1%. CI: manual workflow_dispatch with suite selector. Co-Authored-By: Paperclip --- .github/workflows/load-test.yml | 102 +++++++++++++++++++++++++++ load-tests/README.md | 72 +++++++++++++++++++ load-tests/lib/config.js | 61 ++++++++++++++++ load-tests/scripts/auth.js | 121 ++++++++++++++++++++++++++++++++ load-tests/scripts/listings.js | 116 ++++++++++++++++++++++++++++++ load-tests/scripts/payments.js | 95 +++++++++++++++++++++++++ load-tests/scripts/search.js | 96 +++++++++++++++++++++++++ 7 files changed, 663 insertions(+) create mode 100644 .github/workflows/load-test.yml create mode 100644 load-tests/README.md create mode 100644 load-tests/lib/config.js create mode 100644 load-tests/scripts/auth.js create mode 100644 load-tests/scripts/listings.js create mode 100644 load-tests/scripts/payments.js create mode 100644 load-tests/scripts/search.js diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 0000000..6908ed1 --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,102 @@ +name: Load Tests (K6) + +on: + workflow_dispatch: + inputs: + suite: + description: 'Test suite to run' + required: true + default: 'all' + type: choice + options: + - all + - auth + - listings + - search + - payments + +concurrency: + group: load-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + load-test: + name: K6 Load Test — ${{ inputs.suite }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_DB: goodgo_test + POSTGRES_USER: goodgo + POSTGRES_PASSWORD: goodgo_test_secret + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U goodgo -d goodgo_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgresql://goodgo:goodgo_test_secret@localhost:5432/goodgo_test + NODE_ENV: test + JWT_SECRET: load-test-jwt-secret-key + JWT_REFRESH_SECRET: load-test-refresh-secret-key + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install K6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install k6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run database migrations + run: pnpm db:migrate:deploy + + - name: Build and start API + run: | + pnpm --filter @goodgo/api run build + pnpm --filter @goodgo/api run start & + sleep 10 + + - name: Run load tests + run: | + mkdir -p results + if [ "${{ inputs.suite }}" = "all" ]; then + for f in load-tests/scripts/*.js; do + name=$(basename "$f" .js) + echo "=== Running $name ===" + k6 run --out json="results/${name}.json" "$f" || true + done + else + k6 run --out json="results/${{ inputs.suite }}.json" \ + "load-tests/scripts/${{ inputs.suite }}.js" + fi + + - name: Upload results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: k6-results-${{ inputs.suite }} + path: results/ + retention-days: 30 diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 0000000..3caa3aa --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,72 @@ +# K6 Load Testing — Goodgo Platform + +## Overview + +Performance load tests for critical API paths using [K6](https://k6.io/). + +## Test Suites + +| Script | Target | Peak VUs | Duration | +|--------|--------|----------|----------| +| `auth.js` | Login/Register | 100 | 2min | +| `listings.js` | Search + Detail | 500 | 3min | +| `search.js` | Text + Geo search | 200 | 3min | +| `payments.js` | Create + List | 50 | 2min | + +## SLA Thresholds + +| Metric | Threshold | +|--------|-----------| +| p50 latency | < 200ms | +| p95 latency | < 500ms | +| p99 latency | < 1000ms | +| Error rate | < 1% | + +## Prerequisites + +```bash +# Install K6 +brew install k6 # macOS +# or: https://grafana.com/docs/k6/latest/set-up/install-k6/ + +# Start the API +pnpm --filter @goodgo/api run dev +``` + +## Running Tests + +```bash +# Run individual suite +k6 run load-tests/scripts/auth.js +k6 run load-tests/scripts/listings.js +k6 run load-tests/scripts/search.js +k6 run load-tests/scripts/payments.js + +# Run against a custom API URL +k6 run -e API_BASE_URL=https://staging.goodgo.vn load-tests/scripts/auth.js + +# Run all suites sequentially +for f in load-tests/scripts/*.js; do k6 run "$f"; done + +# Export results as JSON +k6 run --out json=results/auth.json load-tests/scripts/auth.js +``` + +## CI Integration + +The `load-test.yml` workflow runs as an optional manual stage in GitHub Actions. +Trigger via `workflow_dispatch` with a suite selector. + +## Directory Structure + +``` +load-tests/ +├── lib/ +│ └── config.js # Shared config, helpers, SLA thresholds +├── scripts/ +│ ├── auth.js # Auth flow load tests +│ ├── listings.js # Listings search + detail +│ ├── search.js # Full-text + geo search +│ └── payments.js # Payment creation + listing +└── README.md +``` diff --git a/load-tests/lib/config.js b/load-tests/lib/config.js new file mode 100644 index 0000000..872f3c1 --- /dev/null +++ b/load-tests/lib/config.js @@ -0,0 +1,61 @@ +/** + * Shared K6 configuration and helpers for Goodgo load tests. + */ + +export const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3001'; + +/** Standard SLA thresholds — all endpoints must meet these. */ +export const SLA_THRESHOLDS = { + http_req_duration: ['p(50)<200', 'p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], // <1% error rate +}; + +/** Generate a unique phone number for test users. */ +export function uniquePhone() { + const suffix = String(Date.now()).slice(-8); + return `09${suffix}`; +} + +/** Register a test user and return { accessToken, refreshToken }. */ +export function registerTestUser(http, suffix) { + const phone = suffix || uniquePhone(); + const payload = JSON.stringify({ + phone, + password: 'LoadTest@1234!', + fullName: `K6 User ${phone}`, + email: `k6-${phone}@goodgo.test`, + }); + + const res = http.post(`${BASE_URL}/auth/register`, payload, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'setup_register' }, + }); + + if (res.status === 201 || res.status === 200) { + return JSON.parse(res.body); + } + + // User may already exist — try login + const loginRes = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ phone, password: 'LoadTest@1234!' }), + { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'setup_login' }, + }, + ); + + if (loginRes.status === 200 || loginRes.status === 201) { + return JSON.parse(loginRes.body); + } + + return null; +} + +/** Build auth headers from an access token. */ +export function authHeaders(accessToken) { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; +} diff --git a/load-tests/scripts/auth.js b/load-tests/scripts/auth.js new file mode 100644 index 0000000..83e6da5 --- /dev/null +++ b/load-tests/scripts/auth.js @@ -0,0 +1,121 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { BASE_URL, SLA_THRESHOLDS, uniquePhone } from '../lib/config.js'; + +const loginDuration = new Trend('login_duration', true); +const registerDuration = new Trend('register_duration', true); +const loginFailRate = new Rate('login_failures'); + +export const options = { + scenarios: { + auth_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 50 }, // ramp up + { duration: '1m', target: 100 }, // sustain 100 concurrent + { duration: '30s', target: 0 }, // ramp down + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + ...SLA_THRESHOLDS, + login_duration: ['p(95)<500'], + register_duration: ['p(95)<800'], + login_failures: ['rate<0.05'], + }, +}; + +export function setup() { + // Pre-register a pool of users for login tests + const users = []; + for (let i = 0; i < 50; i++) { + const phone = `0910${String(i).padStart(6, '0')}`; + const payload = JSON.stringify({ + phone, + password: 'LoadTest@1234!', + fullName: `K6 Auth User ${i}`, + email: `k6-auth-${i}@goodgo.test`, + }); + + const res = http.post(`${BASE_URL}/auth/register`, payload, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'setup_register' }, + }); + + if (res.status === 200 || res.status === 201 || res.status === 409) { + users.push({ phone, password: 'LoadTest@1234!' }); + } + } + return { users }; +} + +export default function (data) { + const vu = __VU; + const iter = __ITER; + + // Alternate between register and login + if (iter % 3 === 0) { + // Register new user + const phone = uniquePhone(); + const payload = JSON.stringify({ + phone, + password: 'LoadTest@1234!', + fullName: `K6 New User ${phone}`, + email: `k6-new-${phone}@goodgo.test`, + }); + + const res = http.post(`${BASE_URL}/auth/register`, payload, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'POST /auth/register' }, + }); + + registerDuration.add(res.timings.duration); + check(res, { + 'register: status 200|201': (r) => r.status === 200 || r.status === 201, + 'register: has accessToken': (r) => { + try { return JSON.parse(r.body).accessToken !== undefined; } catch { return false; } + }, + }); + } else { + // Login existing user + const user = data.users[vu % data.users.length]; + if (!user) return; + + const res = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ phone: user.phone, password: user.password }), + { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'POST /auth/login' }, + }, + ); + + loginDuration.add(res.timings.duration); + const ok = check(res, { + 'login: status 200|201': (r) => r.status === 200 || r.status === 201, + 'login: has accessToken': (r) => { + try { return JSON.parse(r.body).accessToken !== undefined; } catch { return false; } + }, + }); + loginFailRate.add(!ok); + + // Optionally hit profile with the token + if (res.status === 200 || res.status === 201) { + try { + const tokens = JSON.parse(res.body); + const profileRes = http.get(`${BASE_URL}/auth/profile`, { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + tags: { name: 'GET /auth/profile' }, + }); + check(profileRes, { + 'profile: status 200': (r) => r.status === 200, + }); + } catch (_) { /* ignore parse errors */ } + } + } + + sleep(Math.random() * 2 + 0.5); +} diff --git a/load-tests/scripts/listings.js b/load-tests/scripts/listings.js new file mode 100644 index 0000000..2ec08b7 --- /dev/null +++ b/load-tests/scripts/listings.js @@ -0,0 +1,116 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, SLA_THRESHOLDS, registerTestUser, authHeaders } from '../lib/config.js'; + +const searchDuration = new Trend('listings_search_duration', true); +const detailDuration = new Trend('listings_detail_duration', true); + +export const options = { + scenarios: { + listings_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 100 }, + { duration: '2m', target: 500 }, // peak: 500 concurrent + { duration: '30s', target: 0 }, + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + ...SLA_THRESHOLDS, + listings_search_duration: ['p(95)<500'], + listings_detail_duration: ['p(95)<300'], + }, +}; + +export function setup() { + // Register a user and create some listings for search/detail tests + const tokens = registerTestUser(http); + if (!tokens) return { accessToken: null, listingIds: [] }; + + const listingIds = []; + const cities = ['Hà Nội', 'TP. Hồ Chí Minh', 'Đà Nẵng']; + const types = ['APARTMENT', 'HOUSE', 'LAND']; + + for (let i = 0; i < 10; i++) { + const listing = { + title: `K6 Load Test Listing ${i}`, + description: `Performance test listing #${i} for load testing`, + transactionType: i % 2 === 0 ? 'SALE' : 'RENT', + propertyType: types[i % types.length], + address: `${100 + i} Đường Tải Thử`, + ward: 'Phường 1', + district: 'Quận 1', + city: cities[i % cities.length], + latitude: 10.7769 + (i * 0.001), + longitude: 106.7009 + (i * 0.001), + area: 50 + (i * 10), + bedrooms: 1 + (i % 4), + bathrooms: 1 + (i % 3), + floors: 1 + (i % 3), + priceVND: 1000000000 + (i * 500000000), + direction: 'EAST', + }; + + const res = http.post( + `${BASE_URL}/listings`, + JSON.stringify(listing), + { headers: authHeaders(tokens.accessToken), tags: { name: 'setup_create_listing' } }, + ); + + if (res.status === 201 || res.status === 200) { + try { + listingIds.push(JSON.parse(res.body).id); + } catch (_) { /* skip */ } + } + } + + return { accessToken: tokens.accessToken, listingIds }; +} + +export default function (data) { + const iter = __ITER; + + if (iter % 3 === 0 && data.listingIds.length > 0) { + // Detail view — pick a random listing + const id = data.listingIds[iter % data.listingIds.length]; + const res = http.get(`${BASE_URL}/listings/${id}`, { + tags: { name: 'GET /listings/:id' }, + }); + + detailDuration.add(res.timings.duration); + check(res, { + 'detail: status 200': (r) => r.status === 200, + 'detail: has id': (r) => { + try { return JSON.parse(r.body).id !== undefined; } catch { return false; } + }, + }); + } else { + // Search listings with various filters + const queries = [ + '?limit=20&offset=0', + '?propertyType=APARTMENT&limit=10', + '?transactionType=SALE&limit=10', + '?city=Hà Nội&limit=10', + '?limit=20&offset=20', + ]; + const query = queries[iter % queries.length]; + + const res = http.get(`${BASE_URL}/listings${query}`, { + tags: { name: 'GET /listings (search)' }, + }); + + searchDuration.add(res.timings.duration); + check(res, { + 'search: status 200': (r) => r.status === 200, + 'search: has data array': (r) => { + try { return Array.isArray(JSON.parse(r.body).data); } catch { return false; } + }, + }); + } + + sleep(Math.random() * 1 + 0.3); +} diff --git a/load-tests/scripts/payments.js b/load-tests/scripts/payments.js new file mode 100644 index 0000000..4dcdcc6 --- /dev/null +++ b/load-tests/scripts/payments.js @@ -0,0 +1,95 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { BASE_URL, SLA_THRESHOLDS, registerTestUser, authHeaders } from '../lib/config.js'; + +const paymentCreateDuration = new Trend('payment_create_duration', true); +const paymentListDuration = new Trend('payment_list_duration', true); +const paymentFailRate = new Rate('payment_failures'); + +export const options = { + scenarios: { + payments_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, // peak: 50 concurrent + { duration: '30s', target: 0 }, + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + ...SLA_THRESHOLDS, + payment_create_duration: ['p(95)<500'], + payment_list_duration: ['p(95)<300'], + payment_failures: ['rate<0.05'], + }, +}; + +export function setup() { + // Register multiple users for payment tests + const tokens = []; + for (let i = 0; i < 10; i++) { + const phone = `0920${String(i).padStart(6, '0')}`; + const t = registerTestUser(http, phone); + if (t) tokens.push(t.accessToken); + } + return { tokens }; +} + +export default function (data) { + if (!data.tokens.length) return; + + const token = data.tokens[__VU % data.tokens.length]; + const headers = authHeaders(token); + const iter = __ITER; + + if (iter % 3 === 0) { + // List transactions + const res = http.get(`${BASE_URL}/payments?limit=10&offset=0`, { + headers, + tags: { name: 'GET /payments (list)' }, + }); + + paymentListDuration.add(res.timings.duration); + check(res, { + 'list payments: status 200': (r) => r.status === 200, + 'list payments: has data': (r) => { + try { return JSON.parse(r.body).data !== undefined; } catch { return false; } + }, + }); + } else { + // Create payment + const amounts = [100000, 500000, 1000000, 2500000, 5000000]; + const payload = JSON.stringify({ + provider: 'VNPAY', + type: 'LISTING_FEE', + amountVND: amounts[iter % amounts.length], + description: `K6 load test payment ${__VU}-${iter}`, + returnUrl: 'http://localhost:3000/payments/callback', + }); + + const res = http.post(`${BASE_URL}/payments`, payload, { + headers, + tags: { name: 'POST /payments (create)' }, + }); + + paymentCreateDuration.add(res.timings.duration); + + // Payment gateway may not be available — 502/503 is acceptable in test + const ok = check(res, { + 'create payment: status 201|502|503': (r) => + r.status === 201 || r.status === 502 || r.status === 503, + 'create payment: has id or gateway error': (r) => { + if (r.status >= 500) return true; // gateway unavailable + try { return JSON.parse(r.body).id !== undefined; } catch { return false; } + }, + }); + + if (!ok) paymentFailRate.add(1); + } + + sleep(Math.random() * 2 + 1); +} diff --git a/load-tests/scripts/search.js b/load-tests/scripts/search.js new file mode 100644 index 0000000..02ad95b --- /dev/null +++ b/load-tests/scripts/search.js @@ -0,0 +1,96 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, SLA_THRESHOLDS } from '../lib/config.js'; + +const textSearchDuration = new Trend('text_search_duration', true); +const geoSearchDuration = new Trend('geo_search_duration', true); + +export const options = { + scenarios: { + search_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 50 }, + { duration: '2m', target: 200 }, // peak: 200 concurrent + { duration: '30s', target: 0 }, + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + ...SLA_THRESHOLDS, + text_search_duration: ['p(95)<500'], + geo_search_duration: ['p(95)<500'], + }, +}; + +const TEXT_QUERIES = [ + 'căn hộ', + 'nhà phố quận 1', + 'đất nền', + 'chung cư', + 'biệt thự', + 'apartment Ho Chi Minh', + 'house Hanoi', +]; + +const GEO_SEARCHES = [ + { lat: 10.7769, lng: 106.7009, radius: 5000 }, // HCM center + { lat: 21.0285, lng: 105.8542, radius: 5000 }, // Hanoi center + { lat: 16.0544, lng: 108.2022, radius: 10000 }, // Da Nang + { lat: 10.8231, lng: 106.6297, radius: 3000 }, // HCM Tan Binh + { lat: 21.0067, lng: 105.8400, radius: 2000 }, // Hanoi Ba Dinh +]; + +export default function () { + const iter = __ITER; + + if (iter % 2 === 0) { + // Full-text search + const query = TEXT_QUERIES[iter % TEXT_QUERIES.length]; + const params = new URLSearchParams({ + q: query, + limit: '20', + offset: '0', + }); + + const res = http.get(`${BASE_URL}/search?${params.toString()}`, { + tags: { name: 'GET /search (text)' }, + }); + + textSearchDuration.add(res.timings.duration); + check(res, { + 'text search: status 200|503': (r) => r.status === 200 || r.status === 503, + 'text search: valid response': (r) => { + if (r.status === 503) return true; // Typesense unavailable — acceptable + try { return JSON.parse(r.body).data !== undefined; } catch { return false; } + }, + }); + } else { + // Geo search + const geo = GEO_SEARCHES[iter % GEO_SEARCHES.length]; + const params = new URLSearchParams({ + lat: String(geo.lat), + lng: String(geo.lng), + radius: String(geo.radius), + limit: '20', + }); + + const res = http.get(`${BASE_URL}/search/geo?${params.toString()}`, { + tags: { name: 'GET /search/geo' }, + }); + + geoSearchDuration.add(res.timings.duration); + check(res, { + 'geo search: status 200|503': (r) => r.status === 200 || r.status === 503, + 'geo search: valid response': (r) => { + if (r.status === 503) return true; + try { return JSON.parse(r.body).data !== undefined; } catch { return false; } + }, + }); + } + + sleep(Math.random() * 1 + 0.5); +}