From 0205aab46177733daa9b9fc8bb4e6f91db267ef0 Mon Sep 17 00:00:00 2001 From: Missez Date: Fri, 6 Mar 2026 11:24:10 +0700 Subject: [PATCH 1/8] feat: Introduce core authentication service, several new admin management pages, and instructor feature tests. --- .../pages/admin/audit-log/index.vue | 26 +- .../pages/admin/categories/index.vue | 13 +- .../pages/admin/courses/[id].vue | 2 +- .../pages/admin/recommended-courses/index.vue | 5 +- frontend_management/pages/register.vue | 2 +- frontend_management/services/auth.service.ts | 72 +-- .../tests/auth/register.spec.ts | 50 +-- .../instructor/course-detail-tabs.spec.ts | 298 +++++++++++++ .../tests/instructor/courses-list.spec.ts | 33 +- .../tests/instructor/create-course.spec.ts | 417 ++++++++++++++++++ .../tests/instructor/dashboard.spec.ts | 21 +- 11 files changed, 818 insertions(+), 121 deletions(-) create mode 100644 frontend_management/tests/instructor/course-detail-tabs.spec.ts create mode 100644 frontend_management/tests/instructor/create-course.spec.ts diff --git a/frontend_management/pages/admin/audit-log/index.vue b/frontend_management/pages/admin/audit-log/index.vue index c456b0c1..1214298e 100644 --- a/frontend_management/pages/admin/audit-log/index.vue +++ b/frontend_management/pages/admin/audit-log/index.vue @@ -168,7 +168,7 @@ {{ selectedLog.action }}
-
Time
+
Date & Time
{{ formatDate(selectedLog.created_at) }}
@@ -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 = { + 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 diff --git a/frontend_management/pages/admin/categories/index.vue b/frontend_management/pages/admin/categories/index.vue index 65bf74fd..02648abe 100644 --- a/frontend_management/pages/admin/categories/index.vue +++ b/frontend_management/pages/admin/categories/index.vue @@ -307,8 +307,17 @@ const handleSave = async () => { const confirmDelete = (category: CategoryResponse) => { $q.dialog({ title: 'ยืนยันการลบ', - message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`, - cancel: true, + message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?
การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่`, + html: true, + cancel: { + label: 'ยกเลิก', + color: 'grey', + flat: true + }, + ok: { + label: 'ลบหมวดหมู่', + color: 'negative' + }, persistent: true }).onOk(async () => { try { diff --git a/frontend_management/pages/admin/courses/[id].vue b/frontend_management/pages/admin/courses/[id].vue index ad2dd0f7..07c78cf3 100644 --- a/frontend_management/pages/admin/courses/[id].vue +++ b/frontend_management/pages/admin/courses/[id].vue @@ -56,7 +56,7 @@
- + diff --git a/frontend_management/pages/admin/recommended-courses/index.vue b/frontend_management/pages/admin/recommended-courses/index.vue index c8aac610..73b414ba 100644 --- a/frontend_management/pages/admin/recommended-courses/index.vue +++ b/frontend_management/pages/admin/recommended-courses/index.vue @@ -153,7 +153,8 @@
หมวดหมู่ (Category):
-
{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})
+
{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})
+
ไม่มีหมวดหมู่
@@ -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 }, diff --git a/frontend_management/pages/register.vue b/frontend_management/pages/register.vue index 24235ee9..a080d082 100644 --- a/frontend_management/pages/register.vue +++ b/frontend_management/pages/register.vue @@ -36,7 +36,7 @@ { }); 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'); diff --git a/frontend_management/tests/instructor/course-detail-tabs.spec.ts b/frontend_management/tests/instructor/course-detail-tabs.spec.ts new file mode 100644 index 00000000..fff2fce8 --- /dev/null +++ b/frontend_management/tests/instructor/course-detail-tabs.spec.ts @@ -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(); + }); +}); diff --git a/frontend_management/tests/instructor/courses-list.spec.ts b/frontend_management/tests/instructor/courses-list.spec.ts index fadb8452..854fac5a 100644 --- a/frontend_management/tests/instructor/courses-list.spec.ts +++ b/frontend_management/tests/instructor/courses-list.spec.ts @@ -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 diff --git a/frontend_management/tests/instructor/create-course.spec.ts b/frontend_management/tests/instructor/create-course.spec.ts new file mode 100644 index 00000000..a4b2bffc --- /dev/null +++ b/frontend_management/tests/instructor/create-course.spec.ts @@ -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(); + }); +}); diff --git a/frontend_management/tests/instructor/dashboard.spec.ts b/frontend_management/tests/instructor/dashboard.spec.ts index 0ac7b077..134a1f6b 100644 --- a/frontend_management/tests/instructor/dashboard.spec.ts +++ b/frontend_management/tests/instructor/dashboard.spec.ts @@ -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/); From b0b665f5884309543def9d5ff52dfa1b484cbf57 Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Fri, 6 Mar 2026 12:43:49 +0700 Subject: [PATCH 2/8] feat: Implement E2E tests for authentication, student account, discovery, and classroom features, alongside new browse pages and a `useAuth` composable. --- Frontend-Learner/composables/useAuth.ts | 39 ++- Frontend-Learner/pages/browse/discovery.vue | 52 ++-- Frontend-Learner/pages/browse/index.vue | 2 +- Frontend-Learner/playwright-report/index.html | 2 +- Frontend-Learner/tests/e2e/auth.spec.ts | 141 +++++----- Frontend-Learner/tests/e2e/classroom.spec.ts | 230 +++++++++++------ Frontend-Learner/tests/e2e/discovery.spec.ts | 82 +++--- .../tests/e2e/forgot-password.spec.ts | 102 -------- Frontend-Learner/tests/e2e/helpers.ts | 129 ++++++++++ Frontend-Learner/tests/e2e/login.spec.ts | 122 --------- Frontend-Learner/tests/e2e/quiz.spec.ts | 125 --------- Frontend-Learner/tests/e2e/register.spec.ts | 241 ------------------ .../tests/e2e/student-account.spec.ts | 141 +++++----- .../e2e/screenshots/discovery-curriculum.png | Bin 0 -> 55199 bytes .../e2e/screenshots/discovery-enroll-btn.png | Bin 0 -> 54333 bytes tests/e2e/screenshots/discovery-filter.png | Bin 0 -> 612294 bytes tests/e2e/screenshots/discovery-home.png | Bin 0 -> 1004319 bytes tests/e2e/screenshots/discovery-search.png | Bin 0 -> 221331 bytes tests/e2e/screenshots/forgot-01-smoke.png | Bin 48467 -> 48452 bytes .../e2e/screenshots/forgot-02-thai-email.png | Bin 48824 -> 48879 bytes .../e2e/screenshots/forgot-03-back-login.png | Bin 57976 -> 58742 bytes .../screenshots/forgot-04-mock-success.png | Bin 49526 -> 49543 bytes tests/e2e/screenshots/login-invalid-email.png | Bin 62192 -> 62171 bytes tests/e2e/screenshots/login-thai-email.png | Bin 60414 -> 60419 bytes tests/e2e/screenshots/login-to-dashboard.png | Bin 193995 -> 200356 bytes .../e2e/screenshots/login-wrong-password.png | Bin 67965 -> 66634 bytes tests/e2e/screenshots/register-go-login.png | Bin 57112 -> 57049 bytes .../register-invalid-email-thai.png | Bin 64155 -> 64177 bytes tests/e2e/screenshots/register-page.png | Bin 60464 -> 60577 bytes .../register-password-mismatch.png | Bin 69232 -> 69262 bytes .../screenshots/register-redirect-login.png | Bin 65826 -> 65536 bytes tests/e2e/screenshots/student-dashboard.png | Bin 0 -> 210479 bytes .../e2e/screenshots/student-edit-profile.png | Bin 0 -> 96713 bytes tests/e2e/screenshots/student-my-courses.png | Bin 0 -> 294599 bytes .../e2e/screenshots/student-search-empty.png | Bin 0 -> 61119 bytes 35 files changed, 546 insertions(+), 862 deletions(-) delete mode 100644 Frontend-Learner/tests/e2e/forgot-password.spec.ts create mode 100644 Frontend-Learner/tests/e2e/helpers.ts delete mode 100644 Frontend-Learner/tests/e2e/login.spec.ts delete mode 100644 Frontend-Learner/tests/e2e/quiz.spec.ts delete mode 100644 Frontend-Learner/tests/e2e/register.spec.ts create mode 100644 tests/e2e/screenshots/discovery-curriculum.png create mode 100644 tests/e2e/screenshots/discovery-enroll-btn.png create mode 100644 tests/e2e/screenshots/discovery-filter.png create mode 100644 tests/e2e/screenshots/discovery-home.png create mode 100644 tests/e2e/screenshots/discovery-search.png create mode 100644 tests/e2e/screenshots/student-dashboard.png create mode 100644 tests/e2e/screenshots/student-edit-profile.png create mode 100644 tests/e2e/screenshots/student-my-courses.png create mode 100644 tests/e2e/screenshots/student-search-empty.png diff --git a/Frontend-Learner/composables/useAuth.ts b/Frontend-Learner/composables/useAuth.ts index 61f5ee0a..e9e392d1 100644 --- a/Frontend-Learner/composables/useAuth.ts +++ b/Frontend-Learner/composables/useAuth.ts @@ -40,7 +40,7 @@ export const useAuth = () => { // ฟังก์ชันเข้าสู่ระบบ (Login) const login = async (credentials: { email: string; password: string }) => { try { - // API returns { code: 200, message: "...", data: { token, user, ... } } + // API returns { code: 200, message: "...", data: { token, refreshToken } } const response = await $fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', body: credentials @@ -49,16 +49,35 @@ export const useAuth = () => { if (response && response.data) { const data = response.data - // Validation: Ensure user and role exist, then check for Role 'STUDENT' - if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') { - return { success: false, error: 'Email ไม่ถูกต้อง' } - } - + // บันทึก Token ก่อน เพื่อใช้เรียก /user/me token.value = data.token - refreshToken.value = data.refreshToken // บันทึก Refresh Token - - // API ส่งข้อมูล profile มาใน user object - user.value = data.user + refreshToken.value = data.refreshToken + + // ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง user กลับมาแล้ว) + try { + const userData = await $fetch(`${API_BASE_URL}/user/me`, { + headers: { + Authorization: `Bearer ${data.token}` + } + }) + + // Validation: ตรวจสอบ Role ต้องเป็น STUDENT เท่านั้น + if (!userData || !userData.role || userData.role.code !== 'STUDENT') { + // ถ้า Role ไม่ใช่ STUDENT ให้ล้าง Token ออก + token.value = null + refreshToken.value = null + return { success: false, error: 'Email ไม่ถูกต้อง' } + } + + // เก็บข้อมูล User ลง Cookie + user.value = userData + } catch (profileErr) { + // ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก + console.error('Failed to fetch user profile after login:', profileErr) + token.value = null + refreshToken.value = null + return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' } + } return { success: true } } diff --git a/Frontend-Learner/pages/browse/discovery.vue b/Frontend-Learner/pages/browse/discovery.vue index e8f15f77..e44bb5ae 100644 --- a/Frontend-Learner/pages/browse/discovery.vue +++ b/Frontend-Learner/pages/browse/discovery.vue @@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม'); const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด']; const categories = ref([]); -const courses = ref([]); +const allCourses = ref([]); // เก็บคอร์สทั้งหมดเพื่อกรอง client-side const selectedCourse = ref(null); const isLoading = ref(false); @@ -76,20 +76,17 @@ const loadCategories = async () => { if (res.success) categories.value = res.data || []; }; -const loadCourses = async (page = 1) => { +const loadCourses = async () => { isLoading.value = true; - const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number; + // โหลดคอร์สทั้งหมดครั้งเดียว (limit สูงๆ เพื่อ client-side filter) const res = await fetchCourses({ - category_id: categoryId, - search: searchQuery.value, - page: page, - limit: itemsPerPage, + limit: 500, forceRefresh: true, }); if (res.success) { - courses.value = (res.data || []).map(c => { + allCourses.value = (res.data || []).map(c => { const cat = categories.value.find(cat => cat.id === c.category_id); return { ...c, @@ -100,12 +97,33 @@ const loadCourses = async (page = 1) => { reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100 } }); - totalPages.value = res.totalPages || 1; - currentPage.value = res.page || 1; } isLoading.value = false; }; +// Computed: กรองคอร์สแบบ real-time ตาม searchQuery + activeCategory +const filteredCourses = computed(() => { + let result = allCourses.value; + + // กรองตามหมวดหมู่ + if (activeCategory.value !== 'all') { + result = result.filter(c => c.category_id === activeCategory.value); + } + + // กรองตามคำค้นหา (ค้นจากชื่อทั้ง th และ en) + if (searchQuery.value.trim()) { + const query = searchQuery.value.trim().toLowerCase(); + result = result.filter(c => { + const titleTh = (c.title?.th || '').toLowerCase(); + const titleEn = (c.title?.en || '').toLowerCase(); + const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase(); + return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query); + }); + } + + return result; +}); + const selectCourse = async (id: number) => { isLoadingDetail.value = true; selectedCourse.value = null; @@ -137,10 +155,10 @@ watch( activeCategory, () => { currentPage.value = 1; - loadCourses(1); } ); + onMounted(async () => { await loadCategories(); @@ -150,7 +168,7 @@ onMounted(async () => { activeCategory.value = Number(route.query.category_id); } - await loadCourses(1); + await loadCourses(); if (route.query.course_id) { selectCourse(Number(route.query.course_id)); @@ -169,8 +187,8 @@ onMounted(async () => {

คอร์สเรียนทั้งหมด

- - + +
@@ -208,10 +226,10 @@ onMounted(async () => {
-
+
-
+
@@ -241,7 +259,7 @@ onMounted(async () => {
-
+
diff --git a/Frontend-Learner/pages/browse/index.vue b/Frontend-Learner/pages/browse/index.vue index 3e82a01d..c0e3f3c4 100644 --- a/Frontend-Learner/pages/browse/index.vue +++ b/Frontend-Learner/pages/browse/index.vue @@ -54,7 +54,7 @@ await useAsyncData('categories-list', () => fetchCategories()) const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData( 'browse-courses-list', () => { - const params: any = {} + const params: any = { limit: 500 } if (selectedCategory.value !== 'all') { const category = categories.value.find(c => c.slug === selectedCategory.value) if (category) { diff --git a/Frontend-Learner/playwright-report/index.html b/Frontend-Learner/playwright-report/index.html index 15686deb..ba7aaae5 100644 --- a/Frontend-Learner/playwright-report/index.html +++ b/Frontend-Learner/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/Frontend-Learner/tests/e2e/auth.spec.ts b/Frontend-Learner/tests/e2e/auth.spec.ts index 5a87d40f..bf16f492 100644 --- a/Frontend-Learner/tests/e2e/auth.spec.ts +++ b/Frontend-Learner/tests/e2e/auth.spec.ts @@ -1,40 +1,13 @@ +/** + * @file auth.spec.ts + * @description ทดสอบระบบยืนยันตัวตน (Authentication) — Login, Register, Forgot Password + */ import { test, expect, type Page, type Locator } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - -async function waitAppSettled(page: Page) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(250); -} - -// --------------------------- -// Helpers: Login -// --------------------------- -const LOGIN_EMAIL = 'studentedtest@example.com'; -const LOGIN_PASSWORD = 'admin123'; - -function loginEmailLocator(page: Page): Locator { - return page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first(); -} -function loginPasswordLocator(page: Page): Locator { - return page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first(); -} -function loginButtonLocator(page: Page): Locator { - return page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first(); -} -async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - for (const loc of locators) { - try { - if (await loc.isVisible()) return; - } catch {} - } - await page.waitForTimeout(200); - } - throw new Error('None of the expected locators became visible.'); -} +import { + BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT, + waitAppSettled, expectAnyVisible, + emailLocator, passwordLocator, loginButtonLocator, +} from './helpers'; // --------------------------- // Helpers: Register @@ -53,6 +26,7 @@ function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เ function regErrorBox(page: Page) { return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', ')); } + async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') { const combo = regPrefix(page); await combo.selectOption({ label: value }).catch(async () => { @@ -60,6 +34,7 @@ async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นา await page.getByRole('option', { name: value }).click(); }); } + function uniqueUser() { const n = Date.now().toString().slice(-6); const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0'); @@ -90,10 +65,12 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - await loginEmailLocator(page).fill(LOGIN_EMAIL); - await loginPasswordLocator(page).fill(LOGIN_PASSWORD); + + await emailLocator(page).fill(TEST_EMAIL); + await passwordLocator(page).fill(TEST_PASSWORD); await loginButtonLocator(page).click(); - await page.waitForURL('**/dashboard', { timeout: 25_000 }); + + await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN }); await waitAppSettled(page); const dashboardEvidence = [ @@ -102,38 +79,45 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', page.locator('img[src*="avataaars"]').first(), page.locator('img[alt],[alt="User Avatar"]').first() ]; - await expectAnyVisible(page, dashboardEvidence, 20_000); + await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD); }); test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - await loginEmailLocator(page).fill('ทดสอบภาษาไทย'); - await loginPasswordLocator(page).fill(LOGIN_PASSWORD); - const errorHint = page.getByText('ห้ามใส่ภาษาไทย'); - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); + + await emailLocator(page).fill('ทดสอบภาษาไทย'); + await passwordLocator(page).fill(TEST_PASSWORD); + + await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - await loginEmailLocator(page).fill('test@domain'); - await loginPasswordLocator(page).fill(LOGIN_PASSWORD); + + await emailLocator(page).fill('test@domain'); + await passwordLocator(page).fill(TEST_PASSWORD); await loginButtonLocator(page).click(); await waitAppSettled(page); - const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)'); - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); + + await expect( + page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first() + ).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - await loginEmailLocator(page).fill(LOGIN_EMAIL); - await loginPasswordLocator(page).fill('wrong-password-123'); + + await emailLocator(page).fill(TEST_EMAIL); + await passwordLocator(page).fill('wrong-password-123'); await loginButtonLocator(page).click(); await waitAppSettled(page); - const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'); - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); + + await expect( + page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first() + ).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); }); @@ -142,21 +126,22 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - await expect(regHeading(page)).toBeVisible({ timeout: 15_000 }); - await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 }); + await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); }); test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => { await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); await regLoginLink(page).click(); - await page.waitForURL('**/auth/login', { timeout: 15_000 }); + await page.waitForURL('**/auth/login', { timeout: TIMEOUT.PAGE_LOAD }); }); test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => { const u = uniqueUser(); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); + await regUsername(page).fill(u.username); await regEmail(page).fill(u.email); await pickPrefix(page, 'นาย'); @@ -168,15 +153,18 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', await regSubmit(page).click(); await waitAppSettled(page); - const navToLogin = page.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null); - const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'success' as const).catch(() => null); - const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'error' as const).catch(() => null); - + // รอ 3 สัญญาณ: redirect ไป login / success toast / error + const navToLogin = page.waitForURL('**/auth/login', { timeout: TIMEOUT.LOGIN, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null); + const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'success' as const).catch(() => null); + const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'error' as const).catch(() => null); + const result = await Promise.race([navToLogin, successToast, anyError]); if (result === 'error') { - throw new Error('Register errors visible'); + const errs = await regErrorBox(page).allInnerTexts().catch(() => []); + throw new Error(`Register failed with errors: ${errs.join(' | ')}`); } + // ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง if (!page.url().includes('/auth/login')) { const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false); if (hasSuccess) { @@ -185,24 +173,28 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', } } - await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 }); + await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD }); + await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); }); test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => { await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); await regEmail(page).fill('ทดสอบภาษาไทย'); - await regUsername(page).click(); - const err = page.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i })); - await expect(err.first()).toBeVisible({ timeout: 12_000 }); + await regUsername(page).click(); // blur trigger + + const err = page + .getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }) + .or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i })); + await expect(err.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => { const u = uniqueUser(); await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); + await regUsername(page).fill(u.username); await regEmail(page).fill(u.email); await pickPrefix(page, 'นาย'); @@ -210,11 +202,14 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', await regLastName(page).fill(u.lastName); await regPhone(page).fill(u.phone); await regPassword(page).fill('Admin12345!'); - await regConfirmPassword(page).fill('Admin12345?'); + await regConfirmPassword(page).fill('Admin12345?'); // mismatch await regSubmit(page).click(); await waitAppSettled(page); - const mismatchErr = page.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i })); - await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 }); + + const mismatchErr = page + .getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }) + .or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i })); + await expect(mismatchErr.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); }); @@ -235,13 +230,12 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => { await forgotEmail(page).fill('ฟฟฟฟ'); await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click(); - const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first(); - await expect(err).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); }); test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => { await forgotBackLink(page).click(); - await page.waitForURL('**/auth/login', { timeout: 10_000 }); + await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT }); await expect(page).toHaveURL(/\/auth\/login/i); }); @@ -257,9 +251,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', } await route.continue(); }); + await forgotEmail(page).fill('test@gmail.com'); await forgotSubmit(page).click(); - await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: TIMEOUT.ELEMENT }); await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible(); }); }); diff --git a/Frontend-Learner/tests/e2e/classroom.spec.ts b/Frontend-Learner/tests/e2e/classroom.spec.ts index 136ad904..6391fca6 100644 --- a/Frontend-Learner/tests/e2e/classroom.spec.ts +++ b/Frontend-Learner/tests/e2e/classroom.spec.ts @@ -1,95 +1,175 @@ +/** + * @file classroom.spec.ts + * @description ทดสอบระบบห้องเรียนออนไลน์ และระบบแบบทดสอบ + * (Classroom, Learning & Quiz System) + * + * รวม 2 module: + * - Classroom & Learning (Layout, Access Control, Video/Quiz area) + * - Quiz System (Start Screen, Pagination, Submit & Navigation) + */ import { test, expect } from '@playwright/test'; +import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers'; -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; +// ========================================== +// Mock: ข้อมูล Quiz สำหรับ test +// ========================================== +async function mockQuizData(page: any) { + await page.route('**/lessons/*', async (route: any) => { + const mockQuestions = Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` }, + text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` }, + choices: [ + { id: i * 10 + 1, text: { th: 'ก', en: 'A' } }, + { id: i * 10 + 2, text: { th: 'ข', en: 'B' } }, + { id: i * 10 + 3, text: { th: 'ค', en: 'C' } } + ] + })); -async function waitAppSettled(page: any) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(200); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + id: 17, + type: 'QUIZ', + quiz: { + id: 99, + title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' }, + time_limit: 30, + questions: mockQuestions + } + }, + progress: {} + }) + }); + }); } -// ฟังก์ชันจำลองล็อกอิน -async function setupLogin(page: any) { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com'); - await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123'); - await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click(); - - await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {}); - await waitAppSettled(page); -} - -test.describe('ระบบห้องเรียนออนไลน์ (Classroom & Learning)', () => { +// ========================================== +// Tests +// ========================================== +test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => { test.beforeEach(async ({ page }) => { await setupLogin(page); }); - test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => { - // สมมติว่ามี Course ID: 1 ทดสอบแบบเปิดหน้าตรงๆ - await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); - - // 1. โครงร่างของหน้า (Top Bar) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง - const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first(); - await expect(backBtn).toBeVisible({ timeout: 15_000 }); + // -------------------------------------------------- + // Section 1: ห้องเรียน (Classroom & Learning) + // -------------------------------------------------- + test.describe('ห้องเรียน (Classroom Layout & Access)', () => { - const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first(); - await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 }); + test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => { + await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); - // 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM - const sidebar = page.locator('.q-drawer').first(); - if (!await sidebar.isVisible()) { - await menuCurriculumBtn.click(); - } - await expect(sidebar).toBeVisible(); - }); + // 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง + const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first(); + await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); - test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => { - // ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ - // ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter - - page.on('dialog', async dialog => { - // หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้ - expect(dialog.message()).toBeTruthy(); - await dialog.accept(); + const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first(); + await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // 2. Sidebar หลักสูตร + const sidebar = page.locator('.q-drawer').first(); + if (!await sidebar.isVisible()) { + await menuCurriculumBtn.click(); + } + await expect(sidebar).toBeVisible(); }); - await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`); - - // รอดู Loading หายไป - const loadingMask = page.locator('.animate-pulse, .q-spinner'); - await loadingMask.first().waitFor({ state: 'hidden', timeout: 20_000 }).catch(() => {}); + test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => { + page.on('dialog', async dialog => { + expect(dialog.message()).toBeTruthy(); + await dialog.accept(); + }); + + await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`); + + const loadingMask = page.locator('.animate-pulse, .q-spinner'); + await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {}); + }); + + test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => { + await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); + + const videoLocator = page.locator('video').first(); + const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first(); + const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first(); + + try { + await Promise.race([ + videoLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }), + quizLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }), + errorLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }) + ]); + + const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible()); + expect(isOkay).toBeTruthy(); + } catch { + await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true }); + } + }); }); - test('6.3 การแสดงผลช่องวิดีโอ (Video Player) หรือ พื้นที่ทำข้อสอบ (Quiz)', async ({ page }) => { - // เข้าหน้าห้องเรียน Course id: 1 - await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); - - // กรณีที่ 1: อาจแสดง Video ถ้าเป็นบทเรียนวิดีโอ - const videoLocator = page.locator('video').first(); - - // กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ - const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first(); - - // กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง - const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first(); + // -------------------------------------------------- + // Section 2: แบบทดสอบ (Quiz System) + // -------------------------------------------------- + test.describe('แบบทดสอบ (Quiz System)', () => { - try { - await Promise.race([ - videoLocator.waitFor({ state: 'visible', timeout: 20_000 }), - quizLocator.waitFor({ state: 'visible', timeout: 20_000 }), - errorLocator.waitFor({ state: 'visible', timeout: 20_000 }) - ]); - - const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible()); - expect(isOkay).toBeTruthy(); - } catch { - // ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า - // ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน - await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true }); - } + test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => { + await mockQuizData(page); + await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); + + const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); + await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // กดเริ่มทำ + await startBtn.click(); + + // เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา + const questionText = page.locator('h3').first(); + await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + }); + + test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => { + await mockQuizData(page); + await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); + + const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); + await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await startBtn.click(); + + // ลูกศรเลื่อนหน้าข้อสอบ + const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first(); + + if (await nextPaginationPageBtn.isVisible()) { + await expect(nextPaginationPageBtn).toBeEnabled(); + await nextPaginationPageBtn.click(); + + // ข้อที่ 11 ต้องแสดง + const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first(); + await expect(question11Btn).toBeVisible(); + } + }); + + test('7.3 การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => { + await mockQuizData(page); + await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); + + const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); + await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await startBtn.click(); + + // รอคำถามโหลดเสร็จ + await expect(page.locator('h3').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + + const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first(); + const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first(); + + // ข้อแรกต้องมีปุ่มถัดไปหรือปุ่มส่ง + await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + }); }); - }); diff --git a/Frontend-Learner/tests/e2e/discovery.spec.ts b/Frontend-Learner/tests/e2e/discovery.spec.ts index 2783d7da..52cf9ea3 100644 --- a/Frontend-Learner/tests/e2e/discovery.spec.ts +++ b/Frontend-Learner/tests/e2e/discovery.spec.ts @@ -1,91 +1,103 @@ +/** + * @file discovery.spec.ts + * @description ทดสอบหมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse) + */ import { test, expect } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; +import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers'; test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => { - + test.describe('ส่วนหน้าแรก (Home)', () => { test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => { await page.goto(BASE_URL); + await waitAppSettled(page); + const heroTitle = page.locator('h1, h2, .hero-title').first(); - await expect(heroTitle).toBeVisible({ timeout: 15_000 }); - + await expect(heroTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + const ctaButton = page.locator('a[href="/browse"]').first(); if (await ctaButton.isVisible()) { - await expect(ctaButton).toBeVisible(); + await expect(ctaButton).toBeVisible(); } const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first(); - await expect(courseSectionHeading).toBeVisible({ timeout: 10_000 }); + await expect(courseSectionHeading).toBeVisible({ timeout: TIMEOUT.ELEMENT }); const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first(); await expect(allCategoryBtn).toBeVisible(); const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }); - await expect(courseCards.first()).toBeVisible({ timeout: 15_000 }); + await expect(courseCards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); expect(await courseCards.count()).toBeGreaterThan(0); + + await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true }); }); }); test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => { test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => { await page.goto(`${BASE_URL}/browse`); - const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first(); - await searchInput.fill('การเขียนโปรแกรม'); - await searchInput.press('Enter'); + await waitAppSettled(page); - // ในหน้า browse จะใช้ ซึ่ง render เป็น tag + const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first(); + await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + await searchInput.fill('Python'); + await searchInput.press('Enter'); + await waitAppSettled(page); + + // ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first(); - await expect(searchResults).toBeVisible({ timeout: 15_000 }); + const emptyState = page.getByText(/ไม่พบ|ไม่เจอ|No result|not found/i).first() + .or(page.locator('i.q-icon').filter({ hasText: 'search_off' }).first()); + + await expect(searchResults.or(emptyState)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + await page.screenshot({ path: 'tests/e2e/screenshots/discovery-search.png', fullPage: true }); }); test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => { await page.goto(`${BASE_URL}/browse`); + await waitAppSettled(page); + const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first(); - + if (await categoryButton.isVisible()) { await categoryButton.click(); - // ในหน้า browse จะใช้ ซึ่ง render เป็น tag const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first(); - await expect(courseCard).toBeVisible({ timeout: 15_000 }); + await expect(courseCard).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); } + + await page.screenshot({ path: 'tests/e2e/screenshots/discovery-filter.png', fullPage: true }); }); }); test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => { test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => { - await page.goto(`${BASE_URL}`); - const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first(); - await expect(courseCard).toBeVisible({ timeout: 10_000 }); - // Get URL from navigating when clicking the div or finding another link. Since it's a div, we cannot easily get href. - // So let's click it or fallback to /course/1 - const targetUrl = '/course/1'; - - await page.goto(`${BASE_URL}${targetUrl}`); - + await page.goto(`${BASE_URL}/course/1`); + await waitAppSettled(page); + const courseTitle = page.locator('h1').first(); - await expect(courseTitle).toBeVisible({ timeout: 15_000 }); + await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first(); if (await curriculumTab.isVisible()) { - await curriculumTab.click(); + await curriculumTab.click(); } const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]'); await expect(lessonItems.first()).toBeVisible().catch(() => {}); + + await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true }); }); test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => { - await page.goto(`${BASE_URL}`); - const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first(); - await expect(courseCard).toBeVisible({ timeout: 10_000 }); - const targetUrl = '/course/1'; - - await page.goto(`${BASE_URL}${targetUrl}`); - await page.waitForLoadState('networkidle').catch(() => {}); + await page.goto(`${BASE_URL}/course/1`); + await waitAppSettled(page); const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first(); - await expect(enrollStartBtn).toBeVisible({ timeout: 10_000 }); + await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + + await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true }); }); }); }); diff --git a/Frontend-Learner/tests/e2e/forgot-password.spec.ts b/Frontend-Learner/tests/e2e/forgot-password.spec.ts deleted file mode 100644 index 24260d3e..00000000 --- a/Frontend-Learner/tests/e2e/forgot-password.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - -// ✅ หน้าจริงคือ /auth/forgot-password (อ้างอิงจากรูป) -const FORGOT_URL = `${BASE_URL}/auth/forgot-password`; - -async function waitAppSettled(page: Page) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(200); -} - -function emailInput(page: Page) { - // เผื่อบางที input ไม่ได้ type=email แต่เป็น textbox ธรรมดา - return page.locator('input[type="email"]').or(page.getByRole('textbox')).first(); -} - -function submitBtn(page: Page) { - // ปุ่มในรูปเป็น “ส่งลิงก์รีเซ็ต” - return page.getByRole('button', { name: /ส่งลิงก์รีเซ็ต/i }).first(); -} - -function backToLoginLink(page: Page) { - // ในรูปเป็นลิงก์ “กลับไปหน้าเข้าสู่ระบบ” - return page.getByRole('link', { name: /กลับไปหน้าเข้าสู่ระบบ/i }).first(); -} - -test.describe('หน้าลืมรหัสผ่าน (Forgot Password)', () => { - test.beforeEach(async ({ page }) => { - await page.goto(FORGOT_URL, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - }); - - test('3.1 โหลดหน้าลืมรหัสผ่านได้ครบถ้วน (Smoke Test)', async ({ page }) => { - await expect(page.getByRole('heading', { name: /ลืมรหัสผ่าน/i })).toBeVisible(); - await expect(emailInput(page)).toBeVisible(); - await expect(submitBtn(page)).toBeVisible(); - await expect(backToLoginLink(page)).toBeVisible(); - - await page.screenshot({ path: 'tests/e2e/screenshots/forgot-01-smoke.png', fullPage: true }); - }); - - test('3.2 Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => { - await emailInput(page).fill('ฟฟฟฟ'); - - // trigger blur - await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click(); - - // ข้อความจริงในระบบ “ห้ามใส่ภาษาไทย” - const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first(); - await expect(err).toBeVisible({ timeout: 10_000 }); - - await page.screenshot({ path: 'tests/e2e/screenshots/forgot-02-thai-email.png', fullPage: true }); - }); - - test('3.3 กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => { - await backToLoginLink(page).click(); - await page.waitForURL('**/auth/login', { timeout: 10_000 }); - await expect(page).toHaveURL(/\/auth\/login/i); - - await page.screenshot({ path: 'tests/e2e/screenshots/forgot-03-back-login.png', fullPage: true }); - }); - - test('3.4 ทดลองส่งลิงก์รีเซ็ตรหัสผ่าน (API Mock)', async ({ page }) => { - // ✅ ดัก request แบบกว้างขึ้น: POST ที่ URL มี forgot/reset - await page.route('**/*', async (route) => { - const req = route.request(); - const url = req.url(); - const method = req.method(); - - const looksLikeForgotApi = - method === 'POST' && - /forgot|reset/i.test(url) && - // กันไม่ให้ไป intercept asset - !/\.(png|jpg|jpeg|webp|svg|css|js|map)$/i.test(url); - - if (looksLikeForgotApi) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: { message: 'Reset link sent' } }), - }); - return; - } - - await route.continue(); - }); - - await emailInput(page).fill('test@gmail.com'); - await submitBtn(page).click(); - - // ✅ ตรวจหน้าสำเร็จตามที่คุณคาดหวัง - await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible(); - - // ปุ่ม “ส่งอีกครั้ง” (ถ้ามี) - await expect(page.getByRole('button', { name: /ส่งอีกครั้ง/i })).toBeVisible({ timeout: 10_000 }).catch(() => {}); - - await page.screenshot({ path: 'tests/e2e/screenshots/forgot-04-mock-success.png', fullPage: true }); - }); -}); diff --git a/Frontend-Learner/tests/e2e/helpers.ts b/Frontend-Learner/tests/e2e/helpers.ts new file mode 100644 index 00000000..6af1b81b --- /dev/null +++ b/Frontend-Learner/tests/e2e/helpers.ts @@ -0,0 +1,129 @@ +/** + * @file helpers.ts + * @description Shared E2E test helpers — ฟังก์ชันที่ใช้ร่วมกันในทุกไฟล์ test + * รวม: waitAppSettled, login helpers, common locators, constants + */ +import { type Page, type Locator, expect } from '@playwright/test'; + +// ========================================== +// Constants +// ========================================== +export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; +export const TEST_EMAIL = 'studentedtest@example.com'; +export const TEST_PASSWORD = 'admin123'; + +/** Timeout configs — ปรับค่าได้ที่เดียว */ +export const TIMEOUT: Record = { + /** รอหน้าโหลด */ + PAGE_LOAD: 15_000, + /** รอ login + redirect */ + LOGIN: 25_000, + /** รอ element แสดงผล */ + ELEMENT: 12_000, + /** รอ network settle */ + SETTLE: 300, +}; + +// ========================================== +// Wait Helpers +// ========================================== + +/** + * รอให้แอปโหลดเสร็จสมบูรณ์ (DOM + Network + hydration) + */ +export async function waitAppSettled(page: Page, ms = TIMEOUT.SETTLE) { + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(ms); +} + +/** + * รอจนกว่า locator ใดก็ได้ใน array จะ visible + * @throws เมื่อไม่มี locator ไหน visible ภายใน timeout + */ +export async function expectAnyVisible( + page: Page, + locators: Locator[], + timeout = TIMEOUT.PAGE_LOAD +) { + const start = Date.now(); + while (Date.now() - start < timeout) { + for (const loc of locators) { + try { + if (await loc.isVisible()) return; + } catch { /* locator detached / stale — ลองใหม่ */ } + } + await page.waitForTimeout(200); + } + throw new Error( + `None of the expected locators became visible within ${timeout}ms` + ); +} + +// ========================================== +// Login Locators +// ========================================== + +export function emailLocator(page: Page): Locator { + return page + .locator('input[type="email"]') + .or(page.getByRole('textbox', { name: /อีเมล|email/i })) + .first(); +} + +export function passwordLocator(page: Page): Locator { + return page + .locator('input[type="password"]') + .or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })) + .first(); +} + +export function loginButtonLocator(page: Page): Locator { + return page + .getByRole('button', { name: /เข้าสู่ระบบ|login/i }) + .or(page.locator('button[type="submit"]')) + .first(); +} + +// ========================================== +// Login Flow +// ========================================== + +/** + * ล็อกอินด้วย test account — ใช้ใน beforeEach ของ tests ที่ต้อง authenticate + * + * @param page — Playwright Page + * @param opts — ตัวเลือกเสริม + * @param opts.assertDashboard — (default: true) ถ้า true จะ assert ว่าเข้า dashboard สำเร็จ + * + * @throws หาก login ล้มเหลวหรือไม่ถึง dashboard + */ +export async function setupLogin( + page: Page, + opts: { assertDashboard?: boolean } = {} +) { + const { assertDashboard = true } = opts; + + await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); + await waitAppSettled(page); + + // กรอกข้อมูล + await emailLocator(page).fill(TEST_EMAIL); + await passwordLocator(page).fill(TEST_PASSWORD); + await loginButtonLocator(page).click(); + + // รอ redirect ไป dashboard + await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN }); + await waitAppSettled(page); + + if (assertDashboard) { + // ยืนยันว่าเข้า dashboard ได้จริง + const evidence = [ + page.locator('.q-page-container').first(), + page.locator('.q-drawer').first(), + page.locator('img[src*="avataaars"]').first(), + page.locator('img[alt],[alt="User Avatar"]').first(), + ]; + await expectAnyVisible(page, evidence, TIMEOUT.PAGE_LOAD); + } +} diff --git a/Frontend-Learner/tests/e2e/login.spec.ts b/Frontend-Learner/tests/e2e/login.spec.ts deleted file mode 100644 index 7a033655..00000000 --- a/Frontend-Learner/tests/e2e/login.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { test, expect, type Page, type Locator } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - -// ใช้ account ตามที่คุณให้มา -const EMAIL = 'studentedtest@example.com'; -const PASSWORD = 'admin123'; - -async function waitAppSettled(page: Page) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(200); -} - -function emailLocator(page: Page): Locator { - return page - .locator('input[type="email"]') - .or(page.getByRole('textbox', { name: /อีเมล|email/i })) - .first(); -} - -function passwordLocator(page: Page): Locator { - return page - .locator('input[type="password"]') - .or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })) - .first(); -} - -function loginButtonLocator(page: Page): Locator { - return page - .getByRole('button', { name: /เข้าสู่ระบบ|login/i }) - .or(page.locator('button[type="submit"]')) - .first(); -} - -async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - for (const loc of locators) { - try { - if (await loc.isVisible()) return; - } catch {} - } - await page.waitForTimeout(200); - } - throw new Error('None of the expected dashboard locators became visible.'); -} - -test.describe('Login -> Dashboard', () => { - test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - await emailLocator(page).fill(EMAIL); - await passwordLocator(page).fill(PASSWORD); - await loginButtonLocator(page).click(); - - await page.waitForURL('**/dashboard', { timeout: 25_000 }); - await waitAppSettled(page); - - // ✅ ใช้ Locator ที่พบเจอแน่นอนใน Layout/Page โดยไม่ยึดติดกับภาษาปัจจุบัน (I18n) - const dashboardEvidence = [ - // มองหา Layout container ฝั่ง Dashboard - page.locator('.q-page-container').first(), - page.locator('.q-drawer').first(), - // มองหารูปโปรไฟล์ (UserAvatar) - page.locator('img[src*="avataaars"]').first(), - page.locator('img[alt],[alt="User Avatar"]').first() - ]; - - await expectAnyVisible(page, dashboardEvidence, 20_000); - - await page.screenshot({ path: 'tests/e2e/screenshots/login-to-dashboard.png', fullPage: true }); - }); - - test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - await emailLocator(page).fill('ทดสอบภาษาไทย'); - await passwordLocator(page).fill(PASSWORD); - - const errorHint = page.getByText('ห้ามใส่ภาษาไทย'); - - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); - await page.screenshot({ path: 'tests/e2e/screenshots/login-thai-email.png', fullPage: true }); - }); - - test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - // *สำคัญ*: HTML5 จะดักจับ invalid-email-format ตั้งแต่กด Submit (native validation) - // ทำให้ Vue Form ไม่เริ่มทำงาน - // ดังนั้นเพื่อให้ทดสอบเจอ Error จาก useFormValidation จริงๆ เราใช้ 'test@domain' - // ซึ่ง HTML5 ปล่อยผ่าน แต่ /regex/ ของระบบตรวจเจอว่าไม่มี .com - await emailLocator(page).fill('test@domain'); - await passwordLocator(page).fill(PASSWORD); - await loginButtonLocator(page).click(); - await waitAppSettled(page); - - const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)'); - - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); - await page.screenshot({ path: 'tests/e2e/screenshots/login-invalid-email.png', fullPage: true }); - }); - - test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - await emailLocator(page).fill(EMAIL); - await passwordLocator(page).fill('wrong-password-123'); - await loginButtonLocator(page).click(); - await waitAppSettled(page); - - const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'); - - await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); - await page.screenshot({ path: 'tests/e2e/screenshots/login-wrong-password.png', fullPage: true }); - }); -}); diff --git a/Frontend-Learner/tests/e2e/quiz.spec.ts b/Frontend-Learner/tests/e2e/quiz.spec.ts deleted file mode 100644 index 178d2abc..00000000 --- a/Frontend-Learner/tests/e2e/quiz.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - -async function waitAppSettled(page: any) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(200); -} - -// ฟังก์ชันจำลองล็อกอิน (เพราะทำข้อสอบต้องล็อกอินเสมอ) -async function setupLogin(page: any) { - await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); - await waitAppSettled(page); - - await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com'); - await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123'); - await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click(); - - await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {}); - await waitAppSettled(page); -} - -// ฟังก์ชัน Mock ข้อมูลข้อสอบให้ Playwright ไม่ต้องไปดึงจากฐานข้อมูลจริงๆ (เพื่อป้องกันปัญหาคอร์ส/บทเรียนไม่มีอยู่จริง) -async function mockQuizData(page: any) { - await page.route('**/lessons/*', async (route: any) => { - // สมมติข้อมูลข้อสอบจำลองให้มี 15 ข้อเพื่อเทส Pagination ได้ - const mockQuestions = Array.from({ length: 15 }, (_, i) => ({ - id: i + 1, - question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` }, - text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` }, - choices: [ - { id: i * 10 + 1, text: { th: 'ก', en: 'A' } }, - { id: i * 10 + 2, text: { th: 'ข', en: 'B' } }, - { id: i * 10 + 3, text: { th: 'ค', en: 'C' } } - ] - })); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: { - id: 17, - type: 'QUIZ', - quiz: { - id: 99, - title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' }, - time_limit: 30, - questions: mockQuestions - } - }, - progress: {} - }) - }); - }); -} - -test.describe('ระบบทำแบบทดสอบ (Quiz System)', () => { - - test.beforeEach(async ({ page }) => { - // ต้อง Login ก่อนเรียน! - await setupLogin(page); - }); - - test('โหลดหน้า Quiz และคลิกระบบเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => { - await mockQuizData(page); - - // สมมติเอาที่ quiz ใน course 2 lesson 17 (ซึ่ง API เสาะหาจะถูกดักจับและ Mock ไว้ด้านบนแล้ว) - await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); - - // หน้าจอ Start Screen ต้องขึ้นมา - const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); - await expect(startBtn).toBeVisible({ timeout: 15_000 }); - - // ลองกดเริ่มทำ - await startBtn.click(); - - // เช็คว่าหน้า Taking (พื้นที่ทำข้อสอบข้อที่ 1) โผล่มา - const questionText = page.locator('h3').first(); // ชื่อคำถาม - await expect(questionText).toBeVisible({ timeout: 10_000 }); - }); - - test('ทดสอบระบบแถบข้อสอบ แบ่งหน้า (Pagination - เลื่อนซ้าย/ขวา)', async ({ page }) => { - await mockQuizData(page); - await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); - - // เข้าเมนูแบบทดสอบ - const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); - await expect(startBtn).toBeVisible({ timeout: 15_000 }); - await startBtn.click(); - - // เช็คว่ามีลูกศรเลื่อนหน้าข้อสอบ (Paginations) สำหรับแบบทดสอบเกิน 10 ข้อที่สร้างมาใหม่ - const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first(); - - if (await nextPaginationPageBtn.isVisible()) { - // หากปุ่มแสดง (บอกว่ามีข้อสอบหลายหน้า) ลองกดข้าม - await expect(nextPaginationPageBtn).toBeEnabled(); - await nextPaginationPageBtn.click(); - - // เช็คว่ากดแล้ว ข้อที่ 11 โผล่มา - const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first(); - await expect(question11Btn).toBeVisible(); - } - }); - - test('การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => { - await mockQuizData(page); - await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`); - const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first(); - await expect(startBtn).toBeVisible({ timeout: 15_000 }); - await startBtn.click(); - - // รอให้หน้าโหลดคำถามเสร็จก่อน ค่อยหาปุ่ม - await expect(page.locator('h3').first()).toBeVisible({ timeout: 10_000 }); - - const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first(); - const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first(); - - // แบบทดสอบข้อแรก ต้องมีปุ่ม ถัดไป(Next) หรือปุ่มส่ง ถ้ามีแค่ 1 ข้อ - await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: 10_000 }); - }); - -}); diff --git a/Frontend-Learner/tests/e2e/register.spec.ts b/Frontend-Learner/tests/e2e/register.spec.ts deleted file mode 100644 index 5d3c6903..00000000 --- a/Frontend-Learner/tests/e2e/register.spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { test, expect, type Page, type Locator } from '@playwright/test'; - -const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - -async function waitAppSettled(page: Page) { - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle').catch(() => {}); - await page.waitForTimeout(250); -} - -// ===== Anchors / Scope ===== -function headingRegister(page: Page) { - return page.getByRole('heading', { name: 'สร้างบัญชีผู้ใช้งาน' }); -} - -// ===== Inputs (ตาม snapshot ที่คุณส่งมา) ===== -function usernameInput(page: Page): Locator { - // snapshot: textbox "username" - return page.getByRole('textbox', { name: 'username' }).first(); -} - -function emailInput(page: Page): Locator { - // snapshot: textbox "student@example.com" - return page.getByRole('textbox', { name: 'student@example.com' }).first(); -} - -function prefixCombobox(page: Page): Locator { - // snapshot: combobox มี option นาย/นาง/นางสาว - return page.getByRole('combobox').first(); -} - -function firstNameInput(page: Page): Locator { - // snapshot: label "ชื่อ *" + textbox - return page.getByText(/^ชื่อ\s*\*$/).locator('..').getByRole('textbox').first(); -} - -function lastNameInput(page: Page): Locator { - return page.getByText(/^นามสกุล\s*\*$/).locator('..').getByRole('textbox').first(); -} - -function phoneInput(page: Page): Locator { - return page.getByText(/^เบอร์โทรศัพท์\s*\*$/).locator('..').getByRole('textbox').first(); -} - -function passwordInput(page: Page): Locator { - // snapshot: label "รหัสผ่าน *" + textbox (มีปุ่ม visibility อยู่ข้างๆ) - return page.getByText(/^รหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first(); -} - -function confirmPasswordInput(page: Page): Locator { - return page.getByText(/^ยืนยันรหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first(); -} - -function submitButton(page: Page): Locator { - return page.getByRole('button', { name: 'สร้างบัญชี' }); -} - -function loginLink(page: Page): Locator { - return page.getByRole('link', { name: 'เข้าสู่ระบบ' }); -} - -function errorBox(page: Page): Locator { - // ทั้ง field message และ notification/toast/alert - return page.locator( - ['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join( - ', ' - ) - ); -} - -async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') { - const combo = prefixCombobox(page); - - // ถ้าเป็น +
@@ -151,7 +152,7 @@ const viewMode = ref<'grid' | 'list'>('grid') @click="selectCategory('all')" :class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> - ทั้งหมด + {{ $t('discovery.allCategory') }}
@@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
-

{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}

-

ลองใช้คำค้นหาอื่น หรือเลือกหมวดหมู่อื่นเพื่อดูคอร์สที่เรามีให้บริการ

+

{{ $t('discovery.emptyTitle') }}

+

{{ $t('discovery.emptyDesc') }}

From 9e4fcbf04e3ae34c5f106be48afbc8f45c2a6f62 Mon Sep 17 00:00:00 2001 From: Missez Date: Fri, 6 Mar 2026 15:47:37 +0700 Subject: [PATCH 4/8] Add tests result --- .../components/course/AnnouncementsTab.vue | 4 +- frontend_management/playwright.config.ts | 8 +- .../services/instructor.service.ts | 13 +++ frontend_management/stores/instructor.ts | 67 ++++---------- ...10a78b255dbe58da69e9b357af0e9f6017eebf.png | Bin 0 -> 79653 bytes ...6ca155ecb01b787bccd5b36e618f7bfffc2939.png | Bin 0 -> 92869 bytes ...9f8cf2c05bb4db31fe97fd503c31a69097fff5.png | Bin 0 -> 82187 bytes ...47243664146d9bb3273ddf0e15897ad4eba14b.png | Bin 0 -> 79674 bytes ...5217d322c9b956773badf48ef8f6d969ba6757.png | Bin 0 -> 85207 bytes ...bedd3e36d7e6a463160b2e39c6aedfad0c1ec7.png | Bin 0 -> 80342 bytes ...de8f5e141991d0f12359fdd5ac940e2c798b75.png | Bin 0 -> 81564 bytes .../Admin/Audit_Log_Page_Tests/index.html | 85 ++++++++++++++++++ ...e1d6d7d57c179e96128e40db55227dd86a7817.png | Bin 0 -> 66858 bytes ...df25d6cdde602ee836a293f5167004c1a5fa77.png | Bin 0 -> 43023 bytes ...90ba8133de6c748a59d2da9b782a04fc4c6db0.png | Bin 0 -> 95147 bytes ...e85c99c7d03cc714e887a691300a72ec49360c.png | Bin 0 -> 69730 bytes ...fdbc416f0e24dec95ce26a8d821ef9cb3765bf.png | Bin 0 -> 73757 bytes ...7f0948bcc7200f737fc0a088783bbea297f50f.png | Bin 0 -> 65790 bytes ...902bf323a4786f68fa00ea84781ba7957d7d7a.png | Bin 0 -> 65643 bytes ...f7ebb27025218191824d8efc9ca47e1c964b1e.png | Bin 0 -> 49303 bytes ...5e29ca1b01c21b07dca63fe776c2605caf1ccd.png | Bin 0 -> 47205 bytes .../Admin/Categories_Page_Tests/index.html | 85 ++++++++++++++++++ ...b70a62c5681aa2ef14d34994eccd60520d3e47.png | Bin 0 -> 79573 bytes ...5d3eb5a2e8a33833fc35360a3b8a045628cb2d.png | Bin 0 -> 94523 bytes ...6d6a06b33921ae112d5bfea6d92b229038f4bd.png | Bin 0 -> 91639 bytes ...d4e4da3ea2fb373b41f4adbb13d2b24b8ccfe1.png | Bin 0 -> 98014 bytes .../Admin/Dashboard_Tests/index.html | 85 ++++++++++++++++++ ...53f4201cd452cfa653e6439a05e46caba79eb8.png | Bin 0 -> 96549 bytes ...fbe1198619aae53dd8768e53e81002d0b357a5.png | Bin 0 -> 69342 bytes ...8b1769e5aa0373f08be5b1e93ca6bd4de71183.png | Bin 0 -> 57419 bytes ...599641f8347eea9faef8a04424075e5fc3e8dc.png | Bin 0 -> 176839 bytes ...0f425a2702d00bbdd804a1d4ca9fadf55a6562.png | Bin 0 -> 70364 bytes ...51465650cc45a9db3430a37a44a4cd60e679dd.png | Bin 0 -> 45045 bytes ...561250ddaf7ee9f68edf5ab096f616efd38664.png | Bin 0 -> 69407 bytes .../Pending_Courses_Page_Tests/index.html | 85 ++++++++++++++++++ ...9d36dffaf29e9ba9d4e6d0e2d50d69247e576d.png | Bin 0 -> 148128 bytes ...201a029c254b88c8fcee02d0222ed560bca040.png | Bin 0 -> 154851 bytes ...b564f25d91be7a7a19adc86618e4c587c22852.png | Bin 0 -> 98999 bytes ...9d78c1d16c81f14166fcc263feb68daff0b337.png | Bin 0 -> 95342 bytes .../Recommended_Courses_Page_Tests/index.html | 85 ++++++++++++++++++ ...131b544f7ef5ab6ae9159c7b3aeb54a6a35ace.png | Bin 0 -> 94733 bytes ...505851a4cfcd340a2c15e4eede8ba1b00a45f3.png | Bin 0 -> 89930 bytes ...c9b0535c23ea7767a4692c7b1cd5b7efa77198.png | Bin 0 -> 91511 bytes ...d29873e393b6d0d06b99325f7d44448dba4e7d.png | Bin 0 -> 93765 bytes ...36118b9393d5080e9936f45e76adb40b146c51.png | Bin 0 -> 51337 bytes ...4e6793c83ac936ea2d687bc0846ceb6356f757.png | Bin 0 -> 91562 bytes ...7dab7bdd78b963cffec28b4fc8d505bc610443.png | Bin 0 -> 52637 bytes ...0e2afd169f0d440483247810ca3f3aa12fcb8e.png | Bin 0 -> 91345 bytes .../Admin/Users_Page_Tests/index.html | 85 ++++++++++++++++++ ...aa583ffad06b8cb14b557facad1bd9f955f649.png | Bin 0 -> 53407 bytes ...663960da5071a13ba4d0efe05b15795a404dd5.png | Bin 0 -> 132272 bytes ...367ab188ae80996e584f9b58e0a689e5b7736c.png | Bin 0 -> 133279 bytes ...7022d29cc41e4c91622d52c4d4bec33e5ea27b.png | Bin 0 -> 118170 bytes ...4d5765838ec53396637363d765fe9999a23cef.png | Bin 0 -> 132455 bytes ...a76b0d67d10db9794f5eaaa0ad93900dc66ef9.png | Bin 0 -> 126187 bytes ...db8fa9d3f7eebe20c935bc28970c686729b80e.png | Bin 0 -> 138073 bytes ...269d490caef20a582ba9d9105b7d11a23a763a.png | Bin 0 -> 124330 bytes ...5921cba1eca4f1439b107ba699e33ec81efb98.png | Bin 0 -> 83044 bytes .../Course_Detail_Tabs_Tests/index.html | 85 ++++++++++++++++++ ...c8d6b6bda96733768c9040919a1f2a5e01baff.png | Bin 0 -> 144281 bytes ...4aa5c1e2968710d7151e06d900bb12f41eb2f3.png | Bin 0 -> 217693 bytes ...172ca5f26f33c946c982ccccb69b895adabc25.png | Bin 0 -> 49952 bytes ...76fc70092c9da0bdce1f5f6416f09fa381c305.png | Bin 0 -> 140461 bytes ...409841bc0a58fee9b289ae43471f82aeadced6.png | Bin 0 -> 218526 bytes ...b116131594ffd0289eefd700d6491505e1c71a.png | Bin 0 -> 79784 bytes ...b066997ecc48380fe55519c2c5201661b92d65.png | Bin 0 -> 55727 bytes ...fd2a77f7879d1bda44afce77a4e1b085add249.png | Bin 0 -> 217697 bytes ...b6e30487eeb33a334792f175969cd79b956af6.png | Bin 0 -> 216942 bytes .../Courses_List_Page_Tests/index.html | 85 ++++++++++++++++++ ...f0f34d39ba3afdcddd6292f21b1fb2e1f91347.png | Bin 0 -> 67434 bytes ...52458e5d775d7f6a5470a4ebe6dfce88202762.png | Bin 0 -> 52910 bytes ...57c7870ad1af4b313598a422bf95f9975bd4b3.png | Bin 0 -> 32556 bytes ...aead5182882b1c055b71b4263a086d49ae21f9.png | Bin 0 -> 42496 bytes ...f166853a6c394a6ca293b2395842ca32fa59fb.png | Bin 0 -> 55755 bytes ...18c639a925799e58f2c6cd7a6285d1bf3a63da.png | Bin 0 -> 66410 bytes ...3d46f246f6417be4064747a2cadbfce753066e.png | Bin 0 -> 51176 bytes ...e5afbe64b19d3bca6a5563b2b5fac4f58742ca.png | Bin 0 -> 80638 bytes ...a098f9935cd85e715e1c92119eb18b3cc23078.png | Bin 0 -> 54671 bytes ...57c0d509931d0977a2cc80bc5da68b6e474c23.png | Bin 0 -> 38540 bytes .../index.html | 85 ++++++++++++++++++ ...3d6902c0ca4fef1aa2fc65120adc1d08048286.png | Bin 0 -> 85210 bytes ...2ffe0882b032c2d3bab8fe7fe4b31907aa7ec5.png | Bin 0 -> 217748 bytes ...2f57b76392ddca8cf0d10da5302f65a7045e4f.png | Bin 0 -> 87369 bytes ...b3be3b178a6a2e49e992a3f9d0c366b9474fbc.png | Bin 0 -> 93858 bytes ...74e2d38891f831ce99cc3f497ad1b1090077de.png | Bin 0 -> 76654 bytes ...b7f4bf19a7a5908adc27d250a40618847c1cf2.png | Bin 0 -> 53522 bytes .../Instructor/Dashboard_Tests/index.html | 85 ++++++++++++++++++ ...708d259129df93cdd9a5cd6260b44467f430e0.png | Bin 0 -> 206942 bytes ...1c2ea22b6adeaed9641041947a76d74edc18b8.png | Bin 0 -> 204659 bytes ...f8acf6a09faed4c14ed6a633732862db853d36.png | Bin 0 -> 206042 bytes ...79c41c8858b523f5f00ca0d96ee66e8a87263c.png | Bin 0 -> 213305 bytes ...ee2be597a0472f1f34fba60dabc39a3397acc0.png | Bin 0 -> 203259 bytes ...f2781e64d249e5dcb8ae23681d82b7e0306f11.png | Bin 0 -> 208530 bytes ...9a2a90835ca972045b4e2fd9eedab17f6e1cab.png | Bin 0 -> 92020 bytes ...a2a979a4f710cebde1893e6fa7a420bbce07ca.png | Bin 0 -> 93746 bytes ...91cbda97e779364b7c6ab4a9c78a999b4aeef1.png | Bin 0 -> 177782 bytes .../auth/Login_Page_Tests/index.html | 85 ++++++++++++++++++ ...09b14cfe86a6650b5c9ccaea6ab6396a7c6882.png | Bin 0 -> 149491 bytes ...608ae454cb4fdde9edcfee7bc324ff6e9ac00a.png | Bin 0 -> 154481 bytes ...a7199dc793b5cc6db9a6e2ab253bbb974a12cd.png | Bin 0 -> 154032 bytes ...0da5fa1e26962ec6c30ce5825cb0d1385cf2cd.png | Bin 0 -> 214173 bytes ...fa2fbb70373a1485233cf195e488e69133bac6.png | Bin 0 -> 154403 bytes ...755655c173a8648e7d6d3c5b2f02a0946947fb.png | Bin 0 -> 151925 bytes ...5315ba929130c12a0cbd5bf9cb5d7f1cb08f5e.png | Bin 0 -> 153925 bytes ...bfcc1bab4f46872473695bb589c80be175f811.png | Bin 0 -> 202911 bytes .../auth/Register_Page_Tests/index.html | 85 ++++++++++++++++++ .../tests/admin/categories.spec.ts | 2 +- .../tests/admin/recommended-courses.spec.ts | 6 +- frontend_management/tests/auth/login.spec.ts | 8 ++ .../instructor/course-detail-tabs.spec.ts | 10 +-- .../tests/instructor/courses-list.spec.ts | 17 ++-- 111 files changed, 1078 insertions(+), 77 deletions(-) create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/0410a78b255dbe58da69e9b357af0e9f6017eebf.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/1d6ca155ecb01b787bccd5b36e618f7bfffc2939.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/209f8cf2c05bb4db31fe97fd503c31a69097fff5.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/4047243664146d9bb3273ddf0e15897ad4eba14b.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/405217d322c9b956773badf48ef8f6d969ba6757.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/e8bedd3e36d7e6a463160b2e39c6aedfad0c1ec7.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/edde8f5e141991d0f12359fdd5ac940e2c798b75.png create mode 100644 frontend_management/test_result/Admin/Audit_Log_Page_Tests/index.html create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/1ee1d6d7d57c179e96128e40db55227dd86a7817.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/35df25d6cdde602ee836a293f5167004c1a5fa77.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/4090ba8133de6c748a59d2da9b782a04fc4c6db0.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/41e85c99c7d03cc714e887a691300a72ec49360c.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/65fdbc416f0e24dec95ce26a8d821ef9cb3765bf.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/827f0948bcc7200f737fc0a088783bbea297f50f.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/c8902bf323a4786f68fa00ea84781ba7957d7d7a.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/ccf7ebb27025218191824d8efc9ca47e1c964b1e.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/data/f25e29ca1b01c21b07dca63fe776c2605caf1ccd.png create mode 100644 frontend_management/test_result/Admin/Categories_Page_Tests/index.html create mode 100644 frontend_management/test_result/Admin/Dashboard_Tests/data/0db70a62c5681aa2ef14d34994eccd60520d3e47.png create mode 100644 frontend_management/test_result/Admin/Dashboard_Tests/data/4c5d3eb5a2e8a33833fc35360a3b8a045628cb2d.png create mode 100644 frontend_management/test_result/Admin/Dashboard_Tests/data/866d6a06b33921ae112d5bfea6d92b229038f4bd.png create mode 100644 frontend_management/test_result/Admin/Dashboard_Tests/data/a5d4e4da3ea2fb373b41f4adbb13d2b24b8ccfe1.png create mode 100644 frontend_management/test_result/Admin/Dashboard_Tests/index.html create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/0453f4201cd452cfa653e6439a05e46caba79eb8.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/2efbe1198619aae53dd8768e53e81002d0b357a5.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/528b1769e5aa0373f08be5b1e93ca6bd4de71183.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/6c599641f8347eea9faef8a04424075e5fc3e8dc.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/aa0f425a2702d00bbdd804a1d4ca9fadf55a6562.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/b051465650cc45a9db3430a37a44a4cd60e679dd.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/c5561250ddaf7ee9f68edf5ab096f616efd38664.png create mode 100644 frontend_management/test_result/Admin/Pending_Courses_Page_Tests/index.html create mode 100644 frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/069d36dffaf29e9ba9d4e6d0e2d50d69247e576d.png create mode 100644 frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/20201a029c254b88c8fcee02d0222ed560bca040.png create mode 100644 frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/adb564f25d91be7a7a19adc86618e4c587c22852.png create mode 100644 frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/ae9d78c1d16c81f14166fcc263feb68daff0b337.png create mode 100644 frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/index.html create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/01131b544f7ef5ab6ae9159c7b3aeb54a6a35ace.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/14505851a4cfcd340a2c15e4eede8ba1b00a45f3.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/61c9b0535c23ea7767a4692c7b1cd5b7efa77198.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/8cd29873e393b6d0d06b99325f7d44448dba4e7d.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/9136118b9393d5080e9936f45e76adb40b146c51.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/a54e6793c83ac936ea2d687bc0846ceb6356f757.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/c57dab7bdd78b963cffec28b4fc8d505bc610443.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/data/f00e2afd169f0d440483247810ca3f3aa12fcb8e.png create mode 100644 frontend_management/test_result/Admin/Users_Page_Tests/index.html create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/3daa583ffad06b8cb14b557facad1bd9f955f649.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6a663960da5071a13ba4d0efe05b15795a404dd5.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6f367ab188ae80996e584f9b58e0a689e5b7736c.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/7a7022d29cc41e4c91622d52c4d4bec33e5ea27b.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/bb4d5765838ec53396637363d765fe9999a23cef.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c5a76b0d67d10db9794f5eaaa0ad93900dc66ef9.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c9db8fa9d3f7eebe20c935bc28970c686729b80e.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/d8269d490caef20a582ba9d9105b7d11a23a763a.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/fd5921cba1eca4f1439b107ba699e33ec81efb98.png create mode 100644 frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/index.html create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/05c8d6b6bda96733768c9040919a1f2a5e01baff.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/124aa5c1e2968710d7151e06d900bb12f41eb2f3.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3d172ca5f26f33c946c982ccccb69b895adabc25.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3e76fc70092c9da0bdce1f5f6416f09fa381c305.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/59409841bc0a58fee9b289ae43471f82aeadced6.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/5ab116131594ffd0289eefd700d6491505e1c71a.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ceb066997ecc48380fe55519c2c5201661b92d65.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/e7fd2a77f7879d1bda44afce77a4e1b085add249.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ebb6e30487eeb33a334792f175969cd79b956af6.png create mode 100644 frontend_management/test_result/Instructor/Courses_List_Page_Tests/index.html create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/27f0f34d39ba3afdcddd6292f21b1fb2e1f91347.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/2a52458e5d775d7f6a5470a4ebe6dfce88202762.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/7957c7870ad1af4b313598a422bf95f9975bd4b3.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/aeaead5182882b1c055b71b4263a086d49ae21f9.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/bbf166853a6c394a6ca293b2395842ca32fa59fb.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c218c639a925799e58f2c6cd7a6285d1bf3a63da.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c33d46f246f6417be4064747a2cadbfce753066e.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c7e5afbe64b19d3bca6a5563b2b5fac4f58742ca.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/f0a098f9935cd85e715e1c92119eb18b3cc23078.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/fa57c0d509931d0977a2cc80bc5da68b6e474c23.png create mode 100644 frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/index.html create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/153d6902c0ca4fef1aa2fc65120adc1d08048286.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/452ffe0882b032c2d3bab8fe7fe4b31907aa7ec5.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/4e2f57b76392ddca8cf0d10da5302f65a7045e4f.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/85b3be3b178a6a2e49e992a3f9d0c366b9474fbc.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/c774e2d38891f831ce99cc3f497ad1b1090077de.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/data/ccb7f4bf19a7a5908adc27d250a40618847c1cf2.png create mode 100644 frontend_management/test_result/Instructor/Dashboard_Tests/index.html create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/16708d259129df93cdd9a5cd6260b44467f430e0.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/631c2ea22b6adeaed9641041947a76d74edc18b8.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/65f8acf6a09faed4c14ed6a633732862db853d36.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/7e79c41c8858b523f5f00ca0d96ee66e8a87263c.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/84ee2be597a0472f1f34fba60dabc39a3397acc0.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/a9f2781e64d249e5dcb8ae23681d82b7e0306f11.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/bc9a2a90835ca972045b4e2fd9eedab17f6e1cab.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/dca2a979a4f710cebde1893e6fa7a420bbce07ca.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/data/e391cbda97e779364b7c6ab4a9c78a999b4aeef1.png create mode 100644 frontend_management/test_result/auth/Login_Page_Tests/index.html create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/0809b14cfe86a6650b5c9ccaea6ab6396a7c6882.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/34608ae454cb4fdde9edcfee7bc324ff6e9ac00a.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/34a7199dc793b5cc6db9a6e2ab253bbb974a12cd.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/7d0da5fa1e26962ec6c30ce5825cb0d1385cf2cd.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/9efa2fbb70373a1485233cf195e488e69133bac6.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/bd755655c173a8648e7d6d3c5b2f02a0946947fb.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/c95315ba929130c12a0cbd5bf9cb5d7f1cb08f5e.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/data/efbfcc1bab4f46872473695bb589c80be175f811.png create mode 100644 frontend_management/test_result/auth/Register_Page_Tests/index.html diff --git a/frontend_management/components/course/AnnouncementsTab.vue b/frontend_management/components/course/AnnouncementsTab.vue index 1efe7f2b..aa529d46 100644 --- a/frontend_management/components/course/AnnouncementsTab.vue +++ b/frontend_management/components/course/AnnouncementsTab.vue @@ -343,10 +343,12 @@ const save = async () => { saving.value = true; try { // Convert local datetime to ISO string to preserve timezone - const payload = { ...form.value }; + const payload: any = { ...form.value }; if (payload.published_at) { const localDate = new Date(payload.published_at.replace(' ', 'T')); payload.published_at = localDate.toISOString(); + } else { + delete payload.published_at; } if (editing.value) { diff --git a/frontend_management/playwright.config.ts b/frontend_management/playwright.config.ts index 4c0c0541..647c4016 100644 --- a/frontend_management/playwright.config.ts +++ b/frontend_management/playwright.config.ts @@ -34,11 +34,11 @@ export default defineConfig({ use: { baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ headless: false, // false = เห็น browser ขณะรัน - screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail + screenshot: 'on', // เก็บ screenshot trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail - // launchOptions: { - // slowMo: 1000, - // }, // ช้าลง 10 วินาที + launchOptions: { + slowMo: 500, + }, // ช้าลง 10 วินาที }, /* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */ diff --git a/frontend_management/services/instructor.service.ts b/frontend_management/services/instructor.service.ts index 1c3ab0b4..f40d5588 100644 --- a/frontend_management/services/instructor.service.ts +++ b/frontend_management/services/instructor.service.ts @@ -610,6 +610,19 @@ export const instructorService = { { method: 'DELETE' } ); }, + async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> { + const response = await authRequest<{ + code: number; + message: string; + total_students: number; + total_completed: number; + }>('/api/instructors/courses/my-students'); + return { + total_students: response.total_students, + total_completed: response.total_completed + }; + }, + async getCourseApprovalHistory(courseId: number): Promise { const response = await authRequest<{ code: number; diff --git a/frontend_management/stores/instructor.ts b/frontend_management/stores/instructor.ts index 57cade09..cf8e2152 100644 --- a/frontend_management/stores/instructor.ts +++ b/frontend_management/stores/instructor.ts @@ -51,56 +51,16 @@ export const useInstructorStore = defineStore('instructor', { async fetchDashboardData() { this.loading = true; try { - // Fetch real courses from API - const courses = await instructorService.getCourses(); + // Fetch courses and student stats in parallel + const [courses, studentStats] = await Promise.all([ + instructorService.getCourses(), + instructorService.getMyStudentsStats() + ]); - // Fetch student counts for each course - let totalStudents = 0; - let completedStudents = 0; - const courseDetails: Course[] = []; - - for (const course of courses.slice(0, 5)) { - try { - // Get student counts - const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1); - const courseStudents = studentsResponse.total || 0; - totalStudents += courseStudents; - - // Get completed count from full list (if small) or estimate - if (courseStudents > 0 && courseStudents <= 100) { - const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100); - completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length; - } - - // Get lesson count from course detail - const courseDetail = await instructorService.getCourseById(course.id); - const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0); - - courseDetails.push({ - id: course.id, - title: course.title.th, - students: courseStudents, - lessons: lessonCount, - icon: 'book', - thumbnail: course.thumbnail_url || null - }); - } catch (e) { - // Course might not have students endpoint - courseDetails.push({ - id: course.id, - title: course.title.th, - students: 0, - lessons: 0, - icon: 'book', - thumbnail: course.thumbnail_url || null - }); - } - } - - // Update stats + // Update student stats from dedicated API this.stats.totalCourses = courses.length; - this.stats.totalStudents = totalStudents; - this.stats.completedStudents = completedStudents; + this.stats.totalStudents = studentStats.total_students; + this.stats.completedStudents = studentStats.total_completed; // Update course status counts this.courseStatusCounts = { @@ -110,8 +70,15 @@ export const useInstructorStore = defineStore('instructor', { rejected: courses.filter(c => c.status === 'REJECTED').length }; - // Update recent courses (first 3) - this.recentCourses = courseDetails.slice(0, 3); + // Build recent courses list (first 3) from existing data + this.recentCourses = courses.slice(0, 3).map(course => ({ + id: course.id, + title: course.title.th, + students: 0, + lessons: 0, + icon: 'book', + thumbnail: course.thumbnail_url || null + })); } catch (error) { console.error('Failed to fetch dashboard data:', error); } finally { diff --git a/frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/0410a78b255dbe58da69e9b357af0e9f6017eebf.png b/frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/0410a78b255dbe58da69e9b357af0e9f6017eebf.png new file mode 100644 index 0000000000000000000000000000000000000000..b617029a289df19d626b14738165434603a0d4d0 GIT binary patch literal 79653 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 S!n?pPAR~RV>!rGm&;A8wlLBD? literal 0 HcmV?d00001 diff --git a/frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/1d6ca155ecb01b787bccd5b36e618f7bfffc2939.png b/frontend_management/test_result/Admin/Audit_Log_Page_Tests/data/1d6ca155ecb01b787bccd5b36e618f7bfffc2939.png new file mode 100644 index 0000000000000000000000000000000000000000..05815fe0ca75a8dfedc3e3e8aaf92966b14734cb GIT binary patch literal 92869 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
Date & Time
-
{{ formatDate(selectedLog.created_at) }}
+
{{ formatDateTime(selectedLog.created_at) }}
@@ -241,7 +241,7 @@ diff --git a/frontend_management/pages/instructor/profile/index.vue b/frontend_management/pages/instructor/profile/index.vue index 6a602a39..6a36b365 100644 --- a/frontend_management/pages/instructor/profile/index.vue +++ b/frontend_management/pages/instructor/profile/index.vue @@ -301,7 +301,7 @@