import { test, expect, type Page } from '@playwright/test'; const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); function decodeJwtPayload(token: string): Record | 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; } catch { return null; } } 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。 * 深链到 `/course` 会触发守卫;多机构且无上下文时会先进入 `/org/select`,需点选机构后再继续。 */ async function resolveOrgContextForCoursePage(page: Page) { for (let attempt = 0; attempt < 6; attempt++) { await page.goto('/course', { waitUntil: 'domcontentloaded', timeout: 90_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); if (!page.url().includes('/org/select')) { return; } 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 }); } throw new Error('resolveOrgContextForCoursePage: 无法在合理步数内离开机构选择页'); } test.describe('多机构数据隔离(会话与 token)', () => { test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONID(Secret 或 .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 = payload!.current_org_id; expect(jwtOrgId, 'JWT 应包含 current_org_id').toBeTruthy(); expect(storage.currentOrgId, '进入业务路由后应写入 CurrentOrgId').toBeTruthy(); expect(String(jwtOrgId)).toBe(storage.currentOrgId); }); 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: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await expect(page.getByText('请选择机构')).toBeVisible({ timeout: 60_000, }); const orgCells = page.locator('.main .van-cell-group .van-cell'); const count = await orgCells.count(); test.skip( count < 2, '账号仅绑定单一机构时跳过切换断言(仍可通过 JWT 对齐用例校验隔离键)' ); const beforeOrg = await page.evaluate(() => window.localStorage.getItem('CurrentOrgId') ); expect( beforeOrg, '切换前应已有当前机构(resolveOrgContextForCoursePage 已落库)' ).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 ?? ''); expect(payloadAfter?.current_org_id).toBeTruthy(); expect(String(payloadAfter!.current_org_id)).toBe(afterOrg); }); test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({ page, }) => { await establishSession(page); if ( await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) ) { test.skip(true, '当前 unionid 会话未处于可用登录态'); } await resolveOrgContextForCoursePage(page); await expect( page.getByPlaceholder('请输入课程体系名称搜索') ).toBeVisible({ timeout: 90_000 }); await expect( page.getByText('请求失败,点击重新加载') ).not.toBeVisible({ timeout: 45_000 }); }); });