Files
huike-e2e-moicen/tests/course-package.spec.ts
T
weli 524c27c278 test(course-package): add music-room frontend UI tests for course-package
Verify: nav link from summary page, list page with tabs/search/add button,
and form fields on add page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 13:58:44 +08:00

417 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | 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<string, unknown>;
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<string, unknown>)?.role_key as string | undefined,
)
.filter((k): k is string => typeof k === 'string' && k.length > 0);
}
/** 共用的登录 + JWT 提取逻辑 */
async function loginAndGetJwt(page: any): Promise<string> {
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_UNIONIDSecret 或 .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<string, string> = {
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<string, string> = {
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 reloadwindow.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_UNIONIDSecret 或 .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 });
});
});