diff --git a/Frontend-Learner/composables/useAuth.ts b/Frontend-Learner/composables/useAuth.ts index e9e392d1..61f5ee0a 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, refreshToken } } + // API returns { code: 200, message: "...", data: { token, user, ... } } const response = await $fetch(`${API_BASE_URL}/auth/login`, { method: 'POST', body: credentials @@ -49,35 +49,16 @@ export const useAuth = () => { if (response && response.data) { const data = response.data - // บันทึก Token ก่อน เพื่อใช้เรียก /user/me - token.value = data.token - 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: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' } + // 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.value = data.token + refreshToken.value = data.refreshToken // บันทึก Refresh Token + + // API ส่งข้อมูล profile มาใน user object + user.value = data.user return { success: true } } diff --git a/Frontend-Learner/i18n/locales/en.json b/Frontend-Learner/i18n/locales/en.json index 15c01013..b854b7c6 100644 --- a/Frontend-Learner/i18n/locales/en.json +++ b/Frontend-Learner/i18n/locales/en.json @@ -117,11 +117,7 @@ "foundTotal": "Found Total", "items": "items", "subtitle": "Choose to learn new skills from our curated quality courses", - "searchBtn": "Search", - "allCategory": "All", - "byInstructor": "by", - "students": "students", - "viewDetails": "View Details" + "searchBtn": "Search" }, "myCourses": { "title": "My Courses", diff --git a/Frontend-Learner/i18n/locales/th.json b/Frontend-Learner/i18n/locales/th.json index fd62257e..d096b4ad 100644 --- a/Frontend-Learner/i18n/locales/th.json +++ b/Frontend-Learner/i18n/locales/th.json @@ -117,11 +117,7 @@ "foundTotal": "พบทั้งหมด", "items": "รายการ", "subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ", - "searchBtn": "ค้นหา", - "allCategory": "ทั้งหมด", - "byInstructor": "โดย", - "students": "นักเรียน", - "viewDetails": "ดูรายละเอียด" + "searchBtn": "ค้นหา" }, "myCourses": { "title": "คอร์สของฉัน", diff --git a/Frontend-Learner/pages/browse/discovery.vue b/Frontend-Learner/pages/browse/discovery.vue index 36e7dad6..e8f15f77 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 allCourses = ref([]); // เก็บคอร์สทั้งหมดเพื่อกรอง client-side +const courses = ref([]); const selectedCourse = ref(null); const isLoading = ref(false); @@ -76,17 +76,20 @@ const loadCategories = async () => { if (res.success) categories.value = res.data || []; }; -const loadCourses = async () => { +const loadCourses = async (page = 1) => { isLoading.value = true; + const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number; - // โหลดคอร์สทั้งหมดครั้งเดียว (limit สูงๆ เพื่อ client-side filter) const res = await fetchCourses({ - limit: 500, + category_id: categoryId, + search: searchQuery.value, + page: page, + limit: itemsPerPage, forceRefresh: true, }); if (res.success) { - allCourses.value = (res.data || []).map(c => { + courses.value = (res.data || []).map(c => { const cat = categories.value.find(cat => cat.id === c.category_id); return { ...c, @@ -97,33 +100,12 @@ const loadCourses = async () => { 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; @@ -155,10 +137,10 @@ watch( activeCategory, () => { currentPage.value = 1; + loadCourses(1); } ); - onMounted(async () => { await loadCategories(); @@ -168,7 +150,7 @@ onMounted(async () => { activeCategory.value = Number(route.query.category_id); } - await loadCourses(); + await loadCourses(1); if (route.query.course_id) { selectCourse(Number(route.query.course_id)); @@ -180,19 +162,19 @@ onMounted(async () => {
-
+
-

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

+

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

