From abeb99ed61f16538119e07286cafdcf0561bfb79 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Sun, 17 May 2026 02:21:28 +0700 Subject: [PATCH] test(performance): implement k6 load testing scenarios and java result parser --- .../demo/k6-tests/configs/load-test.json | 49 + .../demo/k6-tests/configs/smoke-test.json | 34 + .../demo/k6-tests/configs/spike-test.json | 26 + .../demo/k6-tests/configs/stress-test.json | 26 + .../demo/k6-tests/modules/api-client.js | 408 ++++++++ .../demo/k6-tests/modules/auth-helper.js | 168 ++++ .../demo/k6-tests/modules/metrics-helper.js | 259 +++++ .../demo/k6-tests/modules/test-data.js | 265 +++++ .../demo/k6-tests/run-all-tests.sh | 236 +++++ .../demo/k6-tests/scenarios/auth-flow.js | 147 +++ .../k6-tests/scenarios/location-update.js | 138 +++ .../k6-tests/scenarios/notification-send.js | 173 ++++ .../k6-tests/scenarios/obstacle-logging.js | 110 +++ .../demo/k6-tests/scenarios/pairing-flow.js | 184 ++++ .../demo/k6-tests/scenarios/sos-flow.js | 148 +++ .../demo/k6-tests/scenarios/timeline-query.js | 183 ++++ .../demo/k6-tests/utils/html-reporter.js | 216 +++++ .../demo/k6-tests/utils/result-parser.js | 359 +++++++ .../performance/K6ResultParserTest.java | 907 ++++++++++++++++++ 19 files changed, 4036 insertions(+) create mode 100644 walkguide-backend/demo/k6-tests/configs/load-test.json create mode 100644 walkguide-backend/demo/k6-tests/configs/smoke-test.json create mode 100644 walkguide-backend/demo/k6-tests/configs/spike-test.json create mode 100644 walkguide-backend/demo/k6-tests/configs/stress-test.json create mode 100644 walkguide-backend/demo/k6-tests/modules/api-client.js create mode 100644 walkguide-backend/demo/k6-tests/modules/auth-helper.js create mode 100644 walkguide-backend/demo/k6-tests/modules/metrics-helper.js create mode 100644 walkguide-backend/demo/k6-tests/modules/test-data.js create mode 100644 walkguide-backend/demo/k6-tests/run-all-tests.sh create mode 100644 walkguide-backend/demo/k6-tests/scenarios/auth-flow.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/location-update.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/notification-send.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/sos-flow.js create mode 100644 walkguide-backend/demo/k6-tests/scenarios/timeline-query.js create mode 100644 walkguide-backend/demo/k6-tests/utils/html-reporter.js create mode 100644 walkguide-backend/demo/k6-tests/utils/result-parser.js create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/performance/K6ResultParserTest.java diff --git a/walkguide-backend/demo/k6-tests/configs/load-test.json b/walkguide-backend/demo/k6-tests/configs/load-test.json new file mode 100644 index 0000000..c16c6ab --- /dev/null +++ b/walkguide-backend/demo/k6-tests/configs/load-test.json @@ -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" +} diff --git a/walkguide-backend/demo/k6-tests/configs/smoke-test.json b/walkguide-backend/demo/k6-tests/configs/smoke-test.json new file mode 100644 index 0000000..6108fa3 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/configs/smoke-test.json @@ -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" +} diff --git a/walkguide-backend/demo/k6-tests/configs/spike-test.json b/walkguide-backend/demo/k6-tests/configs/spike-test.json new file mode 100644 index 0000000..72c4c01 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/configs/spike-test.json @@ -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" +} diff --git a/walkguide-backend/demo/k6-tests/configs/stress-test.json b/walkguide-backend/demo/k6-tests/configs/stress-test.json new file mode 100644 index 0000000..b99bfe2 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/configs/stress-test.json @@ -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" +} diff --git a/walkguide-backend/demo/k6-tests/modules/api-client.js b/walkguide-backend/demo/k6-tests/modules/api-client.js new file mode 100644 index 0000000..83385ad --- /dev/null +++ b/walkguide-backend/demo/k6-tests/modules/api-client.js @@ -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; + } +} diff --git a/walkguide-backend/demo/k6-tests/modules/auth-helper.js b/walkguide-backend/demo/k6-tests/modules/auth-helper.js new file mode 100644 index 0000000..49a7e5f --- /dev/null +++ b/walkguide-backend/demo/k6-tests/modules/auth-helper.js @@ -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, + }; +} diff --git a/walkguide-backend/demo/k6-tests/modules/metrics-helper.js b/walkguide-backend/demo/k6-tests/modules/metrics-helper.js new file mode 100644 index 0000000..c310a56 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/modules/metrics-helper.js @@ -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"; +} diff --git a/walkguide-backend/demo/k6-tests/modules/test-data.js b/walkguide-backend/demo/k6-tests/modules/test-data.js new file mode 100644 index 0000000..0795770 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/modules/test-data.js @@ -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, +}; diff --git a/walkguide-backend/demo/k6-tests/run-all-tests.sh b/walkguide-backend/demo/k6-tests/run-all-tests.sh new file mode 100644 index 0000000..59f105b --- /dev/null +++ b/walkguide-backend/demo/k6-tests/run-all-tests.sh @@ -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 \ No newline at end of file diff --git a/walkguide-backend/demo/k6-tests/scenarios/auth-flow.js b/walkguide-backend/demo/k6-tests/scenarios/auth-flow.js new file mode 100644 index 0000000..6547f45 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/auth-flow.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/location-update.js b/walkguide-backend/demo/k6-tests/scenarios/location-update.js new file mode 100644 index 0000000..4086adb --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/location-update.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/notification-send.js b/walkguide-backend/demo/k6-tests/scenarios/notification-send.js new file mode 100644 index 0000000..619484b --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/notification-send.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js b/walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js new file mode 100644 index 0000000..6c33853 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/obstacle-logging.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js b/walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js new file mode 100644 index 0000000..10cd7c0 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/pairing-flow.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/sos-flow.js b/walkguide-backend/demo/k6-tests/scenarios/sos-flow.js new file mode 100644 index 0000000..227631a --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/sos-flow.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/scenarios/timeline-query.js b/walkguide-backend/demo/k6-tests/scenarios/timeline-query.js new file mode 100644 index 0000000..92d9fa6 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/scenarios/timeline-query.js @@ -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 }; diff --git a/walkguide-backend/demo/k6-tests/utils/html-reporter.js b/walkguide-backend/demo/k6-tests/utils/html-reporter.js new file mode 100644 index 0000000..3ac0c18 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/utils/html-reporter.js @@ -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 [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 [output.html]", + ); + process.exit(1); +} + +const report = JSON.parse(fs.readFileSync(inputFile, "utf8")); + +// ── HTML Template ───────────────────────────────────────────────────────────── +function badge(result) { + if (!result) return 'SKIP'; + if (result.includes("PASS")) return '✅ PASS'; + if (result.includes("FAIL")) return '❌ FAIL'; + return `${result}`; +} + +function metricRow(label, val) { + return `${label}${val || "N/A"}`; +} + +function endpointTableRows() { + const rows = []; + for (const [ep, metrics] of Object.entries(report.endpoints || {})) { + const dur = metrics["http_req_duration"]; + if (!dur) continue; + rows.push(` + + ${ep} + ${dur.count || "—"} + ${dur.avg || "—"} + ${dur.p95 || "—"} + ${dur.p99 || "—"} + ${dur.max || "—"} + + `); + } + return rows.join(""); +} + +function thresholdRows() { + return (report.thresholdResults || []) + .map( + (t) => ` + + ${t.name} + ${t.metric} + ${t.threshold} + ${t.actual} + ${badge(t.result)} + + `, + ) + .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 ` +
+
${c.label}
+
${data ? data.p95 : "N/A"}
+
p95 | threshold ${c.threshold}
+
avg: ${data ? data.avg : "N/A"} | p99: ${data ? data.p99 : "N/A"}
+
+ `; + }) + .join(""); +} + +const html = ` + + + + + WalkGuide — k6 Load Test Report + + + +
+

