fix: keep large approval batches observable
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-20 02:10:37 +00:00
parent dc86130711
commit ca95c41fdd
4 changed files with 219 additions and 38 deletions
+40 -1
View File
@@ -6,10 +6,49 @@ import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import {
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer,
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, bridgeWriteOutcomeUncertainResult, createBridgeMcpServer,
syncBundledSkillForLegacyUpdate,
} from "../src/bridge.js";
test("bridge write timeout fallback remains structured and forbids retries", () => {
const result = bridgeWriteOutcomeUncertainResult("approval_process_batch", {
action: "approve",
items: [{ approvalId: "approval-one" }, { approvalId: "approval-two" }],
});
assert.equal(result?.structuredContent.result.status, "outcome_uncertain");
assert.equal(result?.structuredContent.result.uncertain, 2);
assert.equal(result?.structuredContent.result.retryable, false);
assert.equal(result?.structuredContent.result.requiresRelist, true);
assert.equal(result?.structuredContent.result.results.length, 2);
assert.match(result?.content[0]?.text ?? "", /禁止自动重试/);
assert.equal(bridgeWriteOutcomeUncertainResult("approval_list_todos", {}), undefined);
});
test("stdio bridge converts a remote batch timeout into a structured uncertain outcome", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-write-timeout-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
throw new Error("network should not be used by the injected client");
});
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() { throw new Error("Request timed out"); },
async close() {},
};
const result = await bridge.call("approval_process_batch", {
action: "approve",
items: [{ approvalId: "approval-one" }],
});
assert.equal(result.structuredContent.result.status, "outcome_uncertain");
assert.equal(result.structuredContent.result.uncertain, 1);
assert.equal(result.structuredContent.result.retryable, false);
assert.match(result.content[0].text, /重新列出实时待办核对/);
});
test("legacy updater post-install syncs the complete bundled Skill", () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
const bundled = join(root, "package", "skill", "dingtalk-approval");
+53
View File
@@ -329,6 +329,59 @@ test("Xft OpenAPI derives empId server-side and validates ownership before and a
assert.equal(actionBodies[0]?.empId, "EMP-1");
});
test("Xft approval batches revalidate each write and share one post-write todo scan", async () => {
const billIds = Array.from({ length: 20 }, (_, index) => String(10_000_000_100 + index));
const pending = new Set(billIds);
let todoQueries = 0;
let actionCalls = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
const body = init?.body ? decodedBody(init) : {};
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")) {
todoQueries += 1;
return encrypted({ code: "0", data: { records: [...pending].map((billId) => ({
todoTaskId: `TD-${billId}`, groupId: billId, appCode: "xft-itrip",
})) } });
}
if (path.endsWith("todo-task/detail/by-ids")) {
const taskIds = Array.isArray(body.todoTaskIds) ? body.todoTaskIds : [];
return encrypted({ code: "0", data: taskIds.map((taskId) => ({
todoTaskId: taskId, businessId: String(taskId).replace(/^TD-/, ""), appCode: "xft-itrip",
})) });
}
if (path.endsWith("bill-list")) {
const ids = Array.isArray(body.billIds) ? body.billIds.map(String) : [];
return encrypted({ code: "0", data: { list: ids.map((billId) => ({ billId, eventType: "COMMON_REIM" })) } });
}
if (path.endsWith("approve/pass")) {
actionCalls += 1;
const businessList = Array.isArray(body.businessList) ? body.businessList : [];
const businessId = businessList[0] && typeof businessList[0] === "object"
? String((businessList[0] as Record<string, unknown>).businessId)
: "";
pending.delete(businessId);
return encrypted({ code: "0", data: {} });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.processApprovalBatch(
{ userId: "ding-u1" },
"agree",
"同意",
billIds.map((billId) => ({ billId })),
);
assert.equal(result.succeeded, 20);
assert.equal(result.failed, 0);
assert.equal(actionCalls, 20);
assert.equal(todoQueries, 21, "each write needs a live ownership scan, followed by one shared post-write scan");
assert.deepEqual(result.results.map((item) => item.billId), billIds);
});
test("Xft submission discovers live templates and derives protected create fields server-side", async () => {
const createBodies: Record<string, unknown>[] = [];
const fakeFetch: typeof fetch = async (input, init) => {