test(performance): implement k6 load testing scenarios and java result parser
This commit is contained in:
parent
4a0ae1d615
commit
abeb99ed61
49
walkguide-backend/demo/k6-tests/configs/load-test.json
Normal file
49
walkguide-backend/demo/k6-tests/configs/load-test.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"_comment": "WalkGuide Load Test — 50 VUs, 5 minutes. Normal production load simulation.",
|
||||
"_meta": {
|
||||
"min_vu_requirement": 50,
|
||||
"note": "Pillar 3 ketentuan: load test harus >= 50 concurrent users"
|
||||
},
|
||||
"scenarios": {
|
||||
"load_auth": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "30s", "target": 10 },
|
||||
{ "duration": "60s", "target": 20 },
|
||||
{ "duration": "120s", "target": 20 },
|
||||
{ "duration": "30s", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "15s"
|
||||
},
|
||||
"load_location": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "30s", "target": 15 },
|
||||
{ "duration": "120s", "target": 20 },
|
||||
{ "duration": "30s", "target": 0 }
|
||||
],
|
||||
"startTime": "10s",
|
||||
"gracefulRampDown": "15s"
|
||||
},
|
||||
"load_obstacle": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 10,
|
||||
"duration": "180s",
|
||||
"startTime": "20s"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<500", "p(99)<1000"],
|
||||
"http_req_failed": ["rate<0.01"],
|
||||
"walkguide_error_rate": ["rate<0.01"],
|
||||
"walkguide_location_latency_ms": ["p(95)<300"],
|
||||
"walkguide_obstacle_latency_ms": ["p(95)<400"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<200"],
|
||||
"walkguide_auth_latency_ms": ["p(95)<800"],
|
||||
"walkguide_timeline_latency_ms": ["p(95)<1000"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/load-test-summary.json"
|
||||
}
|
||||
34
walkguide-backend/demo/k6-tests/configs/smoke-test.json
Normal file
34
walkguide-backend/demo/k6-tests/configs/smoke-test.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "WalkGuide Smoke Test — 10 VUs, 1 minute. Verifikasi semua endpoint reachable dan basic functionality OK.",
|
||||
"scenarios": {
|
||||
"smoke_auth": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 3,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "auth" }
|
||||
},
|
||||
"smoke_location": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 4,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "location" }
|
||||
},
|
||||
"smoke_analytics": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 3,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "analytics" }
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<1000"],
|
||||
"http_req_failed": ["rate<0.01"],
|
||||
"walkguide_error_rate": ["rate<0.01"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<500"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/smoke-test-summary.json"
|
||||
}
|
||||
26
walkguide-backend/demo/k6-tests/configs/spike-test.json
Normal file
26
walkguide-backend/demo/k6-tests/configs/spike-test.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"_comment": "WalkGuide Spike Test — 0 → 200 VUs sudden spike. Simulasi viral event / banyak user pakai serentak.",
|
||||
"scenarios": {
|
||||
"spike": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "10s", "target": 5 },
|
||||
{ "duration": "1s", "target": 200 },
|
||||
{ "duration": "2m", "target": 200 },
|
||||
{ "duration": "10s", "target": 5 },
|
||||
{ "duration": "3m", "target": 5 },
|
||||
{ "duration": "10s", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "30s"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<3000"],
|
||||
"http_req_failed": ["rate<0.10"],
|
||||
"walkguide_error_rate": ["rate<0.10"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<1000"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/spike-test-summary.json"
|
||||
}
|
||||
26
walkguide-backend/demo/k6-tests/configs/stress-test.json
Normal file
26
walkguide-backend/demo/k6-tests/configs/stress-test.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"_comment": "WalkGuide Stress Test — 100 VUs, 10 minutes. Find breaking points.",
|
||||
"scenarios": {
|
||||
"stress_all": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "1m", "target": 25 },
|
||||
{ "duration": "2m", "target": 50 },
|
||||
{ "duration": "2m", "target": 75 },
|
||||
{ "duration": "2m", "target": 100 },
|
||||
{ "duration": "2m", "target": 100 },
|
||||
{ "duration": "1m", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "30s"
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/stress-test-summary.json"
|
||||
}
|
||||
408
walkguide-backend/demo/k6-tests/modules/api-client.js
Normal file
408
walkguide-backend/demo/k6-tests/modules/api-client.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
168
walkguide-backend/demo/k6-tests/modules/auth-helper.js
Normal file
168
walkguide-backend/demo/k6-tests/modules/auth-helper.js
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
259
walkguide-backend/demo/k6-tests/modules/metrics-helper.js
Normal file
259
walkguide-backend/demo/k6-tests/modules/metrics-helper.js
Normal file
@ -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";
|
||||
}
|
||||
265
walkguide-backend/demo/k6-tests/modules/test-data.js
Normal file
265
walkguide-backend/demo/k6-tests/modules/test-data.js
Normal file
@ -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,
|
||||
};
|
||||
236
walkguide-backend/demo/k6-tests/run-all-tests.sh
Normal file
236
walkguide-backend/demo/k6-tests/run-all-tests.sh
Normal file
@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WalkGuide — k6 Test Runner
|
||||
# Jalankan semua k6 scenarios secara berurutan atau individual.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x run-all-tests.sh
|
||||
# ./run-all-tests.sh [mode] [BASE_URL]
|
||||
#
|
||||
# Modes:
|
||||
# smoke — 10 VUs, 1 menit (quick sanity check)
|
||||
# load — 50 VUs, 5 menit (normal production load, WAJIB untuk exam)
|
||||
# stress — 100 VUs, 10 menit (breaking point)
|
||||
# spike — 0→200 VUs sudden spike
|
||||
# all — jalankan smoke → load → stress → spike berurutan
|
||||
# auth — hanya auth-flow.js
|
||||
# pairing — hanya pairing-flow.js
|
||||
# location — hanya location-update.js
|
||||
# obstacle — hanya obstacle-logging.js
|
||||
# sos — hanya sos-flow.js
|
||||
# notif — hanya notification-send.js
|
||||
# timeline — hanya timeline-query.js
|
||||
#
|
||||
# Contoh:
|
||||
# ./run-all-tests.sh load http://202.46.28.160:8080
|
||||
# ./run-all-tests.sh smoke
|
||||
# ./run-all-tests.sh all http://202.46.28.160:8080
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
MODE="${1:-load}"
|
||||
BASE_URL="${2:-http://202.46.28.160:8080}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
RESULTS_DIR="${SCRIPT_DIR}/k6-results"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN}[✅ OK]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[⚠️ WARN]${NC} $1"; }
|
||||
err() { echo -e "${RED}[❌ ERR]${NC} $1"; }
|
||||
hr() { echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"; }
|
||||
|
||||
# ── Pre-flight checks ─────────────────────────────────────────────────────────
|
||||
hr
|
||||
echo -e "${BOLD} 🦺 WalkGuide — k6 Load Test Runner${NC}"
|
||||
echo -e " Mode: ${YELLOW}${MODE}${NC} | Backend: ${YELLOW}${BASE_URL}${NC}"
|
||||
hr
|
||||
|
||||
# Check k6 installed
|
||||
if ! command -v k6 &> /dev/null; then
|
||||
err "k6 not installed!"
|
||||
echo "Install: https://k6.io/docs/getting-started/installation/"
|
||||
echo "Ubuntu: sudo apt-get install k6"
|
||||
echo "Mac: brew install k6"
|
||||
echo "Docker: docker run grafana/k6 run ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
K6_VERSION=$(k6 version 2>&1 | head -1)
|
||||
ok "k6 found: $K6_VERSION"
|
||||
|
||||
# Check Node.js (for result parsing)
|
||||
if command -v node &> /dev/null; then
|
||||
ok "Node.js: $(node --version)"
|
||||
else
|
||||
warn "Node.js not found — skipping HTML report generation"
|
||||
fi
|
||||
|
||||
# Create results dir
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
ok "Results dir: $RESULTS_DIR"
|
||||
|
||||
# ── Backend reachability check ────────────────────────────────────────────────
|
||||
log "Checking backend reachability..."
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/auth/ping" --max-time 10 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_STATUS" = "200" ]; then
|
||||
ok "Backend reachable (HTTP $HTTP_STATUS)"
|
||||
elif [ "$HTTP_STATUS" = "000" ]; then
|
||||
warn "Backend not reachable or timeout. Continuing anyway (tests will fail gracefully)."
|
||||
else
|
||||
warn "Backend returned HTTP $HTTP_STATUS. Continuing..."
|
||||
fi
|
||||
|
||||
# ── k6 run helper ─────────────────────────────────────────────────────────────
|
||||
run_k6() {
|
||||
local scenario_file="$1"
|
||||
local scenario_name="$2"
|
||||
local extra_args="${3:-}"
|
||||
|
||||
local output_json="${RESULTS_DIR}/${scenario_name}_${TIMESTAMP}.ndjson"
|
||||
local summary_json="${RESULTS_DIR}/${scenario_name}_summary_${TIMESTAMP}.json"
|
||||
|
||||
log "Running scenario: ${BOLD}${scenario_name}${NC}"
|
||||
log "Script: ${scenario_file}"
|
||||
|
||||
local k6_cmd="k6 run \
|
||||
--out json=${output_json} \
|
||||
-e BASE_URL=${BASE_URL} \
|
||||
--summary-export=${summary_json} \
|
||||
--no-color \
|
||||
${extra_args} \
|
||||
${SCRIPT_DIR}/scenarios/${scenario_file}"
|
||||
|
||||
echo "Command: $k6_cmd"
|
||||
hr
|
||||
|
||||
if eval "$k6_cmd"; then
|
||||
ok "Scenario ${scenario_name} completed successfully"
|
||||
else
|
||||
warn "Scenario ${scenario_name} completed with threshold violations (check results)"
|
||||
fi
|
||||
|
||||
# Parse results if Node.js available
|
||||
if command -v node &> /dev/null && [ -f "$output_json" ]; then
|
||||
log "Parsing results..."
|
||||
local parsed_json="${RESULTS_DIR}/${scenario_name}_parsed_${TIMESTAMP}.json"
|
||||
local html_report="${RESULTS_DIR}/${scenario_name}_report_${TIMESTAMP}.html"
|
||||
|
||||
node "${SCRIPT_DIR}/utils/result-parser.js" "$output_json" "$parsed_json" 2>/dev/null || true
|
||||
node "${SCRIPT_DIR}/utils/html-reporter.js" "$parsed_json" "$html_report" 2>/dev/null || true
|
||||
|
||||
if [ -f "$html_report" ]; then
|
||||
ok "HTML report: $html_report"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Stage-specific k6 args ────────────────────────────────────────────────────
|
||||
SMOKE_ARGS="--stage 30s:3 --stage 60s:10 --stage 30s:0"
|
||||
# Note: --stage shorthand diganti inline di script JSON
|
||||
|
||||
# ── Test execution ─────────────────────────────────────────────────────────────
|
||||
case "$MODE" in
|
||||
|
||||
smoke)
|
||||
log "=== SMOKE TEST (10 VUs, 1 min) ==="
|
||||
run_k6 "auth-flow.js" "smoke_auth" "--vus 3 --duration 60s"
|
||||
run_k6 "location-update.js" "smoke_location" "--vus 3 --duration 60s"
|
||||
run_k6 "sos-flow.js" "smoke_sos" "--vus 2 --duration 60s"
|
||||
;;
|
||||
|
||||
load)
|
||||
log "=== LOAD TEST (50+ VUs, 5 min) — EXAM REQUIRED ==="
|
||||
run_k6 "auth-flow.js" "load_auth"
|
||||
run_k6 "pairing-flow.js" "load_pairing"
|
||||
run_k6 "location-update.js" "load_location"
|
||||
run_k6 "obstacle-logging.js" "load_obstacle"
|
||||
run_k6 "sos-flow.js" "load_sos"
|
||||
run_k6 "notification-send.js" "load_notif"
|
||||
run_k6 "timeline-query.js" "load_timeline"
|
||||
;;
|
||||
|
||||
stress)
|
||||
log "=== STRESS TEST (100 VUs, 10 min) ==="
|
||||
run_k6 "location-update.js" "stress_location"
|
||||
run_k6 "obstacle-logging.js" "stress_obstacle"
|
||||
run_k6 "notification-send.js" "stress_notif"
|
||||
run_k6 "timeline-query.js" "stress_timeline"
|
||||
;;
|
||||
|
||||
spike)
|
||||
log "=== SPIKE TEST (0→200 VUs sudden) ==="
|
||||
run_k6 "location-update.js" "spike_location"
|
||||
run_k6 "obstacle-logging.js" "spike_obstacle"
|
||||
;;
|
||||
|
||||
all)
|
||||
log "=== FULL TEST SUITE (smoke → load → stress → spike) ==="
|
||||
warn "Estimated total time: 30-45 minutes"
|
||||
read -p "Continue? [y/N] " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "Aborted."; exit 0
|
||||
fi
|
||||
|
||||
# Smoke
|
||||
log "--- Phase 1: Smoke Test ---"
|
||||
run_k6 "auth-flow.js" "fullsuite_smoke_auth" "--vus 3 --duration 60s"
|
||||
run_k6 "location-update.js" "fullsuite_smoke_location" "--vus 3 --duration 60s"
|
||||
|
||||
# Load
|
||||
log "--- Phase 2: Load Test ---"
|
||||
run_k6 "auth-flow.js" "fullsuite_load_auth"
|
||||
run_k6 "location-update.js" "fullsuite_load_location"
|
||||
run_k6 "obstacle-logging.js" "fullsuite_load_obstacle"
|
||||
run_k6 "sos-flow.js" "fullsuite_load_sos"
|
||||
run_k6 "notification-send.js" "fullsuite_load_notif"
|
||||
run_k6 "timeline-query.js" "fullsuite_load_timeline"
|
||||
|
||||
# Stress
|
||||
log "--- Phase 3: Stress Test ---"
|
||||
run_k6 "location-update.js" "fullsuite_stress_location"
|
||||
|
||||
# Spike
|
||||
log "--- Phase 4: Spike Test ---"
|
||||
run_k6 "location-update.js" "fullsuite_spike_location"
|
||||
;;
|
||||
|
||||
# Individual scenarios
|
||||
auth) run_k6 "auth-flow.js" "individual_auth" ;;
|
||||
pairing) run_k6 "pairing-flow.js" "individual_pairing" ;;
|
||||
location) run_k6 "location-update.js" "individual_location" ;;
|
||||
obstacle) run_k6 "obstacle-logging.js" "individual_obstacle" ;;
|
||||
sos) run_k6 "sos-flow.js" "individual_sos" ;;
|
||||
notif) run_k6 "notification-send.js" "individual_notif" ;;
|
||||
timeline) run_k6 "timeline-query.js" "individual_timeline" ;;
|
||||
|
||||
*)
|
||||
err "Unknown mode: $MODE"
|
||||
echo "Valid modes: smoke | load | stress | spike | all | auth | pairing | location | obstacle | sos | notif | timeline"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Final summary ─────────────────────────────────────────────────────────────
|
||||
hr
|
||||
ok "All k6 tests finished!"
|
||||
log "Results saved to: $RESULTS_DIR"
|
||||
echo ""
|
||||
echo "Files generated:"
|
||||
ls -lh "$RESULTS_DIR"/*.{json,html} 2>/dev/null || echo " (no files found)"
|
||||
hr
|
||||
147
walkguide-backend/demo/k6-tests/scenarios/auth-flow.js
Normal file
147
walkguide-backend/demo/k6-tests/scenarios/auth-flow.js
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* SCENARIO: auth-flow.js
|
||||
* Register → Login → Refresh Token → Logout flow
|
||||
*
|
||||
* Tujuan: Stress test auth endpoints yang dipakai setiap user saat app dibuka.
|
||||
* Target: p95 auth latency < 800ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
import { registerAndLogin, doRefreshToken } from "../modules/auth-helper.js";
|
||||
|
||||
// ── Konfigurasi test — override via --config atau env ───────────────────────
|
||||
export const options = {
|
||||
scenarios: {
|
||||
auth_flow: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 }, // warm-up
|
||||
{ duration: "60s", target: 30 }, // ramp-up
|
||||
{ duration: "60s", target: 30 }, // sustain
|
||||
{ duration: "30s", target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_auth_latency_ms: ["p(95)<800", "p(99)<1500"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function authFlow() {
|
||||
const vuId = __VU;
|
||||
|
||||
// ── Step 1: Register user baru (setiap VU punya unique email) ───────────────
|
||||
const role = vuId % 2 === 0 ? "ROLE_GUARDIAN" : "ROLE_USER";
|
||||
const suffix = `${vuId}_${__ITER}`;
|
||||
const payload =
|
||||
role === "ROLE_GUARDIAN"
|
||||
? testData.guardianRegisterPayload(suffix)
|
||||
: testData.userRegisterPayload(suffix);
|
||||
|
||||
const regRes = api.register(payload);
|
||||
check(regRes, {
|
||||
"register: status 200": (r) => r.status === 200,
|
||||
"register: has accessToken": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
});
|
||||
|
||||
let tokens = null;
|
||||
if (regRes.status === 200) {
|
||||
const body = api.parseBody(regRes);
|
||||
if (body && body.data) tokens = body.data;
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── Step 2: Login ulang dengan credentials yang sama ────────────────────────
|
||||
const loginRes = api.login(payload.email, payload.password);
|
||||
check(loginRes, {
|
||||
"login: status 200": (r) => r.status === 200,
|
||||
"login: accessToken set": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
"login: refreshToken set": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.refreshToken;
|
||||
},
|
||||
"login: role correct": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.role === role;
|
||||
},
|
||||
});
|
||||
|
||||
if (loginRes.status === 200) {
|
||||
const body = api.parseBody(loginRes);
|
||||
if (body && body.data) tokens = body.data;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Refresh token (simulasi token expire) ───────────────────────────
|
||||
if (tokens && tokens.refreshToken) {
|
||||
const refreshRes = api.refreshToken(tokens.refreshToken);
|
||||
check(refreshRes, {
|
||||
"refresh: status 200": (r) => r.status === 200,
|
||||
"refresh: new accessToken": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
});
|
||||
|
||||
// Update access token
|
||||
if (refreshRes.status === 200) {
|
||||
const body = api.parseBody(refreshRes);
|
||||
if (body && body.data) tokens.accessToken = body.data.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 4: Update FCM token (dilakukan tiap login di production) ───────────
|
||||
if (tokens && tokens.accessToken) {
|
||||
const fakeFcm = `fcm_${Math.random().toString(36).slice(2, 30)}:APA91b${Math.random().toString(36).slice(2, 50)}`;
|
||||
const fcmRes = api.updateFcmToken(tokens.accessToken, fakeFcm);
|
||||
check(fcmRes, {
|
||||
"fcm-token: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 5: Logout ──────────────────────────────────────────────────────────
|
||||
if (tokens && tokens.accessToken) {
|
||||
const logoutRes = api.logout(tokens.accessToken);
|
||||
check(logoutRes, {
|
||||
"logout: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Think time: user nyata butuh beberapa detik antar aksi ──────────────────
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
// ── Health check sebelum test dimulai ────────────────────────────────────────
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Backend tidak reachable. Ping returned ${res.status}. URL: ${__ENV.BASE_URL || "http://202.46.28.160:8080"}`,
|
||||
);
|
||||
}
|
||||
console.log("✅ Backend reachable. Auth flow test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
138
walkguide-backend/demo/k6-tests/scenarios/location-update.js
Normal file
138
walkguide-backend/demo/k6-tests/scenarios/location-update.js
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* SCENARIO: location-update.js
|
||||
* Real-time location stress test — simulasi banyak User berjalan serentak.
|
||||
*
|
||||
* WalkGuide mengirim lokasi setiap 5 detik saat WalkGuide aktif.
|
||||
* Ini endpoint paling high-frequency di seluruh sistem.
|
||||
*
|
||||
* Target: p95 < 300ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
metricsHelper,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://202.46.28.160:8080";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
location_stress: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "30s", target: 20 }, // ramp-up
|
||||
{ duration: "90s", target: 50 }, // sustain at 50 walking users
|
||||
{ duration: "60s", target: 100 }, // peak load
|
||||
{ duration: "30s", target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_location_latency_ms: ["p(95)<300", "p(99)<600"],
|
||||
http_req_duration: ["p(95)<400"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── VU shared state (tiap VU punya token sendiri) ─────────────────────────────
|
||||
let vuTokens = null;
|
||||
let vuBaseLat = null;
|
||||
let vuBaseLng = null;
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
export default function locationStress() {
|
||||
// ── Inisialisasi: register + login sekali per VU ───────────────────────────
|
||||
if (!vuTokens) {
|
||||
const suffix = `loc_${__VU}_${Date.now()}`;
|
||||
const payload = testData.userRegisterPayload(suffix);
|
||||
const res = api.register(payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data) vuTokens = body.data;
|
||||
}
|
||||
|
||||
if (!vuTokens) {
|
||||
console.error(`[location] VU ${__VU} failed to get tokens`);
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Posisi awal acak di Surabaya
|
||||
const coord = testData.randomSurabayaCoord(0.05);
|
||||
vuBaseLat = coord.lat;
|
||||
vuBaseLng = coord.lng;
|
||||
|
||||
// Start walkguide session
|
||||
const startRes = api.startWalkguide(vuTokens.accessToken);
|
||||
check(startRes, {
|
||||
"walkguide-start: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Poll actuator untuk JVM heap (hanya VU 1, tidak mengganggu tes lain) ────
|
||||
if (__VU === 1 && __ITER % 10 === 0) {
|
||||
metricsHelper.pollJvmHeap(BASE_URL);
|
||||
metricsHelper.pollDbConnections(BASE_URL);
|
||||
}
|
||||
|
||||
// ── Simulasi walking: kirim 3 location update berturut-turut (15 detik real) ─
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const locPayload = testData.locationUpdatePayload(vuBaseLat, vuBaseLng);
|
||||
|
||||
const res = api.updateLocation(
|
||||
vuTokens.accessToken,
|
||||
locPayload.lat,
|
||||
locPayload.lng,
|
||||
locPayload.accuracy,
|
||||
locPayload.speed,
|
||||
locPayload.heading,
|
||||
);
|
||||
|
||||
check(res, {
|
||||
"location-update: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"location-update: latency ok": (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
// Update base coordinate (simulasi pergerakan)
|
||||
vuBaseLat += testData.randomFloat(-0.0003, 0.0003);
|
||||
vuBaseLng += testData.randomFloat(-0.0003, 0.0003);
|
||||
|
||||
// 5 detik interval seperti production
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
// ── Kadang Guardian juga poll lokasi (setiap 10 iteration) ──────────────────
|
||||
// Ini simulasi Guardian membuka map screen
|
||||
if (__ITER % 5 === 0 && vuTokens) {
|
||||
// Re-login as guardian to test guardian endpoint
|
||||
// Dalam real load test, Guardian VU terpisah — di sini kita skip
|
||||
// karena token kita adalah ROLE_USER
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup: siapkan backend ────────────────────────────────────────────────────
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log(`✅ Location stress test starting. Backend: ${BASE_URL}`);
|
||||
return { startTime: Date.now() };
|
||||
}
|
||||
|
||||
// ── Teardown: stop semua walkguide session ────────────────────────────────────
|
||||
export function teardown(data) {
|
||||
console.log(
|
||||
`Location stress test finished. Duration: ${(Date.now() - data.startTime) / 1000}s`,
|
||||
);
|
||||
// Note: individual VU stop walkguide tidak bisa dilakukan di teardown
|
||||
// karena token sudah tidak tersedia. Dalam production, session timeout handles ini.
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
173
walkguide-backend/demo/k6-tests/scenarios/notification-send.js
Normal file
173
walkguide-backend/demo/k6-tests/scenarios/notification-send.js
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* SCENARIO: notification-send.js
|
||||
* Mass notification broadcast — Guardian kirim pesan ke User.
|
||||
*
|
||||
* Simulasi banyak Guardian mengirim notifikasi serentak + User membaca.
|
||||
* Termasuk test mark-read dan unread-count endpoints.
|
||||
*
|
||||
* Target: p95 < 500ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
// Scenario A: Guardian send notifications
|
||||
guardian_send: {
|
||||
executor: "constant-vus",
|
||||
vus: 25,
|
||||
duration: "120s",
|
||||
exec: "guardianSend",
|
||||
tags: { scenario: "guardian_send" },
|
||||
},
|
||||
// Scenario B: User read notifications (mulai setelah 10s)
|
||||
user_read: {
|
||||
executor: "constant-vus",
|
||||
vus: 25,
|
||||
duration: "100s",
|
||||
startTime: "10s",
|
||||
exec: "userRead",
|
||||
tags: { scenario: "user_read" },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_notif_latency_ms: ["p(95)<500", "p(99)<1000"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Shared VU states ──────────────────────────────────────────────────────────
|
||||
let guardianVuToken = null;
|
||||
let userVuToken = null;
|
||||
|
||||
// ── EXEC: Guardian send notifications ────────────────────────────────────────
|
||||
export function guardianSend() {
|
||||
if (!guardianVuToken) {
|
||||
const suffix = `notif_g_${__VU}_${Date.now()}`;
|
||||
const res = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (res.status === 200) {
|
||||
const b = api.parseBody(res);
|
||||
if (b && b.data) guardianVuToken = b.data.accessToken;
|
||||
}
|
||||
if (!guardianVuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// Kirim 1-3 notifikasi per iterasi
|
||||
const count = testData.randomInt(1, 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const payload = testData.sendNotificationPayload();
|
||||
const res = api.sendNotification(guardianVuToken, payload);
|
||||
|
||||
check(res, {
|
||||
"send-notif: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"send-notif: latency < 500": (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// Cek activity log guardian
|
||||
if (__ITER % 5 === 0) {
|
||||
const logsRes = api.getActivityLogsGuardian(guardianVuToken);
|
||||
check(logsRes, {
|
||||
"guardian-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
// ── EXEC: User read notifications ─────────────────────────────────────────────
|
||||
export function userRead() {
|
||||
if (!userVuToken) {
|
||||
const suffix = `notif_u_${__VU}_${Date.now()}`;
|
||||
const res = api.register(testData.userRegisterPayload(suffix));
|
||||
if (res.status === 200) {
|
||||
const b = api.parseBody(res);
|
||||
if (b && b.data) userVuToken = b.data.accessToken;
|
||||
}
|
||||
if (!userVuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// ── Step 1: Cek unread count ─────────────────────────────────────────────
|
||||
const unreadRes = api.getUnreadCount(userVuToken);
|
||||
check(unreadRes, {
|
||||
"unread-count: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"unread-count: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 2: Get notification list ────────────────────────────────────────
|
||||
const page = __ITER % 3; // test pagination
|
||||
const notifRes = api.getNotifications(userVuToken, page, 20);
|
||||
check(notifRes, {
|
||||
"get-notifs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"get-notifs: has list": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Extract first notif id untuk test mark-read
|
||||
let firstNotifId = null;
|
||||
if (notifRes.status >= 200 && notifRes.status < 300) {
|
||||
const body = api.parseBody(notifRes);
|
||||
if (
|
||||
body &&
|
||||
body.data &&
|
||||
Array.isArray(body.data.content) &&
|
||||
body.data.content.length > 0
|
||||
) {
|
||||
firstNotifId = body.data.content[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 3: Mark individual notification read (jika ada) ─────────────────
|
||||
if (firstNotifId) {
|
||||
const markRes = api.markOneRead(userVuToken, firstNotifId);
|
||||
check(markRes, {
|
||||
"mark-one-read: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// ── Step 4: Mark all read (setiap 5 iterasi) ─────────────────────────────
|
||||
if (__ITER % 5 === 0) {
|
||||
const markAllRes = api.markAllRead(userVuToken);
|
||||
check(markAllRes, {
|
||||
"mark-all-read: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(testData.randomFloat(0.5, 2.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log(
|
||||
"📬 Notification send/read test starting (2 parallel scenarios).",
|
||||
);
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
110
walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js
Normal file
110
walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* SCENARIO: obstacle-logging.js
|
||||
* High-frequency obstacle detection log — simulasi YOLO inference results.
|
||||
*
|
||||
* WalkGuide menjalankan YOLO pada 5 FPS, setiap obstacle terdeteksi dikirim ke backend.
|
||||
* Ini endpoint write-heavy terbesar karena obstacle bisa sangat sering.
|
||||
*
|
||||
* Target: p95 < 400ms, throughput > 200 req/s pada 50 VUs
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
obstacle_burst: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "120s", target: 50 }, // sustain high load
|
||||
{ duration: "20s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "10s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_obstacle_latency_ms: ["p(95)<400", "p(99)<800"],
|
||||
http_reqs: ["rate>100"], // throughput > 100 req/s
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let vuToken = null;
|
||||
|
||||
export default function obstacleLogging() {
|
||||
// ── Init: register + login ─────────────────────────────────────────────────
|
||||
if (!vuToken) {
|
||||
const suffix = `obs_${__VU}_${Date.now()}`;
|
||||
const payload = testData.userRegisterPayload(suffix);
|
||||
const res = api.register(payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data) vuToken = body.data.accessToken;
|
||||
}
|
||||
|
||||
if (!vuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start walkguide
|
||||
api.startWalkguide(vuToken);
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// ── Burst mode: kirim 5 obstacle logs (simulasi 5 FPS selama 1 detik) ──────
|
||||
const burst = testData.obstacleLogBurst(5);
|
||||
let allSuccess = true;
|
||||
|
||||
for (const obstaclePayload of burst) {
|
||||
const res = api.logObstacle(vuToken, obstaclePayload);
|
||||
|
||||
const ok = check(res, {
|
||||
"obstacle-log: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"obstacle-log: fast response": (r) => r.timings.duration < 600,
|
||||
});
|
||||
|
||||
if (!ok) allSuccess = false;
|
||||
|
||||
// Minimal sleep (5 FPS = 200ms per frame)
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// ── Setiap 10 iteration, kirim obstacle dengan confidence tinggi ────────────
|
||||
if (__ITER % 10 === 0) {
|
||||
const criticalObstacle = {
|
||||
label: "person",
|
||||
confidence: 0.95,
|
||||
direction: "CENTER",
|
||||
estimatedDist: "Very Close",
|
||||
lat: testData.randomSurabayaCoord().lat,
|
||||
lng: testData.randomSurabayaCoord().lng,
|
||||
};
|
||||
const res = api.logObstacle(vuToken, criticalObstacle);
|
||||
check(res, {
|
||||
"critical-obstacle: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"critical-obstacle: fast": (r) => r.timings.duration < 300,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Think time minimal — obstacle logging adalah high-frequency ─────────────
|
||||
sleep(testData.randomFloat(0.1, 0.5, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("✅ Obstacle logging stress test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
184
walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js
Normal file
184
walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SCENARIO: pairing-flow.js
|
||||
* Guardian invite → User accept/reject → Pairing status check → Unpair
|
||||
*
|
||||
* Tujuan: Test business logic pairing — paling complex di WalkGuide karena
|
||||
* melibatkan dua role berbeda, constraint unique, dan state transitions.
|
||||
* Target: p95 pairing latency < 600ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
pairing_flow: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 5 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "20s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "20s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_pairing_latency_ms: ["p(95)<600", "p(99)<1200"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helper: register dan dapat tokens ────────────────────────────────────────
|
||||
function registerGetTokens(payload) {
|
||||
const res = api.register(payload);
|
||||
if (res.status !== 200) return null;
|
||||
const body = api.parseBody(res);
|
||||
return body && body.data ? body.data : null;
|
||||
}
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
export default function pairingFlow() {
|
||||
const vuId = __VU;
|
||||
const iter = __ITER;
|
||||
const suffix = `${vuId}_${iter}_${Date.now()}`;
|
||||
|
||||
// ── Step 1: Register Guardian ─────────────────────────────────────────────
|
||||
const gPayload = testData.guardianRegisterPayload(suffix);
|
||||
const gTokens = registerGetTokens(gPayload);
|
||||
|
||||
if (!gTokens) {
|
||||
console.error(`[pairing-flow] Guardian register failed for VU ${vuId}`);
|
||||
sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 2: Register User (tunanetra) ─────────────────────────────────────
|
||||
const uPayload = testData.userRegisterPayload(suffix);
|
||||
const uTokens = registerGetTokens(uPayload);
|
||||
|
||||
if (!uTokens) {
|
||||
console.error(`[pairing-flow] User register failed for VU ${vuId}`);
|
||||
sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// uniqueUserId harus ada untuk user baru
|
||||
const uniqueUserId = uTokens.uniqueUserId;
|
||||
if (!uniqueUserId) {
|
||||
console.warn(`[pairing-flow] uniqueUserId missing, VU ${vuId}`);
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Check pairing status (harus NONE/belum paired) ───────────────
|
||||
const statusRes1 = api.getPairingStatus(gTokens.accessToken);
|
||||
check(statusRes1, {
|
||||
"pairing-status initial: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 4: Guardian invite User ─────────────────────────────────────────
|
||||
const inviteRes = api.inviteUser(
|
||||
gTokens.accessToken,
|
||||
uniqueUserId || testData.fakeUniqueUserId(),
|
||||
);
|
||||
check(inviteRes, {
|
||||
"pairing-invite: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"pairing-invite: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
let pairingId = null;
|
||||
if (inviteRes.status >= 200 && inviteRes.status < 300) {
|
||||
const body = api.parseBody(inviteRes);
|
||||
pairingId = body && body.data ? body.data.pairingId : null;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 5: User check pending invitation ─────────────────────────────────
|
||||
const statusRes2 = api.getPairingStatus(uTokens.accessToken);
|
||||
check(statusRes2, {
|
||||
"user-pairing-status: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"user-pairing-status: PENDING": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
// Bisa PENDING atau NONE jika invite belum proses
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 6: User accept atau reject (alternating per iteration) ───────────
|
||||
if (pairingId) {
|
||||
const shouldAccept = iter % 3 !== 0; // 2/3 accept, 1/3 reject
|
||||
const respondRes = api.respondPairing(
|
||||
uTokens.accessToken,
|
||||
pairingId,
|
||||
shouldAccept,
|
||||
);
|
||||
check(respondRes, {
|
||||
"pairing-respond: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 7: Confirm status setelah respond ──────────────────────────────
|
||||
const statusRes3 = api.getPairingStatus(gTokens.accessToken);
|
||||
check(statusRes3, {
|
||||
"pairing-confirm: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"pairing-confirm: status matches": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
if (!b || !b.data) return true; // optional
|
||||
const s = b.data.status;
|
||||
return shouldAccept ? s === "ACTIVE" : s === "REJECTED" || s === "NONE";
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 8: Unpair (cleanup + test unpair endpoint) ────────────────────
|
||||
if (shouldAccept) {
|
||||
const unpairRes = api.unpair(gTokens.accessToken);
|
||||
check(unpairRes, {
|
||||
"unpair: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 9: Test bahwa Guardian tidak bisa masuk ke /user endpoint ─────────
|
||||
const forbiddenRes = api.getUserProfile(gTokens.accessToken);
|
||||
check(forbiddenRes, {
|
||||
"RBAC: guardian cannot access /user/profile": (r) =>
|
||||
r.status === 403 || r.status === 401,
|
||||
});
|
||||
|
||||
// ── Step 10: Test bahwa User tidak bisa akses /guardian endpoint ───────────
|
||||
const forbiddenRes2 = api.getGuardianDashboard(uTokens.accessToken);
|
||||
check(forbiddenRes2, {
|
||||
"RBAC: user cannot access /guardian/dashboard": (r) =>
|
||||
r.status === 403 || r.status === 401,
|
||||
});
|
||||
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("✅ Pairing flow test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
148
walkguide-backend/demo/k6-tests/scenarios/sos-flow.js
Normal file
148
walkguide-backend/demo/k6-tests/scenarios/sos-flow.js
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* SCENARIO: sos-flow.js
|
||||
* SOS trigger → Guardian acknowledge → Status resolution
|
||||
*
|
||||
* SOS adalah fitur PALING KRITIS di WalkGuide — life-safety feature.
|
||||
* Harus memiliki latency terendah di seluruh sistem.
|
||||
*
|
||||
* Target: p95 < 200ms, error rate = 0% (zero tolerance)
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sos_critical: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "15s", target: 5 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "15s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "10s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
// SOS: zero error tolerance, ultra-low latency
|
||||
walkguide_sos_latency_ms: ["p(95)<200", "p(99)<400", "max<800"],
|
||||
walkguide_error_rate: ["rate<0.005"], // < 0.5% error untuk SOS
|
||||
http_req_failed: ["rate<0.005"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let userToken = null;
|
||||
let guardianToken = null;
|
||||
|
||||
export default function sosFlow() {
|
||||
// ── Init: setup user + guardian pair ──────────────────────────────────────
|
||||
if (!userToken || !guardianToken) {
|
||||
const suffix = `sos_${__VU}_${Date.now()}`;
|
||||
|
||||
// Register user
|
||||
const uRes = api.register(testData.userRegisterPayload(suffix));
|
||||
if (uRes.status === 200) {
|
||||
const b = api.parseBody(uRes);
|
||||
if (b && b.data) userToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
// Register guardian
|
||||
const gRes = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (gRes.status === 200) {
|
||||
const b = api.parseBody(gRes);
|
||||
if (b && b.data) guardianToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
if (!userToken || !guardianToken) {
|
||||
console.error(`[sos-flow] VU ${__VU} failed to get tokens`);
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Step 1: User trigger SOS ───────────────────────────────────────────────
|
||||
const sosTriggerTypes = ["VOICE_COMMAND", "BUTTON", "MANUAL"];
|
||||
const triggerType = sosTriggerTypes[__ITER % 3];
|
||||
const coord = testData.randomSurabayaCoord(0.03);
|
||||
|
||||
const startTs = Date.now();
|
||||
const sosRes = api.triggerSos(userToken, triggerType, coord.lat, coord.lng);
|
||||
const triggerMs = Date.now() - startTs;
|
||||
|
||||
const sosOk = check(sosRes, {
|
||||
"sos-trigger: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"sos-trigger: latency < 200ms": (r) => r.timings.duration < 200,
|
||||
"sos-trigger: has sosId": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.id !== undefined;
|
||||
},
|
||||
"sos-trigger: status TRIGGERED": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.status === "TRIGGERED";
|
||||
},
|
||||
});
|
||||
|
||||
let sosId = null;
|
||||
if (sosRes.status >= 200 && sosRes.status < 300) {
|
||||
const body = api.parseBody(sosRes);
|
||||
sosId = body && body.data ? body.data.id : null;
|
||||
}
|
||||
|
||||
if (!sosOk) {
|
||||
console.warn(
|
||||
`[sos-flow] SOS trigger not OK for VU ${__VU}, trigger latency: ${triggerMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 2: Guardian poll SOS events ──────────────────────────────────────
|
||||
const sosListRes = api.getSosEvents(guardianToken);
|
||||
check(sosListRes, {
|
||||
"guardian-sos-list: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Guardian acknowledge SOS ──────────────────────────────────────
|
||||
if (sosId) {
|
||||
const ackRes = api.acknowledgeSos(guardianToken, sosId);
|
||||
check(ackRes, {
|
||||
"sos-acknowledge: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"sos-acknowledge: status ACKNOWLEDGED": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.status === "ACKNOWLEDGED";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── Step 4: User check activity logs (SOS harus ter-log) ──────────────────
|
||||
const logsRes = api.getActivityLogs(userToken, 0, 5);
|
||||
check(logsRes, {
|
||||
"activity-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// ── Think time antar SOS (SOS tidak terjadi setiap detik di production) ────
|
||||
sleep(testData.randomFloat(1.0, 3.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("🚨 SOS flow test starting — Zero error tolerance mode.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
183
walkguide-backend/demo/k6-tests/scenarios/timeline-query.js
Normal file
183
walkguide-backend/demo/k6-tests/scenarios/timeline-query.js
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* SCENARIO: timeline-query.js
|
||||
* Timeline analytics endpoints — Guardian dashboard, location history, activity logs.
|
||||
*
|
||||
* Simulasi Guardian yang aktif monitoring: buka dashboard, lihat peta,
|
||||
* scroll history, cek obstacle logs. Query-heavy read scenario.
|
||||
*
|
||||
* Target: p95 < 1000ms (analytics boleh lebih lambat), error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
timeline_analytics: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 10 },
|
||||
{ duration: "90s", target: 30 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_timeline_latency_ms: ["p(95)<1000", "p(99)<2000"],
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let guardianToken = null;
|
||||
let userToken = null;
|
||||
|
||||
export default function timelineQuery() {
|
||||
// ── Init: setup both roles ─────────────────────────────────────────────────
|
||||
if (!guardianToken || !userToken) {
|
||||
const suffix = `tl_${__VU}_${Date.now()}`;
|
||||
|
||||
const gRes = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (gRes.status === 200) {
|
||||
const b = api.parseBody(gRes);
|
||||
if (b && b.data) guardianToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
const uRes = api.register(testData.userRegisterPayload(suffix));
|
||||
if (uRes.status === 200) {
|
||||
const b = api.parseBody(uRes);
|
||||
if (b && b.data) userToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
if (!guardianToken || !userToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed beberapa location updates agar ada data untuk query
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const coord = testData.randomSurabayaCoord(0.02);
|
||||
api.updateLocation(userToken, coord.lat, coord.lng, 5.0, 1.2, 90.0);
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
// Seed obstacle logs
|
||||
for (let i = 0; i < 5; i++) {
|
||||
api.logObstacle(userToken, testData.obstacleLogPayload());
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Guardian Dashboard Simulation ─────────────────────────────────────────
|
||||
|
||||
// Step 1: Guardian dashboard (overview semua data)
|
||||
const dashRes = api.getGuardianDashboard(guardianToken);
|
||||
check(dashRes, {
|
||||
"dashboard: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"dashboard: fast": (r) => r.timings.duration < 1000,
|
||||
"dashboard: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
sleep(0.5);
|
||||
|
||||
// Step 2: Get user location (paling sering dipanggil di Guardian map screen)
|
||||
const locRes = api.getUserLocation(guardianToken);
|
||||
check(locRes, {
|
||||
"user-location: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"user-location: fast": (r) => r.timings.duration < 500,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 3: Location history (scroll timeline peta seperti Google Maps Timeline)
|
||||
const pages = [0, 1, 2];
|
||||
const pageSizes = [20, 50, 100];
|
||||
|
||||
for (const page of pages) {
|
||||
const size = pageSizes[page % pageSizes.length];
|
||||
const histRes = api.getLocationHistory(guardianToken, page, size);
|
||||
check(histRes, {
|
||||
[`location-history page${page}: 2xx`]: (r) =>
|
||||
r.status >= 200 && r.status < 300,
|
||||
[`location-history page${page}: latency`]: (r) =>
|
||||
r.timings.duration < 1500,
|
||||
});
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// Step 4: Obstacle logs (Guardian ingin tahu apa saja yang terdeteksi)
|
||||
const obsRes = api.getObstacleLogs(guardianToken, 0, 20);
|
||||
check(obsRes, {
|
||||
"obstacle-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"obstacle-logs: fast": (r) => r.timings.duration < 800,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 5: Activity logs (timeline aktivitas User)
|
||||
const actRes = api.getActivityLogsGuardian(guardianToken, 0, 20);
|
||||
check(actRes, {
|
||||
"activity-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"activity-logs: fast": (r) => r.timings.duration < 1000,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 6: SOS events history
|
||||
const sosRes = api.getSosEvents(guardianToken, 0, 10);
|
||||
check(sosRes, {
|
||||
"sos-events: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
|
||||
// Step 7: AI config (Guardian mau lihat setting YOLO)
|
||||
const aiRes = api.getAiConfig(guardianToken);
|
||||
check(aiRes, {
|
||||
"ai-config: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
|
||||
// Step 8: Geofence config
|
||||
const geoRes = api.getGeofenceConfig(guardianToken);
|
||||
check(geoRes, {
|
||||
"geofence: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// ── User side: check own activity ─────────────────────────────────────────
|
||||
sleep(0.5);
|
||||
|
||||
const uActRes = api.getActivityLogs(userToken, 0, 10);
|
||||
check(uActRes, {
|
||||
"user-activity: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
const uSettRes = api.getUserSettings(userToken);
|
||||
check(uSettRes, {
|
||||
"user-settings: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
const uAiRes = api.getUserAiConfig(userToken);
|
||||
check(uAiRes, {
|
||||
"user-ai-config: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// Think time — Guardian tidak refresh setiap detik
|
||||
sleep(testData.randomFloat(2.0, 5.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("📊 Timeline analytics test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
216
walkguide-backend/demo/k6-tests/utils/html-reporter.js
Normal file
216
walkguide-backend/demo/k6-tests/utils/html-reporter.js
Normal file
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* WalkGuide — k6 HTML Report Generator (Node.js)
|
||||
* Mengkonversi parsed-report.json menjadi HTML report yang rapi untuk submission.
|
||||
*
|
||||
* Usage:
|
||||
* node html-reporter.js <parsed-report.json> [output.html]
|
||||
*
|
||||
* Output: HTML report siap lampir ke laporan final exam.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const inputFile = process.argv[2];
|
||||
const outputFile = process.argv[3] || "k6-results/load-test-report.html";
|
||||
|
||||
if (!inputFile || !fs.existsSync(inputFile)) {
|
||||
console.error(
|
||||
"Usage: node html-reporter.js <parsed-report.json> [output.html]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(inputFile, "utf8"));
|
||||
|
||||
// ── HTML Template ─────────────────────────────────────────────────────────────
|
||||
function badge(result) {
|
||||
if (!result) return '<span class="badge skip">SKIP</span>';
|
||||
if (result.includes("PASS")) return '<span class="badge pass">✅ PASS</span>';
|
||||
if (result.includes("FAIL")) return '<span class="badge fail">❌ FAIL</span>';
|
||||
return `<span class="badge skip">${result}</span>`;
|
||||
}
|
||||
|
||||
function metricRow(label, val) {
|
||||
return `<tr><td>${label}</td><td><strong>${val || "N/A"}</strong></td></tr>`;
|
||||
}
|
||||
|
||||
function endpointTableRows() {
|
||||
const rows = [];
|
||||
for (const [ep, metrics] of Object.entries(report.endpoints || {})) {
|
||||
const dur = metrics["http_req_duration"];
|
||||
if (!dur) continue;
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td><code>${ep}</code></td>
|
||||
<td>${dur.count || "—"}</td>
|
||||
<td>${dur.avg || "—"}</td>
|
||||
<td>${dur.p95 || "—"}</td>
|
||||
<td>${dur.p99 || "—"}</td>
|
||||
<td>${dur.max || "—"}</td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
return rows.join("");
|
||||
}
|
||||
|
||||
function thresholdRows() {
|
||||
return (report.thresholdResults || [])
|
||||
.map(
|
||||
(t) => `
|
||||
<tr>
|
||||
<td>${t.name}</td>
|
||||
<td><code>${t.metric}</code></td>
|
||||
<td>${t.threshold}</td>
|
||||
<td>${t.actual}</td>
|
||||
<td>${badge(t.result)}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function wgMetricCards() {
|
||||
const wg = report.walkguideMetrics || {};
|
||||
const cards = [
|
||||
{ label: "🔐 Auth", key: "authLatency", threshold: "< 800ms" },
|
||||
{ label: "📍 Location", key: "locationLatency", threshold: "< 300ms" },
|
||||
{ label: "🚧 Obstacle", key: "obstacleLatency", threshold: "< 400ms" },
|
||||
{ label: "🚨 SOS", key: "sosLatency", threshold: "< 200ms" },
|
||||
{ label: "📬 Notif", key: "notifLatency", threshold: "< 500ms" },
|
||||
{ label: "📊 Timeline", key: "timelineLatency", threshold: "< 1000ms" },
|
||||
{ label: "🔗 Pairing", key: "pairingLatency", threshold: "< 600ms" },
|
||||
];
|
||||
|
||||
return cards
|
||||
.map((c) => {
|
||||
const data = wg[c.key];
|
||||
return `
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">${c.label}</div>
|
||||
<div class="metric-p95">${data ? data.p95 : "N/A"}</div>
|
||||
<div class="metric-sub">p95 | threshold ${c.threshold}</div>
|
||||
<div class="metric-avg">avg: ${data ? data.avg : "N/A"} | p99: ${data ? data.p99 : "N/A"}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>WalkGuide — k6 Load Test Report</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; }
|
||||
h1 { font-size: 2rem; color: #38bdf8; margin-bottom: 4px; }
|
||||
h2 { font-size: 1.2rem; color: #7dd3fc; margin: 32px 0 12px; border-bottom: 1px solid #1e3a5f; padding-bottom: 6px; }
|
||||
p { color: #94a3b8; margin-bottom: 8px; }
|
||||
.header { margin-bottom: 32px; }
|
||||
.header .meta { color: #64748b; font-size: 0.85rem; margin-top: 4px; }
|
||||
|
||||
/* Key Metrics Grid */
|
||||
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.key-card { background: #1e293b; border-radius: 10px; padding: 18px; border: 1px solid #334155; }
|
||||
.key-card .klabel { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.key-card .kvalue { font-size: 1.5rem; font-weight: 700; color: #f0f9ff; margin: 6px 0 2px; }
|
||||
.key-card .kdesc { font-size: 0.72rem; color: #475569; }
|
||||
|
||||
/* WalkGuide Metric Cards */
|
||||
.wg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
||||
.metric-card { background: #1e293b; border-radius: 8px; padding: 14px; border: 1px solid #1e3a5f; text-align: center; }
|
||||
.metric-label { font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
|
||||
.metric-p95 { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
|
||||
.metric-sub { font-size: 0.7rem; color: #475569; margin-top: 2px; }
|
||||
.metric-avg { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 0.88rem; }
|
||||
th { background: #1e3a5f; color: #7dd3fc; text-align: left; padding: 10px 12px; }
|
||||
td { padding: 9px 12px; border-bottom: 1px solid #1e293b; color: #cbd5e1; }
|
||||
tr:nth-child(even) td { background: #0f1f35; }
|
||||
code { background: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 0.82rem; color: #38bdf8; }
|
||||
|
||||
/* Badges */
|
||||
.badge { padding: 3px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
|
||||
.badge.pass { background: #064e3b; color: #34d399; }
|
||||
.badge.fail { background: #450a0a; color: #f87171; }
|
||||
.badge.skip { background: #1e293b; color: #94a3b8; }
|
||||
|
||||
footer { margin-top: 40px; text-align: center; color: #334155; font-size: 0.78rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🦺 WalkGuide — k6 Load Test Report</h1>
|
||||
<p>Spring Boot Backend Performance Benchmark — Pillar 3</p>
|
||||
<div class="meta">
|
||||
Generated: ${report.generatedAt} |
|
||||
Input: ${path.basename(report.inputFile || "")} |
|
||||
Total Data Points: ${report.totalPoints}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>📊 Key Metrics (Pillar 3 — 5 Required)</h2>
|
||||
<div class="key-grid">
|
||||
<div class="key-card">
|
||||
<div class="klabel">1. Throughput</div>
|
||||
<div class="kvalue">${report.keyMetrics.throughput.value || "N/A"}</div>
|
||||
<div class="kdesc">Requests per second</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">2. p95 Latency</div>
|
||||
<div class="kvalue">${report.keyMetrics.p95Latency.value || "N/A"}</div>
|
||||
<div class="kdesc">95th percentile response time</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">3. Error Rate</div>
|
||||
<div class="kvalue">${report.keyMetrics.errorRate.value || "N/A"}</div>
|
||||
<div class="kdesc">Non-2xx responses | ${report.keyMetrics.errorRate.passFail || "—"}</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">4. DB Query Time</div>
|
||||
<div class="kvalue">${report.keyMetrics.dbQueryTime.value || "N/A"}</div>
|
||||
<div class="kdesc">Estimated via write endpoint p95</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">5. JVM Heap</div>
|
||||
<div class="kvalue">${report.keyMetrics.jvmHeap.value || "N/A"}</div>
|
||||
<div class="kdesc">Spring Actuator jvm.memory.used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>🏃 WalkGuide Endpoint Latency Breakdown</h2>
|
||||
<div class="wg-grid">${wgMetricCards()}</div>
|
||||
|
||||
<h2>✅ Threshold Evaluation</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Check</th><th>Metric</th><th>Threshold</th><th>Actual</th><th>Result</th></tr>
|
||||
</thead>
|
||||
<tbody>${thresholdRows()}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>📋 Per-Endpoint Latency (ms)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Endpoint</th><th>Requests</th><th>Avg</th><th>p95</th><th>p99</th><th>Max</th></tr>
|
||||
</thead>
|
||||
<tbody>${endpointTableRows()}</tbody>
|
||||
</table>
|
||||
|
||||
<footer>
|
||||
WalkGuide AI — Integrated Mobile Application Project Final Exam |
|
||||
Generated by html-reporter.js
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────────────
|
||||
const outDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(outputFile, html);
|
||||
console.log(`✅ HTML report written to: ${outputFile}`);
|
||||
359
walkguide-backend/demo/k6-tests/utils/result-parser.js
Normal file
359
walkguide-backend/demo/k6-tests/utils/result-parser.js
Normal file
@ -0,0 +1,359 @@
|
||||
/**
|
||||
* WalkGuide — k6 JSON Result Parser (Node.js)
|
||||
* Parse output dari k6 --out json=output.ndjson dan generate readable report.
|
||||
*
|
||||
* Usage:
|
||||
* node result-parser.js <input.ndjson> [output-report.json]
|
||||
*
|
||||
* k6 JSON format: satu JSON object per baris (newline-delimited JSON / NDJSON)
|
||||
* Setiap baris adalah metric data point atau test info.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ── Parse args ────────────────────────────────────────────────────────────────
|
||||
const inputFile = process.argv[2];
|
||||
const outputFile = process.argv[3] || "k6-results/parsed-report.json";
|
||||
|
||||
if (!inputFile) {
|
||||
console.error(
|
||||
"Usage: node result-parser.js <k6-output.ndjson> [output.json]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`Input file not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Read and parse NDJSON ─────────────────────────────────────────────────────
|
||||
const raw = fs.readFileSync(inputFile, "utf8");
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
|
||||
const dataPoints = [];
|
||||
const errors = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
dataPoints.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
errors.push({ line: line.slice(0, 100), error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Parsed ${dataPoints.length} data points, ${errors.length} parse errors.`,
|
||||
);
|
||||
|
||||
// ── Aggregate metrics ─────────────────────────────────────────────────────────
|
||||
const metricAggregates = {};
|
||||
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.type !== "Point") continue;
|
||||
|
||||
const metricName = dp.metric;
|
||||
const value = dp.data && dp.data.value !== undefined ? dp.data.value : 0;
|
||||
const tags = dp.data && dp.data.tags ? dp.data.tags : {};
|
||||
|
||||
if (!metricAggregates[metricName]) {
|
||||
metricAggregates[metricName] = {
|
||||
values: [],
|
||||
byEndpoint: {},
|
||||
};
|
||||
}
|
||||
|
||||
metricAggregates[metricName].values.push(value);
|
||||
|
||||
// Group by endpoint tag
|
||||
const endpoint = tags.endpoint || "unknown";
|
||||
if (!metricAggregates[metricName].byEndpoint[endpoint]) {
|
||||
metricAggregates[metricName].byEndpoint[endpoint] = [];
|
||||
}
|
||||
metricAggregates[metricName].byEndpoint[endpoint].push(value);
|
||||
}
|
||||
|
||||
// ── Statistical helpers ───────────────────────────────────────────────────────
|
||||
function percentile(arr, p) {
|
||||
if (!arr || arr.length === 0) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const idx = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
function avg(arr) {
|
||||
if (!arr || arr.length === 0) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
function summarizeSeries(arr) {
|
||||
if (!arr || arr.length === 0) return null;
|
||||
return {
|
||||
count: arr.length,
|
||||
min: Math.min(...arr).toFixed(2),
|
||||
max: Math.max(...arr).toFixed(2),
|
||||
avg: avg(arr).toFixed(2),
|
||||
p50: percentile(arr, 50).toFixed(2),
|
||||
p90: percentile(arr, 90).toFixed(2),
|
||||
p95: percentile(arr, 95).toFixed(2),
|
||||
p99: percentile(arr, 99).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build structured report ───────────────────────────────────────────────────
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
inputFile: path.resolve(inputFile),
|
||||
totalPoints: dataPoints.length,
|
||||
parseErrors: errors.length,
|
||||
|
||||
// 5 KEY METRICS sesuai ketentuan pillar 3:
|
||||
keyMetrics: {
|
||||
// 1. Throughput — request per second
|
||||
throughput: {
|
||||
label: "Throughput (req/s)",
|
||||
description: "Total HTTP requests divided by test duration",
|
||||
value: null, // dihitung di bawah
|
||||
},
|
||||
// 2. p95 Latency — overall
|
||||
p95Latency: {
|
||||
label: "p95 Response Time (ms)",
|
||||
description: "95th percentile of all HTTP request durations",
|
||||
value: null,
|
||||
},
|
||||
// 3. Error Rate
|
||||
errorRate: {
|
||||
label: "Error Rate (%)",
|
||||
description: "Percentage of non-2xx HTTP responses",
|
||||
value: null,
|
||||
passFail: null,
|
||||
},
|
||||
// 4. DB Query Time (estimated via response time)
|
||||
dbQueryTime: {
|
||||
label: "Estimated DB Query Time (ms)",
|
||||
description: "p95 of write endpoints (location + obstacle) as DB proxy",
|
||||
value: null,
|
||||
note: "Actual DB time requires Spring Actuator or DB metrics",
|
||||
},
|
||||
// 5. JVM Heap
|
||||
jvmHeap: {
|
||||
label: "JVM Heap Used (MB)",
|
||||
description: "From Spring Actuator metric, collected during test",
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Per-metric detailed stats
|
||||
metrics: {},
|
||||
|
||||
// Per-endpoint breakdown
|
||||
endpoints: {},
|
||||
|
||||
// WalkGuide-specific breakdown
|
||||
walkguideMetrics: {
|
||||
authLatency: null,
|
||||
locationLatency: null,
|
||||
obstacleLatency: null,
|
||||
sosLatency: null,
|
||||
notifLatency: null,
|
||||
timelineLatency: null,
|
||||
pairingLatency: null,
|
||||
},
|
||||
|
||||
// Threshold evaluation
|
||||
thresholdResults: [],
|
||||
};
|
||||
|
||||
// ── Populate metrics ──────────────────────────────────────────────────────────
|
||||
for (const [name, data] of Object.entries(metricAggregates)) {
|
||||
report.metrics[name] = summarizeSeries(data.values);
|
||||
|
||||
// Per-endpoint
|
||||
for (const [ep, vals] of Object.entries(data.byEndpoint)) {
|
||||
if (!report.endpoints[ep]) report.endpoints[ep] = {};
|
||||
report.endpoints[ep][name] = summarizeSeries(vals);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fill key metrics ──────────────────────────────────────────────────────────
|
||||
if (report.metrics["http_req_duration"]) {
|
||||
report.keyMetrics.p95Latency.value =
|
||||
report.metrics["http_req_duration"].p95 + " ms";
|
||||
}
|
||||
|
||||
if (report.metrics["http_req_failed"]) {
|
||||
const rate = (
|
||||
parseFloat(report.metrics["http_req_failed"].avg) * 100
|
||||
).toFixed(2);
|
||||
report.keyMetrics.errorRate.value = rate + "%";
|
||||
report.keyMetrics.errorRate.passFail = parseFloat(rate) < 1 ? "PASS" : "FAIL";
|
||||
}
|
||||
|
||||
if (report.metrics["walkguide_jvm_heap_used_mb"]) {
|
||||
report.keyMetrics.jvmHeap.value =
|
||||
report.metrics["walkguide_jvm_heap_used_mb"].avg + " MB";
|
||||
}
|
||||
|
||||
// DB estimate: avg of location_update + obstacle_log p95
|
||||
const dbMetrics = [
|
||||
"walkguide_location_latency_ms",
|
||||
"walkguide_obstacle_latency_ms",
|
||||
];
|
||||
const dbVals = dbMetrics
|
||||
.filter((m) => report.metrics[m])
|
||||
.map((m) => parseFloat(report.metrics[m].p95));
|
||||
if (dbVals.length > 0) {
|
||||
report.keyMetrics.dbQueryTime.value =
|
||||
(dbVals.reduce((a, b) => a + b, 0) / dbVals.length).toFixed(2) + " ms";
|
||||
}
|
||||
|
||||
// Throughput: dari http_reqs total / test duration (estimasi dari data points)
|
||||
if (report.metrics["http_reqs"]) {
|
||||
const totalReqs = report.metrics["http_reqs"].count;
|
||||
// Estimasi durasi dari timestamps
|
||||
const timestamps = dataPoints
|
||||
.filter((dp) => dp.type === "Point" && dp.metric === "http_reqs")
|
||||
.map((dp) => new Date(dp.data.time).getTime());
|
||||
if (timestamps.length > 1) {
|
||||
const durationS =
|
||||
(Math.max(...timestamps) - Math.min(...timestamps)) / 1000;
|
||||
report.keyMetrics.throughput.value =
|
||||
(totalReqs / durationS).toFixed(1) + " req/s";
|
||||
} else {
|
||||
report.keyMetrics.throughput.value = totalReqs + " total requests";
|
||||
}
|
||||
}
|
||||
|
||||
// ── WalkGuide-specific ────────────────────────────────────────────────────────
|
||||
const wgMetricMap = {
|
||||
authLatency: "walkguide_auth_latency_ms",
|
||||
locationLatency: "walkguide_location_latency_ms",
|
||||
obstacleLatency: "walkguide_obstacle_latency_ms",
|
||||
sosLatency: "walkguide_sos_latency_ms",
|
||||
notifLatency: "walkguide_notif_latency_ms",
|
||||
timelineLatency: "walkguide_timeline_latency_ms",
|
||||
pairingLatency: "walkguide_pairing_latency_ms",
|
||||
};
|
||||
|
||||
for (const [key, metricName] of Object.entries(wgMetricMap)) {
|
||||
if (report.metrics[metricName]) {
|
||||
report.walkguideMetrics[key] = {
|
||||
p95: report.metrics[metricName].p95 + " ms",
|
||||
p99: report.metrics[metricName].p99 + " ms",
|
||||
avg: report.metrics[metricName].avg + " ms",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Threshold checks ──────────────────────────────────────────────────────────
|
||||
const thresholdChecks = [
|
||||
{
|
||||
name: "p95 overall < 500ms",
|
||||
metric: "http_req_duration",
|
||||
stat: "p95",
|
||||
threshold: 500,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "error rate < 1%",
|
||||
metric: "http_req_failed",
|
||||
stat: "avg",
|
||||
threshold: 0.01,
|
||||
unit: "rate",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "SOS p95 < 200ms",
|
||||
metric: "walkguide_sos_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 200,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Location p95 < 300ms",
|
||||
metric: "walkguide_location_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 300,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Obstacle p95 < 400ms",
|
||||
metric: "walkguide_obstacle_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 400,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Auth p95 < 800ms",
|
||||
metric: "walkguide_auth_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 800,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Timeline p95 < 1000ms",
|
||||
metric: "walkguide_timeline_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 1000,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Notification p95 < 500ms",
|
||||
metric: "walkguide_notif_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 500,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
];
|
||||
|
||||
for (const check of thresholdChecks) {
|
||||
const m = report.metrics[check.metric];
|
||||
if (!m) {
|
||||
report.thresholdResults.push({ ...check, actual: "N/A", result: "SKIP" });
|
||||
continue;
|
||||
}
|
||||
const actual = parseFloat(m[check.stat]);
|
||||
const pass =
|
||||
check.operator === "<"
|
||||
? actual < check.threshold
|
||||
: actual > check.threshold;
|
||||
report.thresholdResults.push({
|
||||
name: check.name,
|
||||
metric: check.metric,
|
||||
threshold: check.threshold + " " + check.unit,
|
||||
actual: actual.toFixed(2) + " " + check.unit,
|
||||
result: pass ? "✅ PASS" : "❌ FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Write output ──────────────────────────────────────────────────────────────
|
||||
const outDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2));
|
||||
console.log(`\n✅ Report written to: ${outputFile}`);
|
||||
|
||||
// ── Print summary to stdout ───────────────────────────────────────────────────
|
||||
console.log("\n═══════════════════════════════════════════════════════");
|
||||
console.log(" WalkGuide k6 — Parsed Result Summary");
|
||||
console.log("═══════════════════════════════════════════════════════");
|
||||
console.log(` Throughput: ${report.keyMetrics.throughput.value || "N/A"}`);
|
||||
console.log(` p95 Latency: ${report.keyMetrics.p95Latency.value || "N/A"}`);
|
||||
console.log(
|
||||
` Error Rate: ${report.keyMetrics.errorRate.value || "N/A"} [${report.keyMetrics.errorRate.passFail || "N/A"}]`,
|
||||
);
|
||||
console.log(` DB Est. Time: ${report.keyMetrics.dbQueryTime.value || "N/A"}`);
|
||||
console.log(` JVM Heap: ${report.keyMetrics.jvmHeap.value || "N/A"}`);
|
||||
console.log("\n Threshold Results:");
|
||||
for (const t of report.thresholdResults) {
|
||||
console.log(` ${t.result} ${t.name} (actual: ${t.actual})`);
|
||||
}
|
||||
console.log("═══════════════════════════════════════════════════════\n");
|
||||
@ -0,0 +1,907 @@
|
||||
package com.walkguide.performance;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* K6ResultParserTest
|
||||
*
|
||||
* Unit tests untuk memverifikasi logika parsing output k6 load test.
|
||||
* Tidak memerlukan file fisik atau koneksi eksternal — semua data di-mock sebagai
|
||||
* in-memory JSON (NDJSON format) sesuai dengan format output k6 --out json.
|
||||
*
|
||||
* Struktur NDJSON k6:
|
||||
* {"type":"Point","metric":"http_req_duration","data":{"time":"...","value":123.45,"tags":{"endpoint":"login"}}}
|
||||
*
|
||||
* Setiap nested class merepresentasikan satu aspek dari pipeline parsing:
|
||||
* 1. NdjsonParser — parsing NDJSON line-by-line
|
||||
* 2. MetricAggregator — aggregasi nilai per metric name
|
||||
* 3. StatCalculator — kalkulasi statistik (avg, p95, p99, dll)
|
||||
* 4. ThresholdEvaluator — evaluasi pass/fail per threshold
|
||||
* 5. KeyMetricExtractor — extract 5 key metrics wajib (Pillar 3)
|
||||
* 6. WalkGuideMetrics — breakdown per endpoint spesifik WalkGuide
|
||||
* 7. EndpointBreakdown — grouping metric berdasarkan tag endpoint
|
||||
* 8. EdgeCases — empty data, malformed JSON, single data point
|
||||
*/
|
||||
@DisplayName("K6ResultParser — Load Test Output Parsing")
|
||||
class K6ResultParserTest {
|
||||
|
||||
private ObjectMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helper: buat satu NDJSON data point seperti output k6
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private ObjectNode makePoint(String metric, double value, String endpoint) {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("type", "Point");
|
||||
root.put("metric", metric);
|
||||
|
||||
ObjectNode data = mapper.createObjectNode();
|
||||
data.put("time", "2024-01-01T00:00:00Z");
|
||||
data.put("value", value);
|
||||
|
||||
ObjectNode tags = mapper.createObjectNode();
|
||||
tags.put("endpoint", endpoint);
|
||||
data.set("tags", tags);
|
||||
|
||||
root.set("data", data);
|
||||
return root;
|
||||
}
|
||||
|
||||
private ObjectNode makePoint(String metric, double value) {
|
||||
return makePoint(metric, value, "unknown");
|
||||
}
|
||||
|
||||
private String toNdjson(List<ObjectNode> points) {
|
||||
return points.stream()
|
||||
.map(ObjectNode::toString)
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Inline parser logic (mirrors result-parser.js untuk ditest di Java)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse NDJSON string → list of JsonNode data points */
|
||||
private List<JsonNode> parseNdjson(String ndjson) {
|
||||
List<JsonNode> points = new ArrayList<>();
|
||||
for (String line : ndjson.split("\n")) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
try {
|
||||
points.add(mapper.readTree(line));
|
||||
} catch (Exception ignored) {
|
||||
// skip malformed lines — same behavior as result-parser.js
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Aggregate all "Point" type entries per metric name → list of values */
|
||||
private Map<String, List<Double>> aggregateMetrics(List<JsonNode> points) {
|
||||
Map<String, List<Double>> result = new HashMap<>();
|
||||
for (JsonNode dp : points) {
|
||||
if (!"Point".equals(dp.path("type").asText())) continue;
|
||||
String metric = dp.path("metric").asText();
|
||||
double value = dp.path("data").path("value").asDouble(0);
|
||||
result.computeIfAbsent(metric, k -> new ArrayList<>()).add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Group values by endpoint tag for a given metric */
|
||||
private Map<String, List<Double>> groupByEndpoint(List<JsonNode> points, String metric) {
|
||||
Map<String, List<Double>> result = new HashMap<>();
|
||||
for (JsonNode dp : points) {
|
||||
if (!"Point".equals(dp.path("type").asText())) continue;
|
||||
if (!metric.equals(dp.path("metric").asText())) continue;
|
||||
String endpoint = dp.path("data").path("tags").path("endpoint").asText("unknown");
|
||||
double value = dp.path("data").path("value").asDouble(0);
|
||||
result.computeIfAbsent(endpoint, k -> new ArrayList<>()).add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Calculate percentile — matches JS logic in result-parser.js exactly */
|
||||
private double percentile(List<Double> values, int p) {
|
||||
if (values == null || values.isEmpty()) return 0;
|
||||
List<Double> sorted = values.stream().sorted().collect(Collectors.toList());
|
||||
int idx = (int) Math.ceil((p / 100.0) * sorted.size()) - 1;
|
||||
return sorted.get(Math.max(0, idx));
|
||||
}
|
||||
|
||||
private double avg(List<Double> values) {
|
||||
if (values == null || values.isEmpty()) return 0;
|
||||
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
||||
}
|
||||
|
||||
/** Evaluate threshold: return PASS/FAIL/SKIP */
|
||||
private String evaluateThreshold(Map<String, List<Double>> metrics,
|
||||
String metricName, String stat, double threshold, String operator) {
|
||||
List<Double> vals = metrics.get(metricName);
|
||||
if (vals == null || vals.isEmpty()) return "SKIP";
|
||||
double actual;
|
||||
switch (stat) {
|
||||
case "p95": actual = percentile(vals, 95); break;
|
||||
case "p99": actual = percentile(vals, 99); break;
|
||||
case "avg": actual = avg(vals); break;
|
||||
case "max": actual = vals.stream().mapToDouble(Double::doubleValue).max().orElse(0); break;
|
||||
default: return "SKIP";
|
||||
}
|
||||
boolean pass = "<".equals(operator) ? actual < threshold : actual > threshold;
|
||||
return pass ? "PASS" : "FAIL";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. NDJSON PARSING
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("1. NDJSON Parser")
|
||||
class NdjsonParserTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus parse baris NDJSON valid menjadi list JsonNode")
|
||||
void shouldParseValidNdjsonLines() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 123.4, "login"),
|
||||
makePoint("http_req_duration", 200.5, "register"),
|
||||
makePoint("http_req_failed", 0.0, "login")
|
||||
);
|
||||
|
||||
List<JsonNode> result = parseNdjson(toNdjson(points));
|
||||
|
||||
assertThat(result).hasSize(3);
|
||||
assertThat(result.get(0).path("metric").asText()).isEqualTo("http_req_duration");
|
||||
assertThat(result.get(2).path("metric").asText()).isEqualTo("http_req_failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip baris kosong tanpa error")
|
||||
void shouldSkipEmptyLines() {
|
||||
String ndjson = makePoint("http_req_duration", 100.0).toString()
|
||||
+ "\n\n"
|
||||
+ makePoint("http_req_duration", 200.0).toString()
|
||||
+ "\n";
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip baris malformed JSON tanpa throw exception")
|
||||
void shouldSkipMalformedJsonLines() {
|
||||
String ndjson = makePoint("http_req_duration", 100.0).toString()
|
||||
+ "\n{INVALID JSON HERE}\n"
|
||||
+ makePoint("http_req_failed", 0.0).toString();
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
// Hanya 2 baris valid yang berhasil di-parse
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus return empty list untuk input kosong")
|
||||
void shouldReturnEmptyListForEmptyInput() {
|
||||
List<JsonNode> result = parseNdjson("");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus skip non-Point type entries saat aggregasi")
|
||||
void shouldSkipNonPointTypeEntries() {
|
||||
ObjectNode metaEntry = mapper.createObjectNode();
|
||||
metaEntry.put("type", "Metric");
|
||||
metaEntry.put("metric", "http_req_duration");
|
||||
|
||||
String ndjson = metaEntry.toString() + "\n"
|
||||
+ makePoint("http_req_duration", 150.0).toString();
|
||||
|
||||
List<JsonNode> points = parseNdjson(ndjson);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(points);
|
||||
|
||||
// Hanya Point yang masuk ke agregasi
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(1);
|
||||
assertThat(metrics.get("http_req_duration").get(0)).isEqualTo(150.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus parse nilai timestamp dari field data.time")
|
||||
void shouldParseTimestampFromDataTime() {
|
||||
String ndjson = makePoint("http_reqs", 1.0, "login").toString();
|
||||
|
||||
List<JsonNode> result = parseNdjson(ndjson);
|
||||
|
||||
assertThat(result.get(0).path("data").path("time").asText())
|
||||
.isEqualTo("2024-01-01T00:00:00Z");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. METRIC AGGREGATION
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("2. Metric Aggregator")
|
||||
class MetricAggregatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus aggregate multiple data points per metric name")
|
||||
void shouldAggregateMultiplePointsPerMetric() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 100.0),
|
||||
makePoint("http_req_duration", 200.0),
|
||||
makePoint("http_req_duration", 300.0),
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 1.0)
|
||||
);
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics).containsKey("http_req_duration");
|
||||
assertThat(metrics).containsKey("http_req_failed");
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(3);
|
||||
assertThat(metrics.get("http_req_failed")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus aggregate semua 7 WalkGuide custom metrics")
|
||||
void shouldAggregateAllWalkGuideCustomMetrics() {
|
||||
List<String> wgMetrics = List.of(
|
||||
"walkguide_auth_latency_ms",
|
||||
"walkguide_location_latency_ms",
|
||||
"walkguide_obstacle_latency_ms",
|
||||
"walkguide_sos_latency_ms",
|
||||
"walkguide_notif_latency_ms",
|
||||
"walkguide_timeline_latency_ms",
|
||||
"walkguide_pairing_latency_ms"
|
||||
);
|
||||
|
||||
List<ObjectNode> points = wgMetrics.stream()
|
||||
.map(m -> makePoint(m, 150.0))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
for (String m : wgMetrics) {
|
||||
assertThat(metrics).containsKey(m);
|
||||
assertThat(metrics.get(m)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus accumulate nilai ke list yang sama untuk metric yang sama")
|
||||
void shouldAccumulateValuesForSameMetric() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
points.add(makePoint("walkguide_sos_latency_ms", i * 10.0));
|
||||
}
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms"))
|
||||
.containsExactlyInAnyOrder(10.0, 20.0, 30.0, 40.0, 50.0,
|
||||
60.0, 70.0, 80.0, 90.0, 100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus handle nilai 0 sebagai valid data point")
|
||||
void shouldHandleZeroValueAsValidDataPoint() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 0.0),
|
||||
makePoint("http_req_failed", 1.0)
|
||||
);
|
||||
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("http_req_failed")).containsExactly(0.0, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. STATISTICAL CALCULATIONS
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("3. Statistical Calculator")
|
||||
class StatCalculatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("avg — harus hitung rata-rata dengan benar")
|
||||
void avgShouldCalculateCorrectly() {
|
||||
assertThat(avg(List.of(100.0, 200.0, 300.0))).isEqualTo(200.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("avg — harus return 0 untuk list kosong")
|
||||
void avgShouldReturnZeroForEmptyList() {
|
||||
assertThat(avg(Collections.emptyList())).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p50 — harus return nilai median yang benar")
|
||||
void p50ShouldReturnMedian() {
|
||||
// sorted: [1,2,3,4,5] → idx = ceil(0.5*5)-1 = 2 → value = 3
|
||||
List<Double> values = List.of(5.0, 1.0, 3.0, 2.0, 4.0);
|
||||
assertThat(percentile(values, 50)).isEqualTo(3.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 — harus return 95th percentile dengan benar (10 nilai)")
|
||||
void p95ShouldCalculateCorrectly() {
|
||||
// Sorted: 10,20,...,100 → idx = ceil(0.95*10)-1 = 10-1 = 9 → value = 100
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 10; i++) values.add(i * 10.0);
|
||||
Collections.shuffle(values);
|
||||
|
||||
assertThat(percentile(values, 95)).isEqualTo(100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 — harus return 95th percentile dengan benar (100 nilai)")
|
||||
void p95ShouldCalculateCorrectlyWith100Values() {
|
||||
// 1..100 → p95 = value at idx ceil(95)-1 = 94 → value = 95
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 100; i++) values.add((double) i);
|
||||
Collections.shuffle(values);
|
||||
|
||||
assertThat(percentile(values, 95)).isEqualTo(95.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p99 — harus return 99th percentile dengan benar")
|
||||
void p99ShouldCalculateCorrectly() {
|
||||
List<Double> values = new ArrayList<>();
|
||||
for (int i = 1; i <= 100; i++) values.add((double) i);
|
||||
|
||||
assertThat(percentile(values, 99)).isEqualTo(99.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("percentile — harus return 0 untuk list kosong atau null")
|
||||
void percentileShouldReturnZeroForEmpty() {
|
||||
assertThat(percentile(Collections.emptyList(), 95)).isEqualTo(0.0);
|
||||
assertThat(percentile(null, 95)).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("percentile — harus return nilai itu sendiri untuk single element")
|
||||
void percentileShouldReturnSingleValue() {
|
||||
List<Double> values = List.of(42.0);
|
||||
assertThat(percentile(values, 50)).isEqualTo(42.0);
|
||||
assertThat(percentile(values, 95)).isEqualTo(42.0);
|
||||
assertThat(percentile(values, 99)).isEqualTo(42.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p99 >= p95 >= p50 >= min dan p99 <= max (monotonic ordering)")
|
||||
void statisticalOrderingShouldBeMonotonic() {
|
||||
List<Double> values = List.of(10.0, 50.0, 30.0, 80.0, 200.0, 150.0, 90.0, 45.0, 175.0, 60.0);
|
||||
|
||||
double p50 = percentile(values, 50);
|
||||
double p95 = percentile(values, 95);
|
||||
double p99 = percentile(values, 99);
|
||||
double min = values.stream().mapToDouble(Double::doubleValue).min().orElse(0);
|
||||
double max = values.stream().mapToDouble(Double::doubleValue).max().orElse(0);
|
||||
|
||||
assertThat(p95).isGreaterThanOrEqualTo(p50);
|
||||
assertThat(p99).isGreaterThanOrEqualTo(p95);
|
||||
assertThat(p50).isGreaterThanOrEqualTo(min);
|
||||
assertThat(p99).isLessThanOrEqualTo(max);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. THRESHOLD EVALUATION
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("4. Threshold Evaluator")
|
||||
class ThresholdEvaluatorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — p95 http_req_duration < 500ms saat semua nilai di bawah threshold")
|
||||
void shouldPassWhenP95BelowThreshold() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration",
|
||||
List.of(100.0, 150.0, 200.0, 300.0, 400.0, 120.0, 180.0, 250.0, 350.0, 450.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — p95 http_req_duration >= 500ms saat banyak nilai tinggi")
|
||||
void shouldFailWhenP95ExceedsThreshold() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// p95 = 900ms
|
||||
metrics.put("http_req_duration",
|
||||
List.of(100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — SOS p95 < 200ms (threshold paling kritis)")
|
||||
void shouldPassSosThresholdWithFastResponses() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> sosTimes = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) sosTimes.add(50.0 + i * 5); // 50ms - 145ms
|
||||
metrics.put("walkguide_sos_latency_ms", sosTimes);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — SOS p95 >= 200ms saat respons lambat")
|
||||
void shouldFailSosThresholdWithSlowResponses() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> sosTimes = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) sosTimes.add(100.0 + i * 20); // hingga 480ms
|
||||
metrics.put("walkguide_sos_latency_ms", sosTimes);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PASS — error rate < 1% saat semua request sukses")
|
||||
void shouldPassErrorRateWithZeroFailures() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 0.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
|
||||
.isEqualTo("PASS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FAIL — error rate >= 1% saat banyak request gagal")
|
||||
void shouldFailErrorRateWithHighFailures() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// 10% error rate
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(90, 0.0));
|
||||
failed.addAll(Collections.nCopies(10, 1.0));
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
|
||||
.isEqualTo("FAIL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SKIP — harus return SKIP untuk metric yang tidak ada di data")
|
||||
void shouldReturnSkipForMissingMetric() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_jvm_heap_used_mb", "avg", 512, "<"))
|
||||
.isEqualTo("SKIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus evaluate semua 8 threshold checks dari result-parser.js dan semua PASS")
|
||||
void shouldEvaluateAllEightThresholdChecksAndAllPass() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", buildRange(20, 50, 400));
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 0.0));
|
||||
metrics.put("walkguide_sos_latency_ms", buildRange(20, 50, 150));
|
||||
metrics.put("walkguide_location_latency_ms", buildRange(20, 50, 250));
|
||||
metrics.put("walkguide_obstacle_latency_ms", buildRange(20, 50, 350));
|
||||
metrics.put("walkguide_auth_latency_ms", buildRange(20, 100, 700));
|
||||
metrics.put("walkguide_timeline_latency_ms", buildRange(20, 200, 900));
|
||||
metrics.put("walkguide_notif_latency_ms", buildRange(20, 50, 450));
|
||||
|
||||
// Mirror dari result-parser.js thresholdChecks array
|
||||
assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_location_latency_ms", "p95", 300, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_obstacle_latency_ms", "p95", 400, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_auth_latency_ms", "p95", 800, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_timeline_latency_ms", "p95", 1000, "<")).isEqualTo("PASS");
|
||||
assertThat(evaluateThreshold(metrics, "walkguide_notif_latency_ms", "p95", 500, "<")).isEqualTo("PASS");
|
||||
}
|
||||
|
||||
private List<Double> buildRange(int count, double min, double max) {
|
||||
List<Double> list = new ArrayList<>();
|
||||
double step = (max - min) / Math.max(1, count - 1);
|
||||
for (int i = 0; i < count; i++) list.add(min + step * i);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5. KEY METRIC EXTRACTION (5 wajib Pillar 3)
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("5. Key Metric Extractor (5 Pillar 3 Metrics)")
|
||||
class KeyMetricExtractorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 1 — Throughput: dihitung dari total req / durasi detik")
|
||||
void shouldCalculateThroughput() {
|
||||
// 1000 requests dalam 20 detik = 50 req/s
|
||||
int totalRequests = 1000;
|
||||
long durationMs = 20_000L;
|
||||
double throughput = (double) totalRequests / (durationMs / 1000.0);
|
||||
|
||||
assertThat(throughput).isEqualTo(50.0);
|
||||
assertThat(String.format("%.1f req/s", throughput)).isEqualTo("50.0 req/s");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 2 — p95 Latency: extract dari http_req_duration p95")
|
||||
void shouldExtractP95LatencyFromHttpReqDuration() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", Collections.nCopies(100, 200.0));
|
||||
|
||||
double p95 = percentile(metrics.get("http_req_duration"), 95);
|
||||
|
||||
assertThat(p95).isEqualTo(200.0);
|
||||
assertThat(String.format("%.2f ms", p95)).isEqualTo("200.00 ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 3 — Error Rate: avg http_req_failed * 100 = persentase")
|
||||
void shouldCalculateErrorRatePercentage() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
// 95 sukses + 5 gagal = 5% error
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(95, 0.0));
|
||||
failed.addAll(Collections.nCopies(5, 1.0));
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
double errorRatePct = avg(metrics.get("http_req_failed")) * 100;
|
||||
|
||||
assertThat(errorRatePct).isCloseTo(5.0, within(0.01));
|
||||
assertThat(errorRatePct < 1.0).isFalse(); // FAIL
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 3 — Error Rate PASS saat < 1%")
|
||||
void errorRateShouldPassWhenBelow1Percent() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
List<Double> failed = new ArrayList<>(Collections.nCopies(995, 0.0));
|
||||
failed.addAll(Collections.nCopies(5, 1.0)); // 0.5% error
|
||||
metrics.put("http_req_failed", failed);
|
||||
|
||||
double errorRatePct = avg(metrics.get("http_req_failed")) * 100;
|
||||
String passFail = errorRatePct < 1.0 ? "PASS" : "FAIL";
|
||||
|
||||
assertThat(passFail).isEqualTo("PASS");
|
||||
assertThat(errorRatePct).isCloseTo(0.5, within(0.01));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 4 — DB Query Time: avg(location p95, obstacle p95)")
|
||||
void shouldEstimateDbQueryTimeFromWriteEndpoints() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("walkguide_location_latency_ms", Collections.nCopies(10, 250.0));
|
||||
metrics.put("walkguide_obstacle_latency_ms", Collections.nCopies(10, 350.0));
|
||||
|
||||
double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
double obstacleP95 = percentile(metrics.get("walkguide_obstacle_latency_ms"), 95);
|
||||
double dbEstimate = (locationP95 + obstacleP95) / 2;
|
||||
|
||||
assertThat(dbEstimate).isEqualTo(300.0);
|
||||
assertThat(String.format("%.2f ms", dbEstimate)).isEqualTo("300.00 ms");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Metric 5 — JVM Heap: avg dari walkguide_jvm_heap_used_mb gauge readings")
|
||||
void shouldExtractJvmHeapFromActuatorGauge() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("walkguide_jvm_heap_used_mb", List.of(256.0, 280.0, 310.0, 295.0, 270.0));
|
||||
|
||||
double heapAvg = avg(metrics.get("walkguide_jvm_heap_used_mb"));
|
||||
|
||||
assertThat(heapAvg).isCloseTo(282.2, within(0.1));
|
||||
assertThat(String.format("%.2f MB", heapAvg)).isEqualTo("282.20 MB");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 6. WALKGUIDE-SPECIFIC METRICS
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("6. WalkGuide Endpoint Metrics")
|
||||
class WalkGuideMetricsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("SOS p95 harus lebih rendah dari auth p95 (life-safety priority)")
|
||||
void sosLatencyShouldBeLowerThanAuthLatency() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double sosP95 = percentile(metrics.get("walkguide_sos_latency_ms"), 95);
|
||||
double authP95 = percentile(metrics.get("walkguide_auth_latency_ms"), 95);
|
||||
|
||||
assertThat(sosP95).isLessThan(authP95);
|
||||
assertThat(sosP95).isLessThan(200.0); // SOS threshold wajib
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Location p95 harus < 300ms karena high-frequency endpoint")
|
||||
void locationLatencyShouldMeetThreshold() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double p95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
|
||||
assertThat(p95).isLessThan(300.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Timeline p95 boleh lebih tinggi dari location p95 (analytics vs real-time)")
|
||||
void timelineLatencyCanBeHigherThanLocationLatency() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
|
||||
double timelineP95 = percentile(metrics.get("walkguide_timeline_latency_ms"), 95);
|
||||
double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
|
||||
|
||||
assertThat(timelineP95).isGreaterThan(locationP95);
|
||||
assertThat(timelineP95).isLessThan(1000.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Semua 7 WalkGuide metrics harus terpopulasi dengan p95, p99, avg")
|
||||
void allSevenWalkGuideMetricsShouldBePopulated() {
|
||||
Map<String, List<Double>> metrics = buildRealisticMetrics();
|
||||
Map<String, String> wgMetricMap = new LinkedHashMap<>();
|
||||
wgMetricMap.put("authLatency", "walkguide_auth_latency_ms");
|
||||
wgMetricMap.put("locationLatency", "walkguide_location_latency_ms");
|
||||
wgMetricMap.put("obstacleLatency", "walkguide_obstacle_latency_ms");
|
||||
wgMetricMap.put("sosLatency", "walkguide_sos_latency_ms");
|
||||
wgMetricMap.put("notifLatency", "walkguide_notif_latency_ms");
|
||||
wgMetricMap.put("timelineLatency", "walkguide_timeline_latency_ms");
|
||||
wgMetricMap.put("pairingLatency", "walkguide_pairing_latency_ms");
|
||||
|
||||
Map<String, Map<String, String>> walkguideMetrics = new HashMap<>();
|
||||
for (Map.Entry<String, String> e : wgMetricMap.entrySet()) {
|
||||
List<Double> vals = metrics.get(e.getValue());
|
||||
if (vals != null && !vals.isEmpty()) {
|
||||
Map<String, String> stats = new HashMap<>();
|
||||
stats.put("p95", String.format("%.2f ms", percentile(vals, 95)));
|
||||
stats.put("p99", String.format("%.2f ms", percentile(vals, 99)));
|
||||
stats.put("avg", String.format("%.2f ms", avg(vals)));
|
||||
walkguideMetrics.put(e.getKey(), stats);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(walkguideMetrics).hasSize(7);
|
||||
assertThat(walkguideMetrics).containsKeys(
|
||||
"authLatency", "locationLatency", "obstacleLatency",
|
||||
"sosLatency", "notifLatency", "timelineLatency", "pairingLatency");
|
||||
// Setiap metric harus punya p95, p99, avg
|
||||
for (Map<String, String> stats : walkguideMetrics.values()) {
|
||||
assertThat(stats).containsKeys("p95", "p99", "avg");
|
||||
assertThat(stats.get("p95")).endsWith("ms");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<Double>> buildRealisticMetrics() {
|
||||
Map<String, List<Double>> m = new HashMap<>();
|
||||
m.put("walkguide_sos_latency_ms", buildRange(50, 50, 150));
|
||||
m.put("walkguide_location_latency_ms", buildRange(50, 80, 260));
|
||||
m.put("walkguide_obstacle_latency_ms", buildRange(50, 100, 360));
|
||||
m.put("walkguide_auth_latency_ms", buildRange(50, 200, 750));
|
||||
m.put("walkguide_notif_latency_ms", buildRange(50, 100, 450));
|
||||
m.put("walkguide_timeline_latency_ms", buildRange(50, 300, 900));
|
||||
m.put("walkguide_pairing_latency_ms", buildRange(50, 150, 550));
|
||||
return m;
|
||||
}
|
||||
|
||||
private List<Double> buildRange(int count, double min, double max) {
|
||||
List<Double> list = new ArrayList<>();
|
||||
double step = (max - min) / Math.max(1, count - 1);
|
||||
for (int i = 0; i < count; i++) list.add(min + step * i);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 7. ENDPOINT BREAKDOWN
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("7. Endpoint Breakdown (byEndpoint grouping)")
|
||||
class EndpointBreakdownTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus group metric values berdasarkan endpoint tag")
|
||||
void shouldGroupValuesByEndpointTag() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 100.0, "login"),
|
||||
makePoint("http_req_duration", 150.0, "login"),
|
||||
makePoint("http_req_duration", 200.0, "register"),
|
||||
makePoint("http_req_duration", 300.0, "register"),
|
||||
makePoint("http_req_duration", 80.0, "ping")
|
||||
);
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
assertThat(byEndpoint).containsKeys("login", "register", "ping");
|
||||
assertThat(byEndpoint.get("login")).containsExactly(100.0, 150.0);
|
||||
assertThat(byEndpoint.get("register")).containsExactly(200.0, 300.0);
|
||||
assertThat(byEndpoint.get("ping")).containsExactly(80.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus support semua WalkGuide endpoint tags dari api-client.js")
|
||||
void shouldSupportAllWalkGuideEndpointTags() {
|
||||
List<String> endpoints = List.of(
|
||||
"login", "register", "refresh_token", "logout",
|
||||
"pairing_invite", "pairing_respond", "pairing_status",
|
||||
"location_update", "obstacle_log", "sos_trigger",
|
||||
"notifications", "send_notification", "unread_count",
|
||||
"guardian_dashboard", "location_history"
|
||||
);
|
||||
|
||||
List<ObjectNode> points = endpoints.stream()
|
||||
.map(ep -> makePoint("http_req_duration", 200.0, ep))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
for (String ep : endpoints) {
|
||||
assertThat(byEndpoint).containsKey(ep);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("p95 per endpoint: sos_trigger harus lebih cepat dari login")
|
||||
void p95PerEndpointShouldShowSosIsFasterThanLogin() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10, "login"));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 50.0 + i * 10, "sos_trigger"));
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
double loginP95 = percentile(byEndpoint.get("login"), 95);
|
||||
double sosP95 = percentile(byEndpoint.get("sos_trigger"), 95);
|
||||
|
||||
assertThat(loginP95).isGreaterThan(sosP95);
|
||||
assertThat(sosP95).isLessThan(200.0);
|
||||
assertThat(loginP95).isLessThan(800.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Endpoint tanpa tag harus masuk ke bucket 'unknown'")
|
||||
void shouldGroupUntaggedEndpointAsUnknown() {
|
||||
List<ObjectNode> points = List.of(
|
||||
makePoint("http_req_duration", 200.0), // → unknown
|
||||
makePoint("http_req_duration", 300.0, "login")
|
||||
);
|
||||
|
||||
Map<String, List<Double>> byEndpoint = groupByEndpoint(
|
||||
parseNdjson(toNdjson(points)), "http_req_duration");
|
||||
|
||||
assertThat(byEndpoint).containsKey("unknown");
|
||||
assertThat(byEndpoint).containsKey("login");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 8. EDGE CASES
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("8. Edge Cases")
|
||||
class EdgeCasesTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Harus handle NDJSON dengan ribuan data points tanpa error")
|
||||
void shouldHandleLargeNdjsonInput() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 5000; i++) {
|
||||
points.add(makePoint("http_req_duration", 100.0 + (i % 400)));
|
||||
}
|
||||
|
||||
List<JsonNode> parsed = parseNdjson(toNdjson(points));
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parsed);
|
||||
|
||||
assertThat(parsed).hasSize(5000);
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(5000);
|
||||
double p95 = percentile(metrics.get("http_req_duration"), 95);
|
||||
assertThat(p95).isGreaterThan(0).isLessThan(600);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Semua baris malformed harus menghasilkan empty metrics tanpa exception")
|
||||
void shouldReturnEmptyMetricsForAllMalformedInput() {
|
||||
String ndjson = "{INVALID}\n{ALSO INVALID}\nnot json at all";
|
||||
|
||||
List<JsonNode> parsed = parseNdjson(ndjson);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parsed);
|
||||
|
||||
assertThat(parsed).isEmpty();
|
||||
assertThat(metrics).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single data point — semua percentile harus return nilai itu sendiri")
|
||||
void singleDataPointShouldReturnItselfForAllPercentiles() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_duration", List.of(250.0));
|
||||
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 50)).isEqualTo(250.0);
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 95)).isEqualTo(250.0);
|
||||
assertThat(percentile(metrics.get("http_req_duration"), 99)).isEqualTo(250.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Threshold SKIP tidak boleh menyebabkan NullPointerException")
|
||||
void thresholdSkipShouldNotThrowNPE() {
|
||||
Map<String, List<Double>> emptyMetrics = new HashMap<>();
|
||||
|
||||
assertThatNoException().isThrownBy(() -> {
|
||||
String r1 = evaluateThreshold(emptyMetrics, "http_req_duration", "p95", 500, "<");
|
||||
String r2 = evaluateThreshold(emptyMetrics, "walkguide_sos_latency_ms", "p95", 200, "<");
|
||||
String r3 = evaluateThreshold(emptyMetrics, "walkguide_jvm_heap_used_mb","avg", 512, "<");
|
||||
assertThat(r1).isEqualTo("SKIP");
|
||||
assertThat(r2).isEqualTo("SKIP");
|
||||
assertThat(r3).isEqualTo("SKIP");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mixed metric types dalam satu stream harus ter-aggregate dengan benar")
|
||||
void shouldAggregateMixedMetricTypesCorrectly() {
|
||||
List<ObjectNode> points = new ArrayList<>();
|
||||
for (int i = 0; i < 20; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10));
|
||||
for (int i = 0; i < 20; i++) points.add(makePoint("http_req_failed", i < 19 ? 0.0 : 1.0));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_sos_latency_ms", 50.0 + i * 10));
|
||||
for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_location_latency_ms", 80.0 + i * 15));
|
||||
for (int i = 0; i < 5; i++) points.add(makePoint("walkguide_jvm_heap_used_mb", 250.0 + i * 10));
|
||||
|
||||
Collections.shuffle(points);
|
||||
Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));
|
||||
|
||||
assertThat(metrics.get("http_req_duration")).hasSize(20);
|
||||
assertThat(metrics.get("http_req_failed")).hasSize(20);
|
||||
assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_location_latency_ms")).hasSize(10);
|
||||
assertThat(metrics.get("walkguide_jvm_heap_used_mb")).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Error rate 0% harus selalu PASS")
|
||||
void zeroErrorRateShouldAlwaysPass() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(1000, 0.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("PASS");
|
||||
assertThat(avg(metrics.get("http_req_failed"))).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Error rate 100% harus selalu FAIL")
|
||||
void hundredPercentErrorRateShouldFail() {
|
||||
Map<String, List<Double>> metrics = new HashMap<>();
|
||||
metrics.put("http_req_failed", Collections.nCopies(100, 1.0));
|
||||
|
||||
assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("FAIL");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user