🦺 WalkGuide — k6 Load Test Report

+

Spring Boot Backend Performance Benchmark — Pillar 3

+
+ Generated: ${report.generatedAt} | + Input: ${path.basename(report.inputFile || "")} | + Total Data Points: ${report.totalPoints} +
+
+ +

📊 Key Metrics (Pillar 3 — 5 Required)

+
+
+
1. Throughput
+
${report.keyMetrics.throughput.value || "N/A"}
+
Requests per second
+
+
+
2. p95 Latency
+
${report.keyMetrics.p95Latency.value || "N/A"}
+
95th percentile response time
+
+
+
3. Error Rate
+
${report.keyMetrics.errorRate.value || "N/A"}
+
Non-2xx responses | ${report.keyMetrics.errorRate.passFail || "—"}
+
+
+
4. DB Query Time
+
${report.keyMetrics.dbQueryTime.value || "N/A"}
+
Estimated via write endpoint p95
+
+
+
5. JVM Heap
+
${report.keyMetrics.jvmHeap.value || "N/A"}
+
Spring Actuator jvm.memory.used
+
+
+ +

🏃 WalkGuide Endpoint Latency Breakdown

+
${wgMetricCards()}
+ +

✅ Threshold Evaluation

+ + + + + ${thresholdRows()} +
CheckMetricThresholdActualResult
+ +

