fix: batch XFT task details and reduce single lookup pages
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-11-05 15:54:23 +00:00
parent 295310ab9a
commit 2dee2c27de
3 changed files with 151 additions and 15 deletions
+128
View File
@@ -3,6 +3,10 @@ import test from "node:test";
import smCrypto from "sm-crypto";
import type { AppConfig } from "../src/config.js";
import { XftOpenApiClient } from "../src/xft-open-api.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { ApprovalService } from "../src/approval-service.js";
import { createApprovalMcpServer } from "../src/server.js";
const privateKey = "1".padStart(64, "0");
const { sm3, sm4 } = smCrypto;
@@ -1270,3 +1274,127 @@ test("Xft OpenAPI never calls an action endpoint for a bill outside the user's l
assert.equal(result.failed, 1);
assert.equal(actionCalls, 0);
});
// All regression reads enter through MCP; only the upstream HTTP service is mocked.
async function withTaskQueryMcp(fakeFetch: typeof fetch, run: (client: Client) => Promise<void>) {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createApprovalMcpServer(config(), {} as ApprovalService, new XftOpenApiClient(config(), fakeFetch));
const client = new Client({ name: "xft-task-query-test", version: "1.0.0" });
await server.connect(serverTransport);
try {
await client.connect(clientTransport);
await run(client);
} finally {
await client.close();
await server.close();
}
}
function taskQueryFixture(count: number, targetIndex = Math.min(count - 1, 499)) {
const records = Array.from({ length: count }, (_, index) => ({ todoTaskId: `SYNTHETIC-${index}` }));
const details = records.map((record, index) => ({
...record, businessId: String(90000000000 + index), appCode: "xft-itrip",
dealTime: "2026-08-14T04:00:00.000Z",
}));
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
const state = { lookup: false, absent: false, failBatch: 0, batches: 0, duplicate: false };
const fetcher: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
const body = init?.body ? decodedBody(init) : {};
calls.push({ path, body });
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")) {
// Oversized list responses also exercise the hard cap at the detail boundary.
const start = state.lookup ? (Number(body.currentPage) - 1) * Number(body.pageSize) : 0;
const page = state.lookup ? records.slice(start, start + Number(body.pageSize)) : records;
return encrypted({ code: "0", data: { records: state.absent ? []
: state.duplicate ? [...page, ...page.slice(0, 20)] : page } });
}
if (path.endsWith("todo-task/detail/by-ids")) {
state.batches += 1;
const ids = body.todoTaskIds as string[];
assert.ok(ids.length <= 300);
if (state.batches === state.failBatch) return encrypted({ code: "XFTBIZ0001", message: "synthetic batch failure" });
// Reverse order to ensure enrichment joins by ID, not response position.
return encrypted({ code: "0", data: details.filter((item) => ids.includes(item.todoTaskId)).reverse() });
}
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: (body.billIds as Array<string | number>).map((id) => ({
billId: String(id), eventType: "COMMON_REIM",
title: String(id) === String(90000000000 + targetIndex) ? "目标报销" : "其他报销",
applyTime: "2026-08-14T04:00:00.000Z",
})) } });
if (path.includes("/detail")) return encrypted({ code: "0", data: { billRemark: "目标报销" } });
throw new Error(`Unexpected upstream operation: ${path}`);
};
return { calls, state, fetcher };
}
const taskHistoryArguments = {
source: "xft", scope: "handled", query: "目标报销", limit: 20,
startDate: "2026-08-01", endDate: "2026-08-31",
};
for (const count of [300, 301, 1000]) {
test(`MCP XFT history batches ${count} task IDs and joins all detail batches`, async () => {
const fixture = taskQueryFixture(count);
fixture.state.duplicate = count === 301;
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments });
assert.notEqual(response.isError, true);
const result = (response.structuredContent as any).result;
assert.equal(result.sourceStatus.xft.status, "ok");
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "目标报销");
const batches = fixture.calls.filter((call) => call.path.endsWith("detail/by-ids"))
.map((call) => call.body.todoTaskIds as string[]);
assert.deepEqual(batches.map((batch) => batch.length), count === 300 ? [300] : count === 301 ? [300, 1] : [300, 300, 300, 100]);
assert.equal(new Set(batches.flat()).size, count);
});
});
}
test("MCP XFT history reports a later batch failure without returning partial success or retrying", async () => {
const fixture = taskQueryFixture(1000);
fixture.state.failBatch = 2;
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments });
const result = (response.structuredContent as any).result;
assert.equal(result.sourceStatus.xft.status, "unavailable");
assert.deepEqual(result.items, []);
assert.equal(fixture.state.batches, 2);
assert.equal(fixture.calls.some((call) => call.path.endsWith("bill-list")), false);
});
});
for (const scope of ["todo", "done"]) {
for (const targetIndex of [0, 100, 199]) {
test(`MCP XFT ${scope} single detail finds index ${targetIndex} with small pages and stops early`, async () => {
const fixture = taskQueryFixture(250, targetIndex);
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const listed = await client.callTool(scope === "done"
? { name: "approval_list_history", arguments: taskHistoryArguments }
: { name: "approval_list_todos", arguments: { source: "xft", query: "目标报销", limit: 20 } });
const approvalId = (listed.structuredContent as any).result.items[0].approvalId;
fixture.calls.length = 0;
fixture.state.lookup = true;
const response = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } });
assert.notEqual(response.isError, true);
const pages = fixture.calls.filter((call) => call.path.endsWith("page/by-user"));
assert.deepEqual(pages.map((call) => call.body), Array.from({ length: Math.floor(targetIndex / 100) + 1 }, (_, index) => ({
currentPage: index + 1, dealStatus: scope, pageSize: 100,
})));
const summaries = fixture.calls.filter((call) => call.path.endsWith("bill-list"));
assert.equal(summaries.length, 1);
assert.deepEqual(summaries[0]!.body.billIds, [90000000000 + targetIndex]);
// Removing the user's live task must invalidate the previously issued reference.
fixture.state.absent = true;
fixture.calls.length = 0;
const missing = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } });
assert.equal(missing.isError, true);
assert.equal(fixture.calls.some((call) => call.path.includes("/detail")), false);
});
});
}
}