Files
huike-e2e-moicen/tests/course-package.spec.ts
T
weli 27ba562f39 test(course-package): add teacher UI test verifying nav link and page render
Navigate to teacher.moicen.com, verify "课包" nav link is visible for
teacher/supervisor, click through to /course-packages, assert seeded
data renders. Catches frontend build/deploy regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 10:36:20 +08:00

255 lines
9.8 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,
}) => {
// 在 teacher.moicen.com 域上独立登录(不同 originlocalStorage 不共享)
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 });
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 会话未处于可用登录态');
}
// 导航栏出现"课包"入口(仅 TEACHER / SUPERVISOR 可见)
const navLink = page.locator('nav a').filter({ hasText: '课包' });
await expect(navLink).toBeVisible({ timeout: 30_000 });
// 点击"课包"进入课包管理页
await navLink.click();
await page.waitForURL('**/course-packages', { timeout: 30_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 });
// 页面应渲染课包列表(含预置种子数据)
await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('声乐基础训练')).toBeVisible({ timeout: 10_000 });
});
});