Files
Ho Ngoc Hai 33c2e5ac1d 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>
2026-04-11 20:14:52 +07:00

268 lines
9.3 KiB
JavaScript

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);
}