test(performance): implement k6 load testing scenarios and java result parser
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user