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>
268 lines
9.3 KiB
JavaScript
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);
|
|
}
|