test(performance): implement k6 load testing scenarios and java result parser

This commit is contained in:
5803024019 2026-05-17 02:21:28 +07:00
parent 4a0ae1d615
commit abeb99ed61
19 changed files with 4036 additions and 0 deletions

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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;
}
}

View 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,
};
}

View 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";
}

View 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,
};

View 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

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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}`);

View 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");

View File

@ -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");
}
}
}