Files
huike-e2e-moicen/tests/course-package.spec.ts
T
weli a2b7825188 Fix course-package:610 flaky — use expect.poll for post-save API check
Replace fixed waitForTimeout(2000) with expect.poll({ timeout: 15_000 })
so the API verification retries until the created package and its item
association are queryable. CI is slower than local, causing false failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:09:38 +08:00

896 lines
38 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();
}
// Wait for JWT to appear in localStorage (login completed)
await page.waitForFunction(
() => !!window.localStorage.getItem('Authorization'),
{ timeout: 90_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('course_group org_visible 开关全链路:创建 → 设为机构可见 → 查询可见列表 → 取消可见 → 验证移除', async ({
page,
request,
}) => {
const authToken = await loginAndGetJwt(page);
const headers: Record<string, string> = {
Authorization: authToken,
HtySudoerToken: authToken,
HtyHost: new URL(kcBase).hostname,
};
const groupName = `e2e-test-org-visible-${Date.now()}`;
let groupId: string;
// ---- CREATE course_group ----
const createRes = await request.post(`${kcBase}/api/v1/ws/create_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { group_name: groupName },
});
expect(createRes.ok(), `CREATE GROUP HTTP ${createRes.status()}`).toBeTruthy();
const createBody = await createRes.json();
expect(createBody.r, `CREATE GROUP 业务失败: ${JSON.stringify(createBody)}`).toBe(true);
// create_course_group 返回 HtyResponse<String>d 是 UUID 字符串
expect(createBody.d).toBeTruthy();
groupId = createBody.d;
try {
// ---- UPDATE org_visible=true ----
const updateVisibleRes = await request.post(`${kcBase}/api/v1/ws/update_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { id: groupId, group_name: groupName, org_visible: true },
});
expect(updateVisibleRes.ok(), `UPDATE VISIBLE HTTP ${updateVisibleRes.status()}`).toBeTruthy();
const updateVisibleBody = await updateVisibleRes.json();
expect(updateVisibleBody.r, `UPDATE VISIBLE 业务失败: ${JSON.stringify(updateVisibleBody)}`).toBe(true);
// ---- FIND org-visible groups - should include our group ----
const visibleRes = await request.get(`${kcBase}/api/v1/ws/find_org_visible_course_groups`, {
headers,
});
expect(visibleRes.ok(), `FIND VISIBLE HTTP ${visibleRes.status()}`).toBeTruthy();
const visibleBody = await visibleRes.json();
expect(visibleBody.r, `FIND VISIBLE 业务失败: ${JSON.stringify(visibleBody)}`).toBe(true);
const visibleList: any[] = visibleBody.d ?? [];
const foundVisible = visibleList.some((g: any) => g.id === groupId);
expect(foundVisible, 'org_visible=true 的分组应出现在机构可见列表中').toBe(true);
// ---- UPDATE org_visible=false ----
const updateInvisibleRes = await request.post(`${kcBase}/api/v1/ws/update_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { id: groupId, group_name: groupName, org_visible: false },
});
expect(updateInvisibleRes.ok(), `UPDATE INVISIBLE HTTP ${updateInvisibleRes.status()}`).toBeTruthy();
// ---- FIND org-visible groups - should NOT include our group ----
const invisibleRes = await request.get(`${kcBase}/api/v1/ws/find_org_visible_course_groups`, {
headers,
});
expect(invisibleRes.ok(), `FIND INVISIBLE HTTP ${invisibleRes.status()}`).toBeTruthy();
const invisibleBody = await invisibleRes.json();
expect(invisibleBody.r, `FIND INVISIBLE 业务失败: ${JSON.stringify(invisibleBody)}`).toBe(true);
const invisibleList: any[] = invisibleBody.d ?? [];
const foundInvisible = invisibleList.some((g: any) => g.id === groupId);
expect(foundInvisible, 'org_visible=false 的分组不应出现在机构可见列表中').toBe(false);
} finally {
// ---- CLEANUP: delete course_group ----
await request.post(`${kcBase}/api/v1/ws/delete_course_group/${groupId}`, { headers });
}
});
test('course_package_item sync 全链路(SUPERVISOR):创建分组 → 设 org_visible → 创建课包 → sync → 列表验证', async ({
page,
request,
}) => {
const authToken = await loginAndGetJwt(page);
const payload = decodeJwtPayload(authToken);
const roles = extractRoleKeys(payload);
const isSupervisor = roles.includes('SUPERVISOR');
test.skip(!isSupervisor, '当前账号无 SUPERVISOR 权限,跳过 sync 测试');
const headers: Record<string, string> = {
Authorization: authToken,
HtySudoerToken: authToken,
HtyHost: new URL(kcBase).hostname,
};
const ts = Date.now();
const groupName = `e2e-test-sync-group-${ts}`;
const pkgName = `e2e-test-sync-pkg-${ts}`;
let groupId: string;
let pkgId: string;
// ---- CREATE course_group with org_visible=true ----
const createGroupRes = await request.post(`${kcBase}/api/v1/ws/create_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { group_name: groupName },
});
expect(createGroupRes.ok(), `CREATE GROUP HTTP ${createGroupRes.status()}`).toBeTruthy();
const createGroupBody = await createGroupRes.json();
expect(createGroupBody.r, `CREATE GROUP 业务失败: ${JSON.stringify(createGroupBody)}`).toBe(true);
// create_course_group 返回 HtyResponse<String>d 是 UUID 字符串
expect(createGroupBody.d).toBeTruthy();
groupId = createGroupBody.d;
await request.post(`${kcBase}/api/v1/ws/update_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { id: groupId, group_name: groupName, org_visible: true },
});
try {
// ---- CREATE course_package ----
const createPkgRes = await request.post(`${kcBase}/api/v1/clazz/course-package/create`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: {
package_name: pkgName,
description: 'e2e test sync',
package_status: 'ACTIVE',
},
});
expect(createPkgRes.ok(), `CREATE PKG HTTP ${createPkgRes.status()}`).toBeTruthy();
const createPkgBody = await createPkgRes.json();
expect(createPkgBody.r, `CREATE PKG 业务失败: ${JSON.stringify(createPkgBody)}`).toBe(true);
expect(createPkgBody.d?.id).toBeTruthy();
pkgId = createPkgBody.d.id;
// ---- SYNC items ----
const syncRes = await request.post(`${kcBase}/api/v1/clazz/course-package/item/sync`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { package_id: pkgId, course_group_ids: [groupId] },
});
expect(syncRes.ok(), `SYNC HTTP ${syncRes.status()}`).toBeTruthy();
const syncBody = await syncRes.json();
expect(syncBody.r, `SYNC 业务失败: ${JSON.stringify(syncBody)}`).toBe(true);
// ---- LIST items ----
const listRes = await request.get(`${kcBase}/api/v1/clazz/course-package/item/list/${pkgId}`, {
headers,
});
expect(listRes.ok(), `LIST ITEMS HTTP ${listRes.status()}`).toBeTruthy();
const listBody = await listRes.json();
expect(listBody.r, `LIST ITEMS 业务失败: ${JSON.stringify(listBody)}`).toBe(true);
const items: any[] = listBody.d ?? [];
const matched = items.some((g: any) => g.course_group_id === groupId);
expect(matched, 'sync 后列表应包含该分组').toBe(true);
} finally {
// ---- CLEANUP ----
if (pkgId) {
await request.post(`${kcBase}/api/v1/clazz/course-package/delete/${pkgId}`, { headers }).catch(() => {});
}
if (groupId) {
await request.post(`${kcBase}/api/v1/ws/delete_course_group/${groupId}`, { headers }).catch(() => {});
}
}
});
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 });
});
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 });
// 验证"包含课节"选择器链接可见
const pickerCell = page.locator('.van-cell').filter({ hasText: '包含课节' });
await expect(pickerCell).toBeVisible({ timeout: 10_000 });
// 验证点击后跳转到选择页
await pickerCell.click();
await expect(page).toHaveURL(/\/course\/group\/pick/, { timeout: 10_000 });
});
test('课包新增:选择课节 → 确认 → 课节应持久保留在新增页', async ({ page, request }) => {
test.info().annotations.push({ type: 'issue', description: '选择课节后确认,回到新增页课节标签不显示,保存后未关联课节' });
// ---- Login (select "教师" role, not the first grid item) ----
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').filter({ hasText: /^教师$/ }).click();
await page.waitForFunction(
() => !window.location.search.includes('status=2'),
{ timeout: 30_000 },
);
}
// Extract JWT for API calls
const authToken = await page.evaluate(() => window.localStorage.getItem('Authorization'));
expect(authToken, '登录后应有 JWT').toBeTruthy();
const headers: Record<string, string> = {
Authorization: authToken!,
HtySudoerToken: authToken!,
HtyHost: new URL(kcBase).hostname,
};
// ---- Create a course_group FIRST (before picker loads data) ----
const groupName = `e2e-ui-picker-${Date.now()}`;
const createRes = await request.post(`${kcBase}/api/v1/ws/create_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { group_name: groupName, org_visible: true },
});
expect(createRes.ok(), `CREATE GROUP HTTP ${createRes.status()}`).toBeTruthy();
const createBody = await createRes.json();
expect(createBody.r, `CREATE GROUP failed: ${JSON.stringify(createBody)}`).toBe(true);
const groupId = createBody.d;
test.info().annotations.push({ type: 'group', description: `created groupId=${groupId}` });
// ---- Resolve org context ----
const { resolveOrgContextForCoursePage } = await import('./helpers/music-room-session');
await resolveOrgContextForCoursePage(page);
let createdPkgId: string | undefined;
try {
// ---- Navigate to course-package add page ----
await page.goto('/course/course-package/add', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 });
// ---- Fill package name (required for form submit) ----
const pkgName = `e2e-ui-pkg-${Date.now()}`;
await page.locator('input[name="package_name"]').fill(pkgName);
// ---- Click "包含课节" to open picker ----
const pickerCell = page.locator('.van-cell').filter({ hasText: '包含课节' });
await expect(pickerCell).toBeVisible({ timeout: 10_000 });
await pickerCell.click();
await expect(page).toHaveURL(/\/course\/group\/pick/, { timeout: 10_000 });
// ---- Switch to "我的课节" tab (default is "机构可见课节" for course-package) ----
await page.locator('.van-tab').filter({ hasText: '我的课节' }).click();
await page.waitForTimeout(1000);
// ---- Select the group we created — it should be in "我的课节" tab ----
const groupItem = page.locator('.course-item').filter({ hasText: groupName });
await expect(groupItem).toBeVisible({ timeout: 30_000 });
const circleIcon = groupItem.locator('.van-icon-circle');
await expect(circleIcon).toBeVisible({ timeout: 5000 });
await circleIcon.click();
await page.waitForTimeout(500);
// ---- Verify selection visual feedback ----
await expect(groupItem.locator('.van-icon-checked')).toBeVisible({ timeout: 5000 });
// ---- Click "确认" button and wait for navigation ----
const confirmBtn = page.locator('.btn-pick');
await expect(confirmBtn).toBeEnabled({ timeout: 5000 });
// Wait for navigation (confirmBtn click → router.back())
await Promise.all([
page.waitForURL(/\/course\/course-package\/add/, { timeout: 15_000 }),
confirmBtn.click(),
]);
await page.waitForTimeout(500);
await page.waitForSelector('input[name="package_name"]', { timeout: 10_000 });
// ---- CRITICAL CHECK: Should see the selected group tag ----
await expect(
page.locator('.van-tag').filter({ hasText: groupName })
).toBeVisible({ timeout: 10_000 });
// ---- Re-fill package name (component was re-created after navigation) ----
await page.locator('input[name="package_name"]').fill(pkgName);
// ---- Submit the form ----
await page.getByText('保存').click();
// ---- Verify via API that package was created with this group ----
// Use poll to retry — save may not be immediately reflected on CI
await expect(async () => {
const myRes = await request.get(
`${kcBase}/api/v1/clazz/course-package/my-packages?page=1&page_size=50`,
{ headers },
);
expect(myRes.ok()).toBeTruthy();
const myBody = await myRes.json();
const myList: any[] = myBody.d?.[0] ?? [];
const newPkg = myList.find((p: any) => p.package_name === pkgName);
expect(newPkg, '创建的课包应出现在我的课包列表中').toBeTruthy();
createdPkgId = newPkg!.id;
const itemsRes = await request.get(
`${kcBase}/api/v1/clazz/course-package/item/list/${createdPkgId}`,
{ headers },
);
expect(itemsRes.ok()).toBeTruthy();
const itemsBody = await itemsRes.json();
const items: any[] = itemsBody.d ?? [];
const matched = items.some((g: any) => g.course_group_id === groupId);
expect(matched, '保存后课包应包含选中的课节分组').toBe(true);
}).toPass({ timeout: 15_000 });
} finally {
// ---- Cleanup ----
if (createdPkgId) {
await request.post(`${kcBase}/api/v1/clazz/course-package/delete/${createdPkgId}`, { headers }).catch(() => {});
}
await request.post(`${kcBase}/api/v1/ws/delete_course_group/${groupId}`, { headers }).catch(() => {});
}
});
test('课包编辑:加载已有课节 → 选择新增课节 → 保存后两组均应存在', async ({ page, request }) => {
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').filter({ hasText: /^教师$/ }).click();
await page.waitForFunction(
() => !window.location.search.includes('status=2'),
{ timeout: 30_000 },
);
}
const authToken = await page.evaluate(() => window.localStorage.getItem('Authorization'));
expect(authToken).toBeTruthy();
const headers: Record<string, string> = {
Authorization: authToken!,
HtySudoerToken: authToken!,
HtyHost: new URL(kcBase).hostname,
};
const groupName1 = `e2e-edit-g1-${Date.now()}`;
const groupName2 = `e2e-edit-g2-${Date.now()}`;
const createG1 = await request.post(`${kcBase}/api/v1/ws/create_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { group_name: groupName1, org_visible: true },
});
const body1 = await createG1.json();
expect(body1.r).toBe(true);
const groupId1 = body1.d;
const createG2 = await request.post(`${kcBase}/api/v1/ws/create_course_group`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { group_name: groupName2, org_visible: true },
});
const body2 = await createG2.json();
expect(body2.r).toBe(true);
const groupId2 = body2.d;
const pkgName = `e2e-edit-pkg-${Date.now()}`;
const createPkg = await request.post(`${kcBase}/api/v1/clazz/course-package/create`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { package_name: pkgName },
});
const pkgBody = await createPkg.json();
expect(pkgBody.r).toBe(true);
const pkgId = pkgBody.d.id;
// Link group1 to package
const syncRes = await request.post(`${kcBase}/api/v1/clazz/course-package/item/sync`, {
headers: { ...headers, 'Content-Type': 'application/json' },
data: { package_id: pkgId, course_group_ids: [groupId1] },
});
expect(syncRes.ok()).toBeTruthy();
try {
const { resolveOrgContextForCoursePage } = await import('./helpers/music-room-session');
await resolveOrgContextForCoursePage(page);
await page.goto(`/course/course-package/edit?id=${pkgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 });
await page.waitForSelector('input[name="package_name"]', { timeout: 10_000 });
await page.waitForTimeout(1000);
// Verify group1 shown (loaded from existing items)
await expect(page.locator('.van-tag').filter({ hasText: groupName1 })).toBeVisible({ timeout: 10_000 });
// Open picker
await page.locator('.van-cell').filter({ hasText: '包含课节' }).click();
await expect(page).toHaveURL(/\/course\/group\/pick/, { timeout: 10_000 });
// Switch to "我的课节" tab and select group2
await page.locator('.van-tab').filter({ hasText: '我的课节' }).click();
await page.waitForTimeout(1000);
const group2Item = page.locator('.course-item').filter({ hasText: groupName2 });
await expect(group2Item).toBeVisible({ timeout: 30_000 });
await group2Item.locator('.van-icon-circle').click();
await page.waitForTimeout(500);
await expect(group2Item.locator('.van-icon-checked')).toBeVisible({ timeout: 5000 });
// Confirm
const confirmBtn = page.locator('.btn-pick');
await expect(confirmBtn).toBeEnabled({ timeout: 5000 });
await Promise.all([
page.waitForURL(/\/course\/course-package\/edit/, { timeout: 15_000 }),
confirmBtn.click(),
]);
await page.waitForTimeout(500);
await page.waitForSelector('input[name="package_name"]', { timeout: 10_000 });
// Verify both groups shown
await expect(page.locator('.van-tag').filter({ hasText: groupName1 })).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.van-tag').filter({ hasText: groupName2 })).toBeVisible({ timeout: 10_000 });
// Submit
await page.getByText('保存').click();
await page.waitForTimeout(2000);
// Verify via API
const itemsRes = await request.get(`${kcBase}/api/v1/clazz/course-package/item/list/${pkgId}`, { headers });
expect(itemsRes.ok()).toBeTruthy();
const itemsBody = await itemsRes.json();
const items: any[] = itemsBody.d ?? [];
const matchedIds = items.map((i: any) => i.course_group_id);
expect(matchedIds).toContain(groupId1);
expect(matchedIds).toContain(groupId2);
} finally {
await request.post(`${kcBase}/api/v1/clazz/course-package/delete/${pkgId}`, { headers }).catch(() => {});
await request.post(`${kcBase}/api/v1/ws/delete_course_group/${groupId1}`, { headers }).catch(() => {});
await request.post(`${kcBase}/api/v1/ws/delete_course_group/${groupId2}`, { headers }).catch(() => {});
}
});
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/group/add', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 });
// 验证"开放给机构"切换开关可见
const orgVisibleCell = page.locator('.van-cell').filter({ hasText: '开放给机构' });
await expect(orgVisibleCell).toBeVisible({ timeout: 10_000 });
// 验证开关组件存在
const switchEl = orgVisibleCell.locator('.van-switch');
await expect(switchEl).toBeVisible({ timeout: 10_000 });
});
});