2026-04-30 08:31:04 +08:00
|
|
|
|
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';
|
2026-04-30 10:36:20 +08:00
|
|
|
|
const teacherBase =
|
|
|
|
|
|
process.env.HTYTEACHER_BASE_URL?.trim() || 'https://teacher.moicen.com';
|
2026-04-30 08:31:04 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-30 08:41:54 +08:00
|
|
|
|
* 从 JWT payload 中提取 role_key 字符串数组(可能嵌在 `sub` JSON 字段内)。
|
|
|
|
|
|
* roles 是对象数组(含 `role_key` 字段),也可能在顶层。
|
2026-04-30 08:31:04 +08:00
|
|
|
|
*/
|
2026-04-30 08:41:54 +08:00
|
|
|
|
function extractRoleKeys(payload: Record<string, unknown> | null): string[] {
|
2026-04-30 08:31:04 +08:00
|
|
|
|
if (!payload) return [];
|
2026-04-30 08:41:54 +08:00
|
|
|
|
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,忽略 */
|
|
|
|
|
|
}
|
2026-04-30 08:31:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-30 08:41:54 +08:00
|
|
|
|
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);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 08:58:15 +08:00
|
|
|
|
/** 共用的登录 + 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!;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 08:31:04 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}) => {
|
2026-04-30 08:58:15 +08:00
|
|
|
|
const authToken = await loginAndGetJwt(page);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
|
2026-04-30 08:58:15 +08:00
|
|
|
|
const payload = decodeJwtPayload(authToken);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
expect(payload, 'JWT 应可解码').not.toBeNull();
|
|
|
|
|
|
|
2026-04-30 08:41:54 +08:00
|
|
|
|
const roles = extractRoleKeys(payload);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}) => {
|
2026-04-30 08:58:15 +08:00
|
|
|
|
const authToken = await loginAndGetJwt(page);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
|
|
|
|
|
|
const headers: Record<string, string> = {
|
2026-04-30 08:58:15 +08:00
|
|
|
|
Authorization: authToken,
|
|
|
|
|
|
HtySudoerToken: authToken,
|
2026-04-30 08:31:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 08:58:15 +08:00
|
|
|
|
test('分页查询:my-packages / org-packages 返回预置种子数据', async ({
|
2026-04-30 08:31:04 +08:00
|
|
|
|
page,
|
|
|
|
|
|
request,
|
|
|
|
|
|
}) => {
|
2026-04-30 08:58:15 +08:00
|
|
|
|
const authToken = await loginAndGetJwt(page);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
|
|
|
|
|
|
const headers: Record<string, string> = {
|
2026-04-30 08:58:15 +08:00
|
|
|
|
Authorization: authToken,
|
|
|
|
|
|
HtySudoerToken: authToken,
|
2026-04-30 08:31:04 +08:00
|
|
|
|
HtyHost: new URL(kcBase).hostname,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-30 08:58:15 +08:00
|
|
|
|
// ---- my-packages(当前教师创建的课包,含 INACTIVE)----
|
2026-04-30 08:31:04 +08:00
|
|
|
|
const myRes = await request.get(
|
2026-04-30 08:58:15 +08:00
|
|
|
|
`${kcBase}/api/v1/clazz/course-package/my-packages?page=1&page_size=10`,
|
2026-04-30 08:31:04 +08:00
|
|
|
|
{ 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);
|
2026-04-30 08:58:15 +08:00
|
|
|
|
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('乐理知识速成');
|
2026-04-30 08:31:04 +08:00
|
|
|
|
expect(typeof myBody.d?.[1] === 'number').toBe(true); // total pages
|
|
|
|
|
|
expect(typeof myBody.d?.[2] === 'number').toBe(true); // total count
|
|
|
|
|
|
|
2026-04-30 08:58:15 +08:00
|
|
|
|
// ---- org-packages(机构下活跃课包,仅 ACTIVE,按 sort_order 升序)----
|
2026-04-30 08:31:04 +08:00
|
|
|
|
const orgRes = await request.get(
|
2026-04-30 08:58:15 +08:00
|
|
|
|
`${kcBase}/api/v1/clazz/course-package/org-packages?page=1&page_size=10`,
|
2026-04-30 08:31:04 +08:00
|
|
|
|
{ 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);
|
2026-04-30 08:58:15 +08:00
|
|
|
|
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);
|
2026-04-30 08:31:04 +08:00
|
|
|
|
expect(typeof orgBody.d?.[1] === 'number').toBe(true);
|
|
|
|
|
|
expect(typeof orgBody.d?.[2] === 'number').toBe(true);
|
|
|
|
|
|
});
|
2026-04-30 10:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
test('教师端 UI:导航栏出现"课包"入口,页面加载种子数据', async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
2026-04-30 10:50:44 +08:00
|
|
|
|
// 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
|
2026-04-30 10:36:20 +08:00
|
|
|
|
const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' });
|
2026-04-30 10:50:44 +08:00
|
|
|
|
await page.goto(`/?${q.toString()}`, {
|
2026-04-30 10:36:20 +08:00
|
|
|
|
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 会话未处于可用登录态');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 10:50:44 +08:00
|
|
|
|
// Step 2: 从 music-room 提取 auth 数据
|
|
|
|
|
|
const jwt = await page.evaluate(() =>
|
|
|
|
|
|
window.localStorage.getItem('Authorization'),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(jwt, '应有 JWT').toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
const currentRole = await page.evaluate(() =>
|
|
|
|
|
|
window.localStorage.getItem('CurrentUserRole'),
|
|
|
|
|
|
);
|
|
|
|
|
|
const currentOrgId = await page.evaluate(() =>
|
|
|
|
|
|
window.localStorage.getItem('CurrentOrgId'),
|
|
|
|
|
|
);
|
|
|
|
|
|
const currentUserName = await page.evaluate(() =>
|
|
|
|
|
|
window.localStorage.getItem('CurrentUserName'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 3: 跳转到 teacher 域名,注入 cookie + localStorage
|
|
|
|
|
|
await page.goto(`${teacherBase}/`, {
|
|
|
|
|
|
waitUntil: 'domcontentloaded',
|
|
|
|
|
|
timeout: 60_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const teacherHost = new URL(teacherBase).hostname;
|
|
|
|
|
|
await page.context().addCookies([
|
|
|
|
|
|
{ name: 'HtyTeacherToken', value: jwt!, domain: teacherHost, path: '/' },
|
|
|
|
|
|
{ name: 'CurrentUserRole', value: currentRole || 'TEACHER', domain: teacherHost, path: '/' },
|
|
|
|
|
|
{ name: 'CurrentUserName', value: currentUserName || '', domain: teacherHost, path: '/' },
|
|
|
|
|
|
]);
|
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
|
({ orgId }) => {
|
|
|
|
|
|
if (orgId) window.localStorage.setItem('CurrentOrgId', orgId);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ orgId: currentOrgId },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 4: 刷新页面,教师端应识别已登录态
|
|
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
|
|
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Step 5: 等待导航栏渲染,检查"课包"入口(仅 TEACHER / SUPERVISOR 可见)
|
|
|
|
|
|
// 首次加载可能要等 useUser() 异步 fetch 完成
|
|
|
|
|
|
await page.waitForTimeout(5000);
|
2026-04-30 10:36:20 +08:00
|
|
|
|
const navLink = page.locator('nav a').filter({ hasText: '课包' });
|
|
|
|
|
|
await expect(navLink).toBeVisible({ timeout: 30_000 });
|
|
|
|
|
|
|
2026-04-30 10:50:44 +08:00
|
|
|
|
// Step 6: 点击进入课包管理页
|
2026-04-30 10:36:20 +08:00
|
|
|
|
await navLink.click();
|
|
|
|
|
|
await page.waitForURL('**/course-packages', { timeout: 30_000 });
|
|
|
|
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 30_000 });
|
|
|
|
|
|
|
2026-04-30 10:50:44 +08:00
|
|
|
|
// Step 7: 页面应渲染课包列表(含预置种子数据)
|
2026-04-30 10:36:20 +08:00
|
|
|
|
await expect(page.getByText('钢琴一对一课程')).toBeVisible({ timeout: 30_000 });
|
|
|
|
|
|
await expect(page.getByText('声乐基础训练')).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
});
|
2026-04-30 08:31:04 +08:00
|
|
|
|
});
|