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
1273 lines
71 KiB
TypeScript
1273 lines
71 KiB
TypeScript
import assert from "node:assert/strict";
|
||
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";
|
||
|
||
const privateKey = "1".padStart(64, "0");
|
||
const { sm3, sm4 } = smCrypto;
|
||
|
||
function config(): AppConfig {
|
||
return {
|
||
clientId: "ding-app",
|
||
clientSecret: "ding-secret",
|
||
apiBaseUrl: "https://ding.test",
|
||
oapiBaseUrl: "https://ding-oapi.test",
|
||
publicBaseUrl: "http://localhost:3000",
|
||
trustedLocalUser: { userId: "ding-u1" },
|
||
port: 3000,
|
||
requestTimeoutMs: 2_000,
|
||
officialLookbackDays: 120,
|
||
officialScanConcurrency: 2,
|
||
officialClientOnlyProcessCodes: [],
|
||
approvalRefSecret: "fixed-reference-secret-for-tests",
|
||
batchLimit: 20,
|
||
xftOpenApi: {
|
||
appId: "app-id",
|
||
authoritySecret: privateKey,
|
||
enterpriseId: "TEST0001",
|
||
apiBaseUrl: "https://xft-api.test",
|
||
encrypted: true,
|
||
dingTalkAppCode: "DDingTalk",
|
||
},
|
||
};
|
||
}
|
||
|
||
function encrypted(value: unknown): Response {
|
||
return new Response(String(sm4.encrypt(JSON.stringify(value), privateKey.slice(0, 32))), {
|
||
headers: { "content-type": "text/plain" },
|
||
});
|
||
}
|
||
|
||
function decodedBody(init?: RequestInit): Record<string, unknown> {
|
||
const envelope = JSON.parse(String(init?.body)) as { secretMsg: string };
|
||
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
|
||
}
|
||
|
||
function findRecordByKey(value: unknown, key: string): Record<string, unknown> | undefined {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const match = findRecordByKey(item, key);
|
||
if (match) return match;
|
||
}
|
||
return undefined;
|
||
}
|
||
if (!value || typeof value !== "object") return undefined;
|
||
const record = value as Record<string, unknown>;
|
||
if (record.key === key) return record;
|
||
for (const child of Object.values(record)) {
|
||
const match = findRecordByKey(child, key);
|
||
if (match) return match;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function findRecordsByKey(value: unknown, key: string): Array<Record<string, unknown>> {
|
||
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key));
|
||
if (!value || typeof value !== "object") return [];
|
||
const record = value as Record<string, unknown>;
|
||
return [...(record.key === key ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key))];
|
||
}
|
||
|
||
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
|
||
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
|
||
const fakeFetch: typeof fetch = async (input, init) => {
|
||
const url = new URL(String(input));
|
||
const body = decodedBody(init);
|
||
const headers = new Headers(init?.headers);
|
||
calls.push({ path: url.pathname, body, headers, url });
|
||
if (url.pathname.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1", userName: "用户一" }] } });
|
||
if (url.pathname.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010100000001", appCode: "xft-bpm", businessCode: "OA000001" }], totalSize: 1 } });
|
||
if (url.pathname.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010100000001", appCode: "TEST0001_TRIP", businessCode: "TEST0001_TRIP_TRIP_2099010100000002" }] });
|
||
if (url.pathname.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010100000001", eventType: "COMMON_REIM", title: "费用报销", applicantName: "测试用户丁", totalAmount: 4812 }] } });
|
||
throw new Error(`unexpected ${url.pathname}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
|
||
|
||
assert.equal(result.items[0]?.billId, "2099010100000001");
|
||
assert.equal(result.items[0]?.amount, 4812);
|
||
assert.equal(result.identity.enterpriseUserId, "EMP-1");
|
||
assert.deepEqual(calls[0]?.body.thirdStaffIds, ["ding-u1"]);
|
||
assert.equal(calls[0]?.body.appCode, "DDingTalk");
|
||
for (const call of calls) {
|
||
assert.equal(call.url.searchParams.get("CSCAPPUID"), "app-id");
|
||
assert.equal(call.url.searchParams.get("OPAUID"), "TEST0001");
|
||
assert.equal(call.headers.get("x-alb-verify"), "sm3withsm2");
|
||
assert.equal(call.headers.get("apisign")?.length, 128);
|
||
assert.equal(call.headers.get("x-alb-digest"), sm3(JSON.stringify({ secretMsg: sm4.encrypt(JSON.stringify(call.body), privateKey.slice(0, 32)) })));
|
||
}
|
||
const todoCall = calls.find((call) => call.path.endsWith("todo-task/page/by-user"));
|
||
assert.equal(todoCall?.url.searchParams.get("CSCUSRNBR"), "EMP-1");
|
||
assert.equal(todoCall?.url.searchParams.get("CSCPRJCOD"), "TEST0001");
|
||
});
|
||
|
||
test("Xft OpenAPI reads the official billAmount in todo summaries", async () => {
|
||
const fakeFetch: typeof fetch = async (input, init) => {
|
||
const path = new URL(String(input)).pathname;
|
||
void 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")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
|
||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
|
||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount: 2336 }] } });
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
|
||
assert.equal(result.items[0]?.amount, 2336);
|
||
});
|
||
|
||
test("Xft OpenAPI lists handled tasks and本人 submitted bills as separate read-only history scopes", async () => {
|
||
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||
const fakeFetch: typeof fetch = async (input, init) => {
|
||
const path = new URL(String(input)).pathname;
|
||
const 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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
|
||
todoTaskId: "DONE-1", groupId: "TRIP_2026081400000001", appCode: "xft-bpm", businessCode: "OA000001",
|
||
dealTime: "2026-08-14T04:00:00.000Z",
|
||
}] } });
|
||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{
|
||
todoTaskId: "DONE-1", businessId: "TRIP_2026081400000001", appCode: "TEST0001_TRIP",
|
||
}] });
|
||
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: { pageData: {
|
||
records: [
|
||
{ billId: "2026081400000002", billSts: "1", eventType: "COMMON_REIM", applyTime: "2026-08-14T05:00:00.000Z" },
|
||
{ billId: "2026070100000003", billSts: "2", eventType: "COMMON_REIM", applyTime: "2026-07-01T05:00:00.000Z" },
|
||
], total: 2,
|
||
} } });
|
||
if (path.endsWith("bill-list")) {
|
||
const ids = body.billIds as Array<string | number>;
|
||
return encrypted({ code: "0", data: { list: ids.map((billId) => ({
|
||
billId: String(billId), eventType: "COMMON_REIM",
|
||
applyTime: String(billId).endsWith("3") ? "2026-07-01T05:00:00.000Z" : "2026-08-14T03:00:00.000Z",
|
||
title: String(billId).endsWith("1") ? "已处理报销" : "本人发起报销",
|
||
applicantName: String(billId).endsWith("1") ? "同事甲" : "用户一", billAmount: 88,
|
||
})) } });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const historyRange = { startTime: Date.parse("2026-08-01T00:00:00+08:00"), endTime: Date.parse("2026-08-31T23:59:59+08:00") };
|
||
|
||
const handled = await client.listMyHistory({ userId: "ding-u1" }, "handled", 20, "", historyRange);
|
||
const submitted = await client.listMyHistory({ userId: "ding-u1" }, "submitted", 20, "", historyRange);
|
||
|
||
assert.equal(handled.items[0]?.billId, "2026081400000001");
|
||
assert.equal(handled.items[0]?.historyScope, "handled");
|
||
assert.equal("allowPass" in handled.items[0]!, false);
|
||
assert.equal(submitted.items[0]?.billId, "2026081400000002");
|
||
assert.equal(submitted.items.length, 1, "submitted history excludes bills outside the requested time range");
|
||
assert.equal(submitted.items[0]?.historyScope, "submitted");
|
||
assert.equal(calls.find((call) => call.path.endsWith("todo-task/page/by-user"))?.body.dealStatus, "done");
|
||
assert.deepEqual(calls.find((call) => call.path.endsWith("bills/queryBillByCondition"))?.body, {
|
||
empId: "E001", start: 0, limit: 100,
|
||
});
|
||
});
|
||
|
||
test("Xft OpenAPI does not turn missing or blank amounts into zero", async () => {
|
||
for (const billAmount of [null, ""]) {
|
||
const fakeFetch: typeof fetch = 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-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
|
||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
|
||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount }] } });
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const result = await new XftOpenApiClient(config(), fakeFetch).listMyTodos({ userId: "ding-u1" }, 50);
|
||
assert.equal(result.items[0]?.amount, undefined);
|
||
}
|
||
});
|
||
|
||
test("Xft OpenAPI keeps unadapted personal todos visible instead of filtering them", async () => {
|
||
const pages: number[] = [];
|
||
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: [{
|
||
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: targetBillId, eventType: "INVITATION_APPLY", title: "招待费用申请单",
|
||
}] } });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
|
||
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 () => {
|
||
for (const [upstream, expected] of [
|
||
[{ code: "XFTSYS9999", message: "temporary unavailable", status: 503 }, "outcome_uncertain"],
|
||
[{ code: "BIZ001", message: "not allowed", status: 400 }, "failed"],
|
||
] as const) {
|
||
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-1", groupId: "2026081400000001", appCode: "xft-itrip",
|
||
}] } });
|
||
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" }] } });
|
||
if (path.endsWith("approve/pass")) {
|
||
return new Response(String(sm4.encrypt(JSON.stringify({ code: upstream.code, message: upstream.message }), privateKey.slice(0, 32))), {
|
||
status: upstream.status,
|
||
});
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "2026081400000001" }]);
|
||
assert.equal(result.results[0]?.status, expected);
|
||
}
|
||
});
|
||
|
||
test("Xft OpenAPI fails closed when the DingTalk user has no enterprise mapping", async () => {
|
||
let calls = 0;
|
||
const client = new XftOpenApiClient(config(), async () => {
|
||
calls += 1;
|
||
return encrypted({ code: "0", data: { records: [] } });
|
||
});
|
||
await assert.rejects(client.listMyTodos({ userId: "unknown" }, 50), /未映射到薪福通企业成员/);
|
||
assert.equal(calls, 1);
|
||
});
|
||
|
||
test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappings", async () => {
|
||
for (const records of [
|
||
[{ thirdStaffId: "victim-user", enterpriseUserId: "EMP-VICTIM" }],
|
||
[{ enterpriseUserId: "EMP-WITHOUT-DING-ID" }],
|
||
[
|
||
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" },
|
||
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-2" },
|
||
],
|
||
]) {
|
||
let mappingCalls = 0;
|
||
let downstreamCalls = 0;
|
||
const client = new XftOpenApiClient(config(), async (input) => {
|
||
if (new URL(String(input)).pathname.includes("user-mapping")) {
|
||
mappingCalls += 1;
|
||
return encrypted({ code: "0", data: { records } });
|
||
}
|
||
downstreamCalls += 1;
|
||
return encrypted({ code: "0", data: {} });
|
||
});
|
||
await assert.rejects(client.listMyTodos({ userId: "ding-u1" }, 50), /未映射|多个薪福通成员映射/);
|
||
assert.equal(mappingCalls, 1);
|
||
assert.equal(downstreamCalls, 0);
|
||
}
|
||
});
|
||
|
||
test("Xft OpenAPI surfaces XFT framework business errors", async () => {
|
||
const client = new XftOpenApiClient(config(), async () => new Response(
|
||
String(sm4.encrypt(JSON.stringify({
|
||
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
|
||
}), privateKey.slice(0, 32))),
|
||
{ status: 403 },
|
||
));
|
||
await assert.rejects(
|
||
client.authStatus({ userId: "ding-u1" }),
|
||
/应用状态错误(操作:用户身份映射;HTTP 403;薪福通错误码 SYOPA16)/,
|
||
);
|
||
});
|
||
|
||
test("Xft OpenAPI exposes safe transport diagnostics without leaking the native error message", async () => {
|
||
for (const [cause, expected] of [
|
||
[Object.assign(new Error("connect ECONNRESET https://user:password@private.invalid/?token=secret"), { code: "ECONNRESET" }),
|
||
/用户身份映射请求失败:网络连接被重置或中断(底层错误码 ECONNRESET;超时上限 2000ms)/],
|
||
[Object.assign(new Error("getaddrinfo ENOTFOUND private.invalid"), { code: "ENOTFOUND" }),
|
||
/用户身份映射请求失败:DNS 解析失败(底层错误码 ENOTFOUND;超时上限 2000ms)/],
|
||
[Object.assign(new Error("connect timed out"), { code: "UND_ERR_CONNECT_TIMEOUT", name: "ConnectTimeoutError" }),
|
||
/用户身份映射请求失败:连接或等待响应超时(底层错误码 UND_ERR_CONNECT_TIMEOUT;错误类型 ConnectTimeoutError;超时上限 2000ms)/],
|
||
] as const) {
|
||
const client = new XftOpenApiClient(config(), async () => {
|
||
throw new TypeError("fetch failed", { cause });
|
||
});
|
||
await assert.rejects(client.authStatus({ userId: "ding-u1" }), (error: unknown) => {
|
||
assert.ok(error instanceof Error);
|
||
assert.match(error.message, expected);
|
||
assert.doesNotMatch(error.message, /password|private\.invalid|token=secret|fetch failed/);
|
||
return true;
|
||
});
|
||
}
|
||
});
|
||
|
||
test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => {
|
||
let capturedUrl: URL | undefined;
|
||
let capturedInit: RequestInit | undefined;
|
||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||
capturedUrl = new URL(String(input));
|
||
capturedInit = init;
|
||
return encrypted({ code: "0", data: { billId: "2099010200000001" } });
|
||
});
|
||
const request = client as unknown as {
|
||
request(path: string, body: undefined, method: "GET", identity: { enterpriseUserId: string }): Promise<unknown>;
|
||
};
|
||
await request.request(
|
||
"/itrip/xft-api/v1/reimburse/bill/common/detail?billId=2099010200000001",
|
||
undefined,
|
||
"GET",
|
||
{ enterpriseUserId: "EMP-1" },
|
||
);
|
||
|
||
assert.equal(capturedUrl?.pathname, "/itrip/xft-api/v1/reimburse/bill/common/detail");
|
||
assert.equal(capturedUrl?.searchParams.get("billId"), "2099010200000001");
|
||
assert.equal(capturedUrl?.searchParams.get("CSCUSRNBR"), "EMP-1");
|
||
assert.equal(capturedInit?.method, "GET");
|
||
assert.equal(capturedInit?.body, undefined);
|
||
assert.equal(new Headers(capturedInit?.headers).has("x-alb-digest"), false);
|
||
});
|
||
|
||
test("Xft OpenAPI derives empId server-side and validates ownership before and after a write", async () => {
|
||
let todoQueries = 0;
|
||
const actionBodies: Record<string, unknown>[] = [];
|
||
const fakeFetch: typeof fetch = async (input, init) => {
|
||
const path = new URL(String(input)).pathname;
|
||
const body = decodedBody(init);
|
||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||
if (path.endsWith("todo-task/page/by-user")) {
|
||
todoQueries += 1;
|
||
return encrypted({ code: "0", data: todoQueries === 1 ? { records: [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] } : { records: [] } });
|
||
}
|
||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "10000000001", appCode: "xft-itrip" }] });
|
||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "10000000001", eventType: "COMMON_REIM" }] } });
|
||
if (path.endsWith("approve/pass")) { actionBodies.push(body); return encrypted({ code: "0", data: {} }); }
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
|
||
|
||
assert.equal(result.succeeded, 1);
|
||
assert.equal(todoQueries, 2);
|
||
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) => {
|
||
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("member/get/by-id")) {
|
||
assert.deepEqual(body, { id: "EMP-1", extFields: ["historyId", "org"] });
|
||
return encrypted({ returnCode: "SUC0000", body: {
|
||
memberId: "EMP-1", number: "E001", idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
|
||
} });
|
||
}
|
||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { total: 1, list: [{
|
||
eventSeqId: 12345, eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
}] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
|
||
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
|
||
}, {
|
||
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
|
||
}, {
|
||
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||
}, {
|
||
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||
}, {
|
||
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: false, options: [],
|
||
}] });
|
||
if (path.endsWith("reimburse/bill/create")) {
|
||
createBodies.push(body);
|
||
return encrypted({ code: "SUC0000", body: 2026081200000001, success: true });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||
assert.equal(templates[0]?.kind, "common_reimburse");
|
||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!);
|
||
assert.equal(schema.fields[0]?.fieldNumber, "C1");
|
||
assert.equal(schema.fields.length, 2);
|
||
assert.deepEqual(schema.fields.find((field) => field.fieldNumber === "ACCESSORIES_1")?.attachmentCapability, {
|
||
status: "supported",
|
||
input: "local_file",
|
||
uploadWhen: "after_final_confirmation",
|
||
maxFileBytes: 50 * 1024 * 1024,
|
||
maxFilesPerSubmission: 10,
|
||
});
|
||
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
|
||
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
|
||
assert.match(JSON.stringify(minimalExample), /报销总金额.*费用类型选项.*本笔费用金额/);
|
||
const expenseList = standardFields.find((field) => field.key === "expenseList");
|
||
const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
|
||
assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [
|
||
"expTypeCode", "currencyCode", "originalCurrencyReimAmt",
|
||
]);
|
||
assert.deepEqual(expenseItemFields.find((field) => field.key === "expTypeCode")?.optionSource, {
|
||
tool: "approval_get_submission_options", kind: "expense_type",
|
||
});
|
||
assert.equal(expenseItemFields.find((field) => field.key === "expTypeCode")?.label, "费用类型");
|
||
const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" });
|
||
assert.equal(identityCheck.validated, true);
|
||
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
|
||
const result = await client.submitApproval({ userId: "ding-u1" }, templates[0]!, {
|
||
reimAmount: 10, reimAmountCurrencyCode: "CNY", expenseList: [{ amount: 10 }],
|
||
});
|
||
assert.equal(result.billId, "2026081200000001");
|
||
assert.equal(createBodies[0]?.eventNumber, "FORM-1");
|
||
assert.equal(createBodies[0]?.empNumber, "E001");
|
||
assert.equal(createBodies[0]?.billStatus, "APPRV_PEND");
|
||
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
|
||
});
|
||
|
||
test("Xft attachment upload follows the official multipart signing mode and injects opaque file IDs only at create", async () => {
|
||
const file = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
|
||
const sha256 = (await import("node:crypto")).createHash("sha256").update(file).digest("hex");
|
||
let uploadHeaders: Headers | undefined;
|
||
let uploadBody: Buffer | undefined;
|
||
let createBody: Record<string, unknown> | undefined;
|
||
const fakeFetch: typeof fetch = async (input, init) => {
|
||
const url = new URL(String(input));
|
||
const path = url.pathname;
|
||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
|
||
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
|
||
}] } });
|
||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
}] } });
|
||
if (path.endsWith("accessory/custom-field/upload")) {
|
||
uploadHeaders = new Headers(init?.headers);
|
||
uploadBody = Buffer.from(init?.body as ArrayBuffer);
|
||
assert.equal(url.searchParams.get("uploaderNumber"), "E001");
|
||
return encrypted({ code: "SUC0000", body: { fileId: "file-secret-1" } });
|
||
}
|
||
if (path.endsWith("reimburse/bill/create")) {
|
||
createBody = decodedBody(init);
|
||
return encrypted({ code: "SUC0000", body: 2026090100000001 });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
};
|
||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||
const confirmationId = "confirmation-bound-to-user-preview";
|
||
const uploaded = await client.uploadSubmissionAttachment({ userId: "ding-u1" }, {
|
||
fieldName: "发票附件", fileName: "invoice.pdf", size: file.length, sha256, content: file, confirmationId,
|
||
});
|
||
assert.equal(uploadHeaders?.has("x-alb-digest"), false, "official SDK omits body digest for multipart uploads");
|
||
assert.equal(uploadHeaders?.get("x-alb-verify"), "sm3withsm2");
|
||
assert.equal(uploadHeaders?.get("apisign")?.length, 128);
|
||
assert.match(uploadHeaders?.get("content-type") ?? "", /^multipart\/form-data;.*boundary=/);
|
||
assert.match(uploadBody?.toString("utf8") ?? "", /name="file"; filename="invoice\.pdf"/);
|
||
assert.ok(uploadBody?.includes(file));
|
||
assert.doesNotMatch(uploaded.attachmentRef, /file-secret-1/);
|
||
|
||
const template = {
|
||
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单",
|
||
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
|
||
};
|
||
await client.submitApproval({ userId: "ding-u1" }, template, {
|
||
reimAmount: "551.00", reimAmountCurrencyCode: "CNY", expenseList: [{ expTypeCode: "MEAL" }],
|
||
}, [{
|
||
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
|
||
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
|
||
}], confirmationId);
|
||
assert.deepEqual(createBody?.customFieldValues, [{
|
||
fieldNumber: "ACCESSORIES_1", fieldValue: JSON.stringify(["file-secret-1"]),
|
||
}]);
|
||
assert.doesNotMatch(JSON.stringify(createBody), /confirmation-bound|attachmentRef|sha256/);
|
||
|
||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}, [{
|
||
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
|
||
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
|
||
}], "another-confirmation"), /与本次用户确认.*不一致/);
|
||
});
|
||
|
||
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
|
||
};
|
||
let cityRequests = 0;
|
||
let expenseSourceRequests = 0;
|
||
let unrelatedSourceRequests = 0;
|
||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||
const url = new URL(String(input));
|
||
const path = url.pathname;
|
||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.endsWith("member/get/by-id")) {
|
||
assert.deepEqual(decodedBody(init), { id: "EMP-1", extFields: ["historyId", "org"] });
|
||
return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "SECRET-EMPLOYEE-NUMBER",
|
||
idRelation: { enterpriseUserId: "EMP-1" },
|
||
organizationRelations: [
|
||
{ organizationCode: "ORG-SZ", organizationName: "深圳总部" },
|
||
{ organization: { code: "ORG-RD", name: "研发部" } },
|
||
],
|
||
} });
|
||
}
|
||
if (path.endsWith("common/city/getAllCity")) {
|
||
cityRequests += 1;
|
||
return encrypted({ code: "0", data: {
|
||
regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }],
|
||
} });
|
||
}
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
|
||
assert.equal(init?.method, "POST");
|
||
assert.equal(url.searchParams.get("start"), "0");
|
||
assert.equal(url.searchParams.get("limit"), "100");
|
||
assert.deepEqual(decodedBody(init), {});
|
||
return encrypted({ success: true, message: "调用成功!", totalCount: 2, list: [
|
||
{ seqId: 150, id: "DS-EXP", selectSourceName: "企业费用类型" },
|
||
{ seqId: 151, id: "DS-PROJECT", selectSourceName: "项目档案" },
|
||
] });
|
||
}
|
||
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
|
||
expenseSourceRequests += 1;
|
||
return encrypted({ success: true, message: "调用成功!", data: {
|
||
id: "DS-EXP",
|
||
selectSourceName: "企业费用类型",
|
||
optionSaveDtoList: [
|
||
{ seqId: 601, selectOptionName: "交通费", optionSaveDtoList: null },
|
||
{ seqId: 602, selectOptionName: "住宿费", optionSaveDtoList: null },
|
||
{ seqId: 603, selectOptionName: "停用费用", optionSaveDtoList: null, selectable: false },
|
||
],
|
||
} });
|
||
}
|
||
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
|
||
unrelatedSourceRequests += 1;
|
||
return encrypted({ success: true, message: "调用成功!", data: {
|
||
optionSaveDtoList: [{ seqId: 701, selectOptionName: "项目一", optionSaveDtoList: null }],
|
||
} });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
const departments = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "department");
|
||
assert.deepEqual(departments.items, [
|
||
{ code: "ORG-SZ", label: "深圳总部" },
|
||
{ code: "ORG-RD", label: "研发部" },
|
||
]);
|
||
assert.doesNotMatch(JSON.stringify(departments), /SECRET-EMPLOYEE-NUMBER|EMP-1/);
|
||
const cities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "深圳", 10);
|
||
assert.deepEqual(cities.items, [{ code: "440300", label: "深圳市" }]);
|
||
const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10);
|
||
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
|
||
assert.equal(cityRequests, 1);
|
||
const expenseTypes = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "住宿", 10);
|
||
assert.deepEqual(expenseTypes.items, [{ code: "602", label: "住宿费" }]);
|
||
assert.equal(expenseTypes.total, 1);
|
||
assert.equal(expenseTypes.hasMore, false);
|
||
assert.equal(expenseSourceRequests, 1);
|
||
assert.equal(unrelatedSourceRequests, 0);
|
||
});
|
||
|
||
test("all formal Xft expense fields declare a user-readable expense type option source", async () => {
|
||
const records = [
|
||
{ eventSeqId: 11, eventNumber: "EXP-11", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||
{ eventSeqId: 12, eventNumber: "TRIP-12", eventName: "出差费用申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||
{ eventSeqId: 13, eventNumber: "REIM-13", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||
];
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 10);
|
||
const expected = ["expenseNumber", "expenseTypeNumber", "expTypeCode"];
|
||
for (const [index, fieldKey] of expected.entries()) {
|
||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[index]!);
|
||
const field = findRecordByKey(schema.inputContract, fieldKey);
|
||
assert.equal(field?.label, "费用类型");
|
||
assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" });
|
||
assert.deepEqual(field?.selectionCapability, {
|
||
status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false,
|
||
});
|
||
assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/);
|
||
}
|
||
});
|
||
|
||
test("all formal Xft city and enum choices expose readable labels while unresolved references fail closed", async () => {
|
||
const records = [
|
||
{ eventSeqId: 21, eventNumber: "EXP-21", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||
{ eventSeqId: 22, eventNumber: "ENT-22", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
|
||
{ eventSeqId: 23, eventNumber: "TRIP-23", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||
{ eventSeqId: 24, eventNumber: "REIM-24", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||
{ eventSeqId: 25, eventNumber: "TR-25", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
|
||
];
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||
const schemas = await Promise.all(templates.map((template) => client.getSubmissionSchema({ userId: "ding-u1" }, template)));
|
||
const combined = schemas.map((schema) => schema.inputContract);
|
||
for (const schema of schemas) {
|
||
const values = schema.inputContract.values as Record<string, unknown>;
|
||
assert.ok(values.minimalExample && typeof values.minimalExample === "object");
|
||
assert.match(JSON.stringify(values.minimalExample), /</);
|
||
}
|
||
for (const key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
|
||
const matches = findRecordsByKey(combined, key);
|
||
assert.ok(matches.length > 0, `${key} should exist`);
|
||
for (const field of matches) {
|
||
assert.deepEqual(field.optionSource, { tool: "approval_get_submission_options", kind: "city" });
|
||
assert.equal((field.selectionCapability as Record<string, unknown>)?.exposeInternalValue, false);
|
||
assert.doesNotMatch(String(field.label), /编码/);
|
||
}
|
||
}
|
||
const travelMode = findRecordByKey(combined, "travelMode");
|
||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.AirPlane, "飞机");
|
||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.RideHailing, "网约车");
|
||
assert.equal((findRecordByKey(combined, "roundTrip")?.enumLabels as Record<string, string>)?.Y, "往返");
|
||
assert.equal((findRecordByKey(combined, "beginTimePrecision")?.enumLabels as Record<string, string>)?.AM, "上午");
|
||
assert.equal((findRecordByKey(combined, "currencyCode")?.enumLabels as Record<string, string>)?.CNY, "人民币");
|
||
assert.equal((findRecordByKey(combined, "hotelStar")?.enumLabels as Record<string, string>)?.["1"], "五星");
|
||
for (const key of ["peerEmpNumbers", "participants", "customArchiveOptionNumbers", "aircraftCabin", "trainCabin", "shipCabin", "relatedBillId"]) {
|
||
const field = findRecordByKey(combined, key);
|
||
assert.equal((field?.selectionCapability as Record<string, unknown>)?.status, "client_only", key);
|
||
assert.doesNotMatch(String(field?.label ?? ""), /工号|编码| ID/);
|
||
}
|
||
});
|
||
|
||
test("live Xft custom choices normalize labels recursively and unsupported selectors are explicit", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 31, eventNumber: "CUSTOM-31", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||
};
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [
|
||
{ fieldNumber: "S1", name: "餐别", type: "SELECT", required: true, options: [
|
||
{ optionCode: "LUNCH", optionName: "午餐" }, { optionCode: "DINNER", optionName: "晚餐" },
|
||
] },
|
||
{ fieldNumber: "S2", name: "无名称映射", type: "SELECT", required: false, options: ["1", "2"] },
|
||
{ fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true },
|
||
{ fieldNumber: "D1", name: "成本部门", type: "ADV_DEPARTMENT", required: true },
|
||
{ fieldNumber: "T1", name: "明细", type: "TABLE", required: true, columns: [
|
||
{ titleCode: "TS", name: "用餐类型", type: "SELECT", required: true, options: [{ code: "TEAM", label: "团队餐" }] },
|
||
{ titleCode: "TP", name: "同行人", type: "PERSON", required: false },
|
||
] },
|
||
] });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
const fields = (await client.getSubmissionSchema({ userId: "ding-u1" }, template)).fields;
|
||
const select = fields.find((field) => field.fieldNumber === "S1")!;
|
||
assert.deepEqual(select.options, [{ code: "LUNCH", label: "午餐" }, { code: "DINNER", label: "晚餐" }]);
|
||
assert.equal(select.selectionCapability?.status, "supported");
|
||
assert.equal(fields.find((field) => field.fieldNumber === "S2")?.selectionCapability?.status, "client_only");
|
||
assert.equal(fields.find((field) => field.fieldNumber === "P1")?.selectionCapability?.status, "client_only");
|
||
assert.deepEqual(fields.find((field) => field.fieldNumber === "D1")?.optionSource, {
|
||
tool: "approval_get_submission_options", kind: "department",
|
||
});
|
||
const tableFields = fields.find((field) => field.fieldNumber === "T1")?.itemFields ?? [];
|
||
assert.deepEqual(tableFields.find((field) => field.titleCode === "TS")?.options, [{ code: "TEAM", label: "团队餐" }]);
|
||
assert.equal((tableFields.find((field) => field.titleCode === "TP")?.selectionCapability as Record<string, unknown>)?.status, "client_only");
|
||
});
|
||
|
||
test("Xft expense types prefer user-readable live template options and fail closed when unavailable", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 8, eventNumber: "EXP-8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||
};
|
||
let dataSourceRequests = 0;
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
|
||
fieldNumber: "expenseType", name: "费用类型", type: "SELECT", required: true,
|
||
options: [
|
||
{ optionCode: "MEAL", optionName: "客户招待费" },
|
||
{ optionCode: "CAR", optionName: "市内交通费" },
|
||
{ optionCode: "其他费", optionName: "其他费" },
|
||
],
|
||
}] });
|
||
if (path.includes("dataSource/")) {
|
||
dataSourceRequests += 1;
|
||
throw new Error("data source must not be called");
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, template);
|
||
assert.deepEqual(schema.fields[0]?.optionSource, {
|
||
tool: "approval_get_submission_options", kind: "expense_type",
|
||
});
|
||
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "招待", 10);
|
||
assert.deepEqual(result.items, [{ code: "MEAL", label: "客户招待费" }]);
|
||
const sameCodeAndLabel = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "其他", 10);
|
||
assert.deepEqual(sameCodeAndLabel.items, [{ code: "其他费", label: "其他费" }]);
|
||
assert.equal(dataSourceRequests, 0);
|
||
|
||
const unavailable = 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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
|
||
success: true, message: "调用成功!", totalCount: 1, list: [
|
||
{ seqId: 100, id: "DS-PROJECT", selectSourceName: "项目档案" },
|
||
],
|
||
});
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const unavailableTemplate = (await unavailable.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(
|
||
unavailable.getSubmissionOptions({ userId: "ding-u1" }, unavailableTemplate, "expense_type"),
|
||
/提单实时字段未提供内嵌选项,且企业多级数据源共返回 1 项,但没有费用类型数据源.*正式 OpenAPI 未提供独立的费用类型目录查询/,
|
||
);
|
||
});
|
||
|
||
test("Xft expense type lookup explains a successful empty official data-source response", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 16, eventNumber: "EXP-16", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
};
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
|
||
success: true, message: "调用成功!", totalCount: 0, list: [],
|
||
});
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(
|
||
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
|
||
/提单实时字段未提供内嵌选项,且企业多级数据源列表为空.*请在费用管理页面确认配置或使用薪福通客户端完成/,
|
||
);
|
||
});
|
||
|
||
test("Xft expense type lookup follows the official data-source paging and detail contract", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 17, eventNumber: "EXP-17", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
};
|
||
let listRequests = 0;
|
||
let detailRequests = 0;
|
||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||
const url = new URL(String(input));
|
||
const path = url.pathname;
|
||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
|
||
listRequests += 1;
|
||
assert.equal(init?.method, "POST");
|
||
assert.equal(url.searchParams.get("start"), "0");
|
||
assert.equal(url.searchParams.get("limit"), "100");
|
||
assert.deepEqual(decodedBody(init), {});
|
||
return encrypted({
|
||
success: true, message: "调用成功!", totalCount: 1, list: [
|
||
{ seqId: 150, id: "SOUR01", selectSourceName: "企业费用类型" },
|
||
],
|
||
});
|
||
}
|
||
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
|
||
detailRequests += 1;
|
||
assert.equal(init?.method, "GET");
|
||
assert.equal(init?.body, undefined);
|
||
return encrypted({
|
||
success: true,
|
||
message: "调用成功!",
|
||
data: {
|
||
id: "SOUR01",
|
||
selectSourceName: "企业费用类型",
|
||
optionSaveDtoList: [{
|
||
seqId: 1214876,
|
||
selectOptionName: "交通费",
|
||
optionSaveDtoList: [{ seqId: 1214877, selectOptionName: "出租车费", optionSaveDtoList: null }],
|
||
}],
|
||
},
|
||
});
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type");
|
||
assert.equal(listRequests, 1);
|
||
assert.equal(detailRequests, 1);
|
||
assert.deepEqual(result.items, [
|
||
{ code: "1214876", label: "交通费" },
|
||
{ code: "1214877", label: "出租车费" },
|
||
]);
|
||
});
|
||
|
||
test("Xft expense type lookup preserves data-source API errors instead of replacing them with an empty result", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 18, eventNumber: "EXP-18", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
};
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
|
||
return new Response(String(sm4.encrypt(JSON.stringify({
|
||
code: "NO_PERMISSION", message: "未开通多级数据源查询权限",
|
||
}), privateKey.slice(0, 32))), { status: 403 });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(
|
||
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
|
||
/未开通多级数据源查询权限(操作:费用类型数据源列表;HTTP 403;薪福通错误码 NO_PERMISSION)/,
|
||
);
|
||
});
|
||
|
||
test("Xft expense type lookup preserves a failed matching data-source detail", async () => {
|
||
const templateRecord = {
|
||
eventSeqId: 19, eventNumber: "EXP-19", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||
};
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
|
||
success: true, message: "调用成功!", totalCount: 1, list: [
|
||
{ seqId: 151, id: "SOUR02", selectSourceName: "企业费用类型" },
|
||
],
|
||
});
|
||
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
|
||
throw new TypeError("fetch failed", {
|
||
cause: Object.assign(new Error("socket closed with internal address"), { code: "UND_ERR_SOCKET" }),
|
||
});
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(
|
||
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
|
||
/费用类型数据源详情请求失败:网络连接被重置或中断(底层错误码 UND_ERR_SOCKET;超时上限 2000ms)/,
|
||
);
|
||
});
|
||
|
||
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
|
||
const recallBodies: Record<string, unknown>[] = [];
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||
} });
|
||
if (path.endsWith("approve/recall")) {
|
||
recallBodies.push(decodedBody(init));
|
||
return encrypted({ code: "0", data: { success: true, error: "", successKey: [2026081200000001] } });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001", "测试撤回");
|
||
assert.equal(result.status, "revoked");
|
||
assert.deepEqual(recallBodies[0], {
|
||
empId: "E001", businessId: 2026081200000001, approveCategory: "BILL", withdrawReason: "测试撤回",
|
||
});
|
||
});
|
||
|
||
test("Xft recall reports deterministic failure without retrying", async () => {
|
||
let recallCalls = 0;
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||
} });
|
||
if (path.endsWith("approve/recall")) {
|
||
recallCalls += 1;
|
||
return encrypted({ code: "0", data: { success: false, error: {
|
||
"2026081200000001": { errorMessage: "单据状态不允许撤回", traceId: "must-not-leak" },
|
||
}, successKey: [] } });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
|
||
assert.equal(result.status, "failed");
|
||
assert.match(result.message ?? "", /状态不允许/);
|
||
assert.doesNotMatch(result.message ?? "", /trace|must-not-leak/i);
|
||
assert.equal(recallCalls, 1);
|
||
});
|
||
|
||
test("Xft recall reconciles a framework service error against live bill status", async () => {
|
||
let recallCalls = 0;
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("approve/recall")) {
|
||
recallCalls += 1;
|
||
return encrypted({ code: "XFTSYS9999", message: "服务异常" });
|
||
}
|
||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||
} });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
|
||
assert.equal(result.status, "failed");
|
||
assert.match(result.message ?? "", /服务异常.*待审批/);
|
||
assert.equal((result.diagnostics as Record<string, unknown>).postWriteVerified, true);
|
||
assert.equal(recallCalls, 1);
|
||
});
|
||
|
||
test("Xft submission status revalidates current-user ownership and returns only compact state", async () => {
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("bills/detail/2099010300000001")) return encrypted({ code: "0", data: {
|
||
billId: 2099010300000001, empNumber: "E001", billSts: "11", billApprSts: "WITHDRAWN", privateFormData: "not returned",
|
||
aprHisList: [{ nodeName: "发起", actions: [{ action: "start", empNumber: "must-not-leak" }] }],
|
||
} });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2099010300000001");
|
||
assert.deepEqual(result, {
|
||
billId: "2099010300000001", status: "11", statusName: "已撤回", approvalStatus: "WITHDRAWN",
|
||
revoked: true, revokeAllowed: false,
|
||
flowHistory: [{ nodeName: "发起", actions: ["start"] }],
|
||
diagnostics: {
|
||
source: "xft_official_openapi", realtime: true, ownershipRevalidated: true,
|
||
applicantIdentity: "server_derived", persistedBusinessData: false,
|
||
},
|
||
});
|
||
assert.doesNotMatch(JSON.stringify(result), /privateFormData|must-not-leak|E001|EMP-1/);
|
||
});
|
||
|
||
test("Xft submission ownership ignores employee numbers from approval history", async () => {
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
|
||
billId: "2026081400000001", empNumber: "E999", billSts: "1",
|
||
aprHisList: [{ actions: [{ empNumber: "E001" }] }],
|
||
} });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
await assert.rejects(client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001"), /不属于当前登录用户/);
|
||
});
|
||
|
||
test("Xft submission ownership accepts object rows from the condition-query fallback", async () => {
|
||
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("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
|
||
billId: "2026081400000001", billSts: "1",
|
||
} });
|
||
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: {
|
||
list: [{ billId: "2026081400000001" }],
|
||
} });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001");
|
||
assert.equal((result.diagnostics as Record<string, unknown>).ownershipRevalidated, true);
|
||
assert.equal(result.revokeAllowed, true);
|
||
});
|
||
|
||
test("Xft submission routes every supported template kind to its documented create endpoint", async () => {
|
||
const templateRecords = [
|
||
{ eventSeqId: 1, eventNumber: "T1", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||
{ eventSeqId: 2, eventNumber: "T2", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||
{ eventSeqId: 3, eventNumber: "T3", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
|
||
{ eventSeqId: 8, eventNumber: "T8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||
{ eventSeqId: 4, eventNumber: "T4", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY" },
|
||
{ eventSeqId: 5, eventNumber: "T5", eventName: "用车申请", eventStatus: "1", eventType: "USE_CAR_APPLY" },
|
||
{ eventSeqId: 6, eventNumber: "T6", eventName: "日常报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||
{ eventSeqId: 7, eventNumber: "T7", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
|
||
];
|
||
const createPaths: string[] = [];
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: templateRecords } });
|
||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||
} });
|
||
if (path.endsWith("/create")) {
|
||
createPaths.push(path);
|
||
return encrypted({ code: "0", data: { billId: String(1000 + createPaths.length) } });
|
||
}
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||
assert.equal(templates.length, 7);
|
||
assert.equal(templates.some((item) => item.eventNumber === "T5"), false);
|
||
assert.equal(templates.find((item) => item.eventNumber === "T8")?.kind, "expense_apply");
|
||
for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {});
|
||
assert.deepEqual(createPaths.sort(), [
|
||
"/itrip/xft-api/v1/bills/apply/common/create",
|
||
"/itrip/xft-api/v1/bills/apply/entertainment/create",
|
||
"/itrip/xft-api/v1/bills/apply/expense/create",
|
||
"/itrip/xft-api/v1/bills/apply/expense/create",
|
||
"/itrip/xft-api/v1/bills/apply/travel/create",
|
||
"/itrip/xft-api/v1/reimburse/bill/create",
|
||
"/itrip/xft-api/v1/reimburse/bill/travel/create",
|
||
]);
|
||
});
|
||
|
||
test("Xft submission fails closed when the authenticated member has no employee number", async () => {
|
||
let createCalls = 0;
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||
}] } });
|
||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
memberId: "EMP-1", number: null, idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
|
||
} });
|
||
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
|
||
return encrypted({ code: "0", data: {} });
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /未维护员工号/);
|
||
assert.equal(createCalls, 0);
|
||
});
|
||
|
||
test("Xft submission rejects a member lookup that does not match the authenticated identity", async () => {
|
||
let createCalls = 0;
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||
}] } });
|
||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||
memberId: "EMP-OTHER", number: "VICTIM-001", idRelation: { enterpriseUserId: "EMP-OTHER" },
|
||
} });
|
||
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
|
||
return encrypted({ code: "0", data: {} });
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /登录身份不一致/);
|
||
assert.equal(createCalls, 0);
|
||
});
|
||
|
||
test("Xft submission rejects caller-controlled applicant and status fields before create", async () => {
|
||
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("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||
}] } });
|
||
throw new Error(`unexpected create call ${path} ${String(init?.body)}`);
|
||
});
|
||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, { empNumber: "victim" }), /Agent 不得传入/);
|
||
});
|
||
|
||
test("Xft OpenAPI tolerates eventual consistency after an accepted write", async () => {
|
||
let todoQueries = 0;
|
||
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")) {
|
||
todoQueries += 1;
|
||
return encrypted({ code: "0", data: { records: todoQueries < 4 ? [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] : [] } });
|
||
}
|
||
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: "10000000001", eventType: "COMMON_REIM" }] } });
|
||
if (path.endsWith("approve/pass")) { void decodedBody(init); return encrypted({ code: "0", data: {} }); }
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
|
||
assert.equal(result.succeeded, 1);
|
||
assert.equal(todoQueries, 4);
|
||
});
|
||
|
||
test("Xft detail falls back to generic bill detail when a specialized endpoint is unavailable", async () => {
|
||
const targetBillId = "2099010400000001";
|
||
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-1", groupId: targetBillId, appCode: "xft-itrip" }] } });
|
||
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: targetBillId, eventType: "EXPENSE_APPLY" }] } });
|
||
if (path.endsWith("bills/apply/expense/detail")) return new Response("Not Found", { status: 404 });
|
||
if (path.endsWith(`bills/detail/${targetBillId}`)) return encrypted({ code: "0", data: { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] } });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
|
||
assert.deepEqual(detail.form, { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] });
|
||
assert.equal((detail.diagnostics as Record<string, unknown>).detailFallback, "generic_bill_detail_after_specialized_404");
|
||
});
|
||
|
||
test("Xft ownership verification scans beyond the first 1000 pending tasks", async () => {
|
||
const targetBillId = "99999999999";
|
||
const requestedPages: number[] = [];
|
||
const firstPage = Array.from({ length: 1000 }, (_, index) => ({
|
||
todoTaskId: `TD-${index}`, groupId: String(10_000_000_000 + index), appCode: "xft-itrip",
|
||
}));
|
||
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 body = decodedBody(init);
|
||
requestedPages.push(Number(body.currentPage));
|
||
return encrypted({ code: "0", data: { records: body.currentPage === 1 ? firstPage : [{ todoTaskId: "TD-TARGET", groupId: targetBillId, appCode: "xft-itrip" }] } });
|
||
}
|
||
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: targetBillId, eventType: "COMMON_REIM" }] } });
|
||
if (path.endsWith("reimburse/bill/common/detail")) return encrypted({ code: "0", data: { billId: targetBillId } });
|
||
throw new Error(`unexpected ${path}`);
|
||
});
|
||
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
|
||
assert.equal(detail.todo.billId, targetBillId);
|
||
assert.deepEqual(requestedPages, [1, 2]);
|
||
});
|
||
|
||
test("Xft OpenAPI never calls an action endpoint for a bill outside the user's live todos", async () => {
|
||
let actionCalls = 0;
|
||
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: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [] } });
|
||
if (path.includes("/approve/")) actionCalls += 1;
|
||
void init;
|
||
return encrypted({ code: "0", data: {} });
|
||
});
|
||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "", [{ billId: "other-user-bill" }]);
|
||
assert.equal(result.failed, 1);
|
||
assert.equal(actionCalls, 0);
|
||
});
|