a2b7825188
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>
896 lines
38 KiB
TypeScript
896 lines
38 KiB
TypeScript
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_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<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 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 });
|
||
});
|
||
|
||
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 });
|
||
});
|
||
});
|