feat: Introduce core authentication service, several new admin management pages, and instructor feature tests.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 52s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 52s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
This commit is contained in:
parent
000f9eea5c
commit
0205aab461
11 changed files with 818 additions and 121 deletions
|
|
@ -168,7 +168,7 @@
|
|||
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey">Time</div>
|
||||
<div class="text-subtitle2 text-grey">Date & Time</div>
|
||||
<div>{{ formatDate(selectedLog.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -291,7 +291,7 @@ const columns = [
|
|||
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
|
||||
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
|
||||
|
||||
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
|
||||
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
||||
];
|
||||
|
||||
|
|
@ -421,13 +421,23 @@ const formatDate = (date: string) => {
|
|||
return new Date(date).toLocaleString('th-TH');
|
||||
};
|
||||
|
||||
const ACTION_COLOR_MAP: Record<string, string> = {
|
||||
DELETE: 'negative',
|
||||
REJECT: 'negative',
|
||||
DEACTIVATE: 'negative',
|
||||
ERROR: 'negative',
|
||||
UPDATE: 'warning',
|
||||
CHANGE: 'warning',
|
||||
CREATE: 'positive',
|
||||
APPROVE: 'positive',
|
||||
ACTIVATE: 'positive',
|
||||
LOGIN: 'info',
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
if (!action) return 'grey';
|
||||
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
|
||||
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
||||
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
||||
if (action.includes('LOGIN')) return 'info';
|
||||
return 'grey-8';
|
||||
if (!action) return 'grey';
|
||||
const keyword = Object.keys(ACTION_COLOR_MAP).find((key) => action.includes(key));
|
||||
return keyword ? ACTION_COLOR_MAP[keyword] : 'grey-8';
|
||||
};
|
||||
|
||||
// Check for deep link to detail
|
||||
|
|
|
|||
|
|
@ -307,8 +307,17 @@ const handleSave = async () => {
|
|||
const confirmDelete = (category: CategoryResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
|
||||
cancel: true,
|
||||
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?<br><span style="color: red;">การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่</span>`,
|
||||
html: true,
|
||||
cancel: {
|
||||
label: 'ยกเลิก',
|
||||
color: 'grey',
|
||||
flat: true
|
||||
},
|
||||
ok: {
|
||||
label: 'ลบหมวดหมู่',
|
||||
color: 'negative'
|
||||
},
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<div class="p-6">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
|
||||
<q-badge color="grey" :label="course.category.name.th" />
|
||||
<q-badge color="grey" :label="course.category?.name?.th || 'ไม่มีหมวดหมู่'" />
|
||||
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
|
||||
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
|
||||
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@
|
|||
<!-- Category -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
||||
<div class="font-bold mb-2">หมวดหมู่ (Category):</div>
|
||||
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
||||
<div v-if="selectedCourse.category" class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
||||
<div v-else class="text-gray-400 italic mb-2">ไม่มีหมวดหมู่</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructors -->
|
||||
|
|
@ -262,7 +263,7 @@ const columns = [
|
|||
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
|
||||
align: 'left' as const
|
||||
},
|
||||
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
|
||||
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category?.name?.th || 'ไม่มีหมวดหมู่', sortable: true, align: 'left' as const },
|
||||
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
|
||||
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
|
||||
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
<q-input
|
||||
v-model="form.email"
|
||||
label="อีเมล *"
|
||||
type="email"
|
||||
type="text"
|
||||
outlined
|
||||
:rules="[
|
||||
val => !!val || 'กรุณากรอกอีเมล',
|
||||
|
|
|
|||
|
|
@ -3,35 +3,20 @@ export interface LoginRequest {
|
|||
password: string;
|
||||
}
|
||||
|
||||
// API Response structure (from backend)
|
||||
// API Response structure (from backend) - new format: only token/refreshToken
|
||||
export interface ApiLoginResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
role: {
|
||||
code: string;
|
||||
name: {
|
||||
en: string;
|
||||
th: string;
|
||||
};
|
||||
};
|
||||
profile: {
|
||||
prefix: {
|
||||
en: string;
|
||||
th: string;
|
||||
};
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
avatar_url: string | null;
|
||||
birth_date: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// JWT Payload structure (decoded from token)
|
||||
export interface JwtPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
roleCode: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// Frontend User structure
|
||||
|
|
@ -55,6 +40,21 @@ export interface ApiResponse<T> {
|
|||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verification (read-only)
|
||||
* Verification is handled by the backend on each request
|
||||
*/
|
||||
function decodeJwtPayload(token: string): JwtPayload {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64).split('').map(c =>
|
||||
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
||||
).join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async login(email: string, password: string): Promise<LoginResponse> {
|
||||
const config = useRuntimeConfig();
|
||||
|
|
@ -71,22 +71,26 @@ export const authService = {
|
|||
|
||||
const loginData = response.data;
|
||||
|
||||
// Decode JWT to get user info
|
||||
const payload = decodeJwtPayload(loginData.token);
|
||||
|
||||
// Check if user role is STUDENT - block login
|
||||
if (loginData.user.role.code === 'STUDENT') {
|
||||
if (payload.roleCode === 'STUDENT') {
|
||||
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
|
||||
}
|
||||
|
||||
// Transform API response to frontend format
|
||||
// Return basic user info from JWT payload
|
||||
// Full profile will be fetched via fetchUserProfile() in the auth store
|
||||
return {
|
||||
token: loginData.token,
|
||||
refreshToken: loginData.refreshToken,
|
||||
user: {
|
||||
id: loginData.user.id.toString(),
|
||||
email: loginData.user.email,
|
||||
firstName: loginData.user.profile.first_name,
|
||||
lastName: loginData.user.profile.last_name,
|
||||
role: loginData.user.role.code,
|
||||
avatarUrl: loginData.user.profile.avatar_url
|
||||
id: payload.id.toString(),
|
||||
email: payload.email,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: payload.roleCode,
|
||||
avatarUrl: null
|
||||
},
|
||||
message: response.message || 'เข้าสู่ระบบสำเร็จ'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,16 +16,6 @@ test.describe('Register Page', () => {
|
|||
await expect(page.getByText('ลงทะเบียนเป็นผู้สอน')).toBeVisible();
|
||||
await expect(page.getByText('สร้างบัญชีเพื่อเริ่มสร้างหลักสูตร')).toBeVisible();
|
||||
|
||||
// Form fields
|
||||
await expect(page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'อีเมล' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'รหัสผ่าน *' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'ยืนยันรหัสผ่าน' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'คำนำหน้า' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'ชื่อจริง' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'นามสกุล' })).toBeVisible();
|
||||
await expect(page.locator('label').filter({ hasText: 'เบอร์โทรศัพท์' })).toBeVisible();
|
||||
|
||||
// Submit button
|
||||
await expect(page.getByRole('button', { name: 'ลงทะเบียน' })).toBeVisible();
|
||||
|
||||
|
|
@ -34,18 +24,6 @@ test.describe('Register Page', () => {
|
|||
await expect(page.getByText('เข้าสู่ระบบ')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation errors for empty fields', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'ลงทะเบียน' }).click();
|
||||
|
||||
await expect(page.getByText('กรุณากรอก username')).toBeVisible();
|
||||
await expect(page.getByText('กรุณากรอกอีเมล')).toBeVisible();
|
||||
await expect(page.getByText('กรุณากรอกรหัสผ่าน')).toBeVisible();
|
||||
await expect(page.getByText('กรุณายืนยันรหัสผ่าน')).toBeVisible();
|
||||
await expect(page.getByText('กรุณากรอกชื่อ')).toBeVisible();
|
||||
await expect(page.getByText('กรุณากรอกนามสกุล')).toBeVisible();
|
||||
await expect(page.getByText('กรุณากรอกเบอร์โทร')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show username min length validation', async ({ page }) => {
|
||||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill('ab');
|
||||
|
|
@ -55,7 +33,9 @@ test.describe('Register Page', () => {
|
|||
});
|
||||
|
||||
test('should show email format validation', async ({ page }) => {
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill('abeee');
|
||||
const emailInput = page.locator('label').filter({ hasText: 'อีเมล' }).locator('input');
|
||||
await emailInput.fill('invalid-email');
|
||||
await page.getByRole('button', { name: 'ลงทะเบียน' }).click();
|
||||
|
||||
|
|
@ -63,7 +43,11 @@ test.describe('Register Page', () => {
|
|||
});
|
||||
|
||||
test('should show password min length validation', async ({ page }) => {
|
||||
const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input');
|
||||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill('abeee');
|
||||
const emailInput = page.locator('label').filter({ hasText: 'อีเมล' }).locator('input');
|
||||
await emailInput.fill('test@example.com');
|
||||
const passwordInput = page.getByRole('textbox', { name: 'รหัสผ่าน *', exact: true });
|
||||
await passwordInput.fill('1234');
|
||||
await page.getByRole('button', { name: 'ลงทะเบียน' }).click();
|
||||
|
||||
|
|
@ -71,8 +55,12 @@ test.describe('Register Page', () => {
|
|||
});
|
||||
|
||||
test('should show password mismatch validation', async ({ page }) => {
|
||||
const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input');
|
||||
const confirmInput = page.locator('label').filter({ hasText: 'ยืนยันรหัสผ่าน' }).locator('input');
|
||||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill('abeee');
|
||||
const emailInput = page.locator('label').filter({ hasText: 'อีเมล' }).locator('input');
|
||||
await emailInput.fill('test@example.com');
|
||||
const passwordInput = page.getByRole('textbox', { name: 'รหัสผ่าน *', exact: true });
|
||||
const confirmInput = page.getByRole('textbox', { name: 'ยืนยันรหัสผ่าน *', exact: true });
|
||||
|
||||
await passwordInput.fill('password123');
|
||||
await confirmInput.fill('differentpass');
|
||||
|
|
@ -82,7 +70,11 @@ test.describe('Register Page', () => {
|
|||
});
|
||||
|
||||
test('should toggle password visibility', async ({ page }) => {
|
||||
const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input');
|
||||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill('abeee');
|
||||
const emailInput = page.locator('label').filter({ hasText: 'อีเมล' }).locator('input');
|
||||
await emailInput.fill('test@example.com');
|
||||
const passwordInput = page.getByRole('textbox', { name: 'รหัสผ่าน *', exact: true });
|
||||
await passwordInput.fill('test1234');
|
||||
|
||||
// Click visibility icon
|
||||
|
|
@ -110,9 +102,9 @@ test.describe('Register Page', () => {
|
|||
const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input');
|
||||
await usernameInput.fill(username);
|
||||
|
||||
await page.locator('input[type="email"]').fill(email);
|
||||
await page.locator('label').filter({ hasText: 'อีเมล' }).locator('input').fill(email);
|
||||
|
||||
const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input');
|
||||
const passwordInput = page.getByRole('textbox', { name: 'รหัสผ่าน *', exact: true });
|
||||
await passwordInput.fill(password);
|
||||
|
||||
const confirmInput = page.locator('label').filter({ hasText: 'ยืนยันรหัสผ่าน' }).locator('input');
|
||||
|
|
|
|||
298
frontend_management/tests/instructor/course-detail-tabs.spec.ts
Normal file
298
frontend_management/tests/instructor/course-detail-tabs.spec.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_URLS } from '../fixtures/test-data';
|
||||
import { faker, fakerTH } from '@faker-js/faker';
|
||||
|
||||
/**
|
||||
* Instructor Course Detail Tabs Tests
|
||||
* ทดสอบการเข้าดูแต่ละ tab ในหน้ารายละเอียดหลักสูตร
|
||||
* ใช้ cookies จาก instructor-setup project (ไม่ต้อง login ซ้ำ)
|
||||
*/
|
||||
|
||||
test.describe.serial('Course Detail Tabs', () => {
|
||||
let courseUrl: string;
|
||||
|
||||
// ── Step 1: ค้นหาและเข้าหลักสูตร "พื้นฐาน Python สำหรับผู้เริ่มต้น" ──
|
||||
test('navigate to Python course detail page', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.instructorCourses);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// ค้นหาหลักสูตร (debounce 600ms)
|
||||
await page.getByPlaceholder('ค้นหาหลักสูตร...').fill('พื้นฐาน Python');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// หา course card ที่มีชื่อตรง แล้วกดปุ่ม visibility (ดูรายละเอียด)
|
||||
const courseCard = page.locator('.bg-white.rounded-xl').filter({ hasText: 'พื้นฐาน Python สำหรับผู้เริ่มต้น' }).first();
|
||||
await expect(courseCard).toBeVisible({ timeout: 10_000 });
|
||||
await courseCard.locator('button').filter({ has: page.locator('.q-icon:has-text("visibility")') }).click();
|
||||
|
||||
// รอเข้าหน้ารายละเอียด
|
||||
await page.waitForURL('**/instructor/courses/*', { timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// เก็บ URL
|
||||
courseUrl = page.url();
|
||||
|
||||
// ตรวจสอบ header elements
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
// ตรวจสอบว่ามี tabs ครบ
|
||||
await expect(page.getByRole('tab', { name: 'โครงสร้าง' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'ผู้เรียน' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'ผู้สอน' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'ผลการทดสอบ' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'ประวัติการขออนุมัติ' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'ประกาศ' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Step 2: tab โครงสร้าง (default) ──
|
||||
test('display structure tab and preview a lesson', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// tab โครงสร้างเป็น default → ควรแสดงอยู่แล้ว
|
||||
const structureTab = page.getByRole('tab', { name: 'โครงสร้าง' });
|
||||
await expect(structureTab).toBeVisible();
|
||||
|
||||
// ตรวจสอบว่ามี chapters หรือ empty state
|
||||
const hasChapters = await page.locator('.font-semibold').getByText(/^Chapter/).first().isVisible().catch(() => false);
|
||||
const hasEmptyState = await page.getByText('ยังไม่มีบทเรียน').isVisible().catch(() => false);
|
||||
|
||||
expect(hasChapters || hasEmptyState).toBeTruthy();
|
||||
|
||||
// ถ้ามี chapters → กดเข้าดู lesson แรก
|
||||
if (hasChapters) {
|
||||
// หา lesson แรกใน structure tab แล้วกด
|
||||
const firstLesson = page.locator('.q-item').filter({ hasText: /^Lesson/ }).first();
|
||||
await expect(firstLesson).toBeVisible();
|
||||
await firstLesson.click();
|
||||
|
||||
// ตรวจสอบว่า LessonPreviewDialog เปิด
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// ตรวจสอบว่าแสดง title ของ lesson
|
||||
await expect(dialog.locator('.text-h6')).toBeVisible();
|
||||
|
||||
// ปิด dialog
|
||||
await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 3_000 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 3: tab ผู้เรียน ──
|
||||
test('display students tab and view student detail', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กด tab ผู้เรียน
|
||||
await page.getByRole('tab', { name: 'ผู้เรียน' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบ: stat cards หรือ empty state
|
||||
const hasStats = await page.getByText('ผู้เรียนทั้งหมด').isVisible().catch(() => false);
|
||||
const hasEmptyState = await page.getByText('ยังไม่มีผู้เรียนในหลักสูตรนี้').isVisible().catch(() => false);
|
||||
|
||||
expect(hasStats || hasEmptyState).toBeTruthy();
|
||||
|
||||
// ถ้ามีผู้เรียน → กดเข้าดูรายละเอียดคนแรก
|
||||
if (hasStats) {
|
||||
await expect(page.getByPlaceholder('ค้นหาผู้เรียน...')).toBeVisible();
|
||||
|
||||
// กดที่นักเรียนคนแรกในรายการ
|
||||
const firstStudent = page.locator('.q-item.cursor-pointer').first();
|
||||
await expect(firstStudent).toBeVisible();
|
||||
await firstStudent.click();
|
||||
|
||||
// ตรวจสอบว่า detail modal เปิด (maximized)
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ตรวจสอบ progress info
|
||||
await expect(dialog.getByText('ความคืบหน้าทั้งหมด')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ปิด modal
|
||||
await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 3_000 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 4: tab ผู้สอน ──
|
||||
test('display instructors tab and search to add instructor', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กด tab ผู้สอน
|
||||
await page.getByRole('tab', { name: 'ผู้สอน' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบ header
|
||||
await expect(page.getByText('ผู้สอนในรายวิชา')).toBeVisible();
|
||||
|
||||
// ตรวจสอบ: มีการ์ดผู้สอนอย่างน้อย 1 คน หรือ empty state
|
||||
const hasInstructors = await page.getByText('หัวหน้าผู้สอน').isVisible().catch(() => false);
|
||||
const hasEmptyState = await page.getByText('ยังไม่มีข้อมูลผู้สอน').isVisible().catch(() => false);
|
||||
|
||||
expect(hasInstructors || hasEmptyState).toBeTruthy();
|
||||
|
||||
// ถ้ามีปุ่มเพิ่มผู้สอน (แสดงเฉพาะ primary instructor)
|
||||
const addBtn = page.getByRole('button', { name: 'เพิ่มผู้สอน' });
|
||||
const hasAddBtn = await addBtn.isVisible().catch(() => false);
|
||||
|
||||
if (hasAddBtn) {
|
||||
// กดปุ่มเพิ่มผู้สอน
|
||||
await addBtn.click();
|
||||
|
||||
// ตรวจสอบว่า dialog เปิด
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.getByText('เพิ่มผู้สอน')).toBeVisible();
|
||||
|
||||
// ค้นหา "lertp" ใน q-select (use-input)
|
||||
const selectInput = dialog.locator('.q-select input[type="search"]');
|
||||
await selectInput.fill('lertp');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// ตรวจสอบว่ามีผลลัพธ์ (dropdown options) หรือ "ไม่พบผู้ใช้"
|
||||
const hasResults = await page.locator('.q-menu .q-item').first().isVisible().catch(() => false);
|
||||
expect(hasResults).toBeTruthy();
|
||||
|
||||
// ปิด dialog
|
||||
await dialog.getByRole('button', { name: 'ยกเลิก' }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 3_000 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 5: tab ผลการทดสอบ ──
|
||||
test('display quiz results and view student detail', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กด tab ผลการทดสอบ
|
||||
await page.getByRole('tab', { name: 'ผลการทดสอบ' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// ตรวจสอบ: มี quiz selector หรือ empty state
|
||||
const hasQuizSelector = await page.getByText('เลือกแบบทดสอบ').isVisible().catch(() => false);
|
||||
const hasEmptyState = await page.getByText('หลักสูตรนี้ยังไม่มีแบบทดสอบ').isVisible().catch(() => false);
|
||||
|
||||
expect(hasQuizSelector || hasEmptyState).toBeTruthy();
|
||||
|
||||
// ถ้ามีแบบทดสอบ → ตรวจสอบตาราง + กดดูรายละเอียดนักเรียน
|
||||
if (hasQuizSelector) {
|
||||
// รอตารางโหลด (auto-select quiz แรก)
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// ตรวจสอบว่ามี stats cards (คะแนนเฉลี่ย)
|
||||
const hasStats = await page.getByText('คะแนนเฉลี่ย').isVisible().catch(() => false);
|
||||
|
||||
// ตรวจสอบว่ามี row ในตาราง
|
||||
const firstRow = page.locator('.q-tr.cursor-pointer').first();
|
||||
const hasStudents = await firstRow.isVisible().catch(() => false);
|
||||
|
||||
if (hasStudents) {
|
||||
// กดนักเรียนคนแรก
|
||||
await firstRow.click();
|
||||
|
||||
// ตรวจสอบ detail dialog
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ตรวจสอบข้อมูลคะแนน
|
||||
await expect(dialog.getByText('คะแนนที่ได้')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ปิด dialog
|
||||
await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 3_000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 6: tab ประวัติการขออนุมัติ ──
|
||||
test('should display approval history tab content', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กด tab ประวัติการขออนุมัติ
|
||||
await page.getByRole('tab', { name: 'ประวัติการขออนุมัติ' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบ: มี timeline หรือ empty state
|
||||
const hasTimeline = await page.locator('.q-timeline').isVisible().catch(() => false);
|
||||
const hasEmptyState = await page.getByText('ไม่พบประวัติการขออนุมัติ').isVisible().catch(() => false);
|
||||
|
||||
expect(hasTimeline || hasEmptyState).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Step 7: tab ประกาศ ──
|
||||
test('display announcements tab and create a new announcement', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กด tab ประกาศ
|
||||
await page.getByRole('tab', { name: 'ประกาศ' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบ header
|
||||
await expect(page.getByText('ประกาศ').first()).toBeVisible();
|
||||
|
||||
// สร้างข้อมูลประกาศ
|
||||
const announcementTitle = fakerTH.lorem.sentence({ min: 3, max: 6 });
|
||||
const announcementTitleEn = faker.lorem.sentence({ min: 3, max: 6 });
|
||||
const announcementContent = fakerTH.lorem.paragraphs(1);
|
||||
const announcementContentEn = faker.lorem.paragraphs(1);
|
||||
|
||||
// กดปุ่มสร้างประกาศ
|
||||
const createBtn = page.getByRole('button', { name: 'สร้างประกาศ' });
|
||||
await expect(createBtn).toBeVisible();
|
||||
await createBtn.click();
|
||||
|
||||
// ตรวจสอบว่า dialog เปิด
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.getByText('สร้างประกาศใหม่')).toBeVisible();
|
||||
|
||||
// กรอกหัวข้อ (ภาษาไทย)
|
||||
await dialog.locator('input').filter({ has: page.locator('[aria-label="หัวข้อ (ภาษาไทย) *"]') }).first().click();
|
||||
await dialog.getByLabel('หัวข้อ (ภาษาไทย) *').fill(announcementTitle);
|
||||
|
||||
// กรอกหัวข้อ (English)
|
||||
await dialog.getByLabel('หัวข้อ (English)').fill(announcementTitleEn);
|
||||
|
||||
// กรอกเนื้อหา (ภาษาไทย)
|
||||
await dialog.getByLabel('เนื้อหา (ภาษาไทย) *').fill(announcementContent);
|
||||
|
||||
// กรอกเนื้อหา (English)
|
||||
await dialog.getByLabel('เนื้อหา (English)').fill(announcementContentEn);
|
||||
|
||||
// กดสร้าง
|
||||
await dialog.getByRole('button', { name: 'สร้าง' }).click();
|
||||
|
||||
// รอ dialog ปิด + ตรวจสอบ success
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// ตรวจสอบว่าประกาศที่สร้างแสดงในรายการ
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ── Step 8: ทดสอบสลับ tabs อย่างรวดเร็ว ──
|
||||
test('should switch between all tabs without errors', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const tabs = ['โครงสร้าง', 'ผู้เรียน', 'ผู้สอน', 'ผลการทดสอบ', 'ประวัติการขออนุมัติ', 'ประกาศ'];
|
||||
|
||||
for (const tabName of tabs) {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// ตรวจสอบว่า tab active (ไม่ crash)
|
||||
const tabPanel = page.locator('.q-tab-panel:visible');
|
||||
await expect(tabPanel).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// สลับกลับไปที่ tab แรก
|
||||
await page.getByRole('tab', { name: 'โครงสร้าง' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.q-tab-panel:visible')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,30 +14,23 @@ test.describe('Instructor Courses List', () => {
|
|||
test('should display page header', async ({ page }) => {
|
||||
await expect(page.getByText('หลักสูตรของฉัน')).toBeVisible();
|
||||
await expect(page.getByText('จัดการหลักสูตรที่คุณสร้าง')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have create course button', async ({ page }) => {
|
||||
const createBtn = page.getByRole('button', { name: /สร้างหลักสูตรใหม่/ });
|
||||
await expect(createBtn).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
test('should navigate to create course page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /สร้างหลักสูตรใหม่/ }).click();
|
||||
await page.waitForURL('**/instructor/courses/create**');
|
||||
await expect(page).toHaveURL(/\/instructor\/courses\/create/);
|
||||
});
|
||||
|
||||
test('should display stats cards', async ({ page }) => {
|
||||
await expect(page.getByText('หลักสูตรทั้งหมด')).toBeVisible();
|
||||
await expect(page.getByText('เผยแพร่แล้ว')).toBeVisible();
|
||||
await expect(page.getByText('รอตรวจสอบ')).toBeVisible();
|
||||
await expect(page.getByText('แบบร่าง')).toBeVisible();
|
||||
await expect(page.getByText('ถูกปฏิเสธ')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have search input', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="ค้นหา"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('JavaScript');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('พื้นฐาน JavaScript', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have status filter dropdown', async ({ page }) => {
|
||||
|
|
@ -46,17 +39,6 @@ test.describe('Instructor Courses List', () => {
|
|||
await expect(statusSelect).toBeVisible();
|
||||
});
|
||||
|
||||
test('should filter by status', async ({ page }) => {
|
||||
// Open status dropdown
|
||||
await page.locator('.q-select').first().click();
|
||||
|
||||
// Select "เผยแพร่แล้ว" (APPROVED)
|
||||
await page.getByText('เผยแพร่แล้ว').click();
|
||||
|
||||
// Wait for API re-fetch
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should toggle between card and table view', async ({ page }) => {
|
||||
// Switch to table view
|
||||
await page.locator('.q-btn-toggle button').last().click();
|
||||
|
|
@ -66,9 +48,8 @@ test.describe('Instructor Courses List', () => {
|
|||
await expect(page.locator('.q-table')).toBeVisible();
|
||||
|
||||
// Table should have expected columns
|
||||
await expect(page.getByText('หลักสูตร')).toBeVisible();
|
||||
await expect(page.getByText('สถานะ')).toBeVisible();
|
||||
await expect(page.getByText('ราคา')).toBeVisible();
|
||||
await expect(page.locator('thead').getByText('สถานะ')).toBeVisible();
|
||||
await expect(page.locator('thead').getByText('ราคา')).toBeVisible();
|
||||
|
||||
// Switch back to card view
|
||||
await page.locator('.q-btn-toggle button').first().click();
|
||||
|
|
@ -114,7 +95,7 @@ test.describe('Instructor Courses List', () => {
|
|||
test('should handle rejected course view details', async ({ page }) => {
|
||||
// Filter by rejected status
|
||||
await page.locator('.q-select').first().click();
|
||||
await page.getByText('ถูกปฏิเสธ').click();
|
||||
await page.getByRole('listbox').getByText('ถูกปฏิเสธ').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// If there are rejected courses, clicking view should show rejection dialog
|
||||
|
|
|
|||
417
frontend_management/tests/instructor/create-course.spec.ts
Normal file
417
frontend_management/tests/instructor/create-course.spec.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_URLS } from '../fixtures/test-data';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
/**
|
||||
* Instructor Create Course & Structure Tests
|
||||
* ใช้ cookies จาก instructor-setup project (ไม่ต้อง login ซ้ำ)
|
||||
* ใช้ faker สุ่มข้อมูลหลักสูตรจริงๆ
|
||||
*/
|
||||
|
||||
// ── Dynamic Course Data Generator (ไม่ hardcode) ────────────────
|
||||
const TOPICS = ['JavaScript', 'Python', 'React', 'Vue.js', 'Node.js', 'TypeScript', 'Docker', 'Kubernetes', 'GraphQL', 'Next.js', 'Flutter', 'Swift', 'Rust', 'Go', 'Machine Learning'];
|
||||
const LEVELS = ['เบื้องต้น', 'พื้นฐาน', 'ขั้นสูง', 'สำหรับมือใหม่', 'เชิงปฏิบัติ'];
|
||||
const LEVELS_EN = ['Fundamentals', 'Basics', 'Advanced', 'for Beginners', 'Hands-on'];
|
||||
const ACTIONS_TH = ['เรียนรู้', 'ทำความเข้าใจ', 'ฝึกปฏิบัติ', 'ประยุกต์ใช้', 'สำรวจ'];
|
||||
const ACTIONS_EN = ['Learn', 'Understand', 'Practice', 'Apply', 'Explore'];
|
||||
const CHAPTER_THEMES_TH = ['แนะนำ', 'พื้นฐาน', 'การใช้งาน', 'เทคนิค', 'โปรเจกต์'];
|
||||
const CHAPTER_THEMES_EN = ['Introduction to', 'Basics of', 'Working with', 'Techniques in', 'Projects with'];
|
||||
const LESSON_TYPES: ('วิดีโอ' | 'แบบทดสอบ')[] = ['วิดีโอ', 'วิดีโอ', 'วิดีโอ', 'แบบทดสอบ']; // 75% video, 25% quiz
|
||||
|
||||
const timestamp = Date.now();
|
||||
const topic = faker.helpers.arrayElement(TOPICS);
|
||||
const levelIndex = faker.number.int({ min: 0, max: LEVELS.length - 1 });
|
||||
|
||||
/** สร้างข้อมูล lesson สุ่ม */
|
||||
function generateLesson(topic: string, chapterIndex: number, lessonIndex: number) {
|
||||
const subtopic = faker.helpers.arrayElement([
|
||||
`${faker.helpers.arrayElement(ACTIONS_TH)} ${topic}`,
|
||||
`${topic} ${faker.helpers.arrayElement(['ตอนที่', 'ส่วนที่', 'หัวข้อที่'])} ${chapterIndex + 1}.${lessonIndex + 1}`,
|
||||
`${faker.helpers.arrayElement(['การตั้งค่า', 'การใช้งาน', 'แนวคิด', 'ตัวอย่าง', 'การทดลอง'])} ${topic} บทที่ ${chapterIndex + 1}`,
|
||||
]);
|
||||
const subtopicEn = faker.helpers.arrayElement([
|
||||
`${faker.helpers.arrayElement(ACTIONS_EN)} ${topic}`,
|
||||
`${topic} Part ${chapterIndex + 1}.${lessonIndex + 1}`,
|
||||
`${faker.helpers.arrayElement(['Setting up', 'Using', 'Concepts of', 'Examples of', 'Experimenting with'])} ${topic} Ch.${chapterIndex + 1}`,
|
||||
]);
|
||||
const type = faker.helpers.arrayElement(LESSON_TYPES);
|
||||
|
||||
return {
|
||||
title: {
|
||||
th: type === 'แบบทดสอบ' ? `แบบทดสอบ ${chapterIndex + 1}.${lessonIndex + 1}: ${subtopic}` : subtopic,
|
||||
en: type === 'แบบทดสอบ' ? `Quiz ${chapterIndex + 1}.${lessonIndex + 1}: ${subtopicEn}` : subtopicEn,
|
||||
},
|
||||
type,
|
||||
content: {
|
||||
th: `ในบทเรียนนี้คุณจะได้${faker.helpers.arrayElement(ACTIONS_TH)} ${topic} ${faker.helpers.arrayElement(['แบบเจาะลึก', 'อย่างละเอียด', 'ผ่านตัวอย่างจริง', 'พร้อมแบบฝึกหัด'])}`,
|
||||
en: `In this lesson you will ${faker.helpers.arrayElement(ACTIONS_EN).toLowerCase()} ${topic} ${faker.helpers.arrayElement(['in depth', 'in detail', 'through real examples', 'with exercises'])}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** สร้างข้อมูล chapter สุ่ม */
|
||||
function generateChapter(topic: string, chapterIndex: number, themeIndex: number) {
|
||||
const lessonCount = faker.number.int({ min: 2, max: 3 });
|
||||
const lessons = Array.from({ length: lessonCount }, (_, i) => generateLesson(topic, chapterIndex, i));
|
||||
|
||||
// ทำให้ lesson สุดท้ายเป็นแบบทดสอบเสมอ
|
||||
lessons[lessons.length - 1] = {
|
||||
...lessons[lessons.length - 1],
|
||||
type: 'แบบทดสอบ',
|
||||
title: {
|
||||
th: `แบบทดสอบ: ${CHAPTER_THEMES_TH[themeIndex]} ${topic}`,
|
||||
en: `Quiz: ${CHAPTER_THEMES_EN[themeIndex]} ${topic}`,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
title: {
|
||||
th: `${CHAPTER_THEMES_TH[themeIndex]} ${topic}`,
|
||||
en: `${CHAPTER_THEMES_EN[themeIndex]} ${topic}`,
|
||||
},
|
||||
description: {
|
||||
th: `${faker.helpers.arrayElement(ACTIONS_TH)} ${CHAPTER_THEMES_TH[themeIndex].toLowerCase()} ${topic} ${faker.helpers.arrayElement(['อย่างเป็นระบบ', 'อย่างมีประสิทธิภาพ', 'แบบ step-by-step', 'ผ่านตัวอย่างจริง'])}`,
|
||||
en: `${faker.helpers.arrayElement(ACTIONS_EN)} ${CHAPTER_THEMES_EN[themeIndex].toLowerCase()} ${topic} ${faker.helpers.arrayElement(['systematically', 'effectively', 'step by step', 'through real examples'])}`,
|
||||
},
|
||||
lessons,
|
||||
};
|
||||
}
|
||||
|
||||
// ── สร้างข้อมูล course ──────────────────────────────────────────
|
||||
const COURSE_DATA = {
|
||||
title: {
|
||||
th: `${topic} ${LEVELS[levelIndex]} ${timestamp}`,
|
||||
en: `${topic} ${LEVELS_EN[levelIndex]} ${timestamp}`,
|
||||
},
|
||||
slug: `${topic.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${timestamp}`,
|
||||
description: {
|
||||
th: `เรียนรู้ ${topic} ${LEVELS[levelIndex]} ครอบคลุมเนื้อหาสำคัญทั้งหมดที่จำเป็น พร้อมตัวอย่างและแบบฝึกหัดที่จะช่วยให้คุณเข้าใจอย่างลึกซึ้ง`,
|
||||
en: `Learn ${topic} ${LEVELS_EN[levelIndex]} covering all essential topics with examples and exercises to help you gain deep understanding.`,
|
||||
},
|
||||
};
|
||||
|
||||
const chapterCount = faker.number.int({ min: 2, max: 3 });
|
||||
// สุ่ม theme ที่ไม่ซ้ำกันสำหรับแต่ละบท
|
||||
const shuffledThemeIndexes = faker.helpers.shuffle([0, 1, 2, 3, 4]).slice(0, chapterCount);
|
||||
const CHAPTERS = shuffledThemeIndexes.map((themeIdx, i) => generateChapter(topic, i, themeIdx));
|
||||
|
||||
// ── Tests (serial — ต้องทำงานต่อเนื่องกัน) ─────────────────────
|
||||
test.describe.serial('Create Course & Structure', () => {
|
||||
let courseUrl: string;
|
||||
|
||||
// ── Step 1: สร้างหลักสูตรใหม่ ──
|
||||
test('create a new course with faker data', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.instructorCreateCourse);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กรอกชื่อหลักสูตร
|
||||
await page.locator('input').filter({ hasText: '' }).nth(0)
|
||||
.or(page.getByLabel('ชื่อหลักสูตร (ภาษาไทย) *'))
|
||||
.fill(COURSE_DATA.title.th);
|
||||
|
||||
await page.getByLabel('ชื่อหลักสูตร (English) *').fill(COURSE_DATA.title.en);
|
||||
|
||||
// กรอก Slug
|
||||
await page.getByLabel('Slug (URL) *').clear();
|
||||
await page.getByLabel('Slug (URL) *').fill(COURSE_DATA.slug);
|
||||
|
||||
// เลือกหมวดหมู่ (เลือกตัวแรกจาก dropdown)
|
||||
await page.locator('.q-select').click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('listbox').locator('.q-item').first().click();
|
||||
|
||||
// กรอกคำอธิบาย
|
||||
await page.getByLabel('คำอธิบาย (ภาษาไทย) *').fill(COURSE_DATA.description.th);
|
||||
await page.getByLabel('คำอธิบาย (English) *').fill(COURSE_DATA.description.en);
|
||||
|
||||
// ตั้งค่า — หลักสูตรฟรี (toggle อยู่แล้วเป็น default)
|
||||
// ตรวจสอบว่า "หลักสูตรฟรี" toggle เปิดอยู่
|
||||
const freeToggle = page.getByText('หลักสูตรฟรี');
|
||||
await expect(freeToggle).toBeVisible();
|
||||
|
||||
// กดสร้าง
|
||||
await page.getByRole('button', { name: 'สร้างหลักสูตร' }).click();
|
||||
|
||||
// รอ redirect ไปหน้า course detail
|
||||
await page.waitForURL('**/instructor/courses/*', { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/instructor\/courses\/\d+/);
|
||||
|
||||
// เก็บ URL สำหรับ test ถัดไป
|
||||
courseUrl = page.url();
|
||||
|
||||
// ตรวจสอบว่าชื่อ course แสดงบนหน้า detail
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText(COURSE_DATA.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Step 2: อัปโหลดรูปหลักสูตร ──
|
||||
test('upload course thumbnail', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// สร้างไฟล์รูปทดสอบ (1x1 PNG ขนาดเล็ก)
|
||||
const pngBuffer = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
// กดที่ thumbnail area เพื่อเปิด file input
|
||||
const fileInput = page.locator('input[type="file"][accept="image/*"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: `test-thumbnail-${Date.now()}.png`,
|
||||
mimeType: 'image/png',
|
||||
buffer: pngBuffer,
|
||||
});
|
||||
|
||||
// รอ upload เสร็จ
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// ตรวจสอบว่ารูปแสดง (img tag ปรากฏ)
|
||||
await expect(page.locator('img[alt]').first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Step 3: ไปหน้าจัดการโครงสร้าง ──
|
||||
test('navigate to structure page', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กดปุ่ม "จัดการโครงสร้าง" ใน Structure tab
|
||||
await page.getByRole('button', { name: 'จัดการโครงสร้าง' }).click();
|
||||
await page.waitForURL('**/structure**', { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/structure/);
|
||||
|
||||
// ตรวจสอบ header
|
||||
await expect(page.getByText('จัดการโครงสร้างหลักสูตร')).toBeVisible();
|
||||
|
||||
// ตรวจสอบ empty state
|
||||
await expect(page.getByText('ยังไม่มีบทเรียน')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Step 4: เพิ่มบทที่ 1 ──
|
||||
test('add chapter 1 with faker data', async ({ page }) => {
|
||||
// Navigate to structure page
|
||||
const structureUrl = courseUrl + '/structure';
|
||||
await page.goto(structureUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กดเพิ่มบทแรก
|
||||
await page.getByRole('button', { name: 'เพิ่มบทแรก' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// กรอกข้อมูล chapter
|
||||
const chapter = CHAPTERS[0];
|
||||
await page.getByLabel('ชื่อบท (ภาษาไทย) *').fill(chapter.title.th);
|
||||
await page.getByLabel('ชื่อบท (English) *').fill(chapter.title.en);
|
||||
await page.getByLabel('คำอธิบาย (ภาษาไทย) *').fill(chapter.description.th);
|
||||
await page.getByLabel('คำอธิบาย (English) *').fill(chapter.description.en);
|
||||
|
||||
// บันทึก
|
||||
await page.getByRole('button', { name: 'บันทึก' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// ตรวจสอบ chapter แสดงผล
|
||||
await expect(page.getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Step 5: เพิ่ม lessons ในบทที่ 1 ──
|
||||
test('add lessons to chapter 1', async ({ page }) => {
|
||||
const structureUrl = courseUrl + '/structure';
|
||||
await page.goto(structureUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const chapter = CHAPTERS[0];
|
||||
|
||||
for (const lesson of chapter.lessons) {
|
||||
// กดปุ่มเพิ่มบทเรียน (ปุ่ม + ที่อยู่ข้าง chapter header)
|
||||
const chapterCard = page.locator('.q-card').filter({ hasText: chapter.title.th });
|
||||
await chapterCard.locator('button').filter({ has: page.locator('.q-icon:has-text("add")') }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// กรอกชื่อบทเรียน
|
||||
await page.getByLabel('ชื่อบทเรียน (ภาษาไทย) *').fill(lesson.title.th);
|
||||
await page.getByLabel('ชื่อบทเรียน (English) *').fill(lesson.title.en);
|
||||
|
||||
// เลือกประเภท
|
||||
await page.locator('.q-dialog .q-select').click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('listbox').getByText(lesson.type).click();
|
||||
|
||||
// กรอกเนื้อหา
|
||||
await page.getByLabel('เนื้อหา (ภาษาไทย) *').fill(lesson.content.th);
|
||||
await page.getByLabel('เนื้อหา (English) *').fill(lesson.content.en);
|
||||
|
||||
// บันทึก
|
||||
await page.locator('.q-dialog').getByRole('button', { name: 'บันทึก' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบว่า lesson แสดง
|
||||
await expect(page.getByText(lesson.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// ตรวจสอบจำนวนบทเรียน
|
||||
await expect(page.getByText(`${chapter.lessons.length} บทเรียน`)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Step 6: เพิ่มบทที่ 2 ──
|
||||
test('add chapter 2 with faker data', async ({ page }) => {
|
||||
const structureUrl = courseUrl + '/structure';
|
||||
await page.goto(structureUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// กดเพิ่มบท (ปุ่ม header)
|
||||
await page.getByRole('button', { name: 'เพิ่มบท' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// กรอกข้อมูล chapter 2
|
||||
const chapter = CHAPTERS[1];
|
||||
await page.getByLabel('ชื่อบท (ภาษาไทย) *').fill(chapter.title.th);
|
||||
await page.getByLabel('ชื่อบท (English) *').fill(chapter.title.en);
|
||||
await page.getByLabel('คำอธิบาย (ภาษาไทย) *').fill(chapter.description.th);
|
||||
await page.getByLabel('คำอธิบาย (English) *').fill(chapter.description.en);
|
||||
|
||||
// บันทึก
|
||||
await page.getByRole('button', { name: 'บันทึก' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// ตรวจสอบ chapter 2 แสดงผล
|
||||
await expect(page.getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Step 7: เพิ่ม lessons ในบทที่ 2 ──
|
||||
test('add lessons to chapter 2', async ({ page }) => {
|
||||
const structureUrl = courseUrl + '/structure';
|
||||
await page.goto(structureUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const chapter = CHAPTERS[1];
|
||||
|
||||
for (const lesson of chapter.lessons) {
|
||||
// กดปุ่มเพิ่มบทเรียนของบทที่ 2 (ปุ่มที่ 2)
|
||||
// หาบทที่ 2 ก่อน แล้วกดปุ่ม add ของ chapter นั้น
|
||||
const chapterCard = page.locator('.q-card').filter({ hasText: chapter.title.th });
|
||||
await chapterCard.locator('button').filter({ has: page.locator('.q-icon:text("add")') }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// กรอกชื่อบทเรียน
|
||||
await page.getByLabel('ชื่อบทเรียน (ภาษาไทย) *').fill(lesson.title.th);
|
||||
await page.getByLabel('ชื่อบทเรียน (English) *').fill(lesson.title.en);
|
||||
|
||||
// เลือกประเภท
|
||||
await page.locator('.q-dialog .q-select').click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('listbox').getByText(lesson.type).click();
|
||||
|
||||
// กรอกเนื้อหา
|
||||
await page.getByLabel('เนื้อหา (ภาษาไทย) *').fill(lesson.content.th);
|
||||
await page.getByLabel('เนื้อหา (English) *').fill(lesson.content.en);
|
||||
|
||||
// บันทึก
|
||||
await page.locator('.q-dialog').getByRole('button', { name: 'บันทึก' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบว่า lesson แสดง
|
||||
await expect(page.getByText(lesson.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 8: เพิ่มข้อสอบในแบบทดสอบ ──
|
||||
test('add quiz questions to a quiz lesson', async ({ page }) => {
|
||||
const structureUrl = courseUrl + '/structure';
|
||||
await page.goto(structureUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// หา quiz lesson แรกจากบทแรก → กดปุ่ม edit (icon "edit") เพื่อเข้าหน้า quiz
|
||||
const firstQuizLesson = CHAPTERS[0].lessons.find(l => l.type === 'แบบทดสอบ');
|
||||
if (!firstQuizLesson) throw new Error('No quiz lesson found in chapter 1');
|
||||
|
||||
// Locate the quiz lesson row and click its edit button
|
||||
const quizRow = page.locator('.q-item').filter({ hasText: firstQuizLesson.title.th });
|
||||
await quizRow.locator('button').filter({ has: page.locator('.q-icon:has-text("edit")') }).click();
|
||||
|
||||
// รอเข้าหน้า quiz
|
||||
await page.waitForURL('**/quiz', { timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('แก้ไขบทเรียน (แบบทดสอบ)')).toBeVisible();
|
||||
|
||||
// สุ่มจำนวนข้อสอบ 2-4 ข้อ
|
||||
const questionCount = faker.number.int({ min: 2, max: 4 });
|
||||
|
||||
for (let q = 0; q < questionCount; q++) {
|
||||
const questionTh = `${faker.helpers.arrayElement(ACTIONS_TH)} ${topic} คำถามที่ ${q + 1}: ${faker.helpers.arrayElement([
|
||||
'ข้อใดถูกต้อง?',
|
||||
'ข้อใดเป็นจริง?',
|
||||
'ข้อใดคือคำตอบที่ดีที่สุด?',
|
||||
'ข้อใดต่อไปนี้ถูกต้อง?',
|
||||
])}`;
|
||||
const questionEn = `${faker.helpers.arrayElement(ACTIONS_EN)} ${topic} Question ${q + 1}: ${faker.helpers.arrayElement([
|
||||
'Which is correct?',
|
||||
'Which is true?',
|
||||
'Which is the best answer?',
|
||||
'Which of the following is correct?',
|
||||
])}`;
|
||||
|
||||
// กดปุ่ม "เพิ่มคำถาม"
|
||||
await page.getByRole('button', { name: 'เพิ่มคำถาม' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// กรอกคำถาม
|
||||
const dialog = page.locator('.q-dialog');
|
||||
await dialog.getByLabel('คำถาม (ภาษาไทย) *').fill(questionTh);
|
||||
await dialog.getByLabel('คำถาม (English)').fill(questionEn);
|
||||
|
||||
// เพิ่มตัวเลือก (default มี 2 ตัว → เพิ่มอีก 2 ให้ครบ 4)
|
||||
await dialog.getByRole('button', { name: 'เพิ่มตัวเลือก' }).click();
|
||||
await dialog.getByRole('button', { name: 'เพิ่มตัวเลือก' }).click();
|
||||
|
||||
// กรอก 4 ตัวเลือก
|
||||
const choices = [
|
||||
{ th: `${topic} คำตอบที่ถูกต้อง ${q + 1}`, en: `${topic} correct answer ${q + 1}` },
|
||||
{ th: `${topic} ตัวเลือกที่ 2 ${q + 1}`, en: `${topic} option 2 ${q + 1}` },
|
||||
{ th: `${topic} ตัวเลือกที่ 3 ${q + 1}`, en: `${topic} option 3 ${q + 1}` },
|
||||
{ th: `${topic} ตัวเลือกที่ 4 ${q + 1}`, en: `${topic} option 4 ${q + 1}` },
|
||||
];
|
||||
|
||||
for (let c = 0; c < 4; c++) {
|
||||
await dialog.getByLabel(`ตัวเลือก ${c + 1} (TH)`).fill(choices[c].th);
|
||||
await dialog.getByLabel(`ตัวเลือก ${c + 1} (EN)`).fill(choices[c].en);
|
||||
}
|
||||
|
||||
// ตัวเลือกแรก (index 0) เป็นคำตอบที่ถูก (default เลือกไว้แล้ว)
|
||||
|
||||
// บันทึก
|
||||
await dialog.getByRole('button', { name: 'บันทึก' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ตรวจสอบว่าคำถามแสดงในรายการ
|
||||
await expect(page.getByText(`คำถามที่ ${q + 1}`).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// ตรวจสอบจำนวนข้อสอบทั้งหมด
|
||||
await expect(page.getByText(`คำถาม (${questionCount} ข้อ)`)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Step 9: ตรวจสอบโครงสร้างทั้งหมดจากหน้า course detail ──
|
||||
test('verify full structure on course detail page', async ({ page }) => {
|
||||
await page.goto(courseUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// ตรวจสอบว่า tab โครงสร้างแสดง chapters ทั้งหมด
|
||||
for (const chapter of CHAPTERS) {
|
||||
// ตรวจสอบ chapter title (scope ไปที่ header element เพื่อกัน text ซ้ำ)
|
||||
await expect(page.locator('.font-semibold').getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ตรวจสอบ lessons ใน chapter
|
||||
for (const lesson of chapter.lessons) {
|
||||
await expect(page.locator('.q-item__label').getByText(lesson.title.th)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
// ตรวจสอบจำนวนบทเรียนรวม
|
||||
const totalLessons = CHAPTERS.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
||||
await expect(page.getByText(`${totalLessons} บทเรียน`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,25 +11,11 @@ test.describe('Instructor Dashboard', () => {
|
|||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should display welcome message', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toContainText('สวัสดี');
|
||||
});
|
||||
|
||||
test('should display stats cards', async ({ page }) => {
|
||||
test('check display dashboard', async ({ page }) => {
|
||||
await expect(page.getByText('หลักสูตรทั้งหมด')).toBeVisible();
|
||||
await expect(page.getByText('ผู้เรียนทั้งหมด')).toBeVisible();
|
||||
await expect(page.getByText('เรียนจบแล้ว')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display course status breakdown', async ({ page }) => {
|
||||
await expect(page.getByText('สถานะหลักสูตร')).toBeVisible();
|
||||
await expect(page.getByText('เผยแพร่แล้ว')).toBeVisible();
|
||||
await expect(page.getByText('รอตรวจสอบ')).toBeVisible();
|
||||
await expect(page.getByText('แบบร่าง')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display recent courses section', async ({ page }) => {
|
||||
await expect(page.getByText('หลักสูตร')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'ดูทั้งหมด' })).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +29,6 @@ test.describe('Instructor Dashboard', () => {
|
|||
await page.locator('.w-12.h-12.rounded-full').click();
|
||||
|
||||
await expect(page.getByText('โปรไฟล์')).toBeVisible();
|
||||
await expect(page.getByText('ออกจากระบบ')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to profile', async ({ page }) => {
|
||||
|
|
@ -56,10 +41,10 @@ test.describe('Instructor Dashboard', () => {
|
|||
|
||||
test('should logout and redirect to login', async ({ page }) => {
|
||||
await page.locator('.w-12.h-12.rounded-full').click();
|
||||
await page.getByText('ออกจากระบบ').click();
|
||||
await page.getByRole('menu').getByText('ออกจากระบบ').click();
|
||||
|
||||
// Confirm logout dialog
|
||||
await page.locator('.q-dialog').getByText('ออกจากระบบ').click();
|
||||
await page.locator('.q-dialog').getByRole('button', { name: 'ออกจากระบบ' }).click();
|
||||
|
||||
await page.waitForURL('**/login**', { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue