Files
huike-e2e-moicen/tests/org-data-isolation.spec.ts
T
weli 62d9829a0c fix(e2e): 机构页就绪判断改为统一轮询
避免 Promise.race 分支 waitFor 超时直接失败,改为 #app 文本与机构 cell 的组合条件轮询。

Made-with: Cursor
2026-04-28 10:52:28 +08:00

321 lines
11 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 type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const raw = token.startsWith('Bearer ') ? token.slice(7).trim() : token;
const parts = raw.split('.');
if (parts.length !== 3) return null;
try {
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const json = Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
/** 与 `huike-front/src/main.ts` 中 `parseCurrentOrgIdFromToken` 一致:claims 可能在顶层或嵌在 `sub` JSON 内 */
function extractOrgIdFromTokenPayload(
payload: Record<string, unknown> | null
): string | undefined {
if (!payload) return undefined;
const top = payload.current_org_id;
if (typeof top === 'string' && top.length > 0) return top;
const sub = payload.sub;
if (typeof sub !== 'string' || sub.length === 0) return undefined;
try {
const inner = JSON.parse(sub) as Record<string, unknown>;
const innerOrg = inner.current_org_id;
if (typeof innerOrg === 'string' && innerOrg.length > 0) return innerOrg;
} catch {
return undefined;
}
return undefined;
}
/**
* 教学资源相关任意壳层可见即可(`/course/summary` 入口链或课程体系列表)。
* CI 上 SPA 长连接会导致 networkidle 不可靠;此处只用 DOM 信号轮询。
*/
async function waitForCourseRealmVisible(page: Page) {
const deadline = Date.now() + 95_000;
while (Date.now() < deadline) {
const titleHit = await page.title().catch(() => '');
if (/教学资源库|课程体系/.test(titleHit)) {
return;
}
if (
await page.getByRole('link', { name: '课程体系' }).isVisible().catch(() => false)
) {
return;
}
for (const label of ['课程体系', '课程', '课节']) {
if (
await page
.locator('.van-cell')
.filter({ hasText: new RegExp(label) })
.first()
.isVisible()
.catch(() => false)
) {
return;
}
}
if (
await page
.getByPlaceholder('请输入课程体系名称搜索')
.isVisible()
.catch(() => false)
) {
return;
}
if (
await page.locator('.van-search').first().isVisible().catch(() => false)
) {
return;
}
await page.waitForTimeout(400);
}
throw new Error(`教学资源壳层未出现:${page.url()}`);
}
async function establishSession(page: 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 });
if (
await page.getByText('请选择您的登录身份').isVisible().catch(() => false)
) {
await page.locator('.van-grid-item').first().click();
}
await expect(
page.getByText(/请选择您的登录身份|欢迎回来|进入工作台/)
).toBeVisible({ timeout: 120_000 });
}
/**
* 停留在 `/` 时 main.ts 不会把 JWT 中的机构写入 CurrentOrgId。
* 深链会触发守卫;多机构且无上下文时会先进入 `/org/select`,需点选机构后再继续。
* 交替尝试 summary 与课程索引:部分账号在 CI 上只对其中之一稳定渲染壳层。
*/
async function resolveOrgContextForCoursePage(page: Page) {
const deepPaths = ['/course/summary', '/course'] as const;
for (let attempt = 0; attempt < 8; attempt++) {
const targetPath = deepPaths[attempt % deepPaths.length];
await page.goto(targetPath, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
if (page.url().includes('/org/select')) {
await expect(page.getByText(/请选择机构|选择机构/)).toBeVisible({
timeout: 60_000,
});
await Promise.race([
page
.locator('.main .van-cell-group .van-cell')
.first()
.waitFor({ state: 'visible', timeout: 60_000 }),
page.getByText('暂无可用机构').waitFor({ state: 'visible', timeout: 60_000 }),
]);
const emptyOrgs = await page
.getByText('暂无可用机构')
.isVisible()
.catch(() => false);
if (emptyOrgs) {
test.skip(true, '账号无可用机构,跳过机构上下文用例');
}
const pickCells = page.locator('.main .van-cell-group .van-cell');
const n = await pickCells.count();
expect(n, '机构选择页应有可选机构').toBeGreaterThan(0);
await pickCells.first().click();
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
continue;
}
try {
const path = new URL(page.url()).pathname || '';
const onCourseRealm =
path === '/course' || path.startsWith('/course/');
if (onCourseRealm) {
await waitForCourseRealmVisible(page);
return;
}
} catch {
/* URL 解析失败则继续重试 */
}
// 守卫可能暂时送回首页「进入工作台」等,下一循环再深链
}
throw new Error(
'resolveOrgContextForCoursePage: 无法在合理步数内进入教学资源链(/course/summary 等)'
);
}
test.describe('多机构数据隔离(会话与 token', () => {
test.describe.configure({ timeout: 180_000 });
test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONIDSecret 或 .env.e2e');
test('本地 CurrentOrgId 与 JWT current_org_id 一致', async ({ page }) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
const storage = await page.evaluate(() => ({
currentOrgId: window.localStorage.getItem('CurrentOrgId'),
authorization: window.localStorage.getItem('Authorization'),
}));
expect(storage.authorization, '登录后应有 Authorization').toBeTruthy();
const payload = decodeJwtPayload(storage.authorization!);
expect(payload, 'Authorization 应为可解析的 JWT').not.toBeNull();
const jwtOrgId = extractOrgIdFromTokenPayload(payload);
expect(jwtOrgId, 'JWT(含 sub 嵌套)应能解析出 current_org_id').toBeTruthy();
if (storage.currentOrgId) {
expect(storage.currentOrgId).toBe(jwtOrgId);
}
});
test('机构选择页存在多个机构时,切换后 CurrentOrgId 变化', async ({
page,
}) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
await page.goto('/org/select', {
waitUntil: 'load',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
await page.waitForURL(/\/org\/select/, { timeout: 90_000 });
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '打开机构页时会话已退回访客态');
}
// 线上壳层可能存在结构差异;轮询任一机构页就绪信号,避免分支超时互相误杀
await expect
.poll(
async () =>
page.evaluate(() => {
const appRoot = document.querySelector('#app');
if (!appRoot) return false;
const rootText = appRoot.textContent ?? '';
const hasOrgTitle = /请选择机构/.test(rootText);
const hasEmptyHint = rootText.includes('暂无可用机构');
const hasOrgCells = !!appRoot.querySelector('.van-cell-group .van-cell');
return hasOrgTitle || hasEmptyHint || hasOrgCells;
}),
{ timeout: 90_000, intervals: [500, 1000, 2000] }
)
.toBeTruthy();
if (
await page.getByText('暂无可用机构').isVisible().catch(() => false)
) {
test.skip(true, '机构接口无数据,跳过多机构切换断言');
}
const orgCells = page.locator('#app .van-cell-group .van-cell');
const count = await orgCells.count();
test.skip(
count < 2,
'账号仅绑定单一机构时跳过切换断言(仍可通过 JWT 对齐用例校验隔离键)'
);
let beforeOrg: string | null = await page.evaluate(() =>
window.localStorage.getItem('CurrentOrgId')
);
if (!beforeOrg) {
const authSnap = await page.evaluate(() =>
window.localStorage.getItem('Authorization')
);
beforeOrg =
extractOrgIdFromTokenPayload(
decodeJwtPayload(authSnap ?? '')
) ?? null;
}
expect(beforeOrg, '切换前应能从 localStorage 或 JWT 解析当前机构').toBeTruthy();
await orgCells.nth(1).click();
await page.waitForURL(
(u) => {
try {
return new URL(u).pathname === '/' || new URL(u).pathname === '';
} catch {
return false;
}
},
{ timeout: 90_000 }
);
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
const afterOrg = await page.evaluate(() =>
window.localStorage.getItem('CurrentOrgId')
);
expect(afterOrg).toBeTruthy();
expect(afterOrg).not.toBe(beforeOrg);
const authAfter = await page.evaluate(() =>
window.localStorage.getItem('Authorization')
);
const payloadAfter = decodeJwtPayload(authAfter ?? '');
const jwtOrgAfter = extractOrgIdFromTokenPayload(payloadAfter);
expect(jwtOrgAfter, '切换后 JWT 应携带 current_org_id').toBeTruthy();
expect(jwtOrgAfter).toBe(afterOrg);
});
test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({
page,
}) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
await page.goto('/course', {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
await waitForCourseRealmVisible(page);
const listError = page.getByText('请求失败,点击重新加载');
await expect(listError).toHaveCount(0, {
timeout: 45_000,
});
});
});