Files
huike-e2e-moicen/tests/course-package.spec.ts
T
weli 2688904a5d fix(course-package): fix teacher UI test cookie injection
Read CurrentUserRole from cookies not localStorage. Add cookies with
.moicen.com domain. Navigate directly to /course-packages after
injection so router guard picks up the auth cookie.

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

306 lines
12 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 无 AuthCore 中间件,不支持 ?unionid= 登录;
// 先在 music-room 域名登录获取 JWT,再注入 teacher 域名的 cookie+localStorage。
await page.goto(`${teacherBase}/`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
// Step 1: 在 music-room 域登录以获取 JWT
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 会话未处于可用登录态');
}
// Step 2: 从 music-room 提取 auth 数据(JWT 在 localStorage,角色在 cookie
const jwt = await page.evaluate(() =>
window.localStorage.getItem('Authorization'),
);
expect(jwt, '应有 JWT').toBeTruthy();
const currentOrgId = await page.evaluate(() =>
window.localStorage.getItem('CurrentOrgId'),
);
const musicRoomCookies = await page.context().cookies();
const findCookie = (name: string) =>
musicRoomCookies.find((c) => c.name === name)?.value;
const currentRole = findCookie('CurrentUserRole') || 'TEACHER';
const currentUserName = findCookie('CurrentUserName') || 'e2e-test';
// Step 3: 跳转到 teacher 域名,注入 cookie + localStorage
await page.goto(`${teacherBase}/`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
const teacherDomain = '.moicen.com';
await page.context().addCookies([
{ name: 'HtyTeacherToken', value: jwt!, domain: teacherDomain, path: '/' },
{ name: 'CurrentUserRole', value: currentRole, domain: teacherDomain, path: '/' },
{ name: 'CurrentUserName', value: currentUserName, domain: teacherDomain, path: '/' },
]);
await page.evaluate(
({ orgId }) => {
if (orgId) window.localStorage.setItem('CurrentOrgId', orgId);
},
{ orgId: currentOrgId },
);
// Step 4: 刷新,然后直接导航到课包页(路由守卫会读 cookie 放行)
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
// Step 5: 导航到 /course-packages,路由守卫应识别 HtyTeacherToken cookie 并放行
await page.goto(`${teacherBase}/course-packages`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
// Step 6: 路由守卫异步 fetch 用户信息后,渲染导航栏与课包内容
// 等待导航栏渲染(表明已登录),同时检查"课包"入口可见
await page.waitForTimeout(3000);
const navLink = page.locator('nav a').filter({ hasText: '课包' });
await expect(navLink).toBeVisible({ timeout: 30_000 });
// Step 7: 页面应渲染课包列表(含预置种子数据)
await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('声乐基础训练')).toBeVisible({ timeout: 10_000 });
});
});