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); - - // ถ้าเป็น