test(performance): implement k6 load testing scenarios and java result parser
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* WalkGuide API Client — Reusable HTTP wrapper for k6 tests
|
||||
* Semua request ke Spring Boot backend melewati modul ini.
|
||||
*/
|
||||
import http from "k6/http";
|
||||
import { check, fail } from "k6";
|
||||
import { metricsHelper } from "./metrics-helper.js";
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://202.46.28.160:8080";
|
||||
const API_BASE = `${BASE_URL}/api/v1`;
|
||||
|
||||
/**
|
||||
* Default headers tanpa auth
|
||||
*/
|
||||
function jsonHeaders(extra = {}) {
|
||||
return Object.assign(
|
||||
{ "Content-Type": "application/json", Accept: "application/json" },
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default headers dengan Bearer token
|
||||
*/
|
||||
function authHeaders(token, extra = {}) {
|
||||
return jsonHeaders(
|
||||
Object.assign({ Authorization: `Bearer ${token}` }, extra),
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// AUTH endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function register(payload) {
|
||||
const res = http.post(`${API_BASE}/auth/register`, JSON.stringify(payload), {
|
||||
headers: jsonHeaders(),
|
||||
tags: { endpoint: "register" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("register", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function login(email, password) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/login`,
|
||||
JSON.stringify({ email, password }),
|
||||
{ headers: jsonHeaders(), tags: { endpoint: "login" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("login", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function refreshToken(refreshTok) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/refresh`,
|
||||
JSON.stringify({ refreshToken: refreshTok }),
|
||||
{ headers: jsonHeaders(), tags: { endpoint: "refresh_token" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("refresh_token", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logout(token) {
|
||||
const res = http.post(`${API_BASE}/auth/logout`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "logout" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("logout", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function ping() {
|
||||
const res = http.get(`${API_BASE}/auth/ping`, { tags: { endpoint: "ping" } });
|
||||
metricsHelper.recordEndpoint("ping", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateFcmToken(token, fcmToken) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/fcm-token`,
|
||||
JSON.stringify({ fcmToken }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "fcm_token" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("fcm_token", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// PAIRING endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function inviteUser(token, uniqueUserId) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/pairing/invite`,
|
||||
JSON.stringify({ uniqueUserId }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "pairing_invite" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("pairing_invite", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function respondPairing(token, pairingId, accept) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/pairing/respond`,
|
||||
JSON.stringify({ pairingId, accept }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "pairing_respond" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("pairing_respond", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getPairingStatus(token) {
|
||||
const res = http.get(`${API_BASE}/pairing/status`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "pairing_status" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("pairing_status", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function unpair(token) {
|
||||
const res = http.del(`${API_BASE}/pairing/unpair`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "pairing_unpair" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("pairing_unpair", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// USER endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function getUserProfile(token) {
|
||||
const res = http.get(`${API_BASE}/user/profile`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_profile" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_profile", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateLocation(
|
||||
token,
|
||||
lat,
|
||||
lng,
|
||||
accuracy = 5.0,
|
||||
speed = 1.4,
|
||||
heading = 90.0,
|
||||
) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/user/location`,
|
||||
JSON.stringify({ lat, lng, accuracy, speed, heading }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "location_update" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("location_update", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logObstacle(token, payload) {
|
||||
const res = http.post(`${API_BASE}/user/obstacle`, JSON.stringify(payload), {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "obstacle_log" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("obstacle_log", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function triggerSos(token, triggerType, lat, lng) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/user/sos`,
|
||||
JSON.stringify({ triggerType, lat, lng }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "sos_trigger" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("sos_trigger", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getActivityLogs(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/user/activity-logs?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "activity_logs" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("activity_logs", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getNotifications(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/user/notifications?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "notifications" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("notifications", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUnreadCount(token) {
|
||||
const res = http.get(`${API_BASE}/user/notifications/unread-count`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "unread_count" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("unread_count", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function markAllRead(token) {
|
||||
const res = http.put(`${API_BASE}/user/notifications/mark-all-read`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "mark_all_read" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("mark_all_read", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function markOneRead(token, notifId) {
|
||||
const res = http.put(`${API_BASE}/user/notifications/${notifId}/read`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "mark_one_read" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("mark_one_read", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function startWalkguide(token) {
|
||||
const res = http.post(`${API_BASE}/user/walkguide/start`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "walkguide_start" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("walkguide_start", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function stopWalkguide(token) {
|
||||
const res = http.post(`${API_BASE}/user/walkguide/stop`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "walkguide_stop" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("walkguide_stop", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserSettings(token) {
|
||||
const res = http.get(`${API_BASE}/user/settings`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_settings_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_settings_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateUserSettings(token, payload) {
|
||||
const res = http.put(`${API_BASE}/user/settings`, JSON.stringify(payload), {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_settings_put" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_settings_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserAiConfig(token) {
|
||||
const res = http.get(`${API_BASE}/user/ai-config`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("ai_config_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// GUARDIAN endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function getGuardianDashboard(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/dashboard`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "guardian_dashboard" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("guardian_dashboard", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserLocation(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/user-location`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "guardian_user_location" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("guardian_user_location", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getLocationHistory(token, page = 0, size = 50) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/location-history?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "location_history" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("location_history", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getObstacleLogs(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/obstacle-logs?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "obstacle_logs_get" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("obstacle_logs_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function sendNotification(token, payload) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/guardian/notifications/send`,
|
||||
JSON.stringify(payload),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "send_notification" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("send_notification", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getSosEvents(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/sos-events?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "sos_events_get" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("sos_events_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function acknowledgeSos(token, sosId) {
|
||||
const res = http.put(`${API_BASE}/guardian/sos/${sosId}/acknowledge`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "sos_acknowledge" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("sos_acknowledge", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getAiConfig(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/ai-config`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_guardian_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("ai_config_guardian_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateAiConfig(token, payload) {
|
||||
const res = http.put(
|
||||
`${API_BASE}/guardian/ai-config`,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_guardian_put" },
|
||||
},
|
||||
);
|
||||
metricsHelper.recordEndpoint("ai_config_guardian_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getActivityLogsGuardian(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/activity-logs?page=${page}&size=${size}`,
|
||||
{
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "activity_logs_guardian" },
|
||||
},
|
||||
);
|
||||
metricsHelper.recordEndpoint("activity_logs_guardian", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getGeofenceConfig(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/geofence`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "geofence_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("geofence_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateGeofenceConfig(token, payload) {
|
||||
const res = http.put(
|
||||
`${API_BASE}/guardian/geofence`,
|
||||
JSON.stringify(payload),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "geofence_put" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("geofence_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// SHARED check helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function checkSuccess(res, label) {
|
||||
check(res, {
|
||||
[`${label}: status 2xx`]: (r) => r.status >= 200 && r.status < 300,
|
||||
[`${label}: has body`]: (r) => r.body && r.body.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBody(res) {
|
||||
try {
|
||||
return JSON.parse(res.body);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* WalkGuide — JWT Token Management Helper
|
||||
* Mengelola login, penyimpanan token, dan auto-refresh untuk k6 VUs.
|
||||
*/
|
||||
import { check, sleep } from "k6";
|
||||
import * as api from "./api-client.js";
|
||||
import { testData } from "./test-data.js";
|
||||
|
||||
// ── Shared token cache (per VU, tidak cross-VU karena k6 isolasi per VU) ────
|
||||
let _cachedTokens = null; // { accessToken, refreshToken, role, userId, uniqueUserId }
|
||||
|
||||
/**
|
||||
* Login dan simpan token di memory VU ini.
|
||||
* Return { accessToken, refreshToken, role, userId }
|
||||
*/
|
||||
export function loginAndStore(email, password) {
|
||||
const res = api.login(email, password);
|
||||
check(res, { "auth-helper: login 200": (r) => r.status === 200 });
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`[auth-helper] Login failed for ${email}: ${res.status} ${res.body}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = api.parseBody(res);
|
||||
if (!body || !body.data || !body.data.accessToken) {
|
||||
console.error(`[auth-helper] Unexpected login response: ${res.body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
_cachedTokens = {
|
||||
accessToken: body.data.accessToken,
|
||||
refreshToken: body.data.refreshToken,
|
||||
role: body.data.role,
|
||||
userId: body.data.userId,
|
||||
uniqueUserId: body.data.uniqueUserId || null,
|
||||
};
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register user baru, lalu login. Return token set.
|
||||
*/
|
||||
export function registerAndLogin(payload) {
|
||||
const regRes = api.register(payload);
|
||||
check(regRes, {
|
||||
"auth-helper: register 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
if (regRes.status !== 200 && regRes.status !== 201) {
|
||||
// Mungkin email sudah ada — coba login saja
|
||||
console.warn(
|
||||
`[auth-helper] Register failed (${regRes.status}), attempting login.`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = api.parseBody(regRes);
|
||||
// Kalau register langsung return token (sesuai AuthService):
|
||||
if (body && body.data && body.data.accessToken) {
|
||||
_cachedTokens = {
|
||||
accessToken: body.data.accessToken,
|
||||
refreshToken: body.data.refreshToken,
|
||||
role: body.data.role,
|
||||
userId: body.data.userId,
|
||||
uniqueUserId: body.data.uniqueUserId || null,
|
||||
};
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
// Fallback: login manual
|
||||
return loginAndStore(payload.email, payload.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil access token dari cache. Kalau null, throw agar test gagal eksplisit.
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
if (!_cachedTokens || !_cachedTokens.accessToken) {
|
||||
throw new Error(
|
||||
"[auth-helper] No access token in cache. Call loginAndStore() first.",
|
||||
);
|
||||
}
|
||||
return _cachedTokens.accessToken;
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
if (!_cachedTokens || !_cachedTokens.refreshToken) {
|
||||
throw new Error("[auth-helper] No refresh token in cache.");
|
||||
}
|
||||
return _cachedTokens.refreshToken;
|
||||
}
|
||||
|
||||
export function getCachedTokens() {
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
export function getCachedUniqueUserId() {
|
||||
return _cachedTokens ? _cachedTokens.uniqueUserId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token menggunakan refresh token yang tersimpan.
|
||||
* Update cache dengan access token baru.
|
||||
*/
|
||||
export function doRefreshToken() {
|
||||
const rt = getRefreshToken();
|
||||
const res = api.refreshToken(rt);
|
||||
check(res, { "auth-helper: refresh 200": (r) => r.status === 200 });
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(`[auth-helper] Refresh token failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data && body.data.accessToken) {
|
||||
_cachedTokens.accessToken = body.data.accessToken;
|
||||
}
|
||||
return _cachedTokens.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear semua cached token (simulasi logout).
|
||||
*/
|
||||
export function clearTokens() {
|
||||
_cachedTokens = null;
|
||||
}
|
||||
|
||||
// ── Pre-built credential sets untuk smoke/load test ──────────────────────────
|
||||
// Test users yang sudah ada di DB kampus (V2__seed_users.sql)
|
||||
// Jika tidak ada, gunakan dynamic register.
|
||||
|
||||
export const PREBUILT_GUARDIAN = {
|
||||
email: __ENV.GUARDIAN_EMAIL || "guardian@walkguide.test",
|
||||
password: __ENV.GUARDIAN_PASSWORD || "Guardian123!",
|
||||
};
|
||||
|
||||
export const PREBUILT_USER = {
|
||||
email: __ENV.USER_EMAIL || "user@walkguide.test",
|
||||
password: __ENV.USER_PASSWORD || "User123!",
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup pair: register guardian + user, lalu pairing.
|
||||
* Return { guardianTokens, userTokens }
|
||||
*/
|
||||
export function setupPairedSession() {
|
||||
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// Register guardian
|
||||
const gPayload = testData.guardianRegisterPayload(suffix);
|
||||
const gTokens = registerAndLogin(gPayload);
|
||||
if (!gTokens) return null;
|
||||
|
||||
// Register user
|
||||
const uPayload = testData.userRegisterPayload(suffix);
|
||||
const uTokens = registerAndLogin(uPayload);
|
||||
if (!uTokens) return null;
|
||||
|
||||
return {
|
||||
guardianTokens: gTokens,
|
||||
userTokens: uTokens,
|
||||
guardianPayload: gPayload,
|
||||
userPayload: uPayload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* WalkGuide — Custom Metric Collectors
|
||||
* Menambah metric k6 custom di atas default http_req_* metrics.
|
||||
*
|
||||
* METRIC YANG DIKUMPULKAN (sesuai ketentuan pillar 3):
|
||||
* 1. throughput — req/s (via http_reqs counter bawaan k6 + custom trend)
|
||||
* 2. p95 latency — custom trend per endpoint
|
||||
* 3. error rate — custom rate
|
||||
* 4. db query time — diperkirakan via response time karena tidak ada akses langsung DB
|
||||
* 5. jvm heap — via Spring Actuator /actuator/metrics/jvm.memory.used
|
||||
*/
|
||||
import { Counter, Gauge, Rate, Trend } from "k6/metrics";
|
||||
import http from "k6/http";
|
||||
|
||||
// ── Custom Metrics ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Total request error rate (non-2xx) */
|
||||
export const errorRate = new Rate("walkguide_error_rate");
|
||||
|
||||
/** Request latency per endpoint */
|
||||
export const endpointLatency = new Trend("walkguide_endpoint_latency_ms", true);
|
||||
|
||||
/** Location update latency khusus (endpoint paling kritis) */
|
||||
export const locationLatency = new Trend("walkguide_location_latency_ms", true);
|
||||
|
||||
/** Obstacle log latency khusus */
|
||||
export const obstacleLatency = new Trend("walkguide_obstacle_latency_ms", true);
|
||||
|
||||
/** SOS trigger latency khusus (harus paling cepat) */
|
||||
export const sosLatency = new Trend("walkguide_sos_latency_ms", true);
|
||||
|
||||
/** Notification send latency */
|
||||
export const notifLatency = new Trend("walkguide_notif_latency_ms", true);
|
||||
|
||||
/** Auth latency */
|
||||
export const authLatency = new Trend("walkguide_auth_latency_ms", true);
|
||||
|
||||
/** Timeline query latency */
|
||||
export const timelineLatency = new Trend("walkguide_timeline_latency_ms", true);
|
||||
|
||||
/** Pairing flow latency */
|
||||
export const pairingLatency = new Trend("walkguide_pairing_latency_ms", true);
|
||||
|
||||
/** Successful requests counter */
|
||||
export const successfulRequests = new Counter("walkguide_successful_requests");
|
||||
|
||||
/** Failed requests counter */
|
||||
export const failedRequests = new Counter("walkguide_failed_requests");
|
||||
|
||||
/** JVM heap usage (MB) — populated via actuator polling */
|
||||
export const jvmHeapUsed = new Gauge("walkguide_jvm_heap_used_mb");
|
||||
|
||||
/** Active DB connections estimate (via actuator) */
|
||||
export const dbConnections = new Gauge("walkguide_db_connections");
|
||||
|
||||
// ── Endpoint → metric mapping ─────────────────────────────────────────────────
|
||||
|
||||
const ENDPOINT_METRIC_MAP = {
|
||||
location_update: locationLatency,
|
||||
obstacle_log: obstacleLatency,
|
||||
sos_trigger: sosLatency,
|
||||
send_notification: notifLatency,
|
||||
login: authLatency,
|
||||
register: authLatency,
|
||||
refresh_token: authLatency,
|
||||
location_history: timelineLatency,
|
||||
activity_logs: timelineLatency,
|
||||
activity_logs_guardian: timelineLatency,
|
||||
pairing_invite: pairingLatency,
|
||||
pairing_respond: pairingLatency,
|
||||
};
|
||||
|
||||
// ── metricsHelper object ──────────────────────────────────────────────────────
|
||||
|
||||
export const metricsHelper = {
|
||||
/**
|
||||
* Catat metric untuk setiap response.
|
||||
* @param {string} endpointTag — string yang di-pass sebagai tags.endpoint
|
||||
* @param {Response} res — k6 http response object
|
||||
*/
|
||||
recordEndpoint(endpointTag, res) {
|
||||
const isSuccess = res.status >= 200 && res.status < 300;
|
||||
const durationMs = res.timings.duration;
|
||||
|
||||
// Error rate
|
||||
errorRate.add(!isSuccess);
|
||||
|
||||
// Generic endpoint latency
|
||||
endpointLatency.add(durationMs, { endpoint: endpointTag });
|
||||
|
||||
// Specific metric kalau ada di map
|
||||
if (ENDPOINT_METRIC_MAP[endpointTag]) {
|
||||
ENDPOINT_METRIC_MAP[endpointTag].add(durationMs);
|
||||
}
|
||||
|
||||
// Success / fail counter
|
||||
if (isSuccess) {
|
||||
successfulRequests.add(1);
|
||||
} else {
|
||||
failedRequests.add(1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll Spring Actuator untuk JVM heap.
|
||||
* Call ini dari setup() atau secara periodik dari satu VU khusus.
|
||||
* @param {string} baseUrl
|
||||
* @param {string} token — optional, actuator mungkin butuh auth
|
||||
*/
|
||||
pollJvmHeap(baseUrl, token = null) {
|
||||
const headers = token
|
||||
? { Authorization: `Bearer ${token}`, Accept: "application/json" }
|
||||
: { Accept: "application/json" };
|
||||
|
||||
try {
|
||||
const res = http.get(`${baseUrl}/actuator/metrics/jvm.memory.used`, {
|
||||
headers,
|
||||
timeout: "5s",
|
||||
tags: { endpoint: "actuator_jvm" },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
// measurement[0].value adalah bytes
|
||||
const heapMb = body.measurements[0].value / (1024 * 1024);
|
||||
jvmHeapUsed.add(heapMb);
|
||||
}
|
||||
} catch (e) {
|
||||
// Actuator mungkin tidak public — skip
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll Spring Actuator untuk DB connection pool (HikariCP).
|
||||
* @param {string} baseUrl
|
||||
* @param {string} token
|
||||
*/
|
||||
pollDbConnections(baseUrl, token = null) {
|
||||
const headers = token
|
||||
? { Authorization: `Bearer ${token}`, Accept: "application/json" }
|
||||
: { Accept: "application/json" };
|
||||
|
||||
try {
|
||||
const res = http.get(
|
||||
`${baseUrl}/actuator/metrics/hikaricp.connections.active`,
|
||||
{ headers, timeout: "5s", tags: { endpoint: "actuator_db" } },
|
||||
);
|
||||
if (res.status === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
const active = body.measurements[0].value;
|
||||
dbConnections.add(active);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate ringkasan teks dari custom metrics (untuk print di teardown).
|
||||
*/
|
||||
summary() {
|
||||
return {
|
||||
errorRate: errorRate,
|
||||
locationP95: locationLatency,
|
||||
obstacleP95: obstacleLatency,
|
||||
sosP95: sosLatency,
|
||||
notifP95: notifLatency,
|
||||
authP95: authLatency,
|
||||
timelineP95: timelineLatency,
|
||||
pairingP95: pairingLatency,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default thresholds yang bisa di-reuse di semua scenario options.
|
||||
* Sesuai ketentuan pillar 3: p95 < 500ms, error rate < 1%.
|
||||
*/
|
||||
export const DEFAULT_THRESHOLDS = {
|
||||
// ── HTTP default k6
|
||||
http_req_duration: ["p(95)<500", "p(99)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
|
||||
// ── WalkGuide custom
|
||||
walkguide_error_rate: ["rate<0.01"],
|
||||
walkguide_endpoint_latency_ms: ["p(95)<500"],
|
||||
walkguide_location_latency_ms: ["p(95)<300"], // location harus cepat
|
||||
walkguide_obstacle_latency_ms: ["p(95)<400"],
|
||||
walkguide_sos_latency_ms: ["p(95)<200"], // SOS PALING KRITIS
|
||||
walkguide_notif_latency_ms: ["p(95)<500"],
|
||||
walkguide_auth_latency_ms: ["p(95)<800"],
|
||||
walkguide_timeline_latency_ms: ["p(95)<1000"], // query analytics boleh lebih lambat
|
||||
walkguide_pairing_latency_ms: ["p(95)<600"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Relaxed thresholds untuk stress test (ekspektasi lebih longgar).
|
||||
*/
|
||||
export const STRESS_THRESHOLDS = {
|
||||
http_req_duration: ["p(95)<1500", "p(99)<3000"],
|
||||
http_req_failed: ["rate<0.05"],
|
||||
walkguide_error_rate: ["rate<0.05"],
|
||||
walkguide_sos_latency_ms: ["p(95)<500"],
|
||||
};
|
||||
|
||||
/**
|
||||
* handleSummary helper — generate output JSON + text.
|
||||
* Import di tiap scenario: export { handleSummary } from '../modules/metrics-helper.js'
|
||||
*/
|
||||
export function handleSummary(data) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const fileName = `k6-results/summary_${stamp}.json`;
|
||||
|
||||
return {
|
||||
stdout: textSummary(data),
|
||||
[fileName]: JSON.stringify(data, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function textSummary(data) {
|
||||
const { metrics } = data;
|
||||
const lines = [
|
||||
"═══════════════════════════════════════════════════════════════",
|
||||
" WalkGuide Load Test — Result Summary",
|
||||
"═══════════════════════════════════════════════════════════════",
|
||||
];
|
||||
|
||||
const pick = (name, stat) => {
|
||||
if (metrics[name]) {
|
||||
const val = metrics[name].values[stat];
|
||||
return val !== undefined ? val.toFixed(2) : "N/A";
|
||||
}
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
lines.push(
|
||||
` http_req_duration p95=${pick("http_req_duration", "p(95)")}ms p99=${pick("http_req_duration", "p(99)")}ms`,
|
||||
);
|
||||
lines.push(` http_req_failed rate=${pick("http_req_failed", "rate")}`);
|
||||
lines.push(
|
||||
` sos_latency p95=${pick("walkguide_sos_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` location_latency p95=${pick("walkguide_location_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` obstacle_latency p95=${pick("walkguide_obstacle_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` notif_latency p95=${pick("walkguide_notif_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` auth_latency p95=${pick("walkguide_auth_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` timeline_latency p95=${pick("walkguide_timeline_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push("═══════════════════════════════════════════════════════════════");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* WalkGuide — Synthetic Test Data Generator
|
||||
* Menghasilkan data dummy yang realistis untuk semua skenario load test.
|
||||
*/
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
|
||||
// ── Alphabet untuk uniqueUserId (12 char alphanumeric) ──────────────────────
|
||||
const ALPHANUM =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
function randomString(len, chars = ALPHANUM) {
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 6) {
|
||||
return parseFloat((Math.random() * (max - min) + min).toFixed(decimals));
|
||||
}
|
||||
|
||||
function randomElement(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
// ── Koordinat realistis: sekitar Surabaya ───────────────────────────────────
|
||||
const SURABAYA_CENTER = { lat: -7.2575, lng: 112.7521 };
|
||||
|
||||
export function randomSurabayaCoord(radiusDegrees = 0.05) {
|
||||
return {
|
||||
lat: SURABAYA_CENTER.lat + randomFloat(-radiusDegrees, radiusDegrees),
|
||||
lng: SURABAYA_CENTER.lng + randomFloat(-radiusDegrees, radiusDegrees),
|
||||
};
|
||||
}
|
||||
|
||||
// ── User / Guardian data ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate payload untuk register GUARDIAN
|
||||
*/
|
||||
export function guardianRegisterPayload(suffix = "") {
|
||||
const uid = suffix || randomString(8);
|
||||
return {
|
||||
email: `guardian_${uid}@walkguide-test.com`,
|
||||
password: `Pass@${randomString(6)}!`,
|
||||
displayName: `Guardian ${uid}`,
|
||||
role: "ROLE_GUARDIAN",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate payload untuk register USER (tunanetra)
|
||||
*/
|
||||
export function userRegisterPayload(suffix = "") {
|
||||
const uid = suffix || randomString(8);
|
||||
return {
|
||||
email: `user_${uid}@walkguide-test.com`,
|
||||
password: `Pass@${randomString(6)}!`,
|
||||
displayName: `User ${uid}`,
|
||||
role: "ROLE_USER",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Location data ────────────────────────────────────────────────────────────
|
||||
|
||||
const DIRECTION_LABELS = [
|
||||
"NORTH",
|
||||
"NORTHEAST",
|
||||
"EAST",
|
||||
"SOUTHEAST",
|
||||
"SOUTH",
|
||||
"SOUTHWEST",
|
||||
"WEST",
|
||||
"NORTHWEST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate satu location update payload (simulasi GPS setiap 5 detik)
|
||||
*/
|
||||
export function locationUpdatePayload(
|
||||
baseLat = SURABAYA_CENTER.lat,
|
||||
baseLng = SURABAYA_CENTER.lng,
|
||||
) {
|
||||
return {
|
||||
lat: baseLat + randomFloat(-0.001, 0.001),
|
||||
lng: baseLng + randomFloat(-0.001, 0.001),
|
||||
accuracy: randomFloat(3.0, 15.0, 1),
|
||||
speed: randomFloat(0.5, 2.5, 2), // walking speed m/s
|
||||
heading: randomFloat(0.0, 359.9, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate array of location updates (simulasi walking path)
|
||||
*/
|
||||
export function walkingPath(
|
||||
steps = 10,
|
||||
startLat = SURABAYA_CENTER.lat,
|
||||
startLng = SURABAYA_CENTER.lng,
|
||||
) {
|
||||
const path = [];
|
||||
let lat = startLat,
|
||||
lng = startLng;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
lat += randomFloat(-0.0002, 0.0002);
|
||||
lng += randomFloat(-0.0002, 0.0002);
|
||||
path.push({
|
||||
lat,
|
||||
lng,
|
||||
accuracy: randomFloat(3, 10, 1),
|
||||
speed: randomFloat(0.8, 1.8, 2),
|
||||
heading: randomFloat(0, 360, 1),
|
||||
});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── Obstacle data ────────────────────────────────────────────────────────────
|
||||
|
||||
const YOLO_LABELS = [
|
||||
"person",
|
||||
"car",
|
||||
"motorcycle",
|
||||
"bicycle",
|
||||
"truck",
|
||||
"bus",
|
||||
"traffic light",
|
||||
"stop sign",
|
||||
"bench",
|
||||
"chair",
|
||||
"potted plant",
|
||||
"fire hydrant",
|
||||
"parking meter",
|
||||
"dog",
|
||||
"cat",
|
||||
"suitcase",
|
||||
];
|
||||
|
||||
const OBSTACLE_DIRECTIONS = ["LEFT", "CENTER", "RIGHT"];
|
||||
const OBSTACLE_DISTANCES = ["Very Close", "Close", "Medium", "Far"];
|
||||
|
||||
/**
|
||||
* Generate satu obstacle log payload (output YOLO detection)
|
||||
*/
|
||||
export function obstacleLogPayload() {
|
||||
const coord = randomSurabayaCoord(0.02);
|
||||
return {
|
||||
label: randomElement(YOLO_LABELS),
|
||||
confidence: randomFloat(0.5, 0.99, 4),
|
||||
direction: randomElement(OBSTACLE_DIRECTIONS),
|
||||
estimatedDist: randomElement(OBSTACLE_DISTANCES),
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate burst obstacle logs (simulasi: 5 FPS deteksi selama beberapa detik)
|
||||
*/
|
||||
export function obstacleLogBurst(count = 5) {
|
||||
return Array.from({ length: count }, () => obstacleLogPayload());
|
||||
}
|
||||
|
||||
// ── SOS data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SOS_TRIGGER_TYPES = ["VOICE_COMMAND", "BUTTON", "MANUAL"];
|
||||
|
||||
export function sosPayload() {
|
||||
const coord = randomSurabayaCoord(0.03);
|
||||
return {
|
||||
triggerType: randomElement(SOS_TRIGGER_TYPES),
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notification data ─────────────────────────────────────────────────────────
|
||||
|
||||
const NOTIFICATION_MESSAGES = [
|
||||
"Hati-hati ada kendaraan di depan!",
|
||||
"Sudah sampai tujuan belum?",
|
||||
"Minta kabar, kamu baik-baik saja?",
|
||||
"Ada yang bisa dibantu?",
|
||||
"Jangan lupa berhati-hati di jalan.",
|
||||
"Aku pantau dari sini ya.",
|
||||
"Sudah mau hujan, cari tempat teduh.",
|
||||
"Ada mobil di sebelah kanan!",
|
||||
"Tolong berhenti dulu.",
|
||||
"Aman ya? Jawab kalau dengar.",
|
||||
];
|
||||
|
||||
export function sendNotificationPayload() {
|
||||
return {
|
||||
notifType: "TEXT",
|
||||
content: randomElement(NOTIFICATION_MESSAGES),
|
||||
};
|
||||
}
|
||||
|
||||
// ── AI Config data ────────────────────────────────────────────────────────────
|
||||
|
||||
export function aiConfigPayload() {
|
||||
return {
|
||||
confidenceThreshold: randomFloat(0.3, 0.8, 2),
|
||||
alertDistanceClose: randomFloat(1.0, 2.5, 1),
|
||||
alertDistanceMedium: randomFloat(2.5, 5.0, 1),
|
||||
maxInferenceFps: randomInt(3, 10),
|
||||
enabledLabels: "ALL",
|
||||
};
|
||||
}
|
||||
|
||||
// ── User Settings data ────────────────────────────────────────────────────────
|
||||
|
||||
export function userSettingsPayload() {
|
||||
return {
|
||||
ttsLanguage: randomElement(["id-ID", "en-US"]),
|
||||
ttsPitch: randomFloat(0.8, 1.5, 1),
|
||||
ttsSpeed: randomFloat(0.7, 1.3, 1),
|
||||
warnNoGuardian: true,
|
||||
hapticEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Geofence Config data ──────────────────────────────────────────────────────
|
||||
|
||||
export function geofenceConfigPayload() {
|
||||
const coord = randomSurabayaCoord(0.01);
|
||||
return {
|
||||
centerLat: coord.lat,
|
||||
centerLng: coord.lng,
|
||||
radiusMeters: randomFloat(200, 1000, 0),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Unique user id (mirror dari backend logic) ───────────────────────────────
|
||||
|
||||
export function fakeUniqueUserId() {
|
||||
return randomString(12);
|
||||
}
|
||||
|
||||
// ── Generic helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export const testData = {
|
||||
guardianRegisterPayload,
|
||||
userRegisterPayload,
|
||||
locationUpdatePayload,
|
||||
walkingPath,
|
||||
obstacleLogPayload,
|
||||
obstacleLogBurst,
|
||||
sosPayload,
|
||||
sendNotificationPayload,
|
||||
aiConfigPayload,
|
||||
userSettingsPayload,
|
||||
geofenceConfigPayload,
|
||||
fakeUniqueUserId,
|
||||
randomSurabayaCoord,
|
||||
randomElement,
|
||||
randomInt,
|
||||
randomFloat,
|
||||
};
|
||||
Reference in New Issue
Block a user