diff --git a/frontend_management/.gitignore b/frontend_management/.gitignore index df2ba243..a674bfac 100644 --- a/frontend_management/.gitignore +++ b/frontend_management/.gitignore @@ -26,10 +26,9 @@ deploy.ps1 *.tar # Playwright -tests -tests/.auth/ -/test-results/ +test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ -playwright.config.ts \ No newline at end of file +/tests/fixtures/test-data.ts +.auth \ No newline at end of file diff --git a/frontend_management/package-lock.json b/frontend_management/package-lock.json index e113ea0c..ff48fbc9 100644 --- a/frontend_management/package-lock.json +++ b/frontend_management/package-lock.json @@ -17,6 +17,7 @@ "vuedraggable": "^4.1.0" }, "devDependencies": { + "@faker-js/faker": "^10.3.0", "@nuxtjs/tailwindcss": "^6.14.0", "@playwright/test": "^1.58.2", "@types/node": "^25.0.3", @@ -1075,6 +1076,23 @@ "node": ">=18" } }, + "node_modules/@faker-js/faker": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz", + "integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", diff --git a/frontend_management/package.json b/frontend_management/package.json index 9ca64017..d71f9d3d 100644 --- a/frontend_management/package.json +++ b/frontend_management/package.json @@ -24,9 +24,10 @@ "vuedraggable": "^4.1.0" }, "devDependencies": { + "@faker-js/faker": "^10.3.0", "@nuxtjs/tailwindcss": "^6.14.0", "@playwright/test": "^1.58.2", "@types/node": "^25.0.3", "nuxt-quasar-ui": "^3.0.0" } -} \ No newline at end of file +} diff --git a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue index fdd0ce17..3ce30e3f 100644 --- a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue +++ b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue @@ -432,7 +432,7 @@ const savePrerequisiteSettings = async () => { ...form.value, prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 ? prerequisiteSettings.value.prerequisite_lesson_ids - : null + : [] }); $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); } catch (error) { diff --git a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue index 1800f252..e9f90c9b 100644 --- a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue +++ b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue @@ -506,7 +506,7 @@ const savePrerequisiteSettings = async () => { ...form.value, prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 ? prerequisiteSettings.value.prerequisite_lesson_ids - : null + : [] }); $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); } catch (error) { diff --git a/frontend_management/playwright.config.ts b/frontend_management/playwright.config.ts new file mode 100644 index 00000000..4c0c0541 --- /dev/null +++ b/frontend_management/playwright.config.ts @@ -0,0 +1,93 @@ +import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + timeout: 60000, // timeout ต่อ test (ms) + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + // workers: process.env.CI ? 1 : undefined, + workers: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ + headless: false, // false = เห็น browser ขณะรัน + screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail + trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail + // launchOptions: { + // slowMo: 1000, + // }, // ช้าลง 10 วินาที + }, + + /* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */ + projects: [ + { + name: 'admin-setup', + testMatch: /admin\.setup\.ts/, + testDir: './tests', + }, + { + name: 'instructor-setup', + testMatch: /instructor\.setup\.ts/, + testDir: './tests', + }, + + /* ──── Auth tests: ไม่ต้อง login ก่อน ──── */ + { + name: 'auth-tests', + testDir: './tests/auth', + use: { ...devices['Desktop Chrome'] }, + }, + + /* ──── Admin tests: ใช้ cookies จาก admin-setup ──── */ + { + name: 'admin-tests', + testDir: './tests/admin', + use: { + ...devices['Desktop Chrome'], + storageState: path.resolve(__dirname, 'tests/.auth/admin.json'), + }, + dependencies: ['admin-setup'], + }, + + /* ──── Instructor tests: ใช้ cookies จาก instructor-setup ──── */ + { + name: 'instructor-tests', + testDir: './tests/instructor', + use: { + ...devices['Desktop Chrome'], + storageState: path.resolve(__dirname, 'tests/.auth/instructor.json'), + }, + dependencies: ['instructor-setup'], + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/frontend_management/tests/admin/audit-log.spec.ts b/frontend_management/tests/admin/audit-log.spec.ts new file mode 100644 index 00000000..d71ab27a --- /dev/null +++ b/frontend_management/tests/admin/audit-log.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Audit Log Page Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Audit Log', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminAuditLog); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('Audit Logs', { exact: true })).toBeVisible(); + await expect(page.getByText('ประวัติการใช้งานระบบและกิจกรรมต่างๆ')).toBeVisible(); + + // Stats cards + await expect(page.getByText('Logs ทั้งหมด')).toBeVisible(); + await expect(page.getByText('Logs วันนี้')).toBeVisible(); + + await expect(page.getByRole('button', { name: 'รีเฟรช' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'ล้างประวัติเก่า' })).toBeVisible(); + }); + + test('should refresh data when clicking refresh button', async ({ page }) => { + const refreshBtn = page.getByRole('button', { name: 'รีเฟรช' }); + await refreshBtn.click(); + await page.waitForLoadState('networkidle'); + + // Table should still be visible after refresh + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter by Action', async ({ page }) => { + // Click the Action select + const actionSelect = page.locator('label').filter({ hasText: 'Action' }); + await actionSelect.click(); + await page.waitForTimeout(300); + + // Select an option from dropdown + const option = page.locator('.q-item__label').filter({ hasText: 'LOGIN' }).first(); + await option.click(); + + // Click search + await page.getByRole('button', { name: 'ค้นหา' }).click(); + await page.waitForLoadState('networkidle'); + + // Table should be visible + await expect(page.locator('.q-table')).toBeVisible(); + + await page.getByRole('button', { name: 'ล้างตัวกรอง' }).click(); + await page.waitForLoadState('networkidle'); + + // Entity type input should be cleared + await expect(option).not.toBeVisible(); + }); + + test('check open details dialog', async ({ page }) => { + // Wait for table data to load + const viewBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await viewBtn.isVisible().catch(() => false); + + if (hasData) { + await viewBtn.click(); + await page.waitForTimeout(300); + + // Details dialog should be visible + await expect(page.getByText('รายละเอียด Audit Log')).toBeVisible(); + + // Close button + await page.getByRole('button', { name: 'ปิด' }).click(); + } + }); + + test('check close cleanup dialog on cancel', async ({ page }) => { + await page.getByRole('button', { name: 'ล้างประวัติเก่า' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.q-dialog')).toBeVisible(); + + // Click cancel + await page.getByRole('button', { name: 'ยกเลิก' }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); + + test('check close cleanup dialog on confirm', async ({ page }) => { + await page.getByRole('button', { name: 'ล้างประวัติเก่า' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.q-dialog')).toBeVisible(); + + // Click confirm + await page.getByRole('button', { name: 'ลบข้อมูล', exact: true }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/categories.spec.ts b/frontend_management/tests/admin/categories.spec.ts new file mode 100644 index 00000000..3268d024 --- /dev/null +++ b/frontend_management/tests/admin/categories.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker } from '@faker-js/faker'; + +/** + * Admin Categories Page Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Categories', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminCategories); + await page.waitForLoadState('networkidle'); + }); + + test('check display page title and table', async ({ page }) => { + await expect(page.getByText('รายการหมวดหมู่')).toBeVisible(); + await expect(page.locator('.q-table')).toBeVisible(); + await expect(page.getByText('ชื่อหมวดหมู่')).toBeVisible(); + await expect(page.getByText('คำอธิบาย')).toBeVisible(); + await expect(page.getByText('วันที่สร้าง')).toBeVisible(); + await expect(page.getByText('การจัดการ')).toBeVisible(); + }); + + + test('check search input', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="ค้นหาหมวดหมู่"]'); + await expect(searchInput).toBeVisible(); + await searchInput.fill('ธุรกิจ'); + await page.waitForTimeout(500); + await expect(page.getByText('ธุรกิจ', { exact: true })).toBeVisible(); + }); + + test('check filter categories by search', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="ค้นหาหมวดหมู่"]'); + await searchInput.fill('ไม่มีหมวดหมู่นี้แน่นอน'); + await page.waitForTimeout(500); + + // ตารางควรไม่มีข้อมูล + await expect(page.getByText('No data available')).toBeVisible(); + }); + + + test('should add new category successfully', async ({ page }) => { + // สร้างข้อมูลสุ่มด้วย faker + const nameTh = faker.word.noun(); + const nameEn = faker.commerce.department(); + const slug = faker.helpers.slugify(nameEn.toLowerCase()) + '-' + faker.string.alpha({ length: 6, casing: 'lower' }); + const descTh = faker.lorem.sentence(); + const descEn = faker.lorem.sentence(); + + // 1. กดปุ่มเพิ่มหมวดหมู่ + await page.getByRole('button', { name: /เพิ่มหมวดหมู่ใหม่/ }).click(); + await expect(page.getByText('เพิ่ม', { exact: true })).toBeVisible(); + + // 2. กรอกข้อมูลในฟอร์ม + const dialog = page.locator('.q-dialog'); + const inputs = dialog.locator('input'); + await inputs.nth(0).fill(nameTh); + await inputs.nth(1).fill(nameEn); + await inputs.nth(2).fill(slug); + + const textareas = dialog.locator('textarea'); + await textareas.nth(0).fill(descTh); + await textareas.nth(1).fill(descEn); + + // 3. กดปุ่ม "เพิ่ม" + await page.getByRole('button', { name: 'เพิ่ม', exact: true }).click(); + + // 4. ตรวจสอบว่ามี notification สำเร็จ + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + + // 5. modal ต้องปิด + await expect(dialog).not.toBeVisible(); + + // 6. ตรวจสอบว่าหมวดหมู่ใหม่แสดงในตาราง + const searchInput = page.locator('input[placeholder*="ค้นหาหมวดหมู่"]'); + await searchInput.fill(nameTh); + await page.waitForTimeout(500); + await expect(page.getByText(nameTh, { exact: true })).toBeVisible(); + + const editBtn = page.locator('.q-table button .q-icon').filter({ hasText: 'edit' }).first(); + const deleteBtn = page.locator('.q-table button .q-icon').filter({ hasText: 'delete' }).first(); + await expect(editBtn).toBeVisible(); + await expect(deleteBtn).toBeVisible(); + + await editBtn.click(); + await page.waitForTimeout(500); + await expect(page.getByText('แก้ไขหมวดหมู่', { exact: true })).toBeVisible(); + await inputs.nth(0).fill(nameTh + ' edit'); + await inputs.nth(1).fill(nameEn + ' edit'); + await inputs.nth(2).fill(slug + 'edit'); + await textareas.nth(0).fill(descTh + ' edit'); + await textareas.nth(1).fill(descEn + ' edit'); + await page.getByRole('button', { name: 'บันทึก', exact: true }).click(); + await page.waitForTimeout(500); + + await searchInput.fill(nameTh + ' edit'); + await page.waitForTimeout(500); + await expect(page.getByText(nameTh + ' edit', { exact: true })).toBeVisible(); + + await deleteBtn.click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(500); + await expect(page.locator('.q-notification').filter({ hasText: 'deleted' })).toBeVisible({ timeout: 10_000 }); + }); + + test('should show validation error when submitting empty form', async ({ page }) => { + // 1. เปิด modal เพิ่มหมวดหมู่ + await page.getByRole('button', { name: /เพิ่มหมวดหมู่ใหม่/ }).click(); + await expect(page.getByText('เพิ่ม', { exact: true })).toBeVisible(); + + // 2. กดปุ่ม "เพิ่ม" โดยไม่กรอกข้อมูล + await page.getByRole('button', { name: 'เพิ่ม', exact: true }).click(); + + // 3. ตรวจสอบ validation error + await expect(page.getByText('กรุณากรอกชื่อ')).toBeVisible(); + }); + + test('should close add modal on cancel', async ({ page }) => { + // 1. เปิด modal + await page.getByRole('button', { name: /เพิ่มหมวดหมู่ใหม่/ }).click(); + await expect(page.getByText('เพิ่ม', { exact: true })).toBeVisible(); + + // 2. กดยกเลิก + await page.getByRole('button', { name: 'ยกเลิก' }).click(); + + // 3. modal ต้องหายไป + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); + + test('should open edit modal when clicking edit button', async ({ page }) => { + // 1. กดปุ่ม edit ของแถวแรก + const editBtn = page.locator('.q-table button[class*="q-btn"]').first(); + await expect(editBtn).toBeVisible(); + await editBtn.click(); + + // 2. ตรวจสอบว่า modal แก้ไขเปิดขึ้น + await expect(page.getByText('แก้ไขหมวดหมู่')).toBeVisible(); + + // 3. ฟอร์มต้องมีข้อมูลอยู่แล้ว (ไม่ว่าง) + const nameInput = page.locator('.q-dialog input').first(); + await expect(nameInput).not.toHaveValue(''); + + // 4. ปุ่มต้องเป็น "บันทึก" ไม่ใช่ "เพิ่ม" + await expect(page.getByRole('button', { name: 'บันทึก' })).toBeVisible(); + }); + + test('should show delete confirmation dialog', async ({ page }) => { + // 1. กดปุ่ม delete ของแถวแรก (ปุ่มที่สองใน actions) + const deleteBtn = page.locator('.q-table .flex.gap-2 button').nth(1); + await expect(deleteBtn).toBeVisible(); + await deleteBtn.click(); + + // 2. ตรวจสอบว่า dialog ยืนยันการลบแสดง + await expect(page.getByText('ยืนยันการลบ')).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/courses-pending.spec.ts b/frontend_management/tests/admin/courses-pending.spec.ts new file mode 100644 index 00000000..e792a75c --- /dev/null +++ b/frontend_management/tests/admin/courses-pending.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Pending Courses Page Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Pending Courses', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminPendingCourses); + await page.waitForLoadState('networkidle'); + }); + + test('should display stats cards', async ({ page }) => { + await expect(page.getByText('รอตรวจสอบ')).toBeVisible(); + await expect(page.getByText('บททั้งหมด')).toBeVisible(); + await expect(page.getByText('บทเรียนทั้งหมด')).toBeVisible(); + }); + + test('should search courses by keyword "พื้นฐาน"', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="ค้นหา"]'); + await expect(searchInput).toBeVisible(); + + // ค้นหาคำว่า "พื้นฐาน" + await searchInput.fill('พื้นฐาน'); + await page.waitForTimeout(500); + + // ตรวจสอบว่าแสดงผลลัพธ์ที่ตรงกัน หรือแสดง empty state + const emptyState = page.getByText('ไม่มีคอร์สที่รอการอนุมัติ'); + const isEmpty = await emptyState.isVisible().catch(() => false); + + if (isEmpty) { + await expect(emptyState).toBeVisible(); + } else { + // ถ้ามีผลลัพธ์ ชื่อคอร์สต้องมีคำว่า "พื้นฐาน" + const courseCards = page.locator('.bg-white.rounded-xl.shadow-sm.overflow-hidden'); + const count = await courseCards.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + await expect(courseCards.nth(i)).toContainText('พื้นฐาน'); + } + } + }); + + test('should filter courses by search text', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="ค้นหา"]'); + await searchInput.fill('nonexistent-course-xyz'); + + // Should show empty state or filtered results + // Wait briefly for filter to apply + await page.waitForTimeout(500); + + // Either shows "ไม่มีคอร์สที่รอการอนุมัติ" or filtered results + const emptyState = page.getByText('ไม่มีคอร์สที่รอการอนุมัติ'); + const courseCards = page.locator('.bg-white.rounded-xl.shadow-sm.overflow-hidden'); + + // One of these should be visible + const isEmpty = await emptyState.isVisible().catch(() => false); + if (isEmpty) { + await expect(emptyState).toBeVisible(); + } + }); + + test('should toggle between card and table view', async ({ page }) => { + // Find the view toggle buttons + const toggleGroup = page.locator('.q-btn-toggle'); + await expect(toggleGroup).toBeVisible(); + + // Click table view + await page.locator('.q-btn-toggle button').last().click(); + await page.waitForTimeout(300); + + // Table should be visible + await expect(page.locator('.q-table')).toBeVisible(); + + // Switch back to card view + await page.locator('.q-btn-toggle button').first().click(); + await page.waitForTimeout(300); + + await page.locator('.q-btn-toggle button').last().click(); + await page.waitForTimeout(300); + + // Table should have expected columns + await expect(page.getByText('ชื่อคอร์ส')).toBeVisible(); + await expect(page.getByText('ผู้สอน')).toBeVisible(); + await expect(page.getByText('วันที่ส่ง')).toBeVisible(); + }); + + test('should have refresh button', async ({ page }) => { + const refreshBtn = page.getByRole('button', { name: /รีเฟรช/ }); + await expect(refreshBtn).toBeVisible(); + + // Click refresh + await refreshBtn.click(); + + // Should trigger loading (spinner appears briefly) + await page.waitForLoadState('networkidle'); + }); + + test('should navigate to course detail on click if ', async ({ page }) => { + // Check if there are any courses + const courseExists = await page.locator('.bg-white.rounded-xl').first().isVisible().catch(() => false); + + if (courseExists) { + // Click "ดูรายละเอียด" button on first course + const viewBtn = page.locator('.q-table button[class*="q-btn"]').first(); + if (await viewBtn.isVisible()) { + await viewBtn.click(); + await page.waitForURL('**/admin/courses/**'); + await expect(page).toHaveURL(/\/admin\/courses\/\d+/); + await page.waitForTimeout(1000); + } + } + }); + +}); diff --git a/frontend_management/tests/admin/dashboard.spec.ts b/frontend_management/tests/admin/dashboard.spec.ts new file mode 100644 index 00000000..3d03f833 --- /dev/null +++ b/frontend_management/tests/admin/dashboard.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Dashboard Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminDashboard); + await page.waitForLoadState('networkidle'); + }); + + test('show display dashboard', async ({ page }) => { + await expect(page.locator('h1')).toContainText('สวัสดี'); + await expect(page.locator("//p[normalize-space(text())='คอร์สรออนุมัติ']")).toBeVisible(); + await expect(page.getByText('กิจกรรมวันนี้')).toBeVisible(); + await expect(page.getByText('ผู้ใช้งานทั้งหมด')).toBeVisible(); + }); + + test('navigate to pending courses and audit-log page', async ({ page }) => { + await page.locator('a[href*="pending"]', { hasText: 'ดูทั้งหมด' }).click(); + await page.waitForURL('**/admin/courses/pending**'); + await expect(page).toHaveURL(/\/admin\/courses\/pending/); + await page.waitForTimeout(500); + + await page.goto(TEST_URLS.adminDashboard); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + await page.locator('a[href*="audit-log"]', { hasText: 'ดูทั้งหมด' }).click(); + await page.waitForURL('**/admin/audit-log**'); + await expect(page).toHaveURL(/\/admin\/audit-log/); + await page.waitForTimeout(500); + }); + + test('show user menu on avatar click', async ({ page }) => { + // Click the avatar / user icon area + await page.locator('.w-12.h-12.rounded-full').click(); + await page.waitForTimeout(500); + // Menu should appear with profile and logout options + await expect(page.getByText('โปรไฟล์')).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/recommended-courses.spec.ts b/frontend_management/tests/admin/recommended-courses.spec.ts new file mode 100644 index 00000000..b3cd9ea9 --- /dev/null +++ b/frontend_management/tests/admin/recommended-courses.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Recommended Courses Page Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Recommended Courses', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminRecommendedCourses); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('จัดการคอร์สแนะนำ')).toBeVisible(); + await expect(page.getByText('Recommended Courses Management')).toBeVisible(); + const toggles = page.locator('.q-table .q-toggle'); + const hasData = await toggles.first().isVisible().catch(() => false); + + if (hasData) { + await expect(toggles.first()).toBeVisible(); + } + }); + + test('should toggle recommendation status', async ({ page }) => { + const toggle = page.locator('.q-table .q-toggle').first(); + const hasData = await toggle.isVisible().catch(() => false); + + if (hasData) { + await toggle.click(); + await page.waitForTimeout(500); + + // Should show notification (success or error) + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + + await toggle.click(); + await page.waitForTimeout(500); + + // Should show notification (success or error) + await expect(page.locator('.q-notification').last()).toBeVisible({ timeout: 10_000 }); + } + }); + + test('open course details dialog', async ({ page }) => { + const viewBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await viewBtn.isVisible().catch(() => false); + + if (hasData) { + await viewBtn.click(); + await page.waitForTimeout(500); + + // Dialog should be visible with course details + await expect(page.getByText('รายละเอียดคอร์ส (Course Details)')).toBeVisible(); + + // Detail sections + await expect(page.getByText('รายละเอียด (Description)')).toBeVisible(); + await expect(page.getByText('หมวดหมู่ (Category):')).toBeVisible(); + await expect(page.getByText('ผู้สอน (Instructors)')).toBeVisible(); + + // Close dialog via close button in q-bar + await page.locator('.q-bar .q-btn').filter({ has: page.locator('[class*="q-icon"]') }).click(); + await page.waitForTimeout(300); + await expect(page.getByText('รายละเอียดคอร์ส (Course Details)')).not.toBeVisible(); + } + }); +}); diff --git a/frontend_management/tests/admin/users.spec.ts b/frontend_management/tests/admin/users.spec.ts new file mode 100644 index 00000000..2b5b63b2 --- /dev/null +++ b/frontend_management/tests/admin/users.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Users Page Tests + * ใช้ cookies จาก admin-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Admin Users', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminUsers); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('จัดการผู้ใช้งาน')).toBeVisible(); + + // Search input + await expect(page.locator('input[placeholder*="ค้นหาชื่อ"]')).toBeVisible(); + + // Stats cards + await expect(page.getByText('ผู้ใช้ทั้งหมด')).toBeVisible(); + await expect(page.getByText('Admin', { exact: true })).toBeVisible(); + await expect(page.getByText('Instructor', { exact: true })).toBeVisible(); + await expect(page.getByText('Student', { exact: true })).toBeVisible(); + + // Table + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter users by search', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="ค้นหาชื่อ"]'); + await searchInput.fill('admin'); + await page.waitForTimeout(500); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter users by role', async ({ page }) => { + // ใช้ outlined q-select (ตัว filter role ไม่ใช่ pagination) + const roleSelect = page.locator('.q-select.q-field--outlined'); + await roleSelect.click(); + await page.waitForTimeout(300); + + // Select Instructor + await page.locator('.q-item__label').filter({ hasText: 'Instructor' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + await roleSelect.click(); + await page.waitForTimeout(300); + // Select Student + await page.locator('.q-item__label').filter({ hasText: 'Student' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + await roleSelect.click(); + await page.waitForTimeout(300); + // Select Admin + await page.locator('.q-item__label').filter({ hasText: 'Admin' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + // กลับไปเลือก "บทบาททั้งหมด" + await roleSelect.click(); + await page.waitForTimeout(300); + await page.locator('.q-item__label').filter({ hasText: 'บทบาททั้งหมด' }).click(); + await page.waitForTimeout(500); + }); + + test('should open action menu', async ({ page }) => { + // Click more_vert button on first row + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Menu should show options + await expect(page.getByText('ดู', { exact: true })).toBeVisible(); + await expect(page.getByText('แก้ Role', { exact: true })).toBeVisible(); + await expect(page.getByText('ลบ', { exact: true })).toBeVisible(); + } + }); + + test('should open view user dialog', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Click "ดู" option + await page.getByText('ดู', { exact: true }).click(); + await page.waitForTimeout(300); + + // Dialog should show user details + await expect(page.getByText('รายละเอียดผู้ใช้')).toBeVisible(); + + // Close button + await page.getByRole('button', { name: 'ปิด' }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + } + }); + + test('open change role dialog', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Click "แก้ Role" + await page.getByText('แก้ Role').click(); + await page.waitForTimeout(300); + + // Change role dialog should appear + await expect(page.getByText('เปลี่ยน Role')).toBeVisible(); + + // Cancel the dialog + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.waitForTimeout(300); + } + }); + + test('change role ', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + const searchInput = page.locator('input[placeholder*="ค้นหาชื่อ"]'); + await searchInput.fill('lertp'); + await page.waitForTimeout(500); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + if (hasData) { + // Click "แก้ Role Student" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('แก้ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('เปลี่ยน Role')).toBeVisible(); + // select role Student + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Student' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + + // Click "แก้ Role Admin" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('แก้ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('เปลี่ยน Role')).toBeVisible(); + // select role Admin + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Admin' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + + + // Click "แก้ Role Instructor" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('แก้ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('เปลี่ยน Role')).toBeVisible(); + // select role Instructor + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Instructor' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + } + }); + + // test('should open delete confirmation dialog', async ({ page }) => { + // const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + // const hasData = await actionBtn.isVisible().catch(() => false); + + // if (hasData) { + // await actionBtn.click(); + // await page.waitForTimeout(300); + + // // Click "ลบ" + // await page.getByText('ลบ').click(); + // await page.waitForTimeout(300); + + // // Delete confirmation dialog + // await expect(page.getByText('ยืนยันการลบ')).toBeVisible(); + + // // Cancel the dialog + // await page.getByRole('button', { name: 'Cancel' }).click(); + // await page.waitForTimeout(300); + // } + // }); +}); diff --git a/frontend_management/tests/auth/login.spec.ts b/frontend_management/tests/auth/login.spec.ts new file mode 100644 index 00000000..d5ea9a99 --- /dev/null +++ b/frontend_management/tests/auth/login.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from '@playwright/test'; +import { TEST_ADMIN, TEST_INSTRUCTOR, TEST_URLS } from '../fixtures/test-data'; + +test.describe('Login Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.login); + }); + + test('should display login form', async ({ page }) => { + // Title + await expect(page.locator('h1')).toContainText('E-Learning'); + + // Email & password fields + await expect(page.locator('input[type="email"]')).toBeVisible(); + await expect(page.locator('input[type="password"]')).toBeVisible(); + + // Submit button + await expect(page.locator('button[type="submit"]')).toBeVisible(); + + // Register link + await expect(page.locator('a[href="/register"]')).toBeVisible(); + }); + + test('should show validation errors for empty fields', async ({ page }) => { + // Click submit without filling fields + await page.locator('button[type="submit"]').click(); + + // Expect validation messages in Thai + await expect(page.getByText('กรุณากรอกอีเมล')).toBeVisible(); + }); + + test('should show validation errors for empty fields password', async ({ page }) => { + // Click submit without filling fields + await page.locator('input[type="email"]').fill('test@email.com'); + await page.locator('button[type="submit"]').click(); + + // Expect validation messages in Thai + await expect(page.getByText('กรุณากรอกรหัสผ่าน')).toBeVisible(); + }); + + test('should toggle password visibility', async ({ page }) => { + const passwordInput = page.locator('input[type="password"]'); + + await page.waitForTimeout(1000); + await passwordInput.fill('test123'); + + await page.waitForTimeout(1000); + // Click the visibility toggle icon + await page.locator('//i[normalize-space(text())="visibility"]').click(); + + // Password field should now be text type + await expect(page.locator('input[type="text"]').last()).toHaveValue('test123'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill('wrong@email.com'); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill('wrongpassword'); + await page.locator('button[type="submit"]').click(); + + // Should show error notification (Quasar Notify) + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + }); + + test('should login as admin and redirect to /admin', async ({ page }) => { + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + await page.locator('button[type="submit"]').click(); + + // Should redirect to admin dashboard + await page.waitForURL('**/admin**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/admin/); + }); + + test('should login as instructor and redirect to /instructor', async ({ page }) => { + await page.locator('input[type="email"]').fill(TEST_INSTRUCTOR.email); + await page.waitForTimeout(1000); + await page.locator('input[type="password"]').fill(TEST_INSTRUCTOR.password); + await page.waitForTimeout(1000); + await page.locator('button[type="submit"]').click(); + + // Should redirect to instructor dashboard + await page.waitForURL('**/instructor**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/instructor/); + }); + + test('should open forgot password modal', async ({ page }) => { + await page.getByText('ลืมรหัสผ่าน?').click(); + + // Modal should be visible + await expect(page.getByText('ลืมรหัสผ่าน').nth(1)).toBeVisible(); + await expect(page.locator('.q-dialog input[type="email"]')).toBeVisible(); + }); + + test('should remember email after login with remember me checked', async ({ page }) => { + // 1. Fill credentials + await page.waitForTimeout(500); + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + await page.waitForTimeout(500); + // 2. Check "remember me" + await page.getByText('จดจำฉันไว้').click(); + + // 3. Login + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/admin**', { timeout: 15_000 }); + + // 4. Logout - click avatar menu then logout + await page.getByText('ออกจากระบบ').click(); + await page.waitForTimeout(1000); + // 5. Confirm logout dialog + await page.locator("(//button[@type='button'])[2]").click(); + + // 6. Should redirect back to login page + await page.waitForURL('**/login**', { timeout: 15_000 }); + + // 7. Verify the email is still pre-filled + await expect(page.locator('input[type="email"]')).toHaveValue(TEST_ADMIN.email); + }); +}); diff --git a/frontend_management/tests/auth/register.spec.ts b/frontend_management/tests/auth/register.spec.ts new file mode 100644 index 00000000..f2532089 --- /dev/null +++ b/frontend_management/tests/auth/register.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker } from '@faker-js/faker'; + +/** + * Register Page Tests + * Test instructor registration flow + */ +test.describe('Register Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.register); + }); + + test('check display register form', async ({ page }) => { + // Header + await expect(page.getByText('ลงทะเบียนเป็นผู้สอน')).toBeVisible(); + await expect(page.getByText('สร้างบัญชีเพื่อเริ่มสร้างหลักสูตร')).toBeVisible(); + + // Form fields + await expect(page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'อีเมล' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'รหัสผ่าน *' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'ยืนยันรหัสผ่าน' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'คำนำหน้า' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'ชื่อจริง' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'นามสกุล' })).toBeVisible(); + await expect(page.locator('label').filter({ hasText: 'เบอร์โทรศัพท์' })).toBeVisible(); + + // Submit button + await expect(page.getByRole('button', { name: 'ลงทะเบียน' })).toBeVisible(); + + // Login link + await expect(page.getByText('มีบัญชีอยู่แล้ว?')).toBeVisible(); + await expect(page.getByText('เข้าสู่ระบบ')).toBeVisible(); + }); + + test('should show validation errors for empty fields', async ({ page }) => { + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + await expect(page.getByText('กรุณากรอก username')).toBeVisible(); + await expect(page.getByText('กรุณากรอกอีเมล')).toBeVisible(); + await expect(page.getByText('กรุณากรอกรหัสผ่าน')).toBeVisible(); + await expect(page.getByText('กรุณายืนยันรหัสผ่าน')).toBeVisible(); + await expect(page.getByText('กรุณากรอกชื่อ')).toBeVisible(); + await expect(page.getByText('กรุณากรอกนามสกุล')).toBeVisible(); + await expect(page.getByText('กรุณากรอกเบอร์โทร')).toBeVisible(); + }); + + test('should show username min length validation', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input'); + await usernameInput.fill('ab'); + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + await expect(page.getByText('อย่างน้อย 4 ตัวอักษร')).toBeVisible(); + }); + + test('should show email format validation', async ({ page }) => { + const emailInput = page.locator('input[type="email"]'); + await emailInput.fill('invalid-email'); + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + await expect(page.getByText('รูปแบบอีเมลไม่ถูกต้อง')).toBeVisible(); + }); + + test('should show password min length validation', async ({ page }) => { + const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input'); + await passwordInput.fill('1234'); + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + await expect(page.getByText('อย่างน้อย 8 ตัวอักษร')).toBeVisible(); + }); + + 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'); + + await passwordInput.fill('password123'); + await confirmInput.fill('differentpass'); + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + await expect(page.getByText('รหัสผ่านไม่ตรงกัน')).toBeVisible(); + }); + + test('should toggle password visibility', async ({ page }) => { + const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input'); + await passwordInput.fill('test1234'); + + // Click visibility icon + await page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('.q-icon').filter({ hasText: 'visibility' }).click(); + + // Both password fields should now be text type + await expect(passwordInput).toHaveAttribute('type', 'text'); + }); + + test('should navigate to login page', async ({ page }) => { + await page.getByText('เข้าสู่ระบบ').click(); + await page.waitForURL('**/login**', { timeout: 10_000 }); + await expect(page).toHaveURL(/\/login/); + }); + + test('should register successfully with valid data', async ({ page }) => { + const username = faker.internet.username().toLowerCase().replace(/[^a-z0-9]/g, '') + faker.string.alpha({ length: 4, casing: 'lower' }); + const email = faker.internet.email().toLowerCase(); + const password = 'Test@1234'; + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const phone = `0${faker.string.numeric(9)}`; + + // Fill form + const usernameInput = page.locator('label').filter({ hasText: 'ชื่อผู้ใช้ (Username)' }).locator('input'); + await usernameInput.fill(username); + + await page.locator('input[type="email"]').fill(email); + + const passwordInput = page.locator('label').filter({ hasText: 'รหัสผ่าน *' }).locator('input'); + await passwordInput.fill(password); + + const confirmInput = page.locator('label').filter({ hasText: 'ยืนยันรหัสผ่าน' }).locator('input'); + await confirmInput.fill(password); + + // Select prefix + const prefixSelect = page.locator('label').filter({ hasText: 'คำนำหน้า' }); + await prefixSelect.click(); + await page.waitForTimeout(300); + await page.locator('.q-item__label').filter({ hasText: 'นาย / Mr.' }).click(); + + // Fill name + const firstNameInput = page.locator('label').filter({ hasText: 'ชื่อจริง' }).locator('input'); + await firstNameInput.fill(firstName); + + const lastNameInput = page.locator('label').filter({ hasText: 'นามสกุล' }).locator('input'); + await lastNameInput.fill(lastName); + + // Fill phone + const phoneInput = page.locator('label').filter({ hasText: 'เบอร์โทรศัพท์' }).locator('input'); + await phoneInput.fill(phone); + + // Submit + await page.getByRole('button', { name: 'ลงทะเบียน' }).click(); + + // Should show success notification and redirect to login + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + await page.waitForURL('**/login**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/frontend_management/tests/fixtures/admin.setup.ts b/frontend_management/tests/fixtures/admin.setup.ts new file mode 100644 index 00000000..a1f74def --- /dev/null +++ b/frontend_management/tests/fixtures/admin.setup.ts @@ -0,0 +1,30 @@ +import { test as setup, expect } from '@playwright/test'; +import { TEST_ADMIN, TEST_URLS } from './test-data'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ADMIN_AUTH_FILE = path.resolve(__dirname, '../.auth/admin.json'); + +/** + * Admin authentication setup + * Logs in as admin and saves browser state (cookies) for reuse + */ +setup('authenticate as admin', async ({ page }) => { + await page.goto(TEST_URLS.login); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + // Fill login form + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + + // Submit + await page.locator('button[type="submit"]').click(); + + // Wait for redirect to admin dashboard + await page.waitForURL('**/admin**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/admin/); + + // Save auth state + await page.context().storageState({ path: ADMIN_AUTH_FILE }); +}); diff --git a/frontend_management/tests/fixtures/instructor.setup.ts b/frontend_management/tests/fixtures/instructor.setup.ts new file mode 100644 index 00000000..9a4dfb8f --- /dev/null +++ b/frontend_management/tests/fixtures/instructor.setup.ts @@ -0,0 +1,31 @@ +import { test as setup, expect } from '@playwright/test'; +import { TEST_INSTRUCTOR, TEST_URLS } from './test-data'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const INSTRUCTOR_AUTH_FILE = path.resolve(__dirname, '../.auth/instructor.json'); + +/** + * Instructor authentication setup + * Logs in as instructor and saves browser state (cookies) for reuse + */ +setup('authenticate as instructor', async ({ page }) => { + await page.goto(TEST_URLS.login); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + // Fill login form + await page.locator('input[type="email"]').fill(TEST_INSTRUCTOR.email); + await page.locator('input[type="password"]').fill(TEST_INSTRUCTOR.password); + + // Submit + await page.locator('button[type="submit"]').click(); + + // Wait for redirect to instructor dashboard + await page.waitForURL('**/instructor**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/instructor/); + + // Save auth state + await page.context().storageState({ path: INSTRUCTOR_AUTH_FILE }); +}); diff --git a/frontend_management/tests/instructor/courses-list.spec.ts b/frontend_management/tests/instructor/courses-list.spec.ts new file mode 100644 index 00000000..fadb8452 --- /dev/null +++ b/frontend_management/tests/instructor/courses-list.spec.ts @@ -0,0 +1,133 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Instructor Courses List Page Tests + * ใช้ cookies จาก instructor-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Instructor Courses List', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.instructorCourses); + await page.waitForLoadState('networkidle'); + }); + + 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(); + }); + + test('should have status filter dropdown', async ({ page }) => { + // Click the status select to open dropdown + const statusSelect = page.locator('.q-select').first(); + 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(); + await page.waitForTimeout(300); + + // Table should appear + 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(); + + // Switch back to card view + await page.locator('.q-btn-toggle button').first().click(); + await page.waitForTimeout(300); + }); + + test('should show course cards with status badges', async ({ page }) => { + // Wait for courses to load (either card or empty state) + const hasCards = await page.locator('.q-badge').first().isVisible().catch(() => false); + const isEmpty = await page.getByText('ยังไม่มีหลักสูตร').isVisible().catch(() => false); + + // Either courses exist with badges or empty state shows + expect(hasCards || isEmpty).toBeTruthy(); + }); + + test('should show course action menu', async ({ page }) => { + const moreBtn = page.locator('button:has(.q-icon[class*="more_vert"])').first(); + const hasCourses = await moreBtn.isVisible().catch(() => false); + + if (hasCourses) { + await moreBtn.click(); + + // Menu should show duplicate and delete options + await expect(page.getByText('ทำสำเนา')).toBeVisible(); + await expect(page.getByText('ลบ')).toBeVisible(); + } + }); + + test('should open clone dialog from menu', async ({ page }) => { + const moreBtn = page.locator('button:has(.q-icon[class*="more_vert"])').first(); + const hasCourses = await moreBtn.isVisible().catch(() => false); + + if (hasCourses) { + await moreBtn.click(); + await page.getByText('ทำสำเนา').click(); + + // Clone dialog should appear + await expect(page.getByText('ทำสำเนาหลักสูตร')).toBeVisible(); + await expect(page.locator('.q-dialog input').first()).toBeVisible(); + } + }); + + 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.waitForLoadState('networkidle'); + + // If there are rejected courses, clicking view should show rejection dialog + const viewBtn = page.locator('button:has(.q-icon[class*="visibility"])').first(); + const hasRejected = await viewBtn.isVisible().catch(() => false); + + if (hasRejected) { + await viewBtn.click(); + + // Rejection dialog should appear + await expect(page.getByText('หลักสูตรถูกปฏิเสธ')).toBeVisible(); + await expect(page.getByText('เหตุผลการปฏิเสธ')).toBeVisible(); + await expect(page.getByRole('button', { name: /คืนสถานะเป็นแบบร่าง/ })).toBeVisible(); + } + }); +}); diff --git a/frontend_management/tests/instructor/dashboard.spec.ts b/frontend_management/tests/instructor/dashboard.spec.ts new file mode 100644 index 00000000..0ac7b077 --- /dev/null +++ b/frontend_management/tests/instructor/dashboard.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Instructor Dashboard Tests + * ใช้ cookies จาก instructor-setup project (ไม่ต้อง login ซ้ำ) + */ +test.describe('Instructor Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.instructorDashboard); + await page.waitForLoadState('networkidle'); + }); + + test('should display welcome message', async ({ page }) => { + await expect(page.locator('h1')).toContainText('สวัสดี'); + }); + + test('should display stats cards', 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(); + }); + + test('should navigate to courses list', async ({ page }) => { + await page.getByRole('button', { name: 'ดูทั้งหมด' }).click(); + await page.waitForURL('**/instructor/courses**'); + await expect(page).toHaveURL(/\/instructor\/courses/); + }); + + test('should show user menu on avatar click', async ({ page }) => { + 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 }) => { + await page.locator('.w-12.h-12.rounded-full').click(); + await page.getByText('โปรไฟล์').click(); + + await page.waitForURL('**/instructor/profile**'); + await expect(page).toHaveURL(/\/instructor\/profile/); + }); + + test('should logout and redirect to login', async ({ page }) => { + await page.locator('.w-12.h-12.rounded-full').click(); + await page.getByText('ออกจากระบบ').click(); + + // Confirm logout dialog + await page.locator('.q-dialog').getByText('ออกจากระบบ').click(); + + await page.waitForURL('**/login**', { timeout: 10_000 }); + await expect(page).toHaveURL(/\/login/); + }); +});