2026-02-19 15:20:34 +07:00
|
|
|
// Backend/tests/k6/video-watching-load-test.js
|
|
|
|
|
//
|
|
|
|
|
// จำลองนักเรียนหลายคนดูวีดีโอพร้อมกัน (Concurrent Video Watching)
|
|
|
|
|
//
|
|
|
|
|
// Flow จริงที่ simulate:
|
|
|
|
|
// 1. Login ด้วย account ของ student แต่ละคน
|
|
|
|
|
// 2. Load หน้าเรียนคอร์ส (getCourseLearning)
|
|
|
|
|
// 3. เปิดบทเรียนวีดีโอ (getLessonContent)
|
|
|
|
|
// 4. Save progress ทุก 5 วินาที (จำลองการ watch)
|
|
|
|
|
// 5. เมื่อดูครบ (≥90%) → mark lesson complete
|
|
|
|
|
//
|
|
|
|
|
// Usage:
|
|
|
|
|
// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 tests/k6/video-watching-load-test.js
|
|
|
|
|
//
|
|
|
|
|
// ปรับจำนวน VUs และ duration ได้ด้วย:
|
|
|
|
|
// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 --vus 30 --duration 2m tests/k6/video-watching-load-test.js
|
|
|
|
|
|
|
|
|
|
import http from 'k6/http';
|
|
|
|
|
import { check, sleep, group } from 'k6';
|
|
|
|
|
import { Rate, Trend, Counter } from 'k6/metrics';
|
|
|
|
|
import { SharedArray } from 'k6/data';
|
|
|
|
|
|
|
|
|
|
// ─── Custom Metrics ───────────────────────────────────────────────────────────
|
|
|
|
|
const errorRate = new Rate('errors');
|
|
|
|
|
const loginTime = new Trend('login_duration', true);
|
|
|
|
|
const courseLearningTime = new Trend('course_learning_duration', true);
|
|
|
|
|
const lessonLoadTime = new Trend('lesson_load_duration', true);
|
|
|
|
|
const progressSaveTime = new Trend('progress_save_duration', true);
|
|
|
|
|
const completeLessonTime = new Trend('complete_lesson_duration', true);
|
|
|
|
|
const completedCount = new Counter('completed_lessons');
|
|
|
|
|
const progressSaveCount = new Counter('progress_saves');
|
2026-02-20 15:16:03 +07:00
|
|
|
const videoLoadTime = new Trend('video_load_duration', true);
|
2026-02-19 15:20:34 +07:00
|
|
|
|
|
|
|
|
// ─── Load student credentials ────────────────────────────────────────────────
|
|
|
|
|
// อ่านจาก test-credentials.json (50 accounts)
|
|
|
|
|
const students = new SharedArray('students', function () {
|
|
|
|
|
return JSON.parse(open('./test-credentials.json')).students;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
|
const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000';
|
|
|
|
|
const COURSE_ID = __ENV.COURSE_ID || '1';
|
|
|
|
|
const LESSON_ID = __ENV.LESSON_ID || '1';
|
|
|
|
|
|
|
|
|
|
// วีดีโอความยาว (วินาที) — ปรับตามจริง
|
2026-02-20 15:16:03 +07:00
|
|
|
const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที
|
2026-02-19 15:20:34 +07:00
|
|
|
|
|
|
|
|
// save progress interval: ทุก 5 วินาที (เหมือน client จริง)
|
|
|
|
|
// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง
|
|
|
|
|
const PROGRESS_INTERVAL_SECONDS = parseInt(__ENV.PROGRESS_INTERVAL || '15');
|
|
|
|
|
|
|
|
|
|
// ─── Test Options ─────────────────────────────────────────────────────────────
|
|
|
|
|
export const options = {
|
|
|
|
|
stages: [
|
|
|
|
|
{ duration: '30s', target: 10 }, // Ramp up: 10 คนเริ่มดูวีดีโอ
|
|
|
|
|
{ duration: '1m', target: 30 }, // Ramp up: เพิ่มเป็น 30 คน
|
|
|
|
|
{ duration: '2m', target: 30 }, // Steady: 30 คนดูพร้อมกัน
|
|
|
|
|
{ duration: '30s', target: 50 }, // Peak: เพิ่มเป็น 50 คน
|
|
|
|
|
{ duration: '1m', target: 50 }, // Steady Peak: 50 คนพร้อมกัน
|
|
|
|
|
{ duration: '30s', target: 0 }, // Ramp down
|
|
|
|
|
],
|
|
|
|
|
thresholds: {
|
|
|
|
|
// Response times
|
|
|
|
|
'login_duration': ['p(95)<2000'], // Login < 2s
|
|
|
|
|
'course_learning_duration': ['p(95)<1000'], // Load course page < 1s
|
|
|
|
|
'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s
|
2026-02-20 15:16:03 +07:00
|
|
|
'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s
|
2026-02-19 15:20:34 +07:00
|
|
|
'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย)
|
|
|
|
|
'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s
|
|
|
|
|
|
|
|
|
|
// Error rate
|
|
|
|
|
'errors': ['rate<0.05'], // Error < 5%
|
|
|
|
|
'http_req_failed': ['rate<0.05'], // HTTP error < 5%
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── Helper ───────────────────────────────────────────────────────────────────
|
|
|
|
|
function jsonHeaders(token) {
|
|
|
|
|
const h = { 'Content-Type': 'application/json' };
|
|
|
|
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
|
|
|
return h;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// ─── Per-VU persistent state (จำข้ามรอบ iteration) ──────────────────────────
|
|
|
|
|
// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU
|
|
|
|
|
// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration)
|
|
|
|
|
let vuToken = null; // token ที่ login ไว้แล้ว
|
|
|
|
|
let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง
|
|
|
|
|
let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที)
|
|
|
|
|
let vuCompleted = false; // lesson complete แล้วหรือยัง
|
|
|
|
|
|
2026-02-19 15:20:34 +07:00
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
export default function () {
|
|
|
|
|
const student = students[__VU % students.length];
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ─────────
|
|
|
|
|
if (!vuToken) {
|
|
|
|
|
group('1. Login', () => {
|
|
|
|
|
const res = http.post(
|
|
|
|
|
`${BASE_URL}/api/auth/login`,
|
|
|
|
|
JSON.stringify({ email: student.email, password: student.password }),
|
|
|
|
|
{ headers: jsonHeaders(null) }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
loginTime.add(res.timings.duration);
|
|
|
|
|
const ok = res.status === 200;
|
|
|
|
|
errorRate.add(!ok);
|
|
|
|
|
|
|
|
|
|
check(res, {
|
|
|
|
|
'login: status 200': (r) => r.status === 200,
|
|
|
|
|
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
try { vuToken = res.json('data.token'); } catch {}
|
|
|
|
|
}
|
2026-02-19 15:20:34 +07:00
|
|
|
});
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
if (!vuToken) {
|
|
|
|
|
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
|
|
|
|
|
sleep(2);
|
|
|
|
|
return;
|
2026-02-19 15:20:34 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ─────────
|
|
|
|
|
|
|
|
|
|
// ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ─────
|
|
|
|
|
if (!vuSetupDone) {
|
|
|
|
|
group('3. Load Course Learning Page', () => {
|
|
|
|
|
const res = http.get(
|
|
|
|
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
|
|
|
|
|
{ headers: jsonHeaders(vuToken) }
|
|
|
|
|
);
|
|
|
|
|
courseLearningTime.add(res.timings.duration);
|
|
|
|
|
errorRate.add(res.status !== 200);
|
|
|
|
|
check(res, { 'course learn: status 200': (r) => r.status === 200 });
|
2026-02-19 15:20:34 +07:00
|
|
|
});
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
sleep(1);
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
let videoUrl = null;
|
|
|
|
|
group('4. Open Lesson', () => {
|
|
|
|
|
const res = http.get(
|
|
|
|
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
|
|
|
|
|
{ headers: jsonHeaders(vuToken) }
|
|
|
|
|
);
|
|
|
|
|
lessonLoadTime.add(res.timings.duration);
|
|
|
|
|
errorRate.add(res.status !== 200);
|
|
|
|
|
check(res, { 'lesson: status 200': (r) => r.status === 200 });
|
|
|
|
|
if (res.status === 200) {
|
|
|
|
|
try { videoUrl = res.json('data.video_url'); } catch {}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// ── Step 4.5: Fetch video จาก MinIO ──────────────────────────────────────
|
|
|
|
|
if (videoUrl) {
|
|
|
|
|
group('4.5 Fetch Video from MinIO', () => {
|
|
|
|
|
const res = http.get(videoUrl, {
|
|
|
|
|
headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก
|
|
|
|
|
timeout: '10s',
|
|
|
|
|
});
|
|
|
|
|
videoLoadTime.add(res.timings.duration);
|
|
|
|
|
const ok = res.status === 200 || res.status === 206;
|
|
|
|
|
errorRate.add(!ok);
|
|
|
|
|
check(res, {
|
|
|
|
|
'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206,
|
|
|
|
|
'minio video: fast': (r) => r.timings.duration < 3000,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`);
|
|
|
|
|
}
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
sleep(2); // รอ buffer เริ่มต้น
|
|
|
|
|
vuSetupDone = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ────────────────────
|
|
|
|
|
// แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง
|
|
|
|
|
if (!vuCompleted) {
|
|
|
|
|
vuProgress += PROGRESS_INTERVAL_SECONDS;
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
group('5. Watch Video (Save Progress)', () => {
|
2026-02-19 15:20:34 +07:00
|
|
|
const res = http.post(
|
|
|
|
|
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
|
|
|
|
|
JSON.stringify({
|
2026-02-20 15:16:03 +07:00
|
|
|
video_progress_seconds: vuProgress,
|
2026-02-19 15:20:34 +07:00
|
|
|
video_duration_seconds: VIDEO_DURATION_SECONDS,
|
|
|
|
|
}),
|
2026-02-20 15:16:03 +07:00
|
|
|
{ headers: jsonHeaders(vuToken) }
|
2026-02-19 15:20:34 +07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
progressSaveTime.add(res.timings.duration);
|
|
|
|
|
progressSaveCount.add(1);
|
|
|
|
|
|
|
|
|
|
const ok = res.status === 200;
|
|
|
|
|
errorRate.add(!ok);
|
|
|
|
|
check(res, {
|
|
|
|
|
'progress save: status 200': (r) => r.status === 200,
|
|
|
|
|
'progress save: fast': (r) => r.timings.duration < 500,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`);
|
|
|
|
|
});
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// ── Step 6: Mark complete เมื่อดูครบ ≥95% ──────────────────────────────
|
|
|
|
|
if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) {
|
|
|
|
|
group('6. Complete Lesson', () => {
|
|
|
|
|
const res = http.post(
|
|
|
|
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
|
|
|
|
|
null,
|
|
|
|
|
{ headers: jsonHeaders(vuToken) }
|
|
|
|
|
);
|
|
|
|
|
completeLessonTime.add(res.timings.duration);
|
|
|
|
|
errorRate.add(res.status !== 200 && res.status !== 409);
|
|
|
|
|
if (res.status === 200) completedCount.add(1);
|
|
|
|
|
check(res, {
|
|
|
|
|
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
vuCompleted = true;
|
|
|
|
|
console.log(`[VU ${__VU}] ✓ Lesson completed`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 15:20:34 +07:00
|
|
|
|
2026-02-20 15:16:03 +07:00
|
|
|
// sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที
|
|
|
|
|
sleep(PROGRESS_INTERVAL_SECONDS);
|
2026-02-19 15:20:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Summary ──────────────────────────────────────────────────────────────────
|
|
|
|
|
export function handleSummary(data) {
|
|
|
|
|
const m = data.metrics;
|
|
|
|
|
|
|
|
|
|
const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A';
|
|
|
|
|
const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A';
|
|
|
|
|
const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2);
|
|
|
|
|
const count = (k) => m[k]?.values?.count ?? 0;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
stdout: `
|
|
|
|
|
╔══════════════════════════════════════════════════════════╗
|
|
|
|
|
║ Concurrent Video Watching — Load Test ║
|
|
|
|
|
╠══════════════════════════════════════════════════════════╣
|
|
|
|
|
║ Course ID : ${COURSE_ID.padEnd(44)}║
|
|
|
|
|
║ Lesson ID : ${LESSON_ID.padEnd(44)}║
|
|
|
|
|
║ Video : ${String(VIDEO_DURATION_SECONDS + 's').padEnd(44)}║
|
|
|
|
|
╠══════════════════════════════════════════════════════════╣
|
|
|
|
|
║ RESPONSE TIMES (avg / p95) ║
|
|
|
|
|
║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║
|
|
|
|
|
║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║
|
|
|
|
|
║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║
|
2026-02-20 15:16:03 +07:00
|
|
|
║ MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}║
|
2026-02-19 15:20:34 +07:00
|
|
|
║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║
|
|
|
|
|
║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║
|
|
|
|
|
╠══════════════════════════════════════════════════════════╣
|
|
|
|
|
║ COUNTS ║
|
|
|
|
|
║ Total Requests : ${String(count('http_reqs')).padEnd(33)}║
|
|
|
|
|
║ Progress Saves : ${String(count('progress_saves')).padEnd(33)}║
|
|
|
|
|
║ Lessons Completed : ${String(count('completed_lessons')).padEnd(33)}║
|
|
|
|
|
╠══════════════════════════════════════════════════════════╣
|
|
|
|
|
║ ERROR RATES ║
|
|
|
|
|
║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(33)}║
|
|
|
|
|
║ Custom Errors : ${(rate('errors') + '%').padEnd(33)}║
|
|
|
|
|
╚══════════════════════════════════════════════════════════╝
|
|
|
|
|
`,
|
|
|
|
|
};
|
|
|
|
|
}
|