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/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index 5de912fc..a78c8d5a 100644 --- a/Backend/src/controllers/AuditController.ts +++ b/Backend/src/controllers/AuditController.ts @@ -169,8 +169,8 @@ export class AuditController { throw new ValidationError('No token provided'); } - if (days < 30) { - throw new ValidationError('Cannot delete logs newer than 30 days'); + if (days < 6) { + throw new ValidationError('Cannot delete logs newer than 6 days'); } const deleted = await auditService.deleteOldLogs(days); 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/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 0e9e5b86..986695b1 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -340,6 +340,19 @@ export class CoursesStudentService { throw new ForbiddenError('You are not enrolled in this course'); } + // Update last_accessed_at (fire-and-forget — ไม่ block response) + if (enrollment.status === 'ENROLLED') { + prisma.enrollment.update({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + data: { last_accessed_at: new Date() }, + }).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`)); + } + // Get all lesson progress for this user and course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ @@ -1249,17 +1262,17 @@ export class CoursesStudentService { } catch (error) { logger.error(`Error completing lesson: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; - await auditService.logSync({ - userId: decoded?.id || 0, - action: AuditAction.ERROR, - entityType: 'LessonProgress', - entityId: input.lesson_id, - metadata: { - operation: 'complete_lesson', - lesson_id: input.lesson_id, - error: error instanceof Error ? error.message : String(error) - } - }); + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonProgress', + entityId: input.lesson_id, + metadata: { + operation: 'complete_lesson', + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } 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/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/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/login-load-test.js b/Backend/tests/k6/login-load-test.js index 2a0c375d..aee4cb4a 100644 --- a/Backend/tests/k6/login-load-test.js +++ b/Backend/tests/k6/login-load-test.js @@ -31,7 +31,7 @@ export const options = { thresholds: { http_req_duration: ['p(95)<2000'], // 95% of requests < 2s errors: ['rate<0.1'], // Error rate < 10% - login_duration: ['p(95)<2000'], // 95% of logins < 2s + login_duration: ['p(95)<2000'], // 95% pof logins < 2s }, }; 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..e3bb205c --- /dev/null +++ b/Backend/tests/k6/video-watching-load-test.js @@ -0,0 +1,269 @@ +// 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)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} diff --git a/Frontend-Learner/app.vue b/Frontend-Learner/app.vue index a1ac35c7..8070d468 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -1,20 +1,27 @@ - diff --git a/Frontend-Learner/components/classroom/CurriculumSidebar.vue b/Frontend-Learner/components/classroom/CurriculumSidebar.vue index a7ccddab..382be584 100644 --- a/Frontend-Learner/components/classroom/CurriculumSidebar.vue +++ b/Frontend-Learner/components/classroom/CurriculumSidebar.vue @@ -21,15 +21,40 @@ const emit = defineEmits<{ const { locale } = useI18n() +// State for expansion items +const chapterOpenState = ref>({}) + // Helper for localization const getLocalizedText = (text: any) => { if (!text) return '' if (typeof text === 'string') return text - const currentLocale = locale.value as 'th' | 'en' + // Safe locale access + const currentLocale = (locale?.value || 'th') as 'th' | 'en' return text[currentLocale] || text.th || text.en || '' } +// Helper: Check if lesson is completed +const isLessonCompleted = (lesson: any) => { + return lesson.is_completed === true || lesson.progress?.is_completed === true +} + +// Reactive Chapter Completion Status +// Computes a map of chapterId -> boolean (true if all lessons are completed) +const chapterCompletionStatus = computed(() => { + const status: Record = {} + if (!props.courseData || !props.courseData.chapters) return status + + props.courseData.chapters.forEach((chapter: any) => { + if (chapter.lessons && chapter.lessons.length > 0) { + status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l)) + } else { + status[chapter.id] = false + } + }) + return status +}) + // Local Progress Calculation const progressPercentage = computed(() => { if (!props.courseData || !props.courseData.chapters) return 0 @@ -38,11 +63,34 @@ const progressPercentage = computed(() => { props.courseData.chapters.forEach((c: any) => { c.lessons.forEach((l: any) => { total++ - if (l.is_completed || l.progress?.is_completed) completed++ + if (isLessonCompleted(l)) completed++ }) }) return total > 0 ? Math.round((completed / total) * 100) : 0 }) + +// Auto-expand chapter containing current lesson +watch(() => props.currentLessonId, (newId) => { + if (newId && props.courseData?.chapters) { + props.courseData.chapters.forEach((chapter: any) => { + const hasLesson = chapter.lessons.some((l: any) => l.id === newId) + if (hasLesson) { + chapterOpenState.value[chapter.id] = true + } + }) + } +}, { immediate: true }) + +// Initialize all chapters as open by default on load +watch(() => props.courseData, (newData) => { + if (newData?.chapters) { + newData.chapters.forEach((chapter: any) => { + if (chapterOpenState.value[chapter.id] === undefined) { + chapterOpenState.value[chapter.id] = true + } + }) + } +}, { immediate: true }) diff --git a/Frontend-Learner/components/course/CourseCard.vue b/Frontend-Learner/components/course/CourseCard.vue index b26d9ea1..1cd55e5a 100644 --- a/Frontend-Learner/components/course/CourseCard.vue +++ b/Frontend-Learner/components/course/CourseCard.vue @@ -25,6 +25,8 @@ interface CourseCardProps { showContinue?: boolean showCertificate?: boolean showStudyAgain?: boolean + hideProgress?: boolean + hideActions?: boolean } const props = withDefaults(defineProps(), { @@ -55,7 +57,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))