Files
dingMCP/test/dingtalk.test.ts
gitea-admin 504145aec6
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
feat: initial public release
2026-08-17 03:04:09 +00:00

704 lines
31 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { ApprovalService } from "../src/approval-service.js";
import { loadConfig, type AppConfig } from "../src/config.js";
import { DingTalkClient, type FetchLike } from "../src/dingtalk.js";
import { DingTalkApiError } from "../src/errors.js";
const config: AppConfig = {
clientId: "app-key",
clientSecret: "app-secret",
corpId: "ding-corp",
apiBaseUrl: "https://api.example.test",
oapiBaseUrl: "https://oapi.example.test",
publicBaseUrl: "http://localhost:3000",
trustedLocalUser: { userId: "user-1", name: "测试用户" },
port: 3000,
requestTimeoutMs: 1_000,
officialLookbackDays: 120,
officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [],
approvalRefSecret: "fixed-reference-secret-for-tests",
batchLimit: 20,
};
const yidaConfig: AppConfig = {
...config,
yida: { adminUserId: "admin-1", organizationToken: "org-token" },
};
test("loadConfig can read DingTalk credentials from a protected JSON file", () => {
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-test-"));
const credentialsPath = join(directory, "credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ clientId: "file-key", clientSecret: "file-secret" }), { mode: 0o600 });
const loaded = loadConfig({
APP_MODE: "gateway",
DINGTALK_CREDENTIALS_FILE: credentialsPath,
DINGTALK_CORP_ID: "ding-corp",
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
MCP_REMOTE_AUTH_ENABLED: "true",
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
XFT_OPEN_API_ENABLED: "true",
XFT_OPEN_API_APP_ID: "xft-app",
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
DINGTALK_CLIENT_ONLY_PROCESS_CODES: "PROC-SIGN, PROC-SIGN,PROC-CLIENT",
APPROVAL_BATCH_LIMIT: "99",
YIDA_ENABLED: "false",
});
assert.equal(loaded.clientId, "file-key");
assert.equal(loaded.clientSecret, "file-secret");
assert.equal(loaded.yida, undefined);
assert.deepEqual(loaded.officialClientOnlyProcessCodes, ["PROC-SIGN", "PROC-CLIENT"]);
assert.equal(loaded.batchLimit, 20);
});
test("loadConfig rejects the removed local DWS architecture", () => {
assert.throws(() => loadConfig({ APP_MODE: "local", DWS_CLI_PATH: "/opt/bin/dws" }), /APP_MODE=gateway/);
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
MCP_REMOTE_AUTH_ENABLED: "true", XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app",
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64), XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
DWS_CLI_PATH: "/opt/bin/dws",
}), /禁止配置本地 DWS/);
});
test("loadConfig reads AuthoritySecret from the second line of an XFT portal credential file", () => {
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-xft-openapi-config-"));
const secretPath = join(directory, "xft.key");
writeFileSync(secretPath, `${"a".repeat(32)}\n${"b".repeat(64)}\n`, { mode: 0o600 });
const loaded = loadConfig({
APP_MODE: "gateway",
DINGTALK_CLIENT_ID: "key",
DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp",
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
MCP_REMOTE_AUTH_ENABLED: "true",
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
XFT_OPEN_API_ENABLED: "true",
XFT_OPEN_API_APP_ID: "open-app",
XFT_OPEN_API_AUTHORITY_SECRET_FILE: secretPath,
XFT_OPEN_API_ENTERPRISE_ID: "TEST0001",
});
assert.equal(loaded.xftOpenApi?.authoritySecret, "b".repeat(64));
});
test("loadConfig requires identity configuration and rejects non-loopback binding", () => {
assert.throws(() => loadConfig({}), /必须显式设置 APP_MODE/);
assert.throws(() => loadConfig({
APP_MODE: "gateway",
DINGTALK_CLIENT_ID: "key",
DINGTALK_CLIENT_SECRET: "secret",
MCP_PUBLIC_BASE_URL: "https://mcp.example.test",
}), /DINGTALK_CORP_ID/);
assert.throws(() => loadConfig({
APP_MODE: "gateway",
MCP_BIND_HOST: "0.0.0.0",
}), /MCP_BIND_HOST 仅允许回环地址/);
});
test("unified gateway requires both remote identity and XFT OpenAPI", () => {
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
}), /薪福通正式 OpenAPI/);
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
MCP_REMOTE_AUTH_ENABLED: "true", MCP_TRUSTED_LOCAL_USER_ID: "victim",
XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app", XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
}), /禁止配置 MCP_TRUSTED/);
});
test("DingTalkClient caches accessToken and unwraps official template response", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "token-1", expireIn: 7200 });
}
return Response.json({
success: true,
result: { processList: [{ name: "采购", processCode: "PROC-1" }], nextToken: 0 },
});
};
const client = new DingTalkClient(config, fakeFetch);
const first = await client.listOfficialTemplates();
const second = await client.listOfficialTemplates();
assert.deepEqual(first.map((item) => item.processCode), ["PROC-1"]);
assert.equal(second.length, 1);
assert.equal(calls.filter((call) => call.url.endsWith("accessToken")).length, 1);
assert.equal(
new Headers(calls[1]!.init.headers).get("x-acs-dingtalk-access-token"),
"token-1",
);
});
test("DingTalkClient invalidates and retries an explicitly rejected app access token once", async () => {
let tokenCalls = 0;
let apiCalls = 0;
const client = new DingTalkClient(config, async (input, init = {}) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) {
tokenCalls += 1;
return Response.json({ accessToken: `token-${tokenCalls}`, expireIn: 7200 });
}
apiCalls += 1;
const headers = new Headers(init.headers);
if (headers.get("x-acs-dingtalk-access-token") === "token-1") {
return Response.json({ code: "InvalidAccessToken", message: "expired" }, { status: 401 });
}
return Response.json({ success: true, result: { processList: [], nextToken: 0 } });
});
await client.listOfficialTemplates();
assert.equal(tokenCalls, 2);
assert.equal(apiCalls, 2);
});
test("DingTalkClient serializes official approval taskId as a JSON number", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "token-1", expireIn: 7200 });
}
return Response.json({ result: true });
};
const client = new DingTalkClient(config, fakeFetch);
await client.executeOfficialApproval({
processInstanceId: "instance-1",
taskId: "222222222222",
actionerUserId: "manager-test",
result: "agree",
remark: "同意",
});
const request = calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances/execute"));
assert.ok(request);
const body = JSON.parse(String(request.init.body)) as Record<string, unknown>;
assert.equal(body.taskId, 222222222222);
assert.equal(typeof body.taskId, "number");
});
test("DingTalkClient reads and normalizes the authenticated user's department IDs", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.includes("/topapi/v2/user/get?")) return Response.json({ result: { dept_id_list: [123, "456", 123, 0, "invalid"] } });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
assert.deepEqual(await client.getUserDepartmentIds("user-1"), [123, 456]);
const request = calls.find((call) => call.url.includes("/topapi/v2/user/get?"));
assert.deepEqual(JSON.parse(String(request?.init.body)), { userid: "user-1", language: "zh_CN" });
});
test("DingTalkClient forecasts through the enterprise API and creates with the authenticated gateway user", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processes/forecast")) {
return Response.json({ result: {
isForecastSuccess: true,
isStaticWorkflow: false,
workflowActivityRules: [{
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", isTargetSelect: true,
activityActioners: [{ name: "测试主管", userId: "manager-1" }],
workflowActor: { actorKey: "manual-1", actorType: "approver", approvalMethod: "NONE" },
}],
workflowForecastNodes: [{ activityId: "approve-1", outId: "finish-1" }],
} });
}
if (url.endsWith("/v1.0/workflow/processInstances")) return Response.json({ result: "instance-new" });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
const request = {
processCode: "PROC-1", originatorUserId: "user-1", deptId: -1,
formComponentValues: [{ name: "事由", value: "测试" }],
targetSelectActioners: [{ actionerKey: "manual-1", actionerStaffIds: ["manager-1"] }],
};
const forecast = await client.forecastOfficialProcess(request);
const instanceId = await client.createOfficialInstance(request);
assert.equal(forecast.forecastSuccess, true);
assert.equal(forecast.staticWorkflow, false);
assert.equal(forecast.supported, true);
assert.equal((forecast.workflowActivityRuleVOs as Array<Record<string, unknown>>)[0]?.targetSelect, true);
assert.equal(instanceId, "instance-new");
const forecastBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/processes/forecast"))?.init.body)) as Record<string, unknown>;
assert.deepEqual(forecastBody, {
processCode: "PROC-1", userId: "user-1", deptId: -1,
formComponentValues: [{ name: "事由", value: "测试" }],
});
const createBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances"))?.init.body)) as Record<string, unknown>;
assert.equal(createBody.originatorUserId, "user-1");
assert.equal(createBody.processCode, "PROC-1");
assert.deepEqual(createBody.formComponentValues, [{ name: "事由", value: "测试" }]);
assert.deepEqual(createBody.targetSelectActioners, [{ actionerKey: "manual-1", actionerUserIds: ["manager-1"] }]);
});
test("DingTalkClient rejects failed or malformed enterprise forecasts before submission confirmation", async () => {
for (const result of [{ isForecastSuccess: false }, { processCode: "PROC-1" }]) {
const client = new DingTalkClient(config, async (input) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processes/forecast")) return Response.json({ result });
throw new Error(`unexpected ${url}`);
});
await assert.rejects(client.forecastOfficialProcess({
processCode: "PROC-1", originatorUserId: "user-1", deptId: 123,
formComponentValues: [{ name: "事由", value: "测试" }],
}), /流程预测失败|缺少预测结果/);
}
});
test("DingTalkClient terminates an approval with server-derived operator identity", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processInstances/terminate")) return Response.json({ success: true });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
await client.terminateOfficialInstance({ processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销" });
const request = calls.find((call) => call.url.endsWith("/terminate"));
const body = JSON.parse(String(request?.init.body)) as Record<string, unknown>;
assert.deepEqual(body, { processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销", isSystem: false });
});
test("ApprovalService revokes only the current user's running submission and verifies the transition", async () => {
let state = "RUNNING";
let terminateCalls = 0;
const fakeClient = {
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: state }; },
async terminateOfficialInstance(input: Record<string, unknown>) {
assert.equal(input.operatingUserId, "user-1");
terminateCalls += 1;
state = "TERMINATED";
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.revokeSubmission("user-1", "instance-1", "误发");
assert.equal(result.status, "revoked");
assert.equal(terminateCalls, 1);
state = "RUNNING";
(fakeClient as unknown as { getOfficialInstance(): Promise<Record<string, unknown>> }).getOfficialInstance = async () => ({
processInstanceId: "instance-1", originatorUserId: "other-user", status: state,
});
await assert.rejects(service.revokeSubmission("user-1", "instance-1"), /不是当前登录用户本人发起/);
assert.equal(terminateCalls, 1);
});
test("ApprovalService derives a submission department and rejects caller-selected foreign departments", async () => {
const fakeClient = {
async getUserDepartmentIds(userId: string) {
assert.equal(userId, "user-1");
return [123, 456];
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
assert.equal(await service.resolveSubmissionDepartment("user-1", -1), 123);
assert.equal(await service.resolveSubmissionDepartment("user-1", 456), 456);
await assert.rejects(service.resolveSubmissionDepartment("user-1", 999), /不属于当前登录用户/);
});
test("ApprovalService returns uncertain and does not retry an unconfirmed revoke write", async () => {
let terminateCalls = 0;
const fakeClient = {
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: "RUNNING" }; },
async terminateOfficialInstance() { terminateCalls += 1; throw new DingTalkApiError("timeout", 0); },
} as unknown as DingTalkClient;
const result = await new ApprovalService(fakeClient, config).revokeSubmission("user-1", "instance-1");
assert.equal(result.status, "outcome_uncertain");
assert.equal(terminateCalls, 1);
});
test("DingTalkClient retries temporary DingTalk QPS throttling", async () => {
let templateCalls = 0;
const fakeFetch: FetchLike = async (input) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "app-token", expireIn: 7200 });
}
templateCalls += 1;
if (templateCalls === 1) {
return Response.json({ code: "Forbidden.AccessDenied.QpsLimitForAppkeyAndApi", message: "qps流控" }, { status: 403 });
}
return Response.json({ result: { processList: [], nextToken: 0 } });
};
const client = new DingTalkClient(config, fakeFetch);
assert.deepEqual(await client.listOfficialTemplates(), []);
assert.equal(templateCalls, 2);
});
test("ApprovalService filters submission templates before applying the result limit", async () => {
const fakeClient = {
async listOfficialTemplates() {
return [
{ processCode: "BLOCKED", name: "其他申请", dirName: "其他分类" },
{ processCode: "ALLOWED-1", name: "请假", dirName: "北极大_人事假勤" },
{ processCode: "ALLOWED-2", name: "报销", dirName: "捷康_费用报销" },
];
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const templates = await service.listSubmissionTemplates(1, "", ["北极大_人事假勤", "捷康_费用报销"]);
assert.deepEqual(templates.map((item) => item.processCode), ["ALLOWED-1"]);
});
test("ApprovalService finds only NEW official tasks owned by current user", async () => {
const fakeClient = {
async listOfficialTodoWorkRecords() {
return {
list: [{ instance_id: "instance-1", task_id: 42, title: "采购电脑" }],
hasMore: false,
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 50);
assert.equal(result.items.length, 1);
assert.equal(result.items[0]!.taskId, "42");
assert.equal(result.items[0]!.source, "official_oa");
});
test("ApprovalService enriches official todo summaries without dropping items when one detail fails", async () => {
const fakeClient = {
async listOfficialTodoWorkRecords() {
return {
list: [
{ instance_id: "instance-1", task_id: 1, title: "费用审批", url: "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?taskId=1" },
{ instance_id: "instance-2", task_id: 2, title: "备用审批", forms: [{ title: "发起人", content: "测试用户戊" }] },
],
hasMore: false,
};
},
async getOfficialInstance(processInstanceId: string) {
if (processInstanceId === "instance-2") throw new Error("detail unavailable");
return {
processCode: "PROC-SIGN",
originatorUserName: "测试用户丁",
originatorUserId: "user-bin",
originatorDeptName: "销售部",
createTime: "2026-08-08T08:30:00+08:00",
formComponentValues: [
{ name: "报销金额", value: "4812元" },
{ name: "报销事由", value: "客户出差" },
],
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 10);
assert.equal(result.items.length, 2);
assert.equal(result.items[0]!.applicantName, "测试用户丁");
assert.equal(result.items[0]!.applicantUserId, "user-bin");
assert.equal(result.items[0]!.applicantDepartment, "销售部");
assert.equal(result.items[0]!.amount, 4812);
assert.equal(result.items[0]!.summary, "客户出差");
assert.equal(result.items[0]!.processCode, "PROC-SIGN");
assert.match(result.items[0]!.clientUrl ?? "", /^https:\/\/aflow\.dingtalk\.com\//);
assert.equal(result.items[1]!.applicantName, "测试用户戊");
assert.equal(result.diagnostics.officialEnrichmentFailures, 1);
});
test("ApprovalService paginates direct official todo work records", async () => {
const offsets: number[] = [];
const firstPage = Array.from({ length: 20 }, (_value, index) => ({
instance_id: `instance-${index + 1}`,
task_id: index + 1,
}));
const fakeClient = {
async listOfficialTodoWorkRecords(_userId: string, offset: number) {
offsets.push(offset);
return offset === 0
? { list: firstPage, hasMore: true }
: { list: [{ instance_id: "instance-21", task_id: 21 }], hasMore: false };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 21);
assert.deepEqual(offsets, [0, 20]);
assert.equal(result.items.length, 21);
assert.equal(result.items.at(-1)!.processInstanceId, "instance-21");
});
test("ApprovalService stops on a short official todo page and removes duplicates", async () => {
let calls = 0;
const fakeClient = {
async listOfficialTodoWorkRecords() {
calls += 1;
return {
list: [
{ instance_id: "instance-1", task_id: 1 },
{ instance_id: "instance-1", task_id: 1 },
],
hasMore: true,
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 50);
assert.equal(calls, 1);
assert.equal(result.items.length, 1);
});
test("DingTalkClient queries completed work records and initiated instance IDs with server-derived user filters", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const client = new DingTalkClient(config, async (input, init = {}) => {
const url = String(input);
const body = init.body ? JSON.parse(String(init.body)) as Record<string, unknown> : {};
calls.push({ url, body });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.includes("/topapi/process/workrecord/task/query")) {
return Response.json({ errcode: 0, result: { list: [{ instance_id: "handled-1", task_id: 91 }], has_more: false } });
}
if (url.includes("/topapi/processinstance/listids")) {
return Response.json({ errcode: 0, result: { list: [{ process_instance_id: "submitted-1" }], next_cursor: "20" } });
}
throw new Error(`unexpected ${url}`);
});
const handled = await client.listOfficialTodoWorkRecords("user-1", 0, 20, 1);
const submitted = await client.listOfficialInitiatedInstanceIds("PROC-1", "user-1", 1000, 2000, 0, 20);
assert.equal(handled.list[0]?.instance_id, "handled-1");
assert.deepEqual(submitted, { instanceIds: ["submitted-1"], nextCursor: 20 });
assert.deepEqual(calls.find((call) => call.url.includes("workrecord/task/query"))?.body, {
userid: "user-1", offset: 0, count: 20, status: 1,
});
assert.deepEqual(calls.find((call) => call.url.includes("processinstance/listids"))?.body, {
process_code: "PROC-1", userid: "user-1", start_time: 1000, end_time: 2000, cursor: 0, size: 20,
});
});
test("ApprovalService lists and revalidates handled and submitted DingTalk history", async () => {
const statuses: number[] = [];
const initiatedRanges: Array<{ startTime: number; endTime: number }> = [];
const fakeClient = {
async listOfficialTodoWorkRecords(_userId: string, _offset: number, _count: number, status: number) {
statuses.push(status);
return { list: [{ instance_id: "handled-1", task_id: 91, title: "已处理采购" }], hasMore: false };
},
async listOfficialTemplates() { return [{ processCode: "PROC-1", name: "采购" }]; },
async listOfficialInitiatedInstanceIds(_processCode: string, _userId: string, startTime: number, endTime: number) {
initiatedRanges.push({ startTime, endTime });
return { instanceIds: ["submitted-1"] };
},
async getOfficialInstance(id: string) {
if (id === "handled-1") return {
processInstanceId: id, title: "已处理采购", status: "COMPLETED", result: "agree",
finishTime: "2026-08-15T02:00:00.000Z", originatorUserId: "other",
tasks: [{ taskId: 91, userId: "user-1", status: "COMPLETED", finishTime: "2026-08-14T02:00:00.000Z" }],
};
return {
processInstanceId: id, title: "本人差旅", status: "RUNNING", createTime: "2026-08-13T01:00:00.000Z",
originatorUserId: "user-1", tasks: [{ taskId: 92, userId: "manager", status: "NEW" }],
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
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 service.listMyHistory("user-1", "handled", 20, historyRange);
const submitted = await service.listMyHistory("user-1", "submitted", 20, historyRange);
assert.deepEqual(statuses, [1]);
assert.deepEqual(initiatedRanges, [historyRange]);
assert.equal(handled.items[0]?.historyScope, "handled");
assert.equal(handled.items[0]?.completedAt, "2026-08-14T02:00:00.000Z");
assert.equal(submitted.items[0]?.historyScope, "submitted");
assert.equal(submitted.items[0]?.submittedAt, "2026-08-13T01:00:00.000Z");
await service.getHistoryDetail("user-1", "handled", "handled-1", "91");
await service.getHistoryDetail("user-1", "submitted", "submitted-1");
await assert.rejects(service.getHistoryDetail("other", "submitted", "submitted-1"), /不是当前登录用户本人发起/);
});
test("ApprovalService rechecks official task ownership and returns partial batch results", async () => {
const executed: unknown[] = [];
const completed = new Set<string>();
const fakeClient = {
async getOfficialInstance(id: string) {
if (completed.has(id)) return { status: "COMPLETED", tasks: [{ taskId: 1, userId: "user-1", status: "COMPLETED" }] };
return id === "allowed"
? { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] }
: { status: "RUNNING", tasks: [{ taskId: 2, userId: "other", status: "NEW" }] };
},
async executeOfficialApproval(input: unknown) {
executed.push(input);
completed.add((input as { processInstanceId: string }).processInstanceId);
return { result: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "official_oa", processInstanceId: "allowed", taskId: "1" },
{ source: "official_oa", processInstanceId: "denied", taskId: "2" },
]);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 1);
assert.equal(executed.length, 1);
assert.deepEqual(executed[0], {
processInstanceId: "allowed",
taskId: "1",
actionerUserId: "user-1",
result: "agree",
remark: "同意",
});
});
test("ApprovalService accepts RUNNING as an official pending task status", async () => {
const executed: unknown[] = [];
let completed = false;
const fakeClient = {
async getOfficialInstance() {
if (completed) return { status: "COMPLETED", tasks: [{ taskId: 9, userId: "user-1", status: "COMPLETED" }] };
return { status: "RUNNING", tasks: [{ taskId: 9, userId: "user-1", status: "RUNNING", result: "NONE" }] };
},
async executeOfficialApproval(input: unknown) {
executed.push(input);
completed = true;
return { result: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "", [
{ source: "official_oa", processInstanceId: "instance-1", taskId: "9" },
]);
assert.equal(result.succeeded, 1);
assert.equal(executed.length, 1);
});
test("ApprovalService distinguishes a follow-up node from an unchanged original task after submit", async () => {
const reads = new Map<string, number>();
const fakeClient = {
async getOfficialInstance(id: string) {
const read = (reads.get(id) ?? 0) + 1;
reads.set(id, read);
if (read === 1) return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
if (id === "follow-up") return { status: "RUNNING", tasks: [{ taskId: 2, userId: "user-1", status: "NEW" }] };
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
},
async executeOfficialApproval() { return { result: true }; },
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "official_oa", processInstanceId: "follow-up", taskId: "1" },
{ source: "official_oa", processInstanceId: "unchanged", taskId: "1" },
]);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 1);
assert.equal(result.results[0]!.followUpRequired, true);
assert.equal(result.results[0]!.completed, false);
assert.equal(result.results[0]!.nextTaskId, "2");
assert.match(String(result.results[1]!.error), /原任务仍在当前用户实时待办/);
});
test("ApprovalService reads Yida task field aliases and nested result pages", async () => {
const fakeClient = {
async listYidaCorpTasks() {
return {
result: {
data: [{
appType: "APP_A",
formInstId: "form-1",
taskId: "task-1",
taskStatus: "RUNNING",
processInstanceTitle: "宜搭采购",
}],
},
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, yidaConfig);
const result = await service.listMyTodos("user-1", "yida", 50);
assert.equal(result.items.length, 1);
assert.equal(result.items[0]!.processInstanceId, "form-1");
assert.equal(result.items[0]!.title, "宜搭采购");
});
test("ApprovalService validates and groups Yida approvals before batch execution", async () => {
const calls: unknown[] = [];
const fakeClient = {
async listYidaCorpTasks() {
return {
data: [
{ appType: "APP_A", formInstId: "form-1", taskId: "task-1", taskStatus: "RUNNING" },
{ appType: "APP_A", processInstanceId: "form-2", taskId: "task-2", status: "NEW" },
],
};
},
async listYidaApplications() {
return [{ appType: "APP_A", systemToken: "system-token" }];
},
async executeYidaBatch(app: unknown, userId: string, action: string, remark: string, tasks: unknown[]) {
calls.push({ app, userId, action, remark, tasks });
return { success: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, yidaConfig);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "yida", appType: "APP_A", processInstanceId: "form-1", taskId: "task-1" },
{ source: "yida", appType: "APP_A", processInstanceId: "form-2", taskId: "task-2" },
{ source: "yida", appType: "APP_A", processInstanceId: "stale", taskId: "task-old" },
]);
assert.equal(result.succeeded, 2);
assert.equal(result.failed, 1);
assert.equal(calls.length, 1);
assert.deepEqual((calls[0] as { tasks: unknown[] }).tasks, [
{ taskId: "task-1", formInstId: "form-1" },
{ taskId: "task-2", formInstId: "form-2" },
]);
});
test("ApprovalService rejects official detail without a current task for the user", async () => {
const fakeClient = {
async getOfficialInstance() {
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "another-user", status: "NEW" }] };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
await assert.rejects(
service.getDetail("user-1", { source: "official_oa", processInstanceId: "instance-1" }),
/当前没有属于登录用户的待审批任务/,
);
});