Files
goodgo-platform/load-tests/scripts/search-advanced.js
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

162 lines
5.8 KiB
JavaScript

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