import { expect, test } from './fixtures'; import { decodeJwtPayload } from './helpers/music-room-session'; const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); const kcBase = process.env.HUIKE_ADMIN_BASE_URL?.trim() || 'https://admin.moicen.com'; const teacherBase = process.env.HTYTEACHER_BASE_URL?.trim() || 'https://teacher.moicen.com'; /** * 从 JWT payload 中提取 role_key 字符串数组(可能嵌在 `sub` JSON 字段内)。 * roles 是对象数组(含 `role_key` 字段),也可能在顶层。 */ function extractRoleKeys(payload: Record | null): string[] { if (!payload) return []; const rawRoles: unknown[] = (Array.isArray(payload.roles) ? payload.roles : []) as unknown[]; if (rawRoles.length === 0) { const subRaw = payload.sub; if (typeof subRaw === 'string') { try { const sub = JSON.parse(subRaw) as Record; if (Array.isArray(sub.roles)) { rawRoles.push(...(sub.roles as unknown[])); } } catch { /* 非 JSON sub,忽略 */ } } } return rawRoles .map((r) => typeof r === 'string' ? r : (r as Record)?.role_key as string | undefined, ) .filter((k): k is string => typeof k === 'string' && k.length > 0); } /** 共用的登录 + JWT 提取逻辑 */ async function loginAndGetJwt(page: any): Promise { const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' }); await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); if ( await page.getByText('请选择您的登录身份').isVisible().catch(() => false) ) { await page.locator('.van-grid-item').first().click(); } await expect( page.getByText(/请选择您的登录身份|欢迎回来|进入工作台/), ).toBeVisible({ timeout: 120_000 }); if ( await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) ) { test.skip(true, '当前 unionid 会话未处于可用登录态'); } const authToken = await page.evaluate(() => window.localStorage.getItem('Authorization'), ); expect(authToken, '登录后应有 JWT').toBeTruthy(); return authToken!; } test.describe('课包(course_package)', () => { test.describe.configure({ timeout: 180_000 }); test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONID(Secret 或 .env.e2e)'); test('权限校验:当前账号 JWT 应包含 TEACHER 或 SUPERVISOR 角色', async ({ page, }) => { const authToken = await loginAndGetJwt(page); const payload = decodeJwtPayload(authToken); expect(payload, 'JWT 应可解码').not.toBeNull(); const roles = extractRoleKeys(payload); const hasCoursePkgRole = roles.some((r) => ['TEACHER', 'SUPERVISOR'].includes(r), ); expect( hasCoursePkgRole, `课包仅对 TEACHER / SUPERVISOR 开放,当前角色:${JSON.stringify(roles)}`, ).toBe(true); }); test('API CRUD 全链路:创建 → 按 ID 查询 → 更新 → 逻辑删除', async ({ page, request, }) => { const authToken = await loginAndGetJwt(page); const headers: Record = { Authorization: authToken, HtySudoerToken: authToken, HtyHost: new URL(kcBase).hostname, }; const pkgName = `e2e-test-pkg-${Date.now()}`; let pkgId: string; // ---- CREATE ---- const createRes = await request.post(`${kcBase}/api/v1/clazz/course-package/create`, { headers: { ...headers, 'Content-Type': 'application/json' }, data: { package_name: pkgName, description: 'e2e test course package', total_lessons: 10, original_price: 200000, selling_price: 150000, validity_days: 365, sort_order: 1, }, }); expect(createRes.ok(), `CREATE HTTP ${createRes.status()}`).toBeTruthy(); const createBody = await createRes.json(); expect(createBody.r, `CREATE 业务失败: ${JSON.stringify(createBody)}`).toBe(true); expect(createBody.d?.id).toBeTruthy(); expect(createBody.d?.package_name).toBe(pkgName); pkgId = createBody.d.id; // ---- FIND by ID ---- const findRes = await request.get( `${kcBase}/api/v1/clazz/course-package/${pkgId}`, { headers }, ); expect(findRes.ok(), `FIND HTTP ${findRes.status()}`).toBeTruthy(); const findBody = await findRes.json(); expect(findBody.r, `FIND 业务失败: ${JSON.stringify(findBody)}`).toBe(true); expect(findBody.d?.package_name).toBe(pkgName); expect(findBody.d?.total_lessons).toBe(10); // ---- UPDATE ---- const updatedName = `${pkgName}-updated`; const updateRes = await request.post(`${kcBase}/api/v1/clazz/course-package/update`, { headers: { ...headers, 'Content-Type': 'application/json' }, data: { id: pkgId, package_name: updatedName, selling_price: 100000, }, }); expect(updateRes.ok(), `UPDATE HTTP ${updateRes.status()}`).toBeTruthy(); const updateBody = await updateRes.json(); expect(updateBody.r, `UPDATE 业务失败: ${JSON.stringify(updateBody)}`).toBe(true); expect(updateBody.d?.package_name).toBe(updatedName); expect(updateBody.d?.selling_price).toBe(100000); // ---- DELETE (logic delete) ---- const deleteRes = await request.post( `${kcBase}/api/v1/clazz/course-package/delete/${pkgId}`, { headers }, ); expect(deleteRes.ok(), `DELETE HTTP ${deleteRes.status()}`).toBeTruthy(); const deleteBody = await deleteRes.json(); expect(deleteBody.r, `DELETE 业务失败: ${JSON.stringify(deleteBody)}`).toBe(true); }); test('分页查询:my-packages / org-packages 返回预置种子数据', async ({ page, request, }) => { const authToken = await loginAndGetJwt(page); const headers: Record = { Authorization: authToken, HtySudoerToken: authToken, HtyHost: new URL(kcBase).hostname, }; // ---- my-packages(当前教师创建的课包,含 INACTIVE)---- const myRes = await request.get( `${kcBase}/api/v1/clazz/course-package/my-packages?page=1&page_size=10`, { headers }, ); expect(myRes.ok(), `my-packages HTTP ${myRes.status()}`).toBeTruthy(); const myBody = await myRes.json(); expect(myBody.r, `my-packages 业务失败: ${JSON.stringify(myBody)}`).toBe(true); const myList: any[] = myBody.d?.[0] ?? []; expect(myList.length).toBeGreaterThanOrEqual(3); // 应包含预置种子:钢琴一对一课程、声乐基础训练、乐理知识速成 const myNames = myList.map((p: any) => p.package_name); expect(myNames).toContain('钢琴一对一课程'); expect(myNames).toContain('声乐基础训练'); expect(myNames).toContain('乐理知识速成'); expect(typeof myBody.d?.[1] === 'number').toBe(true); // total pages expect(typeof myBody.d?.[2] === 'number').toBe(true); // total count // ---- org-packages(机构下活跃课包,仅 ACTIVE,按 sort_order 升序)---- const orgRes = await request.get( `${kcBase}/api/v1/clazz/course-package/org-packages?page=1&page_size=10`, { headers }, ); expect(orgRes.ok(), `org-packages HTTP ${orgRes.status()}`).toBeTruthy(); const orgBody = await orgRes.json(); expect(orgBody.r, `org-packages 业务失败: ${JSON.stringify(orgBody)}`).toBe(true); const orgList: any[] = orgBody.d?.[0] ?? []; // 应包含 2 个 ACTIVE 种子,不包含 INACTIVE 的「乐理知识速成」 expect(orgList.length).toBeGreaterThanOrEqual(2); const orgNames = orgList.map((p: any) => p.package_name); expect(orgNames).toContain('钢琴一对一课程'); expect(orgNames).toContain('声乐基础训练'); expect(orgNames).not.toContain('乐理知识速成'); // 按 sort_order 升序排列,钢琴一对一(sort=1)在前 const pianoIdx = orgNames.indexOf('钢琴一对一课程'); const vocalIdx = orgNames.indexOf('声乐基础训练'); expect(pianoIdx).toBeLessThan(vocalIdx); expect(typeof orgBody.d?.[1] === 'number').toBe(true); expect(typeof orgBody.d?.[2] === 'number').toBe(true); }); test('教师端 UI:导航栏出现"课包"入口,页面加载种子数据', async ({ page, }) => { // 教师端现已支持 ?unionid=X&status=2 直接登录(UNIONID_LOGIN 开关), // 无需再绕 music-room 域提取 JWT 再注入 cookie。 const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' }); await page.goto(`${teacherBase}/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // 路由守卫检测 ?unionid=X&status=2 后调用 login() → sudo() → read() → ensureOrgContext() // 完成后 redirect 到 /(query params 被剥离)。整个过程可能耗时 ~15s(多次 API 调用)。 // 等待 URL 中 status=2 消失,说明登录 + redirect 已完成 await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 90_000 }, ); // 首次登录可能会出现角色选择弹窗(Bootstrap modal) const roleChooser = page.locator('.modal-title').filter({ hasText: '选择角色' }); if (await roleChooser.isVisible({ timeout: 10_000 }).catch(() => false)) { await page.locator('.modal .btn-primary').filter({ hasText: '确认' }).click(); // 角色选择后会 full page reload(window.location.href = "/"),等待 reload 完成 await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); } // 导航栏渲染即表示已登录,检查"课包"入口 const navLink = page.locator('nav a').filter({ hasText: '课包' }); await expect(navLink).toBeVisible({ timeout: 30_000 }); // 导航到课包页并验证列表渲染 await page.goto(`${teacherBase}/course-packages`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // 页面应渲染课包列表(含预置种子数据) await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('声乐基础训练')).toBeVisible({ timeout: 10_000 }); }); }); test.describe('音乐教室端(huike-front)课包 UI', () => { test.describe.configure({ timeout: 180_000 }); test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONID(Secret 或 .env.e2e)'); test('教学资源库导航:课包入口可见,列表页加载种子数据', async ({ page }) => { // 登录音乐教室前端 const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' }); await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // 等待登录 redirect 完成(status=2 消失) await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 90_000 }, ); // 角色选择(Vant 风格网格,仅多角色时出现) if (await page.getByText('请选择您的登录身份').isVisible().catch(() => false)) { await page.locator('.van-grid-item').first().click(); // 选择后可能 reload await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 30_000 }, ); } // 导航到教学资源库 await page.goto('/course/summary', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // 如果是多机构用户,可能进入机构选择页 if (page.url().includes('/org/select')) { const cell = page.locator('.van-cell-group .van-cell').first(); await expect(cell).toBeVisible({ timeout: 30_000 }); await cell.click(); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); } // 验证"课包"入口链接 const coursePkgLink = page.locator('.van-cell').filter({ hasText: '课包' }); await expect(coursePkgLink).toBeVisible({ timeout: 10_000 }); // 点击进入课包列表 await coursePkgLink.click(); await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 }); // 应该导航到 /course/course-package await expect(page).toHaveURL(/\/course\/course-package/); // 验证列表渲染种子数据 await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('声乐基础训练')).toBeVisible({ timeout: 10_000 }); }); test('课包列表页:Tab 切换、搜索框、新增按钮可见', async ({ page }) => { // 登录并解析机构上下文 const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' }); await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 90_000 }, ); if (await page.getByText('请选择您的登录身份').isVisible().catch(() => false)) { await page.locator('.van-grid-item').first().click(); await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 30_000 }, ); } // 直接导航到课包列表页(resolveOrgContextForCoursePage 会处理机构选择) const { resolveOrgContextForCoursePage } = await import('./helpers/music-room-session'); await resolveOrgContextForCoursePage(page); // 导航到课包列表 await page.goto('/course/course-package', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 }); // 验证搜索框 await expect(page.locator('.van-search')).toBeVisible({ timeout: 10_000 }); // 验证 Tab 切换 const myTab = page.locator('.van-tab').filter({ hasText: '我的课包' }); await expect(myTab).toBeVisible({ timeout: 10_000 }); const orgTab = page.locator('.van-tab').filter({ hasText: '机构课包' }); await expect(orgTab).toBeVisible({ timeout: 10_000 }); // 验证新增按钮 await expect(page.locator('.btn-add')).toBeVisible({ timeout: 10_000 }); // 默认 Tab 是我的课包,应有种子数据 await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 }); // 切换到机构课包 Tab await orgTab.click(); await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 }); }); test('课包新增页面:表单字段完整,可提交', async ({ page }) => { // 登录 const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' }); await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 90_000 }, ); if (await page.getByText('请选择您的登录身份').isVisible().catch(() => false)) { await page.locator('.van-grid-item').first().click(); await page.waitForFunction( () => !window.location.search.includes('status=2'), { timeout: 30_000 }, ); } // 处理机构选择 const { resolveOrgContextForCoursePage } = await import('./helpers/music-room-session'); await resolveOrgContextForCoursePage(page); // 导航到新增页 await page.goto('/course/course-package/add', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 }); // 验证表单字段存在 await expect(page.locator('input[name="package_name"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('textarea[name="description"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[name="total_lessons"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[name="original_price"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[name="selling_price"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[name="validity_days"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[name="sort_order"]')).toBeVisible({ timeout: 10_000 }); // 验证提交按钮 await expect(page.getByText('保存')).toBeVisible({ timeout: 10_000 }); }); });