feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests.

This commit is contained in:
JakkrapartXD 2026-02-20 15:16:03 +07:00
parent e3873f616e
commit 45b9c6516b
11 changed files with 515 additions and 139 deletions

View file

@ -20,10 +20,14 @@ export class RecommendedCoursesController {
@SuccessResponse('200', 'Approved courses retrieved successfully') @SuccessResponse('200', 'Approved courses retrieved successfully')
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listApprovedCourses(@Request() request: any): Promise<ListApprovedCoursesResponse> { public async listApprovedCourses(
@Request() request: any,
@Query() search?: string,
@Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token); return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
} }
/** /**

View file

@ -10,7 +10,8 @@ import {
ChangePasswordResponse, ChangePasswordResponse,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
VerifyEmailResponse VerifyEmailResponse,
rolesResponse
} from '../types/user.types'; } from '../types/user.types';
import { ChangePassword } from '../types/auth.types'; import { ChangePassword } from '../types/auth.types';
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
@ -56,6 +57,18 @@ export class UserController {
return await this.userService.updateProfile(token, body); 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<rolesResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getRoles(token);
}
/** /**
* Change password * Change password
* @summary Change user password using old password * @summary Change user password using old password

View file

@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
/** /**
* Get course details for admin review * Get course details for admin review
*/ */
static async getCourseDetail(token: string,courseId: number): Promise<GetCourseDetailForAdminResponse> { static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -133,7 +133,11 @@ export class AdminCourseApprovalService {
}, },
chapters: { chapters: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
include: { select: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: { lessons: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
select: { select: {

View file

@ -8,7 +8,8 @@ import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
GetCourseByIdResponse, GetCourseByIdResponse,
ToggleRecommendedResponse, ToggleRecommendedResponse,
RecommendedCourseData RecommendedCourseData,
RecommendedCourseDetailData
} from '../types/RecommendedCourses.types'; } from '../types/RecommendedCourses.types';
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -18,10 +19,24 @@ export class RecommendedCoursesService {
/** /**
* List all approved courses (for admin to manage recommendations) * List all approved courses (for admin to manage recommendations)
*/ */
static async listApprovedCourses(token: string): Promise<ListApprovedCoursesResponse> { static async listApprovedCourses(
token: string,
filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> {
try { try {
const { search, categoryId } = filters ?? {};
const courses = await prisma.course.findMany({ 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: [ orderBy: [
{ is_recommended: 'desc' }, { is_recommended: 'desc' },
{ updated_at: 'desc' } { updated_at: 'desc' }
@ -40,9 +55,9 @@ export class RecommendedCoursesService {
} }
} }
}, },
chapters: { _count: {
include: { select: {
lessons: true chapters: true
} }
} }
} }
@ -81,8 +96,7 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters_count: course.chapters.length, chapters_count: course._count.chapters,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
} as RecommendedCourseData; } as RecommendedCourseData;
})); }));
@ -158,7 +172,7 @@ export class RecommendedCoursesService {
} }
} }
const data: RecommendedCourseData = { const data: RecommendedCourseDetailData = {
id: course.id, id: course.id,
title: course.title as { th: string; en: string }, title: course.title as { th: string; en: string },
slug: course.slug, slug: course.slug,
@ -181,8 +195,15 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters_count: course.chapters.length, chapters: course.chapters.map(ch => ({
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) 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 { return {

View file

@ -103,20 +103,55 @@ export class CoursesService {
const course = await prisma.course.findFirst({ const course = await prisma.course.findFirst({
where: { where: {
id, id,
status: 'APPROVED' // Only show approved courses to students status: 'APPROVED'
}, },
include: { include: {
chapters: { creator: {
select: { select: {
id: true, id: true,
title: true, username: true,
lessons: { email: true,
profile: {
select: { select: {
id: true, first_name: true,
title: 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: {
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}`); 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 { return {
code: 200, code: 200,
message: 'Course fetched successfully', message: 'Course fetched successfully',
data: { data: {
...course, ...course,
title: course.title as { th: string; en: string },
description: course.description as { th: string; en: string },
thumbnail_url: thumbnail_presigned_url, 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) { } catch (error) {

View file

@ -14,7 +14,8 @@ import {
updateAvatarRequest, updateAvatarRequest,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
VerifyEmailResponse VerifyEmailResponse,
rolesResponse
} from '../types/user.types'; } from '../types/user.types';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -212,6 +213,30 @@ export class UserService {
} }
} }
async getRoles(token: string): Promise<rolesResponse> {
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 * Upload avatar picture to MinIO
*/ */

View file

@ -1,14 +1,10 @@
import { MultiLanguageText } from './index'; import { MultiLanguageText } from './index';
// ============================================
// Request Types
// ============================================
// ============================================ // ============================================
// Response Types // Response Types
// ============================================ // ============================================
/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */
export interface RecommendedCourseData { export interface RecommendedCourseData {
id: number; id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -41,7 +37,19 @@ export interface RecommendedCourseData {
}; };
}>; }>;
chapters_count: number; chapters_count: number;
lessons_count: number; }
/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */
export interface RecommendedCourseDetailData extends Omit<RecommendedCourseData, 'chapters_count'> {
chapters: {
id: number;
title: MultiLanguageText;
sort_order: number;
lessons: {
id: number;
title: MultiLanguageText;
}[];
}[];
} }
export interface ListApprovedCoursesResponse { export interface ListApprovedCoursesResponse {
@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse {
export interface GetCourseByIdResponse { export interface GetCourseByIdResponse {
code: number; code: number;
message: string; message: string;
data: RecommendedCourseData; data: RecommendedCourseDetailData;
} }
export interface ToggleRecommendedResponse { export interface ToggleRecommendedResponse {

View file

@ -1,4 +1,5 @@
import { Course } from '@prisma/client'; import { Course } from '@prisma/client';
import { MultiLanguageText } from './index';
export interface ListCoursesInput { export interface ListCoursesInput {
category_id?: number; category_id?: number;
@ -18,8 +19,47 @@ export interface listCourseResponse {
totalPages: number; totalPages: number;
} }
export interface CourseDetail extends Omit<Course, 'title' | 'description'> {
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 { export interface getCourseResponse {
code: number; code: number;
message: string; message: string;
data: Course | null; data: CourseDetail | null;
} }

View file

@ -59,6 +59,14 @@ export interface ProfileUpdateResponse {
}; };
}; };
export interface role {
id: number;
code: string;
}
export interface rolesResponse {
roles: role[];
}
export interface ChangePasswordRequest { export interface ChangePasswordRequest {
old_password: string; old_password: string;

View file

@ -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)}
`,
};
}

View file

@ -29,6 +29,7 @@ const progressSaveTime = new Trend('progress_save_duration', true);
const completeLessonTime = new Trend('complete_lesson_duration', true); const completeLessonTime = new Trend('complete_lesson_duration', true);
const completedCount = new Counter('completed_lessons'); const completedCount = new Counter('completed_lessons');
const progressSaveCount = new Counter('progress_saves'); const progressSaveCount = new Counter('progress_saves');
const videoLoadTime = new Trend('video_load_duration', true);
// ─── Load student credentials ──────────────────────────────────────────────── // ─── Load student credentials ────────────────────────────────────────────────
// อ่านจาก test-credentials.json (50 accounts) // อ่านจาก test-credentials.json (50 accounts)
@ -42,7 +43,7 @@ const COURSE_ID = __ENV.COURSE_ID || '1';
const LESSON_ID = __ENV.LESSON_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 จริง) // save progress interval: ทุก 5 วินาที (เหมือน client จริง)
// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง // แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง
@ -63,6 +64,7 @@ export const options = {
'login_duration': ['p(95)<2000'], // Login < 2s 'login_duration': ['p(95)<2000'], // Login < 2s
'course_learning_duration': ['p(95)<1000'], // Load course page < 1s 'course_learning_duration': ['p(95)<1000'], // Load course page < 1s
'lesson_load_duration': ['p(95)<1000'], // Load lesson < 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 — บ่อย) 'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย)
'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s 'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s
@ -79,18 +81,24 @@ function jsonHeaders(token) {
return h; 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 ───────────────────────────────────────────────────────────────────── // ─── Main ─────────────────────────────────────────────────────────────────────
export default function () { export default function () {
// แต่ละ VU ใช้ account คนละคน (round-robin ถ้า VU มากกว่า 50)
const student = students[__VU % students.length]; const student = students[__VU % students.length];
let token = null; // ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ─────────
if (!vuToken) {
// ── Step 1: Login ──────────────────────────────────────────────────────────
group('1. Login', () => { group('1. Login', () => {
const res = http.post( const res = http.post(
`${BASE_URL}/api/auth/login`, `${BASE_URL}/api/auth/login`,
JSON.stringify({ identifier: student.email, password: student.password }), JSON.stringify({ email: student.email, password: student.password }),
{ headers: jsonHeaders(null) } { headers: jsonHeaders(null) }
); );
@ -100,95 +108,87 @@ export default function () {
check(res, { check(res, {
'login: status 200': (r) => r.status === 200, 'login: status 200': (r) => r.status === 200,
'login: has token': (r) => { 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
try { return !!r.json('data.token'); } catch { return false; }
},
}); });
if (ok) { if (ok) {
try { token = res.json('data.token'); } catch {} try { vuToken = res.json('data.token'); } catch {}
} }
}); });
if (!token) { if (!vuToken) {
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
sleep(2); sleep(2);
return; return;
} }
}
sleep(1); // ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ─────────
// ── Step 2: Enroll (ถ้ายังไม่ได้ enroll) ────────────────────────────────── // ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ─────
group('2. Enroll Course', () => { if (!vuSetupDone) {
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', () => { group('3. Load Course Learning Page', () => {
const res = http.get( const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`, `${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
{ headers: jsonHeaders(token) } { headers: jsonHeaders(vuToken) }
); );
courseLearningTime.add(res.timings.duration); courseLearningTime.add(res.timings.duration);
const ok = res.status === 200; errorRate.add(res.status !== 200);
errorRate.add(!ok); check(res, { 'course learn: status 200': (r) => r.status === 200 });
check(res, {
'course learn: status 200': (r) => r.status === 200,
});
}); });
sleep(1); sleep(1);
// ── Step 4: Open lesson (load video content) ─────────────────────────────── let videoUrl = null;
group('4. Open Lesson', () => { group('4. Open Lesson', () => {
const res = http.get( const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`, `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
{ headers: jsonHeaders(token) } { headers: jsonHeaders(vuToken) }
); );
lessonLoadTime.add(res.timings.duration); lessonLoadTime.add(res.timings.duration);
const ok = res.status === 200; 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); errorRate.add(!ok);
check(res, { check(res, {
'lesson: status 200': (r) => r.status === 200, '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); // รอ video buffer เล็กน้อย sleep(2); // รอ buffer เริ่มต้น
vuSetupDone = true;
}
// ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ────────────────────
// แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง
if (!vuCompleted) {
vuProgress += PROGRESS_INTERVAL_SECONDS;
// ── Step 5: Simulate watching — save progress ทุก PROGRESS_INTERVAL วิ ─────
// จำลองการดูวีดีโอ: progress เพิ่มขึ้นเรื่อยๆ จนถึง 95% (auto-complete threshold)
group('5. Watch Video (Save Progress)', () => { 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( const res = http.post(
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`, `${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
JSON.stringify({ JSON.stringify({
video_progress_seconds: currentSeconds, video_progress_seconds: vuProgress,
video_duration_seconds: VIDEO_DURATION_SECONDS, video_duration_seconds: VIDEO_DURATION_SECONDS,
}), }),
{ headers: jsonHeaders(token) } { headers: jsonHeaders(vuToken) }
); );
progressSaveTime.add(res.timings.duration); progressSaveTime.add(res.timings.duration);
@ -201,32 +201,32 @@ export default function () {
'progress save: fast': (r) => r.timings.duration < 500, 'progress save: fast': (r) => r.timings.duration < 500,
}); });
// รอ interval จริง (ย่อจาก 5s จริง เหลือ 1s เพื่อให้ test ไม่นานเกิน) console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`);
sleep(1);
}
}); });
// ── Step 6: Mark lesson complete ─────────────────────────────────────────── // ── Step 6: Mark complete เมื่อดูครบ ≥95% ──────────────────────────────
if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) {
group('6. Complete Lesson', () => { group('6. Complete Lesson', () => {
const res = http.post( const res = http.post(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`, `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
null, null,
{ headers: jsonHeaders(token) } { headers: jsonHeaders(vuToken) }
); );
completeLessonTime.add(res.timings.duration); completeLessonTime.add(res.timings.duration);
const ok = res.status === 200;
// 200 = completed now, อาจ 409 ถ้า already completed (ถือว่าโอเค)
errorRate.add(res.status !== 200 && res.status !== 409); errorRate.add(res.status !== 200 && res.status !== 409);
if (ok) completedCount.add(1); if (res.status === 200) completedCount.add(1);
check(res, { check(res, {
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409, 'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
}); });
}); });
sleep(1); vuCompleted = true;
console.log(`[VU ${__VU}] ✓ Lesson completed`);
}
}
// sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที
sleep(PROGRESS_INTERVAL_SECONDS);
} }
// ─── Summary ────────────────────────────────────────────────────────────────── // ─── 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))} 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))} 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))} 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))} 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))} 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))}