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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user