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; 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>)[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; 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; 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; 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) { 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> }).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 }> = []; const client = new DingTalkClient(config, async (input, init = {}) => { const url = String(input); const body = init.body ? JSON.parse(String(init.body)) as Record : {}; 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(); 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(); 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" }), /当前没有属于登录用户的待审批任务/, ); });