📋 Per-Endpoint Latency (ms)

+ + + + + ${endpointTableRows()} +
EndpointRequestsAvgp95p99Max
+ +
+ WalkGuide AI — Integrated Mobile Application Project Final Exam | + Generated by html-reporter.js +
+ +`; + +// ── 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}`); diff --git a/walkguide-backend/demo/k6-tests/utils/result-parser.js b/walkguide-backend/demo/k6-tests/utils/result-parser.js new file mode 100644 index 0000000..1ece904 --- /dev/null +++ b/walkguide-backend/demo/k6-tests/utils/result-parser.js @@ -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 [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 [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"); diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/performance/K6ResultParserTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/performance/K6ResultParserTest.java new file mode 100644 index 0000000..f667e01 --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/performance/K6ResultParserTest.java @@ -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 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 parseNdjson(String ndjson) { + List 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> aggregateMetrics(List points) { + Map> 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> groupByEndpoint(List points, String metric) { + Map> 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 values, int p) { + if (values == null || values.isEmpty()) return 0; + List 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 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> metrics, + String metricName, String stat, double threshold, String operator) { + List 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 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 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 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 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 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 points = parseNdjson(ndjson); + Map> 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 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 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> 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 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 points = wgMetrics.stream() + .map(m -> makePoint(m, 150.0)) + .collect(Collectors.toList()); + + Map> 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 points = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + points.add(makePoint("walkguide_sos_latency_ms", i * 10.0)); + } + + Map> 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 points = List.of( + makePoint("http_req_failed", 0.0), + makePoint("http_req_failed", 0.0), + makePoint("http_req_failed", 1.0) + ); + + Map> 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 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 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 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 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 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 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> 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> 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> metrics = new HashMap<>(); + List 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> metrics = new HashMap<>(); + List 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> 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> metrics = new HashMap<>(); + // 10% error rate + List 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> 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> 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 buildRange(int count, double min, double max) { + List 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> 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> metrics = new HashMap<>(); + // 95 sukses + 5 gagal = 5% error + List 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> metrics = new HashMap<>(); + List 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> 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> 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> 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> 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> 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> metrics = buildRealisticMetrics(); + Map 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> walkguideMetrics = new HashMap<>(); + for (Map.Entry e : wgMetricMap.entrySet()) { + List vals = metrics.get(e.getValue()); + if (vals != null && !vals.isEmpty()) { + Map 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 stats : walkguideMetrics.values()) { + assertThat(stats).containsKeys("p95", "p99", "avg"); + assertThat(stats.get("p95")).endsWith("ms"); + } + } + + private Map> buildRealisticMetrics() { + Map> 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 buildRange(int count, double min, double max) { + List 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 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> 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 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 points = endpoints.stream() + .map(ep -> makePoint("http_req_duration", 200.0, ep)) + .collect(Collectors.toList()); + + Map> 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 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> 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 points = List.of( + makePoint("http_req_duration", 200.0), // → unknown + makePoint("http_req_duration", 300.0, "login") + ); + + Map> 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 points = new ArrayList<>(); + for (int i = 0; i < 5000; i++) { + points.add(makePoint("http_req_duration", 100.0 + (i % 400))); + } + + List parsed = parseNdjson(toNdjson(points)); + Map> 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 parsed = parseNdjson(ndjson); + Map> metrics = aggregateMetrics(parsed); + + assertThat(parsed).isEmpty(); + assertThat(metrics).isEmpty(); + } + + @Test + @DisplayName("Single data point — semua percentile harus return nilai itu sendiri") + void singleDataPointShouldReturnItselfForAllPercentiles() { + Map> 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> 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 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> 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> 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> metrics = new HashMap<>(); + metrics.put("http_req_failed", Collections.nCopies(100, 1.0)); + + assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("FAIL"); + } + } +} \ No newline at end of file