fix: list unadapted XFT approval tasks safely
This commit is contained in:
@@ -388,6 +388,65 @@ test("XFT action restrictions are enforced during zero-write preflight", async (
|
||||
assert.equal(writes, 0);
|
||||
});
|
||||
|
||||
test("unadapted XFT todos stay visible but detail and every write action remain client-only", async () => {
|
||||
let detailCalls = 0;
|
||||
let writes = 0;
|
||||
const ding = {
|
||||
async listMyTodos() {
|
||||
return { items: [{
|
||||
source: "official_oa", processInstanceId: "mixed-instance", taskId: "mixed-task", title: "钉钉审批", raw: {},
|
||||
}] };
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const xft = {
|
||||
async listMyTodos() {
|
||||
return {
|
||||
items: [{
|
||||
source: "xft", taskId: "provider-task-secret", capability: "list_only", unavailableReason: "unsupported_task_type",
|
||||
title: "薪福通其他审批", allowPass: false, allowReject: false,
|
||||
}],
|
||||
diagnostics: { billApiCount: 0, clientOnlyCount: 1 },
|
||||
};
|
||||
},
|
||||
async getTodoDetail() { detailCalls += 1; return {}; },
|
||||
async listReturnNodes() { detailCalls += 1; return []; },
|
||||
async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), ding, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
|
||||
const listed = await service.listTodos(user, "all", 10);
|
||||
assert.equal(listed.returned, 2);
|
||||
assert.equal("selectionId" in listed, false, "a mixed result containing client-only XFT tasks cannot become a batch selection");
|
||||
assert.equal(listed.sourceStatus.xft?.billApiCount, 0);
|
||||
assert.equal(listed.sourceStatus.xft?.clientOnlyCount, 1);
|
||||
const item = listed.items.find((candidate) => candidate.title === "薪福通其他审批")!;
|
||||
assert.equal("businessNo" in item, false);
|
||||
assert.deepEqual(item.availableActions, []);
|
||||
assert.equal((item.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
|
||||
const approvalId = String(item.approvalId);
|
||||
assert.match(approvalId, /^v4\./);
|
||||
assert.doesNotMatch(approvalId, /provider-task-secret/);
|
||||
|
||||
const detail = await service.getDetail(user, approvalId) as unknown as Record<string, unknown>;
|
||||
assert.equal(detail.status, "client_required");
|
||||
assert.deepEqual(detail.diagnostics, { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false });
|
||||
assert.equal(detailCalls, 0, "client-only detail must not call an unverified upstream detail API");
|
||||
|
||||
const actions = await service.getActions(user, approvalId);
|
||||
assert.deepEqual(actions.actions.map((action) => [action.action, action.allowed]), [
|
||||
["approve", false], ["reject", false], ["return", false],
|
||||
]);
|
||||
assert.equal((actions.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
|
||||
assert.equal(detailCalls, 0, "client-only action discovery must not call an unverified upstream action API");
|
||||
|
||||
const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
||||
assert.equal(processed.status, "preflight_failed");
|
||||
assert.equal(processed.writesStarted, false);
|
||||
assert.equal(processed.invalidItems?.[0]?.code, "provider_not_supported");
|
||||
assert.equal(writes, 0);
|
||||
});
|
||||
|
||||
test("approval reference expiry is checked once before the batch starts", async () => {
|
||||
const originalNow = Date.now;
|
||||
let now = originalNow();
|
||||
|
||||
+64
-11
@@ -186,30 +186,83 @@ test("Xft OpenAPI does not turn missing or blank amounts into zero", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("Xft OpenAPI continues past an irrelevant full first todo page", async () => {
|
||||
test("Xft OpenAPI keeps unadapted personal todos visible instead of filtering them", async () => {
|
||||
const pages: number[] = [];
|
||||
const irrelevant = Array.from({ length: 100 }, (_, index) => ({
|
||||
todoTaskId: `OTHER-${index}`, groupId: `OTHER-${index}`, appCode: "other-app",
|
||||
}));
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) {
|
||||
const page = Number(decodedBody(init).currentPage);
|
||||
pages.push(page);
|
||||
return encrypted({ code: "0", data: { records: page === 1 ? irrelevant : [{
|
||||
todoTaskId: "TD-TARGET", groupId: "2026081400000001", appCode: "xft-itrip",
|
||||
}] } });
|
||||
return encrypted({ code: "0", data: { records: [{
|
||||
todoTaskId: "OTHER-1", groupId: "OTHER-1", appCode: "other-app", title: "其他审批",
|
||||
}], totalSize: 1 } });
|
||||
}
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
|
||||
assert.equal(result.items[0]?.title, "其他审批");
|
||||
assert.equal(result.items[0]?.capability, "list_only");
|
||||
assert.equal(result.items[0]?.unavailableReason, "missing_bill_identifier");
|
||||
assert.equal(result.diagnostics.billApiCount, 0);
|
||||
assert.equal(result.diagnostics.clientOnlyCount, 1);
|
||||
assert.deepEqual(pages, [1]);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI upgrades an unknown app todo when the official bill API recognizes it", async () => {
|
||||
const targetBillId = "2026081400000001";
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
|
||||
todoTaskId: "TD-INVITATION", groupId: targetBillId, appCode: "new-approval-app",
|
||||
}], totalSize: 1 } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{
|
||||
billId: "2026081400000001", eventType: "COMMON_REIM", title: "目标费用单",
|
||||
billId: targetBillId, eventType: "INVITATION_APPLY", title: "招待费用申请单",
|
||||
}] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 1);
|
||||
assert.equal(result.items[0]?.billId, "2026081400000001");
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
|
||||
assert.equal(result.items[0]?.billId, targetBillId);
|
||||
assert.equal(result.items[0]?.title, "招待费用申请单");
|
||||
assert.equal(result.items[0]?.capability, "bill_api");
|
||||
assert.equal(result.diagnostics.billApiCount, 1);
|
||||
assert.equal(result.diagnostics.clientOnlyCount, 0);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI degrades a failed unknown bill probe without hiding known expense todos", async () => {
|
||||
const knownBillId = "2026081400000001";
|
||||
const unknownBillId = "2026081400000002";
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
|
||||
todoTaskId: "TD-KNOWN", groupId: knownBillId, appCode: "xft-itrip",
|
||||
}, {
|
||||
todoTaskId: "TD-UNKNOWN", groupId: unknownBillId, appCode: "new-approval-app", title: "新类型审批",
|
||||
}], totalSize: 2 } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) {
|
||||
const ids = (decodedBody(init).billIds as Array<string | number>).map(String);
|
||||
if (ids.includes(unknownBillId)) return encrypted({ code: "BIZ_LOOKUP_FAILED", message: "lookup unavailable" });
|
||||
return encrypted({ code: "0", data: { list: [{ billId: knownBillId, eventType: "COMMON_REIM", title: "费用报销单" }] } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0]?.billId, knownBillId);
|
||||
assert.equal(result.items[0]?.capability, "bill_api");
|
||||
assert.equal(result.items[1]?.title, "新类型审批");
|
||||
assert.equal(result.items[1]?.billId, undefined);
|
||||
assert.equal(result.items[1]?.capability, "list_only");
|
||||
assert.equal(result.items[1]?.unavailableReason, "bill_lookup_unavailable");
|
||||
assert.equal(result.diagnostics.billApiCount, 1);
|
||||
assert.equal(result.diagnostics.clientOnlyCount, 1);
|
||||
});
|
||||
|
||||
test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {
|
||||
|
||||
Reference in New Issue
Block a user