文件
hyb-oa/client/tests/e2e/mobile-approvals-offline.spec.ts
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

236 行
10 KiB
TypeScript

import { devices, expect, test } from "@playwright/test";
import { MANAGER, type E2EUser } from "./helpers/auth";
async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(user.identifier);
await page.getByTestId("login-password").fill(user.password);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/mobile$/);
}
async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, taskId: string) {
await expect
.poll(
async () => {
return page.evaluate(async (currentTaskId) => {
const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
try {
if (!db.objectStoreNames.contains("workflow_task_cache")) {
return false;
}
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
const detail = await new Promise<unknown>((resolve, reject) => {
const query = store.get(currentTaskId);
query.onsuccess = () => resolve(query.result ?? null);
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
});
return Boolean(detail);
} finally {
db.close();
}
}, taskId);
},
{ timeout: 15_000 },
)
.toBe(true);
}
// Daily leave is owned by WeCom; its read-only boundary is covered by leave-flow.spec.ts.
test.use({
...devices["iPhone 13"],
serviceWorkers: "block",
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1",
});
test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => {
const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const now = new Date().toISOString();
const detail = {
task: {
id: taskId,
instanceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
stepId: "finance",
stepName: "财务审批",
assigneeId: "manager",
assigneeRole: "manager",
status: "pending",
outcome: null,
comment: null,
createdAt: now,
completedAt: null,
},
workflowInstance: {
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
entityType: "reimbursement",
currentStatus: "pending",
currentStepId: "finance",
contextData: {
standardViolations: [{ itemId: "sensitive-item", amount: "999.00" }],
budgetCheck: { budgetId: "sensitive-budget", budgetCode: "B-1", controlMode: "warn", usagePercent: 90, exceededThreshold: true, exceededBudget: false },
budgetCheckStatus: "available",
parseStatus: "ready",
},
startedAt: now,
completedAt: null,
},
reimbursement: {
id: reimbursementId,
formNumber: "BX-CACHE-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "999.00",
description: "缓存边界合成报销",
owner: { id: "owner", employeeNo: "E001", name: "合成申请人" },
submitter: { id: "owner", employeeNo: "E001", name: "合成申请人" },
items: [{ id: "must-not-persist" }],
invoices: [{ id: "must-not-persist", amountEvidenceStatus: "manual_review" }],
attachments: [{ id: "must-not-persist", materialUrl: "https://invalid.example/material" }],
sharedProjection: { itemEvidenceModels: ["must-not-persist"] },
},
leaveRequest: null,
attendanceCorrection: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "return"],
history: [],
};
const reimbursementDetail = {
id: reimbursementId,
items: [{
id: "item-1",
expenseTypeId: "type-1",
expenseDate: "2026-09-01",
amount: "999.00",
description: "合成明细",
expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true },
}],
invoices: [{
id: "invoice-1",
status: "bound",
invoiceCode: null,
invoiceNumber: "FP-CACHE-1",
invoiceDate: "2026-09-01",
sellerName: "合成供应商",
buyerName: null,
documentCategory: "invoice",
documentSubtype: null,
totalAmount: null,
originalName: "synthetic.pdf",
mimeType: "application/pdf",
fileSize: 100,
linkedItemId: "item-1",
amountEvidenceStatus: "manual_review",
}],
attachments: [{
id: "attachment-1",
originalName: "support.pdf",
mimeType: "application/pdf",
fileSize: 100,
fileSha256: "must-not-persist",
createdAt: now,
}],
};
let signedIn = false;
const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record<string, string>) => route.fulfill({
status: 200,
contentType: "application/json",
headers,
body: JSON.stringify({ requestId: "cache-boundary", data }),
});
await page.route((url) => [
"/api/auth/sso/providers", "/api/auth/wecom/config", "/api/auth/login", "/api/auth/refresh",
"/api/me", "/api/system/navigation", "/api/notifications", "/api/portal/home",
"/api/workflow/tasks", "/api/workflow/tasks/summary",
].includes(url.pathname), async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/auth/sso/providers") return fulfillData(route, []);
if (url.pathname === "/api/auth/wecom/config") return fulfillData(route, {
enabled: false, entryEnabled: false, oauthEnabled: false, corpId: null, agentId: null,
loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [],
});
if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") {
if (url.pathname === "/api/auth/refresh" && !signedIn) {
return route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_001", message: "未登录" } }) });
}
if (url.pathname === "/api/auth/login") signedIn = true;
return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" });
}
if (url.pathname === "/api/me") return fulfillData(route, {
id: "manager", employeeNo: "M001", name: "合成审批人", phone: null, email: "[email protected]",
departmentId: "department", directManagerId: null, status: "active", notificationPushEnabled: true,
roleCodes: ["manager"], bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 70 },
});
if (url.pathname === "/api/system/navigation") return fulfillData(route, {
workspace: { id: "workspace", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null },
sections: { main: [{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null }], admin: [], backup: [] },
recommendedRolePresets: [],
});
if (url.pathname === "/api/notifications") return fulfillData(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
if (url.pathname === "/api/portal/home") return fulfillData(route, { hero: { title: "工作台", subtitle: "合成测试" }, metrics: [], todos: [], notifications: [] });
if (url.pathname === "/api/workflow/tasks/summary") return fulfillData(route, { scope: "mine", pendingTotal: 1, overdueTotal: 0, earliestPendingAt: now, averagePendingAgeHours: 1, byEntityType: [], byStep: [], byAssigneeRole: [] });
return fulfillData(route, { page: 1, pageSize: 100, total: 0, items: [] });
});
await page.route(`**/api/workflow/tasks/${taskId}`, (route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requestId: "cache-boundary", data: detail }),
}));
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requestId: "cache-boundary", data: reimbursementDetail }),
}));
await loginMobile(page, MANAGER);
await page.goto(`/workflow/tasks/${taskId}`);
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await waitForWorkflowTaskCache(page, taskId);
const rawCache = await page.evaluate(async (currentTaskId) => {
const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
try {
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
return await new Promise<Record<string, unknown>>((resolve, reject) => {
const query = store.get(currentTaskId);
query.onsuccess = () => resolve(query.result);
query.onerror = () => reject(query.error);
});
} finally {
db.close();
}
}, taskId);
const serialized = JSON.stringify(rawCache);
for (const forbiddenField of [
"items", "invoices", "attachments", "amountEvidenceStatus", "materialUrl",
"fileSha256", "sharedProjection", "itemEvidenceModels", "standardViolations",
"budgetCheck", "contextData", "sensitive-item", "sensitive-budget", "must-not-persist",
]) {
expect(serialized).not.toContain(forbiddenField);
}
await page.context().setOffline(true);
await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回修改或转审。")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-transfer")).toBeDisabled();
});