fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
这个提交包含在:
@@ -28,8 +28,10 @@ function envelope<T>(data: T) {
|
||||
return { requestId: "visual-portal", data };
|
||||
}
|
||||
|
||||
function portalPayload(mode: "employee" | "admin" | "finance") {
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
|
||||
type PortalMode = "employee" | "admin" | "finance" | "ceo";
|
||||
|
||||
function portalPayload(mode: PortalMode) {
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
|
||||
const base = {
|
||||
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: displayName },
|
||||
@@ -112,7 +114,7 @@ function portalPayload(mode: "employee" | "admin" | "finance") {
|
||||
};
|
||||
}
|
||||
|
||||
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance", handoffs?: () => PortalHomePayload["businessHandoffs"]) {
|
||||
async function mockVisualPortal(page: Page, mode: PortalMode, handoffs?: () => PortalHomePayload["businessHandoffs"]) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.removeItem("hyb_oa_show_welcome");
|
||||
window.localStorage.removeItem("hyb_oa_onboarding_active");
|
||||
@@ -139,8 +141,8 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/me") {
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
|
||||
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : "HYD000006";
|
||||
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
|
||||
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : mode === "ceo" ? "SYN-CEO" : "HYD000006";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -149,7 +151,7 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
employeeNo,
|
||||
name: displayName,
|
||||
email: `${mode}@hyinda.com`,
|
||||
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"],
|
||||
roleCodes: [mode],
|
||||
notificationPushEnabled: true,
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
|
||||
wecomBinding: null,
|
||||
@@ -194,6 +196,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
});
|
||||
}
|
||||
|
||||
function responseGate() {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
return { pending, release };
|
||||
}
|
||||
|
||||
test.describe("visual portal", () => {
|
||||
for (const mobile of [false, true]) {
|
||||
test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => {
|
||||
@@ -221,15 +229,266 @@ test.describe("visual portal", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("员工退回待修改直接进入本人的报销,不依赖通知未读数", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
await mockVisualPortal(page, "employee", () => ({ total: 1, items: [{ key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }] }));
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/portal");
|
||||
const row = page.getByRole("link").filter({ hasText: "报销退回待修改" });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row).toHaveAttribute("href", "/reimbursements/synthetic");
|
||||
});
|
||||
for (const role of ["employee", "ceo"] as const) {
|
||||
for (const mobile of [false, true]) {
|
||||
test(`非财务${role}的${mobile ? "手机" : "PC"}冷进入加载本人待办并在处理后消失`, async ({ page }, testInfo) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
|
||||
? { key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }
|
||||
: { key: "payment-review:synthetic", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" };
|
||||
let items = [handoff];
|
||||
await mockVisualPortal(page, role, () => ({ total: items.length, items }));
|
||||
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
await page.goto(mobile ? "/mobile" : "/portal");
|
||||
const row = page.getByRole("link").filter({ hasText: handoff.title });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row).toHaveAttribute("href", mobile ? handoff.mobileHref : handoff.href);
|
||||
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
||||
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("nonfinance-handoffs.png"), fullPage: true });
|
||||
items = [];
|
||||
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
|
||||
await page.reload();
|
||||
await refreshed;
|
||||
await expect(row).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test(`非财务${role}手机待办加载失败可重试,不把错误当空态`, async ({ page }, testInfo) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
|
||||
? { key: "reimbursement-edit:retry", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/retry", mobileHref: "/mobile/reimbursements/retry" }
|
||||
: { key: "payment-review:retry", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=retry", mobileHref: "/mobile/payment-batches?id=retry" };
|
||||
await mockVisualPortal(page, role, () => ({ total: 1, items: [handoff] }));
|
||||
let unavailable = true;
|
||||
await page.route("**/api/portal/home", async (route) => {
|
||||
if (unavailable) await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
|
||||
else await route.fallback();
|
||||
});
|
||||
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mobile");
|
||||
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
|
||||
unavailable = false;
|
||||
await page.getByRole("button", { name: "重试加载待办", exact: true }).click();
|
||||
await expect(page.getByRole("link").filter({ hasText: handoff.title })).toBeVisible();
|
||||
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mobile of [false, true]) {
|
||||
for (const scenario of ["slow", "failure", "late", "late401"] as const) {
|
||||
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同文档切账号 ${scenario}`, async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成账号,不访问公网");
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
let actor: "first" | "second" = "first";
|
||||
let holdOldRequest = false;
|
||||
let oldRequestStarted = false;
|
||||
let oldRequestFinished = false;
|
||||
let oldRequestAborted = false;
|
||||
let holdFirstReturn = false;
|
||||
let firstReturnStarted = false;
|
||||
let refreshRequests = 0;
|
||||
let secondFails = scenario === "failure";
|
||||
const requestCounts = { first: 0, second: 0 };
|
||||
const secondResponse = responseGate();
|
||||
const oldResponse = responseGate();
|
||||
const firstReturnResponse = responseGate();
|
||||
const handoff = (identity: "first" | "second") => ({
|
||||
key: `reimbursement-edit:synthetic-cache-${identity}`, kind: "reimbursement_edit" as const,
|
||||
reference: `BX-SYN-CACHE-${identity}`, title: identity === "first" ? "合成甲待办" : "合成乙待办",
|
||||
nextStep: "修改后重新提交", ownerLabel: "报销人",
|
||||
href: `/reimbursements/synthetic-cache-${identity}`, mobileHref: `/mobile/reimbursements/synthetic-cache-${identity}`,
|
||||
});
|
||||
await mockVisualPortal(page, "employee");
|
||||
const requestActor = (authorization?: string) => authorization === "Bearer synthetic-first" ? "first" : "second";
|
||||
page.on("requestfailed", (request) => {
|
||||
if (oldRequestStarted && new URL(request.url()).pathname === "/api/portal/home" && requestActor(request.headers().authorization) === "first") oldRequestAborted = true;
|
||||
});
|
||||
await page.route("**/api/auth/login", async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
|
||||
});
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
refreshRequests += 1;
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
|
||||
});
|
||||
await page.route("**/api/me", async (route) => {
|
||||
const profileActor = requestActor(route.request().headers().authorization);
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
|
||||
id: `synthetic-cache-${profileActor}`, employeeNo: `SYN-${profileActor}`, name: profileActor === "first" ? "合成员工甲" : "合成员工乙",
|
||||
email: `${profileActor}@example.test`, roleCodes: ["employee"], notificationPushEnabled: true, wecomBinding: null,
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
|
||||
})) });
|
||||
});
|
||||
await page.route("**/api/portal/home", async (route) => {
|
||||
const requestedActor = requestActor(route.request().headers().authorization);
|
||||
requestCounts[requestedActor] += 1;
|
||||
const delayedOldRequest = requestedActor === "first" && holdOldRequest;
|
||||
if (delayedOldRequest) {
|
||||
oldRequestStarted = true;
|
||||
await oldResponse.pending;
|
||||
if (scenario === "late401") {
|
||||
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
|
||||
oldRequestFinished = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (requestedActor === "first" && holdFirstReturn) {
|
||||
firstReturnStarted = true;
|
||||
await firstReturnResponse.pending;
|
||||
}
|
||||
if (requestedActor === "second") await secondResponse.pending;
|
||||
if (requestedActor === "second" && secondFails) {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
|
||||
} else {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
|
||||
...portalPayload("employee"), businessHandoffs: { total: 1, items: [handoff(requestedActor)] },
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" }, todoSummary: null, weeklyReport: null, projectSummary: null,
|
||||
})) });
|
||||
}
|
||||
if (delayedOldRequest) oldRequestFinished = true;
|
||||
});
|
||||
try {
|
||||
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
|
||||
const firstRow = page.getByRole("link").filter({ hasText: handoff("first").title });
|
||||
const secondRow = page.getByRole("link").filter({ hasText: handoff("second").title });
|
||||
await expect(firstRow).toBeVisible();
|
||||
await page.evaluate(() => { Reflect.set(window, "__portalSwitchDocument", "same-document"); });
|
||||
if (scenario === "late" || scenario === "late401") {
|
||||
holdOldRequest = true;
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
});
|
||||
await expect.poll(() => oldRequestStarted).toBe(true);
|
||||
}
|
||||
if (mobile) {
|
||||
await page.locator('a[href="/mobile/me"]').last().click();
|
||||
await page.getByRole("button", { name: "退出登录", exact: true }).click();
|
||||
} else {
|
||||
await page.getByTestId("topbar-profile-trigger").first().click();
|
||||
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
||||
}
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
actor = "second";
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
||||
await page.getByTestId("login-submit").click();
|
||||
if (mobile) {
|
||||
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工乙");
|
||||
await page.locator('a[href="/mobile"]').last().click();
|
||||
} else {
|
||||
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工乙");
|
||||
}
|
||||
await expect.poll(() => requestCounts.second).toBeGreaterThan(0);
|
||||
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
|
||||
await expect(firstRow).toHaveCount(0, { timeout: 5000 });
|
||||
await expect(secondRow).toHaveCount(0);
|
||||
secondResponse.release();
|
||||
if (scenario === "failure") {
|
||||
const retry = mobile ? page.getByRole("button", { name: "重试加载待办", exact: true }) : page.getByTestId("portal-load-retry");
|
||||
await expect(retry).toBeVisible();
|
||||
await expect(firstRow).toHaveCount(0);
|
||||
secondFails = false;
|
||||
await retry.click();
|
||||
}
|
||||
await expect(secondRow).toBeVisible();
|
||||
if (scenario === "late" || scenario === "late401") {
|
||||
if (scenario === "late401") await expect.poll(() => oldRequestAborted, { timeout: 5000 }).toBe(true);
|
||||
const refreshRequestsBeforeOldResponse = refreshRequests;
|
||||
oldResponse.release();
|
||||
await expect.poll(() => oldRequestFinished).toBe(true);
|
||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
||||
if (scenario === "late401") expect(refreshRequests).toBe(refreshRequestsBeforeOldResponse);
|
||||
}
|
||||
await expect(firstRow).toHaveCount(0);
|
||||
await expect(secondRow).toHaveCount(1);
|
||||
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
||||
if (scenario === "late401") {
|
||||
if (mobile) {
|
||||
await page.locator('a[href="/mobile/me"]').last().click();
|
||||
await page.getByRole("button", { name: "退出登录", exact: true }).click();
|
||||
} else {
|
||||
await page.getByTestId("topbar-profile-trigger").first().click();
|
||||
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
||||
}
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
actor = "first";
|
||||
holdOldRequest = false;
|
||||
holdFirstReturn = true;
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
||||
await page.getByTestId("login-submit").click();
|
||||
if (mobile) {
|
||||
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工甲");
|
||||
await page.locator('a[href="/mobile"]').last().click();
|
||||
} else {
|
||||
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工甲");
|
||||
}
|
||||
await expect.poll(() => firstReturnStarted).toBe(true);
|
||||
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
|
||||
await expect(secondRow).toHaveCount(0);
|
||||
firstReturnResponse.release();
|
||||
await expect(firstRow).toBeVisible();
|
||||
await expect(secondRow).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
secondResponse.release();
|
||||
oldResponse.release();
|
||||
firstReturnResponse.release();
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const mobile of [false, true]) {
|
||||
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同身份401刷新仍可用`, async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成认证,不访问公网");
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
let portalRequests = 0;
|
||||
let refreshRequests = 0;
|
||||
await mockVisualPortal(page, "employee", () => ({ total: 0, items: [] }));
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
refreshRequests += 1;
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
|
||||
});
|
||||
await page.route("**/api/portal/home", async (route) => {
|
||||
portalRequests += 1;
|
||||
if (portalRequests === 1) {
|
||||
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
|
||||
} else {
|
||||
await route.fallback();
|
||||
}
|
||||
});
|
||||
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
||||
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home" && response.status() === 200);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
|
||||
expect((await (await loaded).json()).data.businessHandoffs.total).toBe(0);
|
||||
await expect.poll(() => portalRequests).toBe(2);
|
||||
expect(refreshRequests).toBe(1);
|
||||
if (mobile) {
|
||||
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
||||
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
|
||||
} else {
|
||||
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("portal-load-error")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("超过八条业务事项时计数与展示不截断", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
|
||||
在新工单中引用
屏蔽一个用户