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 <noreply@paperclip.ing>
This commit is contained in:
102
.github/workflows/load-test.yml
vendored
Normal file
102
.github/workflows/load-test.yml
vendored
Normal file
@@ -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
|
||||||
72
load-tests/README.md
Normal file
72
load-tests/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
61
load-tests/lib/config.js
Normal file
61
load-tests/lib/config.js
Normal file
@@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
121
load-tests/scripts/auth.js
Normal file
121
load-tests/scripts/auth.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
116
load-tests/scripts/listings.js
Normal file
116
load-tests/scripts/listings.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
95
load-tests/scripts/payments.js
Normal file
95
load-tests/scripts/payments.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
96
load-tests/scripts/search.js
Normal file
96
load-tests/scripts/search.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user