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:
161
load-tests/scripts/search-advanced.js
Normal file
161
load-tests/scripts/search-advanced.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user