docs: add K6 endpoints summary and quick start guide
- K6_ENDPOINTS_SUMMARY.md: Quick reference for all API endpoints with request/response shapes - K6_QUICK_START.md: Practical guide with executable examples for search, auth, listing, and payment load tests - Includes example K6 scripts, CI integration template, and troubleshooting - Complete with load test scenarios and reporting options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
342
K6_ENDPOINTS_SUMMARY.md
Normal file
342
K6_ENDPOINTS_SUMMARY.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# GoodGo Platform — K6 Load Testing Endpoints Summary
|
||||||
|
|
||||||
|
Quick reference for all testable API endpoints.
|
||||||
|
|
||||||
|
## 📍 Base URL
|
||||||
|
```
|
||||||
|
http://localhost:3001/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Authentication (Auth Module)
|
||||||
|
|
||||||
|
### Public Endpoints (No Auth Required)
|
||||||
|
|
||||||
|
| Method | Path | Rate Limit | Purpose |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| **POST** | `/auth/register` | 5/hour | Register new user with phone/password/name |
|
||||||
|
| **POST** | `/auth/login` | 5/hour | Login with phone/password (basic auth) |
|
||||||
|
| **POST** | `/auth/refresh` | 5/hour | Refresh expired access token |
|
||||||
|
| **POST** | `/auth/logout` | None | Clear auth cookies |
|
||||||
|
| **POST** | `/auth/exchange-token` | None | Exchange OAuth tokens for httpOnly cookies |
|
||||||
|
|
||||||
|
### Protected Endpoints (JWT Required)
|
||||||
|
|
||||||
|
| Method | Path | Rate Limit | Purpose |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| **GET** | `/auth/profile` | None | Get authenticated user profile |
|
||||||
|
| **GET** | `/auth/profile/agent` | None | Get agent profile (if user is agent) |
|
||||||
|
|
||||||
|
### Admin Only
|
||||||
|
|
||||||
|
| Method | Path | Rate Limit | Auth | Purpose |
|
||||||
|
|--------|------|-----------|------|---------|
|
||||||
|
| **PATCH** | `/auth/kyc` | None | JWT+Admin | Verify/update user KYC status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 Listings (Listings Module)
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Rate Limit | Purpose |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| **GET** | `/listings` | None | Search/filter listings (queryable) |
|
||||||
|
| **GET** | `/listings/:id` | None | Get listing detail by ID |
|
||||||
|
|
||||||
|
### Protected Endpoints (JWT Required)
|
||||||
|
|
||||||
|
| Method | Path | Quota Gate | Purpose |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| **POST** | `/listings` | Yes | Create new property listing |
|
||||||
|
| **PATCH** | `/listings/:id/status` | No | Update listing status (owner only) |
|
||||||
|
| **POST** | `/listings/:id/media` | No | Upload photo/video for listing (owner only) |
|
||||||
|
|
||||||
|
### Admin Only
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| **GET** | `/listings/pending` | Get listings awaiting moderation (paginated) |
|
||||||
|
| **PATCH** | `/listings/:id/moderate` | Approve/reject listing & score it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💳 Payments (Payments Module)
|
||||||
|
|
||||||
|
### Protected Endpoints (JWT Required)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| **POST** | `/payments` | Create payment (initiates payment flow) |
|
||||||
|
| **GET** | `/payments` | List user's transactions (paginated) |
|
||||||
|
| **GET** | `/payments/:id` | Get payment status by ID |
|
||||||
|
|
||||||
|
### Admin Only
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| **POST** | `/payments/:id/refund` | Initiate refund for payment |
|
||||||
|
|
||||||
|
### Webhook (Unthrottled, No Auth)
|
||||||
|
|
||||||
|
| Method | Path | Rate Limit | Purpose |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| **POST** | `/payments/callback/:provider` | 20/min | Handle payment provider callbacks (VNPay, MoMo, ZaloPay) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Search (Search Module)
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose | Query Params |
|
||||||
|
|--------|------|---------|--------------|
|
||||||
|
| **GET** | `/search` | Full-text search listings | q, propertyType, transactionType, priceMin/Max, areaMin/Max, bedrooms, district, city, sortBy, page, perPage |
|
||||||
|
| **GET** | `/search/geo` | Geographic radius search | lat, lng, radiusKm, propertyType, transactionType, priceMin/Max, sortBy, page, perPage |
|
||||||
|
|
||||||
|
### Admin Only
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| **POST** | `/search/reindex` | Reindex all properties in search engine |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Key Data Shapes
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
```json
|
||||||
|
POST /auth/register
|
||||||
|
{
|
||||||
|
"phone": "0901234567",
|
||||||
|
"password": "SecurePass123!",
|
||||||
|
"fullName": "Nguyen Van A",
|
||||||
|
"email": "user@example.com" // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Login
|
||||||
|
```json
|
||||||
|
POST /auth/login
|
||||||
|
{
|
||||||
|
"phone": "0901234567",
|
||||||
|
"password": "SecurePass123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Listing (Minimal)
|
||||||
|
```json
|
||||||
|
POST /listings
|
||||||
|
{
|
||||||
|
"transactionType": "SALE",
|
||||||
|
"priceVND": "5500000000",
|
||||||
|
"propertyType": "APARTMENT",
|
||||||
|
"title": "Căn hộ 3PN view sông",
|
||||||
|
"description": "Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ...",
|
||||||
|
"address": "208 Nguyễn Hữu Cảnh",
|
||||||
|
"ward": "Phường 22",
|
||||||
|
"district": "Bình Thạnh",
|
||||||
|
"city": "Hồ Chí Minh",
|
||||||
|
"latitude": 10.7942,
|
||||||
|
"longitude": 106.7219,
|
||||||
|
"areaM2": 85.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Listings
|
||||||
|
```json
|
||||||
|
GET /listings?transactionType=SALE&city=Hồ Chi Minh&minPrice=2000000000&maxPrice=10000000000&page=1&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full-Text Search
|
||||||
|
```json
|
||||||
|
GET /search?q=chung cu quan 7&propertyType=apartment&transactionType=sale&page=1&perPage=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geo Search
|
||||||
|
```json
|
||||||
|
GET /search/geo?lat=10.7769&lng=106.7009&radiusKm=5&transactionType=sale&page=1&perPage=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Payment
|
||||||
|
```json
|
||||||
|
POST /payments
|
||||||
|
{
|
||||||
|
"provider": "VNPAY",
|
||||||
|
"type": "LISTING_FEE",
|
||||||
|
"amountVND": 500000,
|
||||||
|
"description": "Listing fee",
|
||||||
|
"returnUrl": "https://example.com/return",
|
||||||
|
"idempotencyKey": "uuid-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 K6 Test Scenarios
|
||||||
|
|
||||||
|
### Load Test 1: Search Load (High Volume, Public)
|
||||||
|
- **Endpoint**: `GET /search` & `GET /search/geo`
|
||||||
|
- **Load Profile**: Ramp 100 → 1000 users over 10 min
|
||||||
|
- **Success Criteria**: p(95) < 500ms, p(99) < 1000ms
|
||||||
|
- **Expected**: Public search should handle high concurrent load
|
||||||
|
|
||||||
|
### Load Test 2: Authentication (Moderate Load, Gated)
|
||||||
|
- **Endpoint**: `POST /auth/register`, `POST /auth/login`
|
||||||
|
- **Load Profile**: Ramp 10 → 100 users
|
||||||
|
- **Rate Limit**: 5/hour per endpoint
|
||||||
|
- **Success Criteria**: All requests within rate limit, p(95) < 300ms
|
||||||
|
- **Expected**: Should gracefully reject over-limit requests
|
||||||
|
|
||||||
|
### Load Test 3: Listing Creation (Low Load, Quota Gated)
|
||||||
|
- **Endpoint**: `POST /listings`
|
||||||
|
- **Load Profile**: Ramp 5 → 50 users over 5 min
|
||||||
|
- **Rate Limit**: Quota-gated (per subscription plan)
|
||||||
|
- **Success Criteria**: 201 for successful creates, 403 for quota exceeded
|
||||||
|
- **Expected**: Quota guard enforces plan limits
|
||||||
|
|
||||||
|
### Load Test 4: Payment Processing (Medium Load, Unthrottled)
|
||||||
|
- **Endpoint**: `POST /payments`
|
||||||
|
- **Load Profile**: Ramp 20 → 200 users
|
||||||
|
- **Dependencies**: Requires authenticated session (JWT)
|
||||||
|
- **Success Criteria**: p(95) < 1s (external provider latency)
|
||||||
|
- **Expected**: System handles payment initiation concurrently
|
||||||
|
|
||||||
|
### Load Test 5: Payment Webhooks (High Volume, Throttled)
|
||||||
|
- **Endpoint**: `POST /payments/callback/vnpay`
|
||||||
|
- **Load Profile**: Sustained 20 requests/min (rate limit)
|
||||||
|
- **Rate Limit**: 20/min enforced
|
||||||
|
- **Success Criteria**: All requests process within rate limit
|
||||||
|
- **Expected**: Webhook queue prevents abuse
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Authentication Flow for K6
|
||||||
|
|
||||||
|
### Cookie-Based Flow (Recommended)
|
||||||
|
```javascript
|
||||||
|
// 1. Register/Login
|
||||||
|
const loginRes = http.post(`${BASE_URL}/auth/login`, {
|
||||||
|
phone, password
|
||||||
|
});
|
||||||
|
// Cookies: access_token, refresh_token, goodgo_authenticated
|
||||||
|
|
||||||
|
// 2. Use cookies for authenticated requests
|
||||||
|
const profileRes = http.get(`${BASE_URL}/auth/profile`);
|
||||||
|
// Browser automatically sends cookies
|
||||||
|
|
||||||
|
// 3. Refresh when needed
|
||||||
|
const refreshRes = http.post(`${BASE_URL}/auth/refresh`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token-Based Flow (Alternative)
|
||||||
|
```javascript
|
||||||
|
// 1. Capture tokens from register/login
|
||||||
|
const loginRes = http.post(`${BASE_URL}/auth/login`, { phone, password });
|
||||||
|
const { accessToken, refreshToken } = loginRes.json();
|
||||||
|
|
||||||
|
// 2. Use Bearer token header
|
||||||
|
const params = {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
};
|
||||||
|
const profileRes = http.get(`${BASE_URL}/auth/profile`, params);
|
||||||
|
|
||||||
|
// 3. Refresh access token
|
||||||
|
const refreshRes = http.post(`${BASE_URL}/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏪 Test Data Resources
|
||||||
|
|
||||||
|
### Available Seed Data
|
||||||
|
- **Users**: Various roles (USER, AGENT, ADMIN)
|
||||||
|
- **Listings**: Properties across Ho Chi Minh City districts
|
||||||
|
- **Districts/Wards**: Vietnamese administrative data
|
||||||
|
- **Property Types**: APARTMENT, HOUSE, LAND, SHOP, etc.
|
||||||
|
- **Transaction Types**: SALE, RENT
|
||||||
|
|
||||||
|
### Test Fixtures
|
||||||
|
- See `e2e/fixtures.ts` for test data generators
|
||||||
|
- Use `createTestUser()` to generate unique test users
|
||||||
|
- Test database seeded in `global-setup.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Quick K6 Script Template
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group, sleep } from 'k6';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 100 }, // Ramp up
|
||||||
|
{ duration: '5m', target: 100 }, // Sustain
|
||||||
|
{ duration: '2m', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||||||
|
http_req_failed: ['rate<0.1'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
group('Search - Public, High Volume', () => {
|
||||||
|
const res = http.get(`${BASE_URL}/search?q=chung cu&page=1&perPage=20`);
|
||||||
|
check(res, { 'status is 200': (r) => r.status === 200 });
|
||||||
|
sleep(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Geo Search - Public', () => {
|
||||||
|
const res = http.get(`${BASE_URL}/search/geo?lat=10.77&lng=106.70&radiusKm=5&perPage=20`);
|
||||||
|
check(res, { 'status is 200': (r) => r.status === 200 });
|
||||||
|
sleep(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Important Rate Limits
|
||||||
|
|
||||||
|
| Endpoint | Limit | Window |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| `/auth/register` | 5 | per hour |
|
||||||
|
| `/auth/login` | 5 | per hour |
|
||||||
|
| `/auth/refresh` | 5 | per hour |
|
||||||
|
| `/payments/callback/*` | 20 | per minute |
|
||||||
|
| Others | None | (quota gates apply instead) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Files to Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
K6_LOAD_TESTING_GUIDE.md # Comprehensive guide (THIS FILE IS SUMMARY OF)
|
||||||
|
apps/api/src/modules/*/presentation/controllers/
|
||||||
|
apps/api/src/modules/*/presentation/dto/
|
||||||
|
e2e/fixtures.ts # Test data generators
|
||||||
|
e2e/api/ # Existing E2E tests (reference)
|
||||||
|
.env.example # Environment setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist Before Running K6
|
||||||
|
|
||||||
|
- [ ] API running: `pnpm dev`
|
||||||
|
- [ ] Database seeded: `pnpm db:seed`
|
||||||
|
- [ ] Test database migrated: `.env.test` configured
|
||||||
|
- [ ] K6 installed: `brew install k6` or Docker
|
||||||
|
- [ ] JWT_SECRET set in `.env`
|
||||||
|
- [ ] Base URL correct: `http://localhost:3001/api/v1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
659
K6_QUICK_START.md
Normal file
659
K6_QUICK_START.md
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
# K6 Load Testing — Quick Start Guide
|
||||||
|
|
||||||
|
Get started with K6 load tests for GoodGo Platform API in minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1️⃣ Installation
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
```bash
|
||||||
|
brew install k6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
```bash
|
||||||
|
apt-get install k6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker pull grafana/k6:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Installation
|
||||||
|
```bash
|
||||||
|
k6 version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Setup Test Environment
|
||||||
|
|
||||||
|
### Start API & Database
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start all services
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Terminal 2: Seed test database (if needed)
|
||||||
|
pnpm db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify API is Running
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/v1/docs
|
||||||
|
# Should return Swagger UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ Create Your First K6 Test
|
||||||
|
|
||||||
|
### Simple Search Load Test
|
||||||
|
|
||||||
|
Create file: `load-tests/search.k6.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||||
|
{ duration: '2m', target: 50 }, // Stay at 50 users
|
||||||
|
{ duration: '1m', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
// Full-text search
|
||||||
|
const searchRes = http.get(
|
||||||
|
`${BASE_URL}/search?q=chung cu&page=1&perPage=20`
|
||||||
|
);
|
||||||
|
|
||||||
|
check(searchRes, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'response time < 500ms': (r) => r.timings.duration < 500,
|
||||||
|
'has results': (r) => r.body.includes('items'),
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// Geo search
|
||||||
|
const geoRes = http.get(
|
||||||
|
`${BASE_URL}/search/geo?lat=10.7769&lng=106.7009&radiusKm=5`
|
||||||
|
);
|
||||||
|
|
||||||
|
check(geoRes, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'response time < 500ms': (r) => r.timings.duration < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4️⃣ Run Your First Test
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Base URL
|
||||||
|
```bash
|
||||||
|
BASE_URL=http://localhost:3001/api/v1 k6 run load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Docker
|
||||||
|
```bash
|
||||||
|
docker run -i grafana/k6 run - < load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
```
|
||||||
|
/\ |‾‾| /‾‾/‾‾ |‾‾| /‾‾/‾‾
|
||||||
|
/ \ | |/ / | |/ /
|
||||||
|
/ \ | ( | (
|
||||||
|
/ \ | |\ \ | |\ \
|
||||||
|
/ \ |__| \_/\_/|__| \_/\_/
|
||||||
|
|
||||||
|
execution: local
|
||||||
|
script: load-tests/search.k6.js
|
||||||
|
output: -
|
||||||
|
|
||||||
|
scenarios: (100.00%) 1 local scenario, 50 max VUs, 4m30s max duration
|
||||||
|
|
||||||
|
✓ checks........................ 100%
|
||||||
|
✓ http_req_duration............. 96% ✓ 0 ✗ 12
|
||||||
|
✓ http_req_failed............... 0%
|
||||||
|
|
||||||
|
data_received........: 512 kB (1.8 kB/s)
|
||||||
|
data_sent..............: 45 kB (250 B/s)
|
||||||
|
http_reqs..............: 120 (0.44/s)
|
||||||
|
iteration_duration.....: 4s
|
||||||
|
iterations.............: 60 (0.22/s)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5️⃣ Authentication Test
|
||||||
|
|
||||||
|
Create file: `load-tests/auth.k6.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { Counter, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
const loginCounter = new Counter('login_success');
|
||||||
|
const loginDuration = new Trend('login_duration');
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 10 }, // 10 users
|
||||||
|
{ duration: '1m', target: 10 }, // Hold for 1 min
|
||||||
|
{ duration: '30s', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<300', 'p(99)<500'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate unique phone for each user
|
||||||
|
function generatePhone() {
|
||||||
|
const timestamp = new Date().getTime();
|
||||||
|
const random = Math.floor(Math.random() * 1000000);
|
||||||
|
return `09${String(timestamp + random).slice(-8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
const phone = generatePhone();
|
||||||
|
const password = 'TestPass123!';
|
||||||
|
const fullName = 'Load Test User';
|
||||||
|
|
||||||
|
// Register
|
||||||
|
const registerRes = http.post(`${BASE_URL}/auth/register`, {
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
fullName,
|
||||||
|
});
|
||||||
|
|
||||||
|
check(registerRes, {
|
||||||
|
'register status is 201': (r) => r.status === 201,
|
||||||
|
'has tokens': (r) => r.json().accessToken && r.json().refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login (same credentials)
|
||||||
|
const loginRes = http.post(`${BASE_URL}/auth/login`, {
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
loginDuration.add(loginRes.timings.duration);
|
||||||
|
|
||||||
|
check(loginRes, {
|
||||||
|
'login status is 201': (r) => r.status === 201,
|
||||||
|
'login response time < 300ms': (r) => r.timings.duration < 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginRes.status === 201) {
|
||||||
|
loginCounter.add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Auth Test
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/auth.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6️⃣ Listing Creation Test (Authenticated)
|
||||||
|
|
||||||
|
Create file: `load-tests/listings.k6.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 5 }, // 5 users (quota limited)
|
||||||
|
{ duration: '1m30s', target: 5 },
|
||||||
|
{ duration: '30s', target: 0 },
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<1000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthenticatedSession() {
|
||||||
|
// Generate unique user
|
||||||
|
const phone = `09${Math.random().toString().slice(2, 10)}`;
|
||||||
|
const password = 'TestPass123!';
|
||||||
|
const fullName = 'Listing Agent';
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
const registerRes = http.post(`${BASE_URL}/auth/register`, {
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
fullName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registerRes.status !== 201) {
|
||||||
|
console.error('Registration failed:', registerRes.body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registerRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
const session = createAuthenticatedSession();
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
group('Create Listing', () => {
|
||||||
|
const listingData = {
|
||||||
|
transactionType: 'SALE',
|
||||||
|
priceVND: '5500000000',
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
title: 'Căn hộ 3PN view sông Sài Gòn - Load Test',
|
||||||
|
description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ, view trực diện sông Sài Gòn.',
|
||||||
|
address: '208 Nguyễn Hữu Cảnh',
|
||||||
|
ward: 'Phường 22',
|
||||||
|
district: 'Bình Thạnh',
|
||||||
|
city: 'Hồ Chí Minh',
|
||||||
|
latitude: 10.7942,
|
||||||
|
longitude: 106.7219,
|
||||||
|
areaM2: 85.5,
|
||||||
|
bedrooms: 3,
|
||||||
|
bathrooms: 2,
|
||||||
|
floor: 15,
|
||||||
|
totalFloors: 30,
|
||||||
|
yearBuilt: 2020,
|
||||||
|
legalStatus: 'Sổ hồng',
|
||||||
|
amenities: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/listings`, listingData, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'status is 201': (r) => r.status === 201,
|
||||||
|
'has listing id': (r) => r.json().id !== undefined,
|
||||||
|
'response time < 1s': (r) => r.timings.duration < 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 201) {
|
||||||
|
const listingId = res.json().id;
|
||||||
|
|
||||||
|
// Get listing details
|
||||||
|
group('Get Listing Details', () => {
|
||||||
|
const detailRes = http.get(`${BASE_URL}/listings/${listingId}`);
|
||||||
|
check(detailRes, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'title matches': (r) => r.json().title === listingData.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Listing Test
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/listings.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7️⃣ Payment Test
|
||||||
|
|
||||||
|
Create file: `load-tests/payments.k6.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
import { randomString } from 'https://jslib.k6.io/k6-utils/1.1.0/index.js';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 10 },
|
||||||
|
{ duration: '1m', target: 10 },
|
||||||
|
{ duration: '30s', target: 0 },
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<1000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
// Create session first
|
||||||
|
const phone = `09${Math.random().toString().slice(2, 10)}`;
|
||||||
|
const password = 'TestPass123!';
|
||||||
|
|
||||||
|
const registerRes = http.post(`${BASE_URL}/auth/register`, {
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
fullName: 'Payment Test User',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registerRes.status !== 201) return;
|
||||||
|
|
||||||
|
const { accessToken } = registerRes.json();
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
group('Create Payment', () => {
|
||||||
|
const paymentRes = http.post(`${BASE_URL}/payments`, {
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'LISTING_FEE',
|
||||||
|
amountVND: 500000,
|
||||||
|
description: 'Load test payment',
|
||||||
|
returnUrl: 'http://localhost:3000/payment/return',
|
||||||
|
idempotencyKey: `load-test-${randomString(16)}`,
|
||||||
|
}, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
check(paymentRes, {
|
||||||
|
'status is 201': (r) => r.status === 201,
|
||||||
|
'has paymentUrl': (r) => r.json().paymentUrl !== undefined,
|
||||||
|
'response time < 1s': (r) => r.timings.duration < 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paymentRes.status === 201) {
|
||||||
|
const paymentId = paymentRes.json().id;
|
||||||
|
|
||||||
|
// Check payment status
|
||||||
|
group('Get Payment Status', () => {
|
||||||
|
const statusRes = http.get(`${BASE_URL}/payments/${paymentId}`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
check(statusRes, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'has status field': (r) => r.json().status !== undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Payment Test
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/payments.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8️⃣ Run All Tests with Results
|
||||||
|
|
||||||
|
Create file: `load-tests/all-scenarios.k6.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group, sleep } from 'k6';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 50 }, // Ramp up
|
||||||
|
{ duration: '3m', target: 50 }, // Sustain
|
||||||
|
{ duration: '1m', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
'http_req_duration{scenario:search}': ['p(95)<500'],
|
||||||
|
'http_req_duration{scenario:auth}': ['p(95)<300'],
|
||||||
|
'http_req_failed': ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
// Scenario 1: Public Search (60% of load)
|
||||||
|
group('Search Scenario', () => {
|
||||||
|
const res = http.get(`${BASE_URL}/search?q=chung cu&page=1&perPage=20`, {
|
||||||
|
tags: { scenario: 'search' },
|
||||||
|
});
|
||||||
|
check(res, { 'status 200': (r) => r.status === 200 });
|
||||||
|
sleep(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 2: Auth (20% of load)
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
group('Auth Scenario', () => {
|
||||||
|
const res = http.post(`${BASE_URL}/auth/login`, {
|
||||||
|
phone: '0912345678',
|
||||||
|
password: 'TestPass123!',
|
||||||
|
}, {
|
||||||
|
tags: { scenario: 'auth' },
|
||||||
|
});
|
||||||
|
check(res, { 'status 201 or 401': (r) => r.status === 201 || r.status === 401 });
|
||||||
|
sleep(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 3: Geo Search (20% of load)
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
group('Geo Search Scenario', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/search/geo?lat=10.77&lng=106.70&radiusKm=5`,
|
||||||
|
{ tags: { scenario: 'geo' } }
|
||||||
|
);
|
||||||
|
check(res, { 'status 200': (r) => r.status === 200 });
|
||||||
|
sleep(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Summary
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/all-scenarios.k6.js \
|
||||||
|
--vus=50 \
|
||||||
|
--duration=5m \
|
||||||
|
--summary-export=summary.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9️⃣ Generate Reports
|
||||||
|
|
||||||
|
### JSON Report
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/search.k6.js --summary-export=report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload to Grafana Cloud
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/search.k6.js \
|
||||||
|
--out cloud
|
||||||
|
# Requires: k6 login or K6_CLOUD_TOKEN env var
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate HTML Report (with extension)
|
||||||
|
```bash
|
||||||
|
k6 run load-tests/search.k6.js \
|
||||||
|
--out csv=results.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔟 CI Integration
|
||||||
|
|
||||||
|
Create: `.github/workflows/load-test.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Load Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *' # Daily at 2 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
load-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: goodgo_test
|
||||||
|
POSTGRES_USER: goodgo
|
||||||
|
POSTGRES_PASSWORD: test_secret
|
||||||
|
options: --health-cmd pg_isready --health-interval 10s
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Setup database
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test
|
||||||
|
run: |
|
||||||
|
pnpm db:migrate:deploy
|
||||||
|
pnpm db:seed
|
||||||
|
|
||||||
|
- name: Start API
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test
|
||||||
|
run: pnpm --filter @goodgo/api run build &
|
||||||
|
|
||||||
|
- name: Install K6
|
||||||
|
run: sudo apt-get install -y k6
|
||||||
|
|
||||||
|
- name: Wait for API
|
||||||
|
run: |
|
||||||
|
for i in {1..30}; do
|
||||||
|
curl http://localhost:3001/api/v1/docs && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run Load Tests
|
||||||
|
run: k6 run load-tests/search.k6.js --summary-export=results.json
|
||||||
|
|
||||||
|
- name: Upload Results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: k6-results
|
||||||
|
path: results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Common K6 Checks
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// HTTP Status
|
||||||
|
check(res, { 'status 200': (r) => r.status === 200 });
|
||||||
|
|
||||||
|
// Response Time
|
||||||
|
check(res, { 'response < 500ms': (r) => r.timings.duration < 500 });
|
||||||
|
|
||||||
|
// JSON Content
|
||||||
|
check(res, { 'has id': (r) => r.json().id });
|
||||||
|
|
||||||
|
// Body Contains
|
||||||
|
check(res, { 'body contains': (r) => r.body.includes('text') });
|
||||||
|
|
||||||
|
// Multiple Conditions
|
||||||
|
check(res, {
|
||||||
|
'status is 2xx': (r) => r.status >= 200 && r.status < 300,
|
||||||
|
'response time acceptable': (r) => r.timings.duration < 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response Headers
|
||||||
|
check(res, {
|
||||||
|
'content-type is json': (r) => r.headers['Content-Type'] === 'application/json',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Debugging
|
||||||
|
|
||||||
|
### Verbose Output
|
||||||
|
```bash
|
||||||
|
k6 run -v load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Show Request/Response
|
||||||
|
```bash
|
||||||
|
k6 run --http-debug=full load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limit to Single VU
|
||||||
|
```bash
|
||||||
|
k6 run --vus=1 --iterations=1 load-tests/search.k6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Next Steps
|
||||||
|
|
||||||
|
1. **Read Full Guide**: `K6_LOAD_TESTING_GUIDE.md`
|
||||||
|
2. **Check Endpoints**: `K6_ENDPOINTS_SUMMARY.md`
|
||||||
|
3. **Explore K6 Docs**: https://k6.io/docs
|
||||||
|
4. **Community Scripts**: https://github.com/grafana/k6-templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Troubleshooting
|
||||||
|
|
||||||
|
### "connection refused"
|
||||||
|
- Ensure API is running: `pnpm dev`
|
||||||
|
- Check port: 3001
|
||||||
|
- Verify BASE_URL: `http://localhost:3001/api/v1`
|
||||||
|
|
||||||
|
### "rate limit exceeded"
|
||||||
|
- Auth endpoints: 5/hour limit
|
||||||
|
- Spread requests or use different test data
|
||||||
|
- Check test database has seed data
|
||||||
|
|
||||||
|
### "insufficient credits" (payments)
|
||||||
|
- Payments require authenticated user
|
||||||
|
- Create user session first in test
|
||||||
|
- Use test provider credentials
|
||||||
|
|
||||||
|
### "timeout"
|
||||||
|
- Increase K6 timeout in options
|
||||||
|
- Check API logs for errors
|
||||||
|
- Reduce number of VUs initially
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
|
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
export function OAuthButtons() {
|
export function OAuthButtons() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
|
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export interface SearchListingsParams {
|
|||||||
|
|
||||||
// ─── API Functions ───────────────────────────────────────
|
// ─── API Functions ───────────────────────────────────────
|
||||||
|
|
||||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
|
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
export const listingsApi = {
|
export const listingsApi = {
|
||||||
create: (data: CreateListingPayload) =>
|
create: (data: CreateListingPayload) =>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
|||||||
name: 'api',
|
name: 'api',
|
||||||
testDir: './e2e/api',
|
testDir: './e2e/api',
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.API_BASE_URL ?? 'http://localhost:3001',
|
baseURL: process.env.API_BASE_URL ?? 'http://localhost:3001/api/v1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Web E2E tests — Chromium browser
|
// Web E2E tests — Chromium browser
|
||||||
@@ -58,7 +58,7 @@ export default defineConfig({
|
|||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: 'pnpm --filter @goodgo/api run dev',
|
command: 'pnpm --filter @goodgo/api run dev',
|
||||||
url: 'http://localhost:3001/api/docs',
|
url: 'http://localhost:3001/api/v1/docs',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
Reference in New Issue
Block a user