From ef70d2db3fd6be0e6620829fcf2cbabdd24ffe65 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Fri, 20 Feb 2026 15:16:03 +0700 Subject: [PATCH] feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests. --- .../RecommendedCoursesController.ts | 8 +- Backend/src/controllers/UserController.ts | 15 +- .../services/AdminCourseApproval.service.ts | 8 +- .../services/RecommendedCourses.service.ts | 43 +++- Backend/src/services/courses.service.ts | 102 +++++++- Backend/src/services/user.service.ts | 27 ++- Backend/src/types/RecommendedCourses.types.ts | 22 +- Backend/src/types/courses.types.ts | 42 +++- Backend/src/types/user.types.ts | 8 + Backend/tests/k6/enroll-load-test.js | 160 +++++++++++++ Backend/tests/k6/video-watching-load-test.js | 219 +++++++++--------- 11 files changed, 515 insertions(+), 139 deletions(-) create mode 100644 Backend/tests/k6/enroll-load-test.js diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 06bff36d..720bff7c 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -20,10 +20,14 @@ export class RecommendedCoursesController { @SuccessResponse('200', 'Approved courses retrieved successfully') @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') - public async listApprovedCourses(@Request() request: any): Promise { + public async listApprovedCourses( + @Request() request: any, + @Query() search?: string, + @Query() categoryId?: number + ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.listApprovedCourses(token); + return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); } /** diff --git a/Backend/src/controllers/UserController.ts b/Backend/src/controllers/UserController.ts index b8169827..ccbe7c76 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -10,7 +10,8 @@ import { ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import { ChangePassword } from '../types/auth.types'; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; @@ -56,6 +57,18 @@ export class UserController { return await this.userService.updateProfile(token, body); } + @Get('roles') + @Security('jwt') + @SuccessResponse('200', 'Roles retrieved successfully') + @Response('401', 'Invalid or expired token') + public async getRoles(@Request() request: any): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.userService.getRoles(token); + } + /** * Change password * @summary Change user password using old password diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index f9446457..0596034c 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -113,7 +113,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(token: string,courseId: number): Promise { + static async getCourseDetail(token: string, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -133,7 +133,11 @@ export class AdminCourseApprovalService { }, chapters: { orderBy: { sort_order: 'asc' }, - include: { + select: { + id: true, + title: true, + sort_order: true, + is_published: true, lessons: { orderBy: { sort_order: 'asc' }, select: { diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index 9c185c8a..22440eb2 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -8,7 +8,8 @@ import { ListApprovedCoursesResponse, GetCourseByIdResponse, ToggleRecommendedResponse, - RecommendedCourseData + RecommendedCourseData, + RecommendedCourseDetailData } from '../types/RecommendedCourses.types'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -18,10 +19,24 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses(token: string): Promise { + static async listApprovedCourses( + token: string, + filters?: { search?: string; categoryId?: number } + ): Promise { try { + const { search, categoryId } = filters ?? {}; + const courses = await prisma.course.findMany({ - where: { status: 'APPROVED' }, + where: { + status: 'APPROVED', + ...(categoryId ? { category_id: categoryId } : {}), + ...(search ? { + OR: [ + { title: { path: ['th'], string_contains: search } }, + { title: { path: ['en'], string_contains: search } } + ] + } : {}) + }, orderBy: [ { is_recommended: 'desc' }, { updated_at: 'desc' } @@ -40,9 +55,9 @@ export class RecommendedCoursesService { } } }, - chapters: { - include: { - lessons: true + _count: { + select: { + chapters: true } } } @@ -81,8 +96,7 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course.chapters.length, - lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + chapters_count: course._count.chapters, } as RecommendedCourseData; })); @@ -158,7 +172,7 @@ export class RecommendedCoursesService { } } - const data: RecommendedCourseData = { + const data: RecommendedCourseDetailData = { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, @@ -181,8 +195,15 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course.chapters.length, - lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }; return { diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index a0d96a45..6b810ca9 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -103,18 +103,53 @@ export class CoursesService { const course = await prisma.course.findFirst({ where: { id, - status: 'APPROVED' // Only show approved courses to students + status: 'APPROVED' }, include: { + creator: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + }, + instructors: { + include: { + user: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + } + } + }, + category: { + select: { id: true, name: true } + }, chapters: { + orderBy: { sort_order: 'asc' }, select: { id: true, title: true, + sort_order: true, lessons: { - select: { - id: true, - title: true, - } + orderBy: { sort_order: 'asc' }, + select: { id: true, title: true } } } } @@ -138,12 +173,69 @@ export class CoursesService { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } + + // Generate presigned URL for creator avatar + let creator_avatar_url: string | null = null; + if (course.creator.profile?.avatar_url) { + try { + creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`); + } + } + + // Generate presigned URLs for instructor avatars + const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => { + let avatar_url: string | null = null; + if (i.user.profile?.avatar_url) { + try { + avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`); + } + } + return { + user_id: i.user_id, + is_primary: i.is_primary, + user: { + ...i.user, + profile: i.user.profile ? { + ...i.user.profile, + avatar_url + } : null + } + }; + })); + return { code: 200, message: 'Course fetched successfully', data: { ...course, + title: course.title as { th: string; en: string }, + description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, + creator: { + ...course.creator, + profile: course.creator.profile ? { + ...course.creator.profile, + avatar_url: creator_avatar_url + } : null + }, + instructors: instructorsWithAvatar, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }, }; } catch (error) { diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index 918b12e3..69153d51 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -14,7 +14,8 @@ import { updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; @@ -212,6 +213,30 @@ export class UserService { } } + async getRoles(token: string): Promise { + try { + jwt.verify(token, config.jwt.secret); + const roles = await prisma.role.findMany({ + select: { + id: true, + code: true + } + }); + return { roles }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + logger.error('JWT token expired:', error); + throw new UnauthorizedError('Token expired'); + } + if (error instanceof jwt.JsonWebTokenError) { + logger.error('Invalid JWT token:', error); + throw new UnauthorizedError('Invalid token'); + } + logger.error('Failed to get roles', { error }); + throw error; + } + } + /** * Upload avatar picture to MinIO */ diff --git a/Backend/src/types/RecommendedCourses.types.ts b/Backend/src/types/RecommendedCourses.types.ts index c11c6c93..48f495a7 100644 --- a/Backend/src/types/RecommendedCourses.types.ts +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -1,14 +1,10 @@ import { MultiLanguageText } from './index'; -// ============================================ -// Request Types -// ============================================ - - // ============================================ // Response Types // ============================================ +/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */ export interface RecommendedCourseData { id: number; title: MultiLanguageText; @@ -41,7 +37,19 @@ export interface RecommendedCourseData { }; }>; chapters_count: number; - lessons_count: number; +} + +/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */ +export interface RecommendedCourseDetailData extends Omit { + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; } export interface ListApprovedCoursesResponse { @@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse { export interface GetCourseByIdResponse { code: number; message: string; - data: RecommendedCourseData; + data: RecommendedCourseDetailData; } export interface ToggleRecommendedResponse { diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index 42c83398..a294d7e2 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -1,4 +1,5 @@ import { Course } from '@prisma/client'; +import { MultiLanguageText } from './index'; export interface ListCoursesInput { category_id?: number; @@ -18,8 +19,47 @@ export interface listCourseResponse { totalPages: number; } +export interface CourseDetail extends Omit { + title: MultiLanguageText; + description: MultiLanguageText; + creator: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + }[]; + category: { id: number; name: MultiLanguageText } | null; + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; +} + export interface getCourseResponse { code: number; message: string; - data: Course | null; + data: CourseDetail | null; } diff --git a/Backend/src/types/user.types.ts b/Backend/src/types/user.types.ts index 413cb3f2..42ac8e75 100644 --- a/Backend/src/types/user.types.ts +++ b/Backend/src/types/user.types.ts @@ -59,6 +59,14 @@ export interface ProfileUpdateResponse { }; }; +export interface role { + id: number; + code: string; +} + +export interface rolesResponse { + roles: role[]; +} export interface ChangePasswordRequest { old_password: string; diff --git a/Backend/tests/k6/enroll-load-test.js b/Backend/tests/k6/enroll-load-test.js new file mode 100644 index 00000000..d3e1032b --- /dev/null +++ b/Backend/tests/k6/enroll-load-test.js @@ -0,0 +1,160 @@ +// Backend/tests/k6/enroll-load-test.js +// +// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน +// +// Flow: +// 1. Login +// 2. Enroll คอร์ส +// 3. ตรวจสอบ enrolled courses +// +// Usage: +// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-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 enrollTime = new Trend('enroll_duration', true); +const enrolledCount = new Counter('successful_enrollments'); + +// ─── Load student credentials ───────────────────────────────────────────────── +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'; + +// ─── Test Options ───────────────────────────────────────────────────────────── +export const options = { + stages: [ + { duration: '20s', target: 10 }, // Ramp up + { duration: '1m', target: 30 }, // Increase + { duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + 'login_duration': ['p(95)<2000'], // Login < 2s + 'enroll_duration': ['p(95)<1000'], // Enroll < 1s + 'errors': ['rate<0.05'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +export default function () { + 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({ email: student.email, password: student.password }), + { headers: jsonHeaders(null) } + ); + + loginTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + + check(res, { + 'login: status 200': (r) => r.status === 200, + 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, + }); + + if (res.status === 200) { + try { token = res.json('data.token'); } catch {} + } + }); + + if (!token) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(1); + return; + } + + sleep(0.5); + + // ── Step 2: Enroll ───────────────────────────────────────────────────────── + group('2. Enroll Course', () => { + const res = http.post( + `${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`, + null, + { headers: jsonHeaders(token) } + ); + + enrollTime.add(res.timings.duration); + + // 200 = enrolled, 409 = already enrolled (ถือว่าโอเค) + const ok = res.status === 200 || res.status === 409; + errorRate.add(!ok); + + if (res.status === 200) enrolledCount.add(1); + + check(res, { + 'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409, + 'enroll: fast response': (r) => r.timings.duration < 1000, + }); + }); + + sleep(0.5); + + // ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ───────────────────────── + group('3. Get Enrolled Courses', () => { + const res = http.get( + `${BASE_URL}/api/students/courses`, + { headers: jsonHeaders(token) } + ); + + errorRate.add(res.status !== 200); + + check(res, { + 'enrolled courses: status 200': (r) => r.status === 200, + }); + }); + + 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 cnt = (k) => m[k]?.values?.count ?? 0; + + return { + stdout: ` +╔══════════════════════════════════════════════════════════╗ +║ Course Enroll — Load Test ║ +╠══════════════════════════════════════════════════════════╣ +║ Course ID : ${String(COURSE_ID).padEnd(43)}║ +╠══════════════════════════════════════════════════════════╣ +║ RESPONSE TIMES (avg / p95) ║ +║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms +║ Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms +╠══════════════════════════════════════════════════════════╣ +║ COUNTS ║ +║ Total Requests : ${String(cnt('http_reqs')).padEnd(33)}║ +║ New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}║ +╠══════════════════════════════════════════════════════════╣ +║ ERROR RATES ║ +║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}║ +║ Custom Errors : ${(rate('errors') + '%').padEnd(39)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js index 23c33a42..e3bb205c 100644 --- a/Backend/tests/k6/video-watching-load-test.js +++ b/Backend/tests/k6/video-watching-load-test.js @@ -29,6 +29,7 @@ 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) @@ -42,7 +43,7 @@ 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 นาที +const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที // save progress interval: ทุก 5 วินาที (เหมือน client จริง) // แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง @@ -63,6 +64,7 @@ export const options = { '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 @@ -79,116 +81,114 @@ function jsonHeaders(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 () { - // แต่ละ VU ใช้ account คนละคน (round-robin ถ้า VU มากกว่า 50) const student = students[__VU % students.length]; - let token = null; + // ── 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) } + ); - // ── 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); - 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; } }, + }); - 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 (ok) { - try { token = res.json('data.token'); } catch {} + if (!vuToken) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(2); + return; } - }); - - if (!token) { - console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); - sleep(2); - return; } - sleep(1); + // ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ───────── - // ── 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, + // ── 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); + 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, + 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 {} + } }); - }); - sleep(1); + // ── 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`); + } - // ── 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) } - ); + sleep(2); // รอ buffer เริ่มต้น + vuSetupDone = true; + } - 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; + // ── 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: currentSeconds, + video_progress_seconds: vuProgress, video_duration_seconds: VIDEO_DURATION_SECONDS, }), - { headers: jsonHeaders(token) } + { headers: jsonHeaders(vuToken) } ); progressSaveTime.add(res.timings.duration); @@ -201,32 +201,32 @@ export default function () { '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, + console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`); }); - }); - sleep(1); + // ── 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 ────────────────────────────────────────────────────────────────── @@ -251,6 +251,7 @@ export function handleSummary(data) { ║ 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))}║ ╠══════════════════════════════════════════════════════════╣