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>
162 lines
5.8 KiB
JavaScript
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);
|
|
}
|