feat(08-05-business-detail-routing): 完成采购与资产独立路由
这个提交包含在:
@@ -96,6 +96,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await page.getByTestId("asset-category-save").click();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible();
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("");
|
||||
await page.getByTestId("asset-name").fill(assetName);
|
||||
@@ -116,8 +117,12 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
data: { asset: { id: string } };
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
await expect(page.locator(".detail-action-panel")).toHaveCSS("position", "sticky");
|
||||
|
||||
@@ -129,7 +134,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await logout(page, FINANCE_MGR.name);
|
||||
|
||||
await login(page, ADMINISTRATION);
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await page.goto(`/assets/${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /陈工程/);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
buildAssetDetailPath,
|
||||
buildPurchaseRequestDetailPath,
|
||||
readAssetListFilters,
|
||||
readPurchaseListFilters,
|
||||
resolveAssetLegacyRedirect,
|
||||
resolvePurchaseRequestLegacyRedirect,
|
||||
} from "../../src/lib/businessDetailRoutes";
|
||||
import { resolveNotificationTargetLink } from "../../src/lib/notificationLinks";
|
||||
import { resolvePcDetailSource } from "../../src/lib/pcDetailNavigation";
|
||||
import { resolveMobileEquivalentPath } from "../../src/lib/runtimeContext";
|
||||
import { resolvePageMeta } from "../../src/features/navigation/schema";
|
||||
|
||||
test("采购与资产 canonical 路径统一编码实体 ID,并对空 ID 回落列表", () => {
|
||||
expect(buildPurchaseRequestDetailPath("purchase/001")).toBe("/purchase-requests/purchase%2F001");
|
||||
expect(buildPurchaseRequestDetailPath(" ")).toBe("/purchase-requests");
|
||||
expect(buildAssetDetailPath("asset/001")).toBe("/assets/asset%2F001");
|
||||
expect(buildAssetDetailPath("")).toBe("/assets");
|
||||
|
||||
expect(resolveNotificationTargetLink({ targetType: "purchase_request", targetId: "purchase/001" })).toBe(
|
||||
"/purchase-requests/purchase%2F001",
|
||||
);
|
||||
expect(resolveNotificationTargetLink({ targetType: "asset_card", targetId: "asset/001" })).toBe(
|
||||
"/assets/asset%2F001",
|
||||
);
|
||||
});
|
||||
|
||||
test("旧查询链接只保留白名单筛选,并 replace 到 canonical 详情", () => {
|
||||
const purchaseRedirect = resolvePurchaseRequestLegacyRedirect(new URLSearchParams(
|
||||
"id=purchase%2F001&status=approved&scope=all&supplierId=supplier-1&page=2&unknown=secret",
|
||||
));
|
||||
expect(purchaseRedirect).toEqual({
|
||||
to: "/purchase-requests/purchase%2F001",
|
||||
from: "/purchase-requests?status=approved&scope=all&supplierId=supplier-1&page=2",
|
||||
});
|
||||
|
||||
const assetRedirect = resolveAssetLegacyRedirect(new URLSearchParams(
|
||||
"id=asset-1&q=MacBook&status=in_use&assetType=fixed_asset&sensitivity=internal&scope=own&unknown=secret",
|
||||
));
|
||||
expect(assetRedirect).toEqual({
|
||||
to: "/assets/asset-1",
|
||||
from: "/assets?q=MacBook&status=in_use&assetType=fixed_asset&sensitivity=internal&scope=own",
|
||||
});
|
||||
expect(resolvePurchaseRequestLegacyRedirect(new URLSearchParams("id=%20%20"))).toBeNull();
|
||||
expect(resolveAssetLegacyRedirect(new URLSearchParams("id="))).toBeNull();
|
||||
});
|
||||
|
||||
test("列表筛选读取会收紧非法枚举、页码和越权范围", () => {
|
||||
expect(readPurchaseListFilters(new URLSearchParams("status=unknown&scope=all&page=-2"), false)).toMatchObject({
|
||||
status: "draft",
|
||||
mine: true,
|
||||
page: 1,
|
||||
});
|
||||
expect(readPurchaseListFilters(new URLSearchParams("status=all&scope=all&page=3"), true)).toMatchObject({
|
||||
status: "",
|
||||
mine: false,
|
||||
page: 3,
|
||||
});
|
||||
|
||||
expect(readAssetListFilters(new URLSearchParams("status=unknown&assetType=unknown&sensitivity=unknown&scope=all"), false)).toMatchObject({
|
||||
status: "",
|
||||
assetType: "",
|
||||
sensitivity: "",
|
||||
mine: true,
|
||||
});
|
||||
expect(readAssetListFilters(new URLSearchParams(), true).mine).toBe(false);
|
||||
expect(readAssetListFilters(new URLSearchParams("scope=own"), true).mine).toBe(true);
|
||||
});
|
||||
|
||||
test("详情返回来源按模块白名单校验,拒绝外部、跨模块和当前页", () => {
|
||||
expect(resolvePcDetailSource(
|
||||
"/purchase-requests?status=approved&scope=own",
|
||||
"/purchase-requests/purchase-1",
|
||||
"purchase-request",
|
||||
)).toBe("/purchase-requests?status=approved&scope=own");
|
||||
expect(resolvePcDetailSource("/projects/project-1", "/purchase-requests/purchase-1", "purchase-request")).toBe(
|
||||
"/projects/project-1",
|
||||
);
|
||||
expect(resolvePcDetailSource("/assets?scope=own", "/assets/asset-1", "asset")).toBe("/assets?scope=own");
|
||||
expect(resolvePcDetailSource("/projects/project-1", "/assets/asset-1", "asset")).toBeNull();
|
||||
expect(resolvePcDetailSource("//example.com/assets", "/assets/asset-1", "asset")).toBeNull();
|
||||
expect(resolvePcDetailSource("/assets/asset-1", "/assets/asset-1", "asset")).toBeNull();
|
||||
});
|
||||
|
||||
test("页面元数据与移动工作台回落覆盖新路由", () => {
|
||||
expect(resolvePageMeta("/purchase-requests/purchase-1")).toMatchObject({ title: "采购详情", group: "业务" });
|
||||
expect(resolvePageMeta("/assets/new")).toMatchObject({ title: "新增资产", group: "业务" });
|
||||
expect(resolvePageMeta("/assets/asset-1")).toMatchObject({ title: "资产详情", group: "业务" });
|
||||
expect(resolveMobileEquivalentPath("/purchase-requests/purchase-1")).toBe("/mobile/purchase-requests/purchase-1");
|
||||
expect(resolveMobileEquivalentPath("/assets/new")).toBe("/mobile/assets/scan");
|
||||
expect(resolveMobileEquivalentPath("/assets/asset-1")).toBe("/mobile/assets/scan");
|
||||
});
|
||||
@@ -10,6 +10,146 @@ function envelope<T>(data: T) {
|
||||
}
|
||||
|
||||
async function mockSearchResults(page: Page) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === "/api/search") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/auth/sso/providers") {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([])) });
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/auth/wecom/config") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
enabled: false,
|
||||
entryEnabled: false,
|
||||
oauthEnabled: false,
|
||||
corpId: "",
|
||||
agentId: "",
|
||||
loginUrl: null,
|
||||
bindEnabled: false,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "disabled",
|
||||
statusReasons: [],
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/auth/login" || pathname === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: "mock-global-search-token", expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/me") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
id: "admin-global-search",
|
||||
employeeNo: ADMIN.employeeNo,
|
||||
name: ADMIN.name,
|
||||
phone: null,
|
||||
email: ADMIN.identifier,
|
||||
departmentId: "department-global-search",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 50,
|
||||
},
|
||||
wecomBinding: null,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/system/navigation") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
workspace: {
|
||||
id: "workspace-global-search",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "reimbursements", href: "/reimbursements", label: "报销", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/notifications") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({ page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/portal/home") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
workspace: { id: "workspace-global-search", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: ADMIN.name },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: null,
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({})) });
|
||||
});
|
||||
|
||||
await page.route("**/api/search?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -20,11 +160,11 @@ async function mockSearchResults(page: Page) {
|
||||
{ entityType: "employee", entityId: "employee-1", title: "王小明", subtitle: "E001 · 财务", status: "active", href: "/people/employee-1", matchedBy: "prefix", updatedAt: null },
|
||||
{ entityType: "reimbursement", entityId: "reimbursement-1", title: "BX-001", subtitle: "办公室报销", status: "reviewing", href: "/reimbursements/reimbursement-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "leave", entityId: "leave-1", title: "请假 2026-03-24", subtitle: "年假", status: "approved", href: "/leave-requests", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests/purchase-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests?id=legacy-wrong", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "travel_request", entityId: "travel-1", title: "CL-001", subtitle: "上海出差", status: "draft", href: "/travel-requests/travel-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "project", entityId: "project-1", title: "海音达试点", subtitle: "实施项目", status: "active", href: "/projects/project-1", matchedBy: "exact", updatedAt: null },
|
||||
{ entityType: "announcement", entityId: "announcement-1", title: "本周通知", subtitle: "统一回填周报", status: null, href: "/announcements", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets?highlight=legacy-wrong", matchedBy: "contains", updatedAt: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -80,7 +220,29 @@ test("E2E 全局搜索:查看更多结果会关闭弹窗并跳到分组列表
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:采购与资产忽略旧 href 并使用实体 canonical 路径", async ({ page }) => {
|
||||
await mockSearchResults(page);
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
const purchaseSearchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
|
||||
await purchaseSearchResponse;
|
||||
await page.getByRole("button", { name: /CG-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests\/purchase-1$/);
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
const assetSearchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
|
||||
await assetSearchResponse;
|
||||
await page.getByRole("button", { name: /ZC-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/assets\/asset-1$/);
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
||||
await mockSearchResults(page);
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
|
||||
@@ -139,8 +139,10 @@ async function createAsset(page: Page, categoryName: string, suffix: string, ind
|
||||
const asset = payload.data?.asset;
|
||||
const assetNo = String(asset?.assetNo || "");
|
||||
expect(assetNo).toMatch(config.prefix);
|
||||
await page.goto(`/assets?id=${asset.id}`);
|
||||
await page.goto(`/assets/${asset.id}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(name, { timeout: 30_000 });
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.getByPlaceholder("搜索编号/名称/hash/VER").fill(assetNo);
|
||||
await expect(page.getByTestId("asset-row").filter({ hasText: assetNo })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
|
||||
@@ -1016,7 +1016,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
throw new Error(`创建采购申请后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
state.purchaseRequestId = purchaseRequestId;
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseRequestId}`);
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(`${trialrunMarkerPrefix} 浏览器采购 ${marker}`);
|
||||
await selectFirstEnabledOption(page.getByTestId("purchase-legal-entity"));
|
||||
@@ -1075,7 +1075,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
}
|
||||
|
||||
const employeeAgain = await loginRole(page, "employee");
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseRequestId}`);
|
||||
await expect(page.getByTestId("purchase-detail-status")).toContainText(/已通过|approved|已完成/, { timeout: 30_000 });
|
||||
await expect(page.getByTestId("purchase-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 });
|
||||
await expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1232,7 +1232,7 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
|
||||
|
||||
test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/assets?id=asset-wave3");
|
||||
await page.goto("/assets/asset-wave3");
|
||||
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetDetail.name);
|
||||
await expect(page.getByTestId("asset-detail-action-panel")).toBeVisible();
|
||||
|
||||
@@ -396,6 +396,7 @@ test.describe("R3 权限直连审计", () => {
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
"/assets/new",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, MANAGER, [
|
||||
@@ -503,6 +504,10 @@ test.describe("R3 权限直连审计", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("资产创建路由只允许 asset.manage 角色直连", async ({ browser }) => {
|
||||
await expectDirectRoutesNotForbidden(browser, FINANCE_MGR, ["/assets/new"]);
|
||||
});
|
||||
|
||||
test("后端直连接口按权限拒绝或限制范围", async () => {
|
||||
const clients: AuthenticatedApiClient[] = [];
|
||||
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
|
||||
@@ -42,8 +42,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
data: { purchaseRequest: { id: string } };
|
||||
};
|
||||
const purchaseRequestId = createPayload.data.purchaseRequest.id;
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`id=${purchaseRequestId}`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseRequestId}$`));
|
||||
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(title);
|
||||
@@ -107,6 +106,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseRequestId}$`));
|
||||
|
||||
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 });
|
||||
|
||||
@@ -15,21 +15,12 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
}
|
||||
|
||||
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
|
||||
const taskScreenshotDir = path.resolve(
|
||||
process.cwd(),
|
||||
"../output/playwright/07-18-small-team-internal-readiness",
|
||||
);
|
||||
|
||||
function screenshotPath(fileName: string) {
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
return path.join(screenshotDir, fileName);
|
||||
}
|
||||
|
||||
function taskScreenshotPath(fileName: string) {
|
||||
mkdirSync(taskScreenshotDir, { recursive: true });
|
||||
return path.join(taskScreenshotDir, fileName);
|
||||
}
|
||||
|
||||
const me = {
|
||||
id: "employee-r103",
|
||||
employeeNo: "HYD000103",
|
||||
@@ -501,6 +492,11 @@ async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/assets/${assetId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { asset: assetListItem() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "PATCH") {
|
||||
await fulfillJson(route, { ...purchaseDetail(), ...request.postDataJSON() });
|
||||
return;
|
||||
@@ -622,7 +618,7 @@ async function expectResponsiveBusinessTable(
|
||||
const operation = table.getByRole("link").or(table.getByRole("button")).last();
|
||||
await expect(operation).toBeVisible();
|
||||
await operation.click({ trial: true });
|
||||
await page.screenshot({ path: taskScreenshotPath(target.screenshotName), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath(target.screenshotName), fullPage: true });
|
||||
}
|
||||
|
||||
async function expectLedgerHeaderActions(
|
||||
@@ -710,6 +706,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
||||
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||
@@ -754,6 +751,72 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("采购与资产列表、详情各自只请求职责内数据并恢复 URL 筛选", async ({ page }) => {
|
||||
const requests: Array<{ method: string; pathname: string; search: string }> = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/purchase-requests") || url.pathname.startsWith("/api/assets")) {
|
||||
requests.push({ method: request.method(), pathname: url.pathname, search: url.search });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/purchase-requests?status=draft&scope=own&supplierId=supplier-r103");
|
||||
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/purchase-requests/${purchaseId}`)).toBe(false);
|
||||
requests.length = 0;
|
||||
|
||||
await page.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/purchase-requests")).toBe(false);
|
||||
await page.getByRole("button", { name: "返回采购列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests\?status=draft&scope=own&supplierId=supplier-r103$/);
|
||||
|
||||
requests.length = 0;
|
||||
await page.goto("/assets?q=研发&status=in_use&scope=own");
|
||||
await expect(page.getByTestId("assets-table")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/assets/${assetId}`)).toBe(false);
|
||||
requests.length = 0;
|
||||
|
||||
await page.getByRole("button", { name: "查看详情", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("研发测试笔记本电脑");
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/assets")).toBe(false);
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/assets\?/);
|
||||
const restoredAssetUrl = new URL(page.url());
|
||||
expect(restoredAssetUrl.pathname).toBe("/assets");
|
||||
expect(Object.fromEntries(restoredAssetUrl.searchParams)).toEqual({ q: "研发", status: "in_use", scope: "own" });
|
||||
});
|
||||
|
||||
test("采购与资产长表单详情对 403/404 使用同一安全错误态", async ({ page }) => {
|
||||
const safeError = (message: string) => JSON.stringify({
|
||||
requestId: "mock-business-detail-denied",
|
||||
error: { code: "NOT_FOUND", message },
|
||||
});
|
||||
await page.route("**/api/purchase-requests/missing-purchase", async (route) => {
|
||||
await route.fulfill({ status: 404, contentType: "application/json", body: safeError("内部采购对象不存在") });
|
||||
});
|
||||
await page.route("**/api/assets/missing-asset", async (route) => {
|
||||
await route.fulfill({ status: 403, contentType: "application/json", body: safeError("内部资产对象无权访问") });
|
||||
});
|
||||
|
||||
await page.goto("/purchase-requests/missing-purchase");
|
||||
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("内部采购对象不存在")).toHaveCount(0);
|
||||
|
||||
await page.goto("/assets/missing-asset");
|
||||
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("内部资产对象无权访问")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("资产创建路由不会把列表查看权限当成 asset.manage", async ({ page }) => {
|
||||
await page.goto("/assets");
|
||||
await expect(page).not.toHaveURL(/\/403$/);
|
||||
await page.goto("/assets/new");
|
||||
await expect(page).toHaveURL(/\/403$/);
|
||||
});
|
||||
|
||||
test("票据绑定报销单不展示 UUID 兜底", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/invoice-pool");
|
||||
@@ -782,16 +845,16 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
|
||||
test("采购长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseId}`);
|
||||
const title = page.getByTestId("purchase-title");
|
||||
await expect(title).toBeVisible();
|
||||
await title.fill("办公用品采购(未保存)");
|
||||
|
||||
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
|
||||
const returnToList = page.getByRole("button", { name: "返回采购列表", exact: true });
|
||||
await returnToList.click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("办公用品采购(未保存)");
|
||||
|
||||
await returnToList.click();
|
||||
@@ -799,7 +862,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
await expect(page.getByTestId("purchase-row-open")).toBeVisible();
|
||||
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseId}`);
|
||||
await page.getByTestId("purchase-title").fill("办公用品采购(已保存)");
|
||||
await page.getByTestId("purchase-save").click();
|
||||
await expect(page.getByText("采购草稿已保存")).toBeVisible();
|
||||
@@ -810,17 +873,21 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
|
||||
test("资产长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const requestedPaths: string[] = [];
|
||||
page.on("request", (request) => requestedPaths.push(new URL(request.url()).pathname));
|
||||
await page.goto("/assets");
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
expect(requestedPaths).not.toContain("/api/assets/new");
|
||||
const name = page.getByTestId("asset-name");
|
||||
await expect(name).toBeVisible();
|
||||
await name.fill("合成未保存资产");
|
||||
|
||||
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
|
||||
const returnToList = page.getByRole("button", { name: "返回资产列表", exact: true });
|
||||
await returnToList.click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(name).toHaveValue("合成未保存资产");
|
||||
|
||||
await returnToList.click();
|
||||
@@ -834,7 +901,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.getByRole("link", { name: "采购", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
await page.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
const title = page.getByTestId("purchase-title");
|
||||
await title.fill("浏览器历史未保存验证");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "true");
|
||||
@@ -842,7 +909,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("浏览器历史未保存验证");
|
||||
|
||||
const beforeUnloadPromise = page.waitForEvent("dialog");
|
||||
@@ -851,7 +918,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
expect(beforeUnload.type()).toBe("beforeunload");
|
||||
await beforeUnload.dismiss();
|
||||
await reloadPromise;
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("浏览器历史未保存验证");
|
||||
|
||||
const acceptedBeforeUnloadPromise = page.waitForEvent("dialog");
|
||||
@@ -860,15 +927,14 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
expect(acceptedBeforeUnload.type()).toBe("beforeunload");
|
||||
await acceptedBeforeUnload.accept();
|
||||
await acceptedReloadPromise;
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("办公用品采购");
|
||||
await title.fill("浏览器历史再次未保存验证");
|
||||
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toBeVisible();
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
// 详情查询参数由列表页使用 replace 写入,因此它的真实上一项是进入采购页前的工作台。
|
||||
await expect(page).toHaveURL(/\/portal$/);
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
});
|
||||
|
||||
test("/projects /assets /reimbursements 在中等宽度下操作列可达且页面不横溢", async ({ page }) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户