- - + +
- - + +
@@ -203,17 +185,17 @@ onMounted(async () => {
@@ -226,10 +208,10 @@ onMounted(async () => {
-
+
-
+
@@ -240,7 +222,7 @@ onMounted(async () => {
-

{{ getLocalizedText(course.title) }}

+

{{ getLocalizedText(course.title) }}

@@ -248,7 +230,8 @@ onMounted(async () => {
{{ course.formatted_price }}
-
@@ -258,7 +241,7 @@ onMounted(async () => {
-
+
@@ -267,15 +250,15 @@ onMounted(async () => {
-

{{ getLocalizedText(course.title) }}

+

{{ getLocalizedText(course.title) }}

{{ course.formatted_price }}
-
@@ -289,9 +272,9 @@ onMounted(async () => {
-
+
-

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

+

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

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

@@ -152,7 +151,7 @@ const viewMode = ref<'grid' | 'list'>('grid') @click="selectCategory('all')" :class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none"> - {{ $t('discovery.allCategory') }} + ทั้งหมด
@@ -247,10 +246,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
-

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

-

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

+

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

+

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

diff --git a/Frontend-Learner/playwright-report/index.html b/Frontend-Learner/playwright-report/index.html index ba7aaae5..15686deb 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 bf16f492..5a87d40f 100644 --- a/Frontend-Learner/tests/e2e/auth.spec.ts +++ b/Frontend-Learner/tests/e2e/auth.spec.ts @@ -1,13 +1,40 @@ -/** - * @file auth.spec.ts - * @description ทดสอบระบบยืนยันตัวตน (Authentication) — Login, Register, Forgot Password - */ import { test, expect, type Page, type Locator } from '@playwright/test'; -import { - BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT, - waitAppSettled, expectAnyVisible, - emailLocator, passwordLocator, loginButtonLocator, -} from './helpers'; + +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.'); +} // --------------------------- // Helpers: Register @@ -26,7 +53,6 @@ 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 () => { @@ -34,7 +60,6 @@ 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'); @@ -65,12 +90,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => { 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 loginEmailLocator(page).fill(LOGIN_EMAIL); + await loginPasswordLocator(page).fill(LOGIN_PASSWORD); await loginButtonLocator(page).click(); - - await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN }); + await page.waitForURL('**/dashboard', { timeout: 25_000 }); await waitAppSettled(page); const dashboardEvidence = [ @@ -79,45 +102,38 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', page.locator('img[src*="avataaars"]').first(), page.locator('img[alt],[alt="User Avatar"]').first() ]; - await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD); + await expectAnyVisible(page, dashboardEvidence, 20_000); }); 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(TEST_PASSWORD); - - await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + await loginEmailLocator(page).fill('ทดสอบภาษาไทย'); + await loginPasswordLocator(page).fill(LOGIN_PASSWORD); + const errorHint = page.getByText('ห้ามใส่ภาษาไทย'); + await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); }); test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - - await emailLocator(page).fill('test@domain'); - await passwordLocator(page).fill(TEST_PASSWORD); + await loginEmailLocator(page).fill('test@domain'); + await loginPasswordLocator(page).fill(LOGIN_PASSWORD); await loginButtonLocator(page).click(); await waitAppSettled(page); - - await expect( - page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first() - ).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)'); + await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); }); test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => { await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' }); await waitAppSettled(page); - - await emailLocator(page).fill(TEST_EMAIL); - await passwordLocator(page).fill('wrong-password-123'); + await loginEmailLocator(page).fill(LOGIN_EMAIL); + await loginPasswordLocator(page).fill('wrong-password-123'); await loginButtonLocator(page).click(); await waitAppSettled(page); - - await expect( - page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first() - ).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'); + await expect(errorHint.first()).toBeVisible({ timeout: 12_000 }); }); }); @@ -126,22 +142,21 @@ 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: TIMEOUT.PAGE_LOAD }); - await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await expect(regHeading(page)).toBeVisible({ timeout: 15_000 }); + await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 }); }); 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: TIMEOUT.PAGE_LOAD }); + await page.waitForURL('**/auth/login', { timeout: 15_000 }); }); 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, 'นาย'); @@ -153,18 +168,15 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', await regSubmit(page).click(); await waitAppSettled(page); - // รอ 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 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); + const result = await Promise.race([navToLogin, successToast, anyError]); if (result === 'error') { - const errs = await regErrorBox(page).allInnerTexts().catch(() => []); - throw new Error(`Register failed with errors: ${errs.join(' | ')}`); + throw new Error('Register errors visible'); } - // ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง if (!page.url().includes('/auth/login')) { const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false); if (hasSuccess) { @@ -173,28 +185,24 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', } } - 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 }); + 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 }); }); 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(); // 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 }); + 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 }); }); 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, 'นาย'); @@ -202,14 +210,11 @@ 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?'); // mismatch + await regConfirmPassword(page).fill('Admin12345?'); 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: TIMEOUT.ELEMENT }); + const mismatchErr = page.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i })); + await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 }); }); }); @@ -230,12 +235,13 @@ test.describe('ระบบยืนยันตัวตน (Authentication)', test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => { await forgotEmail(page).fill('ฟฟฟฟ'); await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click(); - await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT }); + const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first(); + await expect(err).toBeVisible({ timeout: 10_000 }); }); test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => { await forgotBackLink(page).click(); - await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT }); + await page.waitForURL('**/auth/login', { timeout: 10_000 }); await expect(page).toHaveURL(/\/auth\/login/i); }); @@ -251,10 +257,9 @@ 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: TIMEOUT.ELEMENT }); + await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 }); 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 6391fca6..136ad904 100644 --- a/Frontend-Learner/tests/e2e/classroom.spec.ts +++ b/Frontend-Learner/tests/e2e/classroom.spec.ts @@ -1,175 +1,95 @@ -/** - * @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'; -// ========================================== -// 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' } } - ] - })); +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; - 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 waitAppSettled(page: any) { + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(200); } -// ========================================== -// Tests -// ========================================== -test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => { +// ฟังก์ชันจำลองล็อกอิน +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)', () => { test.beforeEach(async ({ page }) => { await setupLogin(page); }); - // -------------------------------------------------- - // Section 1: ห้องเรียน (Classroom & Learning) - // -------------------------------------------------- - test.describe('ห้องเรียน (Classroom Layout & Access)', () => { + 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 }); - test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => { - await page.goto(`${BASE_URL}/classroom/learning?course_id=1`); + const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first(); + await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 }); - // 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 }); - - 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(); - }); - - 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 }); - } - }); + // 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM + const sidebar = page.locator('.q-drawer').first(); + if (!await sidebar.isVisible()) { + await menuCurriculumBtn.click(); + } + await expect(sidebar).toBeVisible(); }); - // -------------------------------------------------- - // Section 2: แบบทดสอบ (Quiz System) - // -------------------------------------------------- - test.describe('แบบทดสอบ (Quiz System)', () => { - - 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('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(); }); - 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 }); - }); + 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.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(); + + 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 }); + } + }); + }); diff --git a/Frontend-Learner/tests/e2e/discovery.spec.ts b/Frontend-Learner/tests/e2e/discovery.spec.ts index 52cf9ea3..2783d7da 100644 --- a/Frontend-Learner/tests/e2e/discovery.spec.ts +++ b/Frontend-Learner/tests/e2e/discovery.spec.ts @@ -1,103 +1,91 @@ -/** - * @file discovery.spec.ts - * @description ทดสอบหมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse) - */ import { test, expect } from '@playwright/test'; -import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; 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: TIMEOUT.PAGE_LOAD }); - + await expect(heroTitle).toBeVisible({ timeout: 15_000 }); + 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: TIMEOUT.ELEMENT }); + await expect(courseSectionHeading).toBeVisible({ timeout: 10_000 }); 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: TIMEOUT.PAGE_LOAD }); + await expect(courseCards.first()).toBeVisible({ timeout: 15_000 }); 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`); - await waitAppSettled(page); - const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first(); - await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT }); - await searchInput.fill('Python'); + await searchInput.fill('การเขียนโปรแกรม'); await searchInput.press('Enter'); - await waitAppSettled(page); - // ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state + // ในหน้า browse จะใช้ ซึ่ง render เป็น tag const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first(); - 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 }); + await expect(searchResults).toBeVisible({ timeout: 15_000 }); }); 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: TIMEOUT.PAGE_LOAD }); + await expect(courseCard).toBeVisible({ timeout: 15_000 }); } - - 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}/course/1`); - await waitAppSettled(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}`); + const courseTitle = page.locator('h1').first(); - await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + await expect(courseTitle).toBeVisible({ timeout: 15_000 }); 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}/course/1`); - await waitAppSettled(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(() => {}); const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first(); - await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT }); - - await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true }); + await expect(enrollStartBtn).toBeVisible({ timeout: 10_000 }); }); }); }); diff --git a/Frontend-Learner/tests/e2e/forgot-password.spec.ts b/Frontend-Learner/tests/e2e/forgot-password.spec.ts new file mode 100644 index 00000000..24260d3e --- /dev/null +++ b/Frontend-Learner/tests/e2e/forgot-password.spec.ts @@ -0,0 +1,102 @@ +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 deleted file mode 100644 index 6af1b81b..00000000 --- a/Frontend-Learner/tests/e2e/helpers.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @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 new file mode 100644 index 00000000..7a033655 --- /dev/null +++ b/Frontend-Learner/tests/e2e/login.spec.ts @@ -0,0 +1,122 @@ +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 new file mode 100644 index 00000000..178d2abc --- /dev/null +++ b/Frontend-Learner/tests/e2e/quiz.spec.ts @@ -0,0 +1,125 @@ +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 new file mode 100644 index 00000000..5d3c6903 --- /dev/null +++ b/Frontend-Learner/tests/e2e/register.spec.ts @@ -0,0 +1,241 @@ +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); + + // ถ้าเป็น