elearning/Backend/tests/k6/video-watching-load-test.js
JakkrapartXD c118e5c3dc
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 28s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
feat: Add k6 video watching load test and remove optional comment body from admin course approval.
2026-02-19 15:20:34 +07:00

268 lines
14 KiB
JavaScript

// 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');
// ─── 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 || '300'); // 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
'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;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export default function () {
// แต่ละ VU ใช้ account คนละคน (round-robin ถ้า VU มากกว่า 50)
const student = students[__VU % students.length];
let token = null;
// ── Step 1: Login ──────────────────────────────────────────────────────────
group('1. Login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ identifier: 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 { token = res.json('data.token'); } catch {}
}
});
if (!token) {
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
sleep(2);
return;
}
sleep(1);
// ── Step 2: Enroll (ถ้ายังไม่ได้ enroll) ──────────────────────────────────
group('2. Enroll Course', () => {
const res = http.post(
`${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`,
null,
{ headers: jsonHeaders(token) }
);
// 200 = enrolled OK, 409 = already enrolled (ทั้งคู่ถือว่าดี)
const ok = res.status === 200 || res.status === 409;
errorRate.add(!ok);
check(res, {
'enroll: enrolled or already enrolled': (r) => r.status === 200 || r.status === 409,
});
});
sleep(1);
// ── Step 3: Load course learning page ─────────────────────────────────────
group('3. Load Course Learning Page', () => {
const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
{ headers: jsonHeaders(token) }
);
courseLearningTime.add(res.timings.duration);
const ok = res.status === 200;
errorRate.add(!ok);
check(res, {
'course learn: status 200': (r) => r.status === 200,
});
});
sleep(1);
// ── Step 4: Open lesson (load video content) ───────────────────────────────
group('4. Open Lesson', () => {
const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
{ headers: jsonHeaders(token) }
);
lessonLoadTime.add(res.timings.duration);
const ok = res.status === 200;
errorRate.add(!ok);
check(res, {
'lesson: status 200': (r) => r.status === 200,
});
});
sleep(2); // รอ video buffer เล็กน้อย
// ── Step 5: Simulate watching — save progress ทุก PROGRESS_INTERVAL วิ ─────
// จำลองการดูวีดีโอ: progress เพิ่มขึ้นเรื่อยๆ จนถึง 95% (auto-complete threshold)
group('5. Watch Video (Save Progress)', () => {
const targetSeconds = Math.floor(VIDEO_DURATION_SECONDS * 0.95); // ดูถึง 95%
const steps = Math.ceil(targetSeconds / PROGRESS_INTERVAL_SECONDS); // จำนวนครั้งที่ save
// เพื่อไม่ให้ test นาน เกินไป จำกัดไว้ที่ 20 saves per VU iteration
const maxSaves = Math.min(steps, 20);
for (let i = 1; i <= maxSaves; i++) {
const currentSeconds = i * PROGRESS_INTERVAL_SECONDS;
const res = http.post(
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
JSON.stringify({
video_progress_seconds: currentSeconds,
video_duration_seconds: VIDEO_DURATION_SECONDS,
}),
{ headers: jsonHeaders(token) }
);
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,
});
// รอ interval จริง (ย่อจาก 5s จริง เหลือ 1s เพื่อให้ test ไม่นานเกิน)
sleep(1);
}
});
// ── Step 6: Mark lesson complete ───────────────────────────────────────────
group('6. Complete Lesson', () => {
const res = http.post(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
null,
{ headers: jsonHeaders(token) }
);
completeLessonTime.add(res.timings.duration);
const ok = res.status === 200;
// 200 = completed now, อาจ 409 ถ้า already completed (ถือว่าโอเค)
errorRate.add(res.status !== 200 && res.status !== 409);
if (ok) completedCount.add(1);
check(res, {
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
});
});
sleep(1);
}
// ─── 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))}
║ 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)}
╚══════════════════════════════════════════════════════════╝
`,
};
}