diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index fc60b670..f10b99ca 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -1,11 +1,10 @@ import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; -import { ApproveCourseValidator, RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; +import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; import { ListPendingCoursesResponse, GetCourseDetailForAdminResponse, - ApproveCourseBody, ApproveCourseResponse, RejectCourseBody, RejectCourseResponse, @@ -61,19 +60,12 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async approveCourse( @Request() request: any, - @Path() courseId: number, - @Body() body?: ApproveCourseBody + @Path() courseId: number ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - // Validate body if provided - if (body) { - const { error } = ApproveCourseValidator.validate(body); - if (error) throw new ValidationError(error.details[0].message); - } - - return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); + return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); } /** diff --git a/Backend/src/types/AdminCourseApproval.types.ts b/Backend/src/types/AdminCourseApproval.types.ts index fa98ea6b..d68c8c81 100644 --- a/Backend/src/types/AdminCourseApproval.types.ts +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -117,10 +117,6 @@ export interface GetCourseDetailForAdminResponse { data: CourseDetailForAdmin; } -export interface ApproveCourseBody { - comment?: string; -} - export interface ApproveCourseResponse { code: number; message: string; diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js new file mode 100644 index 00000000..23c33a42 --- /dev/null +++ b/Backend/tests/k6/video-watching-load-test.js @@ -0,0 +1,268 @@ +// 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)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +}