feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests.
This commit is contained in:
parent
e3873f616e
commit
45b9c6516b
11 changed files with 515 additions and 139 deletions
|
|
@ -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<ListApprovedCoursesResponse> {
|
||||
public async listApprovedCourses(
|
||||
@Request() request: any,
|
||||
@Query() search?: string,
|
||||
@Query() categoryId?: number
|
||||
): Promise<ListApprovedCoursesResponse> {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<rolesResponse> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
|
|||
/**
|
||||
* 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 {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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<ListApprovedCoursesResponse> {
|
||||
static async listApprovedCourses(
|
||||
token: string,
|
||||
filters?: { search?: string; categoryId?: number }
|
||||
): Promise<ListApprovedCoursesResponse> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -103,20 +103,55 @@ export class CoursesService {
|
|||
const course = await prisma.course.findFirst({
|
||||
where: {
|
||||
id,
|
||||
status: 'APPROVED' // Only show approved courses to students
|
||||
status: 'APPROVED'
|
||||
},
|
||||
include: {
|
||||
chapters: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
lessons: {
|
||||
username: true,
|
||||
email: true,
|
||||
profile: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
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: {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<RecommendedCourseData, 'chapters_count'> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
code: number;
|
||||
message: string;
|
||||
data: Course | null;
|
||||
data: CourseDetail | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
160
Backend/tests/k6/enroll-load-test.js
Normal file
160
Backend/tests/k6/enroll-load-test.js
Normal 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)}║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,18 +81,24 @@ 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 ──────────────────────────────────────────────────────────
|
||||
// ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ─────────
|
||||
if (!vuToken) {
|
||||
group('1. Login', () => {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/login`,
|
||||
JSON.stringify({ identifier: student.email, password: student.password }),
|
||||
JSON.stringify({ email: student.email, password: student.password }),
|
||||
{ headers: jsonHeaders(null) }
|
||||
);
|
||||
|
||||
|
|
@ -100,95 +108,87 @@ export default function () {
|
|||
|
||||
check(res, {
|
||||
'login: status 200': (r) => r.status === 200,
|
||||
'login: has token': (r) => {
|
||||
try { return !!r.json('data.token'); } catch { return false; }
|
||||
},
|
||||
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
|
||||
});
|
||||
|
||||
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`);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ── Step 3: Load course learning page ─────────────────────────────────────
|
||||
// ── 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(token) }
|
||||
{ headers: jsonHeaders(vuToken) }
|
||||
);
|
||||
|
||||
courseLearningTime.add(res.timings.duration);
|
||||
const ok = res.status === 200;
|
||||
errorRate.add(!ok);
|
||||
check(res, {
|
||||
'course learn: status 200': (r) => r.status === 200,
|
||||
});
|
||||
errorRate.add(res.status !== 200);
|
||||
check(res, { 'course learn: status 200': (r) => r.status === 200 });
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ── Step 4: Open lesson (load video content) ───────────────────────────────
|
||||
let videoUrl = null;
|
||||
group('4. Open Lesson', () => {
|
||||
const res = http.get(
|
||||
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
|
||||
{ headers: jsonHeaders(token) }
|
||||
{ headers: jsonHeaders(vuToken) }
|
||||
);
|
||||
|
||||
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);
|
||||
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)', () => {
|
||||
const targetSeconds = Math.floor(VIDEO_DURATION_SECONDS * 0.95); // ดูถึง 95%
|
||||
const steps = Math.ceil(targetSeconds / PROGRESS_INTERVAL_SECONDS); // จำนวนครั้งที่ save
|
||||
|
||||
// เพื่อไม่ให้ test นาน เกินไป จำกัดไว้ที่ 20 saves per VU iteration
|
||||
const maxSaves = Math.min(steps, 20);
|
||||
|
||||
for (let i = 1; i <= maxSaves; i++) {
|
||||
const currentSeconds = i * PROGRESS_INTERVAL_SECONDS;
|
||||
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
|
||||
JSON.stringify({
|
||||
video_progress_seconds: currentSeconds,
|
||||
video_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);
|
||||
}
|
||||
console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`);
|
||||
});
|
||||
|
||||
// ── Step 6: Mark lesson complete ───────────────────────────────────────────
|
||||
// ── 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(token) }
|
||||
{ headers: jsonHeaders(vuToken) }
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
if (res.status === 200) completedCount.add(1);
|
||||
check(res, {
|
||||
'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 ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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))}║
|
||||
╠══════════════════════════════════════════════════════════╣
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue