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
704 lines
31 KiB
TypeScript
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" }),
|
|
/当前没有属于登录用户的待审批任务/,
|
|
);
|
|
});
|