// 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'); const videoLoadTime = new Trend('video_load_duration', true); // ─── 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'; // วีดีโอความยาว (วินาที) — ปรับตามจริง const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที // 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 'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s '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; } // ─── 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 แล้วหรือยัง // ─── Main ───────────────────────────────────────────────────────────────────── export default function () { const student = students[__VU % students.length]; // ── 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 {} } }); if (!vuToken) { console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); sleep(2); return; } } // ── 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 }); }); sleep(1); 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 {} } }); // ── 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`); } sleep(2); // รอ buffer เริ่มต้น vuSetupDone = true; } // ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ──────────────────── // แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง if (!vuCompleted) { vuProgress += PROGRESS_INTERVAL_SECONDS; group('5. Watch Video (Save Progress)', () => { const res = http.post( `${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`, JSON.stringify({ video_progress_seconds: vuProgress, video_duration_seconds: VIDEO_DURATION_SECONDS, }), { headers: jsonHeaders(vuToken) } ); 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, }); console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`); }); // ── 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, }); }); vuCompleted = true; console.log(`[VU ${__VU}] ✓ Lesson completed`); } } // sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที sleep(PROGRESS_INTERVAL_SECONDS); } // ─── 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))}║ ║ 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))}║ ║ 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)}║ ╚══════════════════════════════════════════════════════════╝ `, }; }