Files

126 lines
3.8 KiB
JavaScript

import http from 'k6/http';
import { check, fail } from 'k6';
const baseUrl = (__ENV.TIKU_BASE_URL || 'http://localhost:5091').replace(/\/$/, '');
const tenantCode = __ENV.TIKU_TENANT_CODE || 'demo-crm-school';
const hostHeader = __ENV.TIKU_HOST_HEADER || '';
const mode = (__ENV.TIKU_MODE || 'mixed').toLowerCase();
const rate = positiveInteger('TIKU_RATE', 100);
const duration = __ENV.TIKU_DURATION || '30s';
const preAllocatedVUs = positiveInteger('TIKU_PRE_ALLOCATED_VUS', Math.max(20, Math.ceil(rate / 20)));
const maxVUs = positiveInteger('TIKU_MAX_VUS', Math.max(100, preAllocatedVUs * 4));
if (!['mixed', 'hot', 'cold', 'ready'].includes(mode)) {
throw new Error(`Unsupported TIKU_MODE '${mode}'. Use mixed, hot, cold, or ready.`);
}
export const options = {
discardResponseBodies: true,
summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
scenarios: {
api: {
executor: 'constant-arrival-rate',
exec: 'apiTraffic',
rate,
timeUnit: '1s',
duration,
preAllocatedVUs,
maxVUs,
gracefulStop: '5s',
},
},
thresholds: {
checks: ['rate>0.99'],
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500', 'p(99)<1000'],
dropped_iterations: ['count==0'],
},
};
const tenantHeaders = {
'x-tenant-code': tenantCode,
Accept: 'application/json',
...(hostHeader ? { Host: hostHeader } : {}),
};
const platformHeaders = hostHeader ? { Host: hostHeader } : {};
export function setup() {
const ready = http.get(`${baseUrl}/api/health/ready`, {
headers: platformHeaders,
responseType: 'text',
tags: { endpoint: 'ready', phase: 'setup' },
});
if (ready.status !== 200) {
fail(`API dependencies are not ready: status=${ready.status} body=${ready.body}`);
}
let readiness;
try {
readiness = ready.json();
} catch (error) {
fail(`Readiness endpoint did not return JSON: ${error}`);
}
if (!readiness.database || !readiness.redis?.configured || !readiness.redis?.ready) {
fail(`PostgreSQL and Redis must both be configured and ready: ${JSON.stringify(readiness)}`);
}
const catalog = http.get(`${baseUrl}/api/catalog/regions`, {
headers: tenantHeaders,
tags: { endpoint: 'catalog_hot', phase: 'setup' },
});
if (catalog.status !== 200) {
fail(`Tenant '${tenantCode}' is not usable: status=${catalog.status} body=${catalog.body}`);
}
}
export function apiTraffic() {
const selected = selectEndpoint();
let response;
if (selected === 'ready') {
response = http.get(`${baseUrl}/api/health/ready`, {
headers: platformHeaders,
tags: { endpoint: 'ready' },
});
} else if (selected === 'cold') {
// A unique query string bypasses output-cache reuse while exercising the same
// tenant resolution and PostgreSQL-backed catalog query as the hot request.
const cacheBuster = `${__VU}-${__ITER}`;
response = http.get(`${baseUrl}/api/catalog/regions?loadProbe=${cacheBuster}`, {
headers: tenantHeaders,
tags: { endpoint: 'catalog_cold' },
});
} else {
response = http.get(`${baseUrl}/api/catalog/regions`, {
headers: tenantHeaders,
tags: { endpoint: 'catalog_hot' },
});
}
check(response, {
[`${selected} returned 200`]: (result) => result.status === 200,
});
}
function selectEndpoint() {
if (mode !== 'mixed') return mode;
// Stable 70/20/10 traffic mix: cached catalog / uncached catalog / dependency readiness.
const bucket = (__ITER + __VU) % 10;
if (bucket === 0) return 'ready';
if (bucket <= 2) return 'cold';
return 'hot';
}
function positiveInteger(name, fallback) {
const raw = __ENV[name];
if (raw === undefined || raw === '') return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, got '${raw}'.`);
}
return value;
}