feat(load-tests): add K6 coverage for search, admin, and MCP endpoints

Add three new K6 load test scripts to cover previously untested API surfaces:

- search-advanced.js: Combined geo + text + filter queries, paginated deep
  search, and sort variations against /search and /search/geo (300 peak VUs)
- admin.js: Moderation queue CRUD, bulk moderation, dashboard stats, audit
  logs, and user management endpoints (50 peak VUs)
- mcp.js: MCP server discovery, SSE connection, property-search tool calls,
  valuation/batch-valuation, and feature extraction (120 peak VUs)

Also updates README with new suite documentation, per-suite custom thresholds,
and adds the new suites to the CI workflow_dispatch selector.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 20:14:52 +07:00
parent 18e50a9649
commit 33c2e5ac1d
7 changed files with 2193 additions and 5 deletions

View File

@@ -11,6 +11,9 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| `auth.js` | Login/Register | 100 | 2min |
| `listings.js` | Search + Detail | 500 | 3min |
| `search.js` | Text + Geo search | 200 | 3min |
| `search-advanced.js` | Combined geo + text + filters, pagination | 300 | 3min |
| `admin.js` | Moderation queue, dashboard, audit logs | 50 | 2.5min |
| `mcp.js` | MCP server discovery, property-search, valuation | 120 | 2.5min |
| `payments.js` | Create + List | 50 | 2min |
## SLA Thresholds
@@ -22,6 +25,18 @@ Performance load tests for critical API paths using [K6](https://k6.io/).
| p99 latency | < 1000ms |
| Error rate | < 1% |
### Per-Suite Custom Thresholds
| Suite | Metric | Threshold |
|-------|--------|-----------|
| search-advanced | advanced_search_duration p95 | < 800ms |
| search-advanced | geo_filter_search_duration p95 | < 800ms |
| admin | moderation_action_duration p95 | < 800ms |
| admin | admin_dashboard_duration p95 | < 500ms |
| mcp | mcp_property_search_duration p95 | < 1500ms |
| mcp | mcp_valuation_duration p95 | < 1000ms |
| mcp | mcp_batch_valuation_duration p95 | < 2000ms |
## Prerequisites
```bash
@@ -40,6 +55,9 @@ pnpm --filter @goodgo/api run dev
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/search-advanced.js
k6 run load-tests/scripts/admin.js
k6 run load-tests/scripts/mcp.js
k6 run load-tests/scripts/payments.js
# Run against a custom API URL
@@ -62,11 +80,16 @@ Trigger via `workflow_dispatch` with a suite selector.
```
load-tests/
├── lib/
│ └── config.js # Shared config, helpers, SLA thresholds
│ └── 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
│ ├── auth.js # Auth flow load tests
│ ├── listings.js # Listings search + detail
│ ├── search.js # Full-text + geo search (basic)
── search-advanced.js # Combined geo + text + filter search
│ ├── admin.js # Admin moderation, dashboard, audit
│ ├── mcp.js # MCP server endpoints (property-search, valuation)
│ └── payments.js # Payment creation + listing
├── results/
│ └── BASELINE-REPORT.md # Baseline performance report
└── README.md
```

271
load-tests/scripts/admin.js Normal file
View File

@@ -0,0 +1,271 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, SLA_THRESHOLDS, authHeaders } from '../lib/config.js';
/**
* Admin Moderation Queue Load Test — Goodgo Platform
*
* Tests admin endpoints: moderation queue listing, approve/reject,
* bulk moderation, user management, dashboard stats, and audit logs.
* Requires an admin user account.
*/
const moderationListDuration = new Trend('moderation_list_duration', true);
const moderationActionDuration = new Trend('moderation_action_duration', true);
const adminDashboardDuration = new Trend('admin_dashboard_duration', true);
const auditLogDuration = new Trend('audit_log_duration', true);
const userListDuration = new Trend('user_list_duration', true);
const adminFailRate = new Rate('admin_failures');
export const options = {
scenarios: {
admin_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '20s', target: 10 }, // admin traffic is lower volume
{ duration: '1m', target: 30 }, // sustain moderate load
{ duration: '1m', target: 50 }, // stress peak
{ duration: '20s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
moderation_list_duration: ['p(95)<500'],
moderation_action_duration: ['p(95)<800'],
admin_dashboard_duration: ['p(95)<500'],
audit_log_duration: ['p(95)<500'],
user_list_duration: ['p(95)<500'],
admin_failures: ['rate<0.05'],
},
};
export function setup() {
// Register admin user via the standard auth flow.
// In a real environment the admin role would be pre-seeded;
// for load testing we attempt registration then login.
const adminPayload = JSON.stringify({
phone: '0900000001',
password: 'AdminLoad@1234!',
fullName: 'K6 Admin User',
email: 'k6-admin@goodgo.test',
});
let res = http.post(`${BASE_URL}/auth/register`, adminPayload, {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'setup_admin_register' },
});
// If already exists, login
if (res.status !== 200 && res.status !== 201) {
res = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ phone: '0900000001', password: 'AdminLoad@1234!' }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'setup_admin_login' },
},
);
}
let accessToken = null;
if (res.status === 200 || res.status === 201) {
try {
accessToken = JSON.parse(res.body).accessToken;
} catch (_) { /* ignore */ }
}
// Also create some test listings for moderation queue
const listingIds = [];
if (accessToken) {
for (let i = 0; i < 20; i++) {
const listing = {
title: `K6 Admin Test Listing ${i}`,
description: `Moderation load test listing #${i}`,
transactionType: i % 2 === 0 ? 'SALE' : 'RENT',
propertyType: ['APARTMENT', 'HOUSE', 'LAND'][i % 3],
address: `${200 + i} Đường Kiểm Duyệt`,
ward: 'Phường 1',
district: 'Quận 1',
city: 'TP. Hồ Chí Minh',
latitude: 10.7769 + (i * 0.002),
longitude: 106.7009 + (i * 0.002),
area: 50 + (i * 5),
bedrooms: 1 + (i % 4),
bathrooms: 1 + (i % 3),
floors: 1 + (i % 3),
priceVND: 1000000000 + (i * 200000000),
direction: 'EAST',
};
const createRes = http.post(
`${BASE_URL}/listings`,
JSON.stringify(listing),
{ headers: authHeaders(accessToken), tags: { name: 'setup_create_listing' } },
);
if (createRes.status === 201 || createRes.status === 200) {
try {
listingIds.push(JSON.parse(createRes.body).id);
} catch (_) { /* skip */ }
}
}
}
return { accessToken, listingIds };
}
export default function (data) {
if (!data.accessToken) {
// Without auth, still test unauthenticated error handling
const res = http.get(`${BASE_URL}/admin/moderation?page=1&limit=10`, {
tags: { name: 'GET /admin/moderation (unauth)' },
});
check(res, {
'unauth moderation: returns 401': (r) => r.status === 401,
});
sleep(1);
return;
}
const headers = authHeaders(data.accessToken);
const iter = __ITER;
const scenario = iter % 7;
if (scenario === 0) {
// --- Moderation queue listing ---
const page = 1 + (iter % 3);
const res = http.get(`${BASE_URL}/admin/moderation?page=${page}&limit=10`, {
headers,
tags: { name: 'GET /admin/moderation' },
});
moderationListDuration.add(res.timings.duration);
const ok = check(res, {
'moderation list: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 1 && data.listingIds.length > 0) {
// --- Approve listing ---
const listingId = data.listingIds[iter % data.listingIds.length];
const payload = JSON.stringify({
listingId,
moderationNotes: `K6 load test approval — iteration ${iter}`,
});
const res = http.post(`${BASE_URL}/admin/moderation/approve`, payload, {
headers,
tags: { name: 'POST /admin/moderation/approve' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'approve: status 200|201|403|404|409': (r) =>
[200, 201, 403, 404, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 2 && data.listingIds.length > 0) {
// --- Reject listing ---
const listingId = data.listingIds[(iter + 1) % data.listingIds.length];
const payload = JSON.stringify({
listingId,
reason: `K6 load test rejection — does not meet criteria (iter ${iter})`,
});
const res = http.post(`${BASE_URL}/admin/moderation/reject`, payload, {
headers,
tags: { name: 'POST /admin/moderation/reject' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'reject: status 200|201|403|404|409': (r) =>
[200, 201, 403, 404, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 3 && data.listingIds.length >= 3) {
// --- Bulk moderation ---
const startIdx = iter % Math.max(1, data.listingIds.length - 3);
const batchIds = data.listingIds.slice(startIdx, startIdx + 3);
const payload = JSON.stringify({
listingIds: batchIds,
action: iter % 2 === 0 ? 'approve' : 'reject',
reason: `K6 bulk moderation test — iteration ${iter}`,
});
const res = http.post(`${BASE_URL}/admin/moderation/bulk`, payload, {
headers,
tags: { name: 'POST /admin/moderation/bulk' },
});
moderationActionDuration.add(res.timings.duration);
const ok = check(res, {
'bulk moderate: status 200|201|403|409': (r) =>
[200, 201, 403, 409].includes(r.status),
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 4) {
// --- Admin dashboard ---
const res = http.get(`${BASE_URL}/admin/dashboard`, {
headers,
tags: { name: 'GET /admin/dashboard' },
});
adminDashboardDuration.add(res.timings.duration);
const ok = check(res, {
'dashboard: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else if (scenario === 5) {
// --- Audit logs with various filters ---
const filters = [
'',
'?action=LISTING_APPROVED',
'?action=USER_BANNED',
`?startDate=2026-01-01&endDate=2026-12-31`,
];
const filter = filters[iter % filters.length];
const res = http.get(`${BASE_URL}/admin/audit-logs${filter}`, {
headers,
tags: { name: 'GET /admin/audit-logs' },
});
auditLogDuration.add(res.timings.duration);
const ok = check(res, {
'audit logs: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
} else {
// --- User management listing ---
const queries = [
'?limit=20',
'?role=AGENT&limit=10',
'?isActive=true&limit=20',
'?search=test&limit=10',
];
const query = queries[iter % queries.length];
const res = http.get(`${BASE_URL}/admin/users${query}`, {
headers,
tags: { name: 'GET /admin/users' },
});
userListDuration.add(res.timings.duration);
const ok = check(res, {
'user list: status 200|403': (r) => r.status === 200 || r.status === 403,
});
if (!ok) adminFailRate.add(1);
}
sleep(Math.random() * 1.5 + 0.5);
}

267
load-tests/scripts/mcp.js Normal file
View File

@@ -0,0 +1,267 @@
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';
/**
* MCP Server Endpoints Load Test — Goodgo Platform
*
* Tests MCP server discovery, SSE connection establishment, and tool
* invocations for property-search and valuation servers. MCP uses an
* SSE+message POST pattern — this test exercises both connection setup
* and message throughput.
*/
const mcpServerListDuration = new Trend('mcp_server_list_duration', true);
const mcpSseConnectDuration = new Trend('mcp_sse_connect_duration', true);
const mcpPropertySearchDuration = new Trend('mcp_property_search_duration', true);
const mcpValuationDuration = new Trend('mcp_valuation_duration', true);
const mcpComparisonDuration = new Trend('mcp_comparison_duration', true);
const mcpBatchValuationDuration = new Trend('mcp_batch_valuation_duration', true);
const mcpFailRate = new Rate('mcp_failures');
export const options = {
scenarios: {
mcp_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '20s', target: 20 }, // warm up
{ duration: '1m', target: 80 }, // ramp to moderate
{ duration: '1m', target: 120 }, // stress peak
{ duration: '20s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
mcp_server_list_duration: ['p(95)<300'],
mcp_sse_connect_duration: ['p(95)<1000'],
mcp_property_search_duration: ['p(95)<1500'],
mcp_valuation_duration: ['p(95)<1000'],
mcp_comparison_duration: ['p(95)<1500'],
mcp_batch_valuation_duration: ['p(95)<2000'],
mcp_failures: ['rate<0.05'],
},
};
// Property search tool invocations
const PROPERTY_SEARCH_CALLS = [
{ query: 'căn hộ quận 1', lat: 10.7769, lng: 106.7009, radiusKm: 5 },
{ query: 'nhà phố Hà Nội', lat: 21.0285, lng: 105.8542, radiusKm: 3 },
{ query: 'đất nền Thủ Đức' },
{ query: 'chung cư giá rẻ', lat: 10.8231, lng: 106.6297, radiusKm: 2 },
{ query: 'biệt thự Đà Nẵng', lat: 16.0544, lng: 108.2022, radiusKm: 10 },
{ query: 'apartment for rent', lat: 10.7769, lng: 106.7009, radiusKm: 3 },
];
// Valuation tool invocations
const VALUATION_CALLS = [
{ area: 80, district: 'Quận 1', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 2, bathrooms: 2, floors: 1 },
{ area: 120, district: 'Quận 7', city: 'TP. Hồ Chí Minh', propertyType: 'HOUSE', bedrooms: 3, bathrooms: 2, floors: 3, frontage: 5 },
{ area: 200, district: 'Cầu Giấy', city: 'Hà Nội', propertyType: 'APARTMENT', bedrooms: 3, bathrooms: 2, floors: 1, yearBuilt: 2020 },
{ area: 500, district: 'Hải Châu', city: 'Đà Nẵng', propertyType: 'LAND', hasLegalPaper: true },
{ area: 60, district: 'Bình Thạnh', city: 'TP. Hồ Chí Minh', propertyType: 'APARTMENT', bedrooms: 1, bathrooms: 1, floors: 1 },
{ area: 150, district: 'Ba Đình', city: 'Hà Nội', propertyType: 'HOUSE', bedrooms: 4, bathrooms: 3, floors: 4, roadWidth: 6 },
];
// Feature extraction text samples (Vietnamese listing descriptions)
const LISTING_TEXTS = [
'Bán căn hộ cao cấp 80m2, 2PN 2WC, view sông, full nội thất, giá 4.5 tỷ',
'Cho thuê nhà phố mặt tiền đường lớn, 5x20m, 3 lầu, phù hợp kinh doanh',
'Đất nền khu dân cư hiện hữu, sổ đỏ riêng, 100m2 thổ cư, giá 2.8 tỷ',
'Chung cư giá rẻ quận 8, 50m2, 1PN, ban công thoáng mát, 1.2 tỷ',
'Biệt thự Phú Mỹ Hưng 300m2 đất, 4 phòng ngủ, hồ bơi riêng, 25 tỷ',
];
export function setup() {
// Register test users for authenticated MCP access
const tokens = [];
for (let i = 0; i < 5; i++) {
const phone = `0930${String(i).padStart(6, '0')}`;
const t = registerTestUser(http, phone);
if (t) tokens.push(t.accessToken);
}
return { tokens };
}
/**
* Build an MCP JSON-RPC message for tool invocation.
*/
function mcpToolCall(method, params, id) {
return JSON.stringify({
jsonrpc: '2.0',
id: id || 1,
method: method,
params: params,
});
}
export default function (data) {
const iter = __ITER;
const hasAuth = data.tokens.length > 0;
const token = hasAuth ? data.tokens[iter % data.tokens.length] : null;
const headers = token ? authHeaders(token) : { 'Content-Type': 'application/json' };
const scenario = iter % 8;
if (scenario === 0) {
// --- List available MCP servers ---
const res = http.get(`${BASE_URL}/mcp/servers`, {
headers,
tags: { name: 'GET /mcp/servers' },
});
mcpServerListDuration.add(res.timings.duration);
const ok = check(res, {
'mcp servers: status 200|401|403': (r) =>
[200, 401, 403].includes(r.status),
'mcp servers: valid response': (r) => {
if (r.status !== 200) return true;
try { return Array.isArray(JSON.parse(r.body)); } catch { return false; }
},
});
if (!ok) mcpFailRate.add(1);
} else if (scenario === 1) {
// --- SSE connection to property-search server ---
// K6 does not natively support SSE, so we test the initial connection
const res = http.get(`${BASE_URL}/mcp/goodgo-property-search/sse`, {
headers,
tags: { name: 'GET /mcp/property-search/sse' },
timeout: '5s',
});
mcpSseConnectDuration.add(res.timings.duration);
const ok = check(res, {
'sse connect: status 200|401|403|404': (r) =>
[200, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario === 2) {
// --- SSE connection to valuation server ---
const res = http.get(`${BASE_URL}/mcp/goodgo-valuation/sse`, {
headers,
tags: { name: 'GET /mcp/valuation/sse' },
timeout: '5s',
});
mcpSseConnectDuration.add(res.timings.duration);
const ok = check(res, {
'sse connect valuation: status 200|401|403|404': (r) =>
[200, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario <= 4) {
// --- Property search tool call (25% of traffic) ---
const searchParams = PROPERTY_SEARCH_CALLS[iter % PROPERTY_SEARCH_CALLS.length];
const message = mcpToolCall('tools/call', {
name: 'search_properties',
arguments: searchParams,
}, iter);
const sessionId = `k6-session-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-property-search/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/property-search/messages' },
},
);
mcpPropertySearchDuration.add(res.timings.duration);
const ok = check(res, {
'property search: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else if (scenario <= 6) {
// --- Valuation tool call (25% of traffic) ---
const valuationParams = VALUATION_CALLS[iter % VALUATION_CALLS.length];
const message = mcpToolCall('tools/call', {
name: 'estimate_property_value',
arguments: valuationParams,
}, iter);
const sessionId = `k6-valuation-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/messages' },
},
);
mcpValuationDuration.add(res.timings.duration);
const ok = check(res, {
'valuation: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else {
// --- Feature extraction + batch valuation (12.5% of traffic) ---
if (iter % 2 === 0) {
// Feature extraction
const text = LISTING_TEXTS[iter % LISTING_TEXTS.length];
const message = mcpToolCall('tools/call', {
name: 'extract_listing_features',
arguments: { text },
}, iter);
const sessionId = `k6-extract-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/extract' },
},
);
mcpValuationDuration.add(res.timings.duration);
const ok = check(res, {
'extract features: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
} else {
// Batch valuation (2-5 properties)
const batchSize = 2 + (iter % 4);
const properties = [];
for (let i = 0; i < batchSize; i++) {
properties.push(VALUATION_CALLS[(iter + i) % VALUATION_CALLS.length]);
}
const message = mcpToolCall('tools/call', {
name: 'batch_valuation',
arguments: { properties },
}, iter);
const sessionId = `k6-batch-${__VU}-${iter}`;
const res = http.post(
`${BASE_URL}/mcp/goodgo-valuation/messages?sessionId=${sessionId}`,
message,
{
headers,
tags: { name: 'POST /mcp/valuation/batch' },
},
);
mcpBatchValuationDuration.add(res.timings.duration);
const ok = check(res, {
'batch valuation: status 200|202|401|403|404': (r) =>
[200, 202, 401, 403, 404].includes(r.status),
});
if (!ok) mcpFailRate.add(1);
}
}
sleep(Math.random() * 1.5 + 0.5);
}

View File

@@ -0,0 +1,161 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, SLA_THRESHOLDS } from '../lib/config.js';
/**
* Advanced Search Load Test — Goodgo Platform
*
* Tests combined geo + text + filter queries against /search and /search/geo
* endpoints. Simulates real user behaviour: multi-filter search, pagination,
* sort variations, and geo-bounded text queries.
*/
const advancedSearchDuration = new Trend('advanced_search_duration', true);
const geoFilterDuration = new Trend('geo_filter_search_duration', true);
const paginatedSearchDuration = new Trend('paginated_search_duration', true);
const searchFailRate = new Rate('advanced_search_failures');
export const options = {
scenarios: {
advanced_search_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 50 }, // warm up
{ duration: '1m', target: 200 }, // ramp to peak
{ duration: '1m', target: 300 }, // stress peak
{ duration: '30s', target: 0 }, // ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
...SLA_THRESHOLDS,
advanced_search_duration: ['p(95)<800'],
geo_filter_search_duration: ['p(95)<800'],
paginated_search_duration: ['p(95)<500'],
advanced_search_failures: ['rate<0.05'],
},
};
// Vietnamese text queries combined with property filters
const COMBINED_QUERIES = [
{ q: 'căn hộ', propertyType: 'APARTMENT', transactionType: 'SALE', priceMin: 1000000000, priceMax: 5000000000 },
{ q: 'nhà phố', propertyType: 'HOUSE', transactionType: 'SALE', city: 'TP. Hồ Chí Minh' },
{ q: 'đất nền', propertyType: 'LAND', areaMin: 100, areaMax: 500, district: 'Quận 9' },
{ q: 'chung cư', transactionType: 'RENT', priceMin: 5000000, priceMax: 20000000, bedrooms: 2 },
{ q: 'biệt thự', propertyType: 'HOUSE', areaMin: 200, city: 'Hà Nội' },
{ q: 'apartment', transactionType: 'SALE', priceMin: 2000000000, bedrooms: 3 },
{ q: 'phòng trọ', transactionType: 'RENT', priceMax: 5000000 },
{ q: 'villa', propertyType: 'HOUSE', areaMin: 300, priceMin: 10000000000 },
];
// Geo-bounded searches with various radii and filters
const GEO_FILTER_QUERIES = [
{ lat: 10.7769, lng: 106.7009, radiusKm: 5, propertyType: 'APARTMENT', transactionType: 'SALE' },
{ lat: 10.7769, lng: 106.7009, radiusKm: 2, propertyType: 'HOUSE', priceMin: 3000000000 },
{ lat: 21.0285, lng: 105.8542, radiusKm: 3, transactionType: 'RENT', bedrooms: 2 },
{ lat: 10.8231, lng: 106.6297, radiusKm: 1, propertyType: 'LAND', areaMin: 50 },
{ lat: 16.0544, lng: 108.2022, radiusKm: 10, transactionType: 'SALE' },
{ lat: 21.0067, lng: 105.8400, radiusKm: 5, propertyType: 'APARTMENT', priceMax: 5000000000 },
{ lat: 10.8500, lng: 106.7700, radiusKm: 3, propertyType: 'HOUSE', bedrooms: 3 },
{ lat: 10.7300, lng: 106.6500, radiusKm: 2, transactionType: 'RENT', priceMax: 15000000 },
];
// Sort options
const SORT_OPTIONS = ['priceAsc', 'priceDesc', 'newest', 'areaDesc'];
function buildQueryString(params) {
const parts = [];
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
parts.push(`${key}=${encodeURIComponent(value)}`);
}
}
return parts.join('&');
}
export default function () {
const iter = __ITER;
const scenario = iter % 5;
if (scenario <= 1) {
// --- Combined text + filter search (40% of traffic) ---
const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
const sortBy = SORT_OPTIONS[iter % SORT_OPTIONS.length];
const qs = buildQueryString({
...query,
sortBy,
page: 1,
perPage: 20,
});
const url = `${BASE_URL}/search?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search (advanced)' },
});
advancedSearchDuration.add(res.timings.duration);
const ok = check(res, {
'advanced search: status 200|503': (r) => r.status === 200 || r.status === 503,
'advanced search: valid response': (r) => {
if (r.status === 503) return true; // Typesense unavailable
try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
},
});
if (!ok) searchFailRate.add(1);
} else if (scenario <= 3) {
// --- Geo + filter search (40% of traffic) ---
const geoQuery = GEO_FILTER_QUERIES[iter % GEO_FILTER_QUERIES.length];
const { lat, lng, radiusKm, ...filters } = geoQuery;
const qs = buildQueryString({
lat,
lng,
radiusKm,
...filters,
limit: 20,
});
const url = `${BASE_URL}/search/geo?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search/geo (filtered)' },
});
geoFilterDuration.add(res.timings.duration);
const ok = check(res, {
'geo filter: status 200|503': (r) => r.status === 200 || r.status === 503,
'geo filter: valid response': (r) => {
if (r.status === 503) return true;
try { return JSON.parse(r.body).data !== undefined; } catch { return false; }
},
});
if (!ok) searchFailRate.add(1);
} else {
// --- Paginated deep search (20% of traffic) ---
const query = COMBINED_QUERIES[iter % COMBINED_QUERIES.length];
const page = 1 + (iter % 5); // pages 1-5
const qs = buildQueryString({
q: query.q,
propertyType: query.propertyType,
page,
perPage: 20,
sortBy: SORT_OPTIONS[iter % SORT_OPTIONS.length],
});
const url = `${BASE_URL}/search?${qs}`;
const res = http.get(url, {
tags: { name: 'GET /search (paginated)' },
});
paginatedSearchDuration.add(res.timings.duration);
const ok = check(res, {
'paginated search: status 200|503': (r) => r.status === 200 || r.status === 503,
});
if (!ok) searchFailRate.add(1);
}
sleep(Math.random() * 1 + 0.3);
}