1418 lines
72 KiB
TypeScript
1418 lines
72 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
|
import test from "node:test";
|
|
import { deflateRawSync } from "node:zlib";
|
|
import type { AppConfig } from "../src/config.js";
|
|
import { UnifiedApprovalService } from "../src/unified-approval.js";
|
|
import type { ApprovalService } from "../src/approval-service.js";
|
|
import type { XftApprovalProvider, XftSubmissionField, XftSubmissionTemplate } from "../src/xft.js";
|
|
|
|
function config(): AppConfig {
|
|
return {
|
|
clientId: "client", clientSecret: "secret", apiBaseUrl: "https://api.test", oapiBaseUrl: "https://oapi.test",
|
|
publicBaseUrl: "http://localhost:3000", trustedLocalUser: { userId: "user-1" }, port: 3000, requestTimeoutMs: 1000, officialLookbackDays: 120,
|
|
officialScanConcurrency: 2, officialClientOnlyProcessCodes: [], batchLimit: 20,
|
|
approvalRefSecret: "fixed-reference-secret-for-tests",
|
|
};
|
|
}
|
|
|
|
test("unified history lists handled/submitted records as read-only references and revalidates detail ownership", async () => {
|
|
const detailCalls: Array<Record<string, unknown>> = [];
|
|
const ding = {
|
|
async listMyHistory(_userId: string, scope: string) {
|
|
return { items: [{
|
|
source: "official_oa", historyScope: scope, processInstanceId: `${scope}-oa`,
|
|
...(scope === "handled" ? { taskId: "task-1", completedAt: "2026-08-14T02:00:00.000Z" } : {}),
|
|
title: scope === "handled" ? "我处理过的采购" : "我发起的采购", applicantName: "申请人甲",
|
|
submittedAt: "2026-08-13T01:00:00.000Z", status: scope === "handled" ? "COMPLETED" : "RUNNING", raw: {},
|
|
}] };
|
|
},
|
|
async getHistoryDetail(userId: string, scope: string, processInstanceId: string, taskId?: string) {
|
|
detailCalls.push({ provider: "dingtalk", userId, scope, processInstanceId, taskId });
|
|
return { title: "历史采购", originatorUserName: "申请人甲", formComponentValues: [{ name: "事由", value: "测试" }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyHistory(_user: unknown, scope: string) {
|
|
return { items: [{
|
|
source: "xft", historyScope: scope, billId: `${scope === "handled" ? "2026081400000001" : "2026081400000002"}`,
|
|
title: scope === "handled" ? "我处理过的报销" : "我发起的报销", applicantName: "申请人乙",
|
|
applyTime: "2026-08-13T03:00:00.000Z", amount: 99,
|
|
}], totalCount: 1, diagnostics: {} };
|
|
},
|
|
async getHistoryDetail(_user: unknown, scope: string, billId: string) {
|
|
detailCalls.push({ provider: "xft", scope, billId });
|
|
return { todo: { title: "历史报销", amount: 99 }, form: { billId, reason: "测试报销" } };
|
|
},
|
|
} as unknown as XftApprovalProvider;
|
|
const service = new UnifiedApprovalService(config(), ding, xft);
|
|
const user = { userId: "user-1" };
|
|
|
|
const handled = await service.listHistory(user, "handled", "all", 20, "", "2026-08-01", "2026-08-31");
|
|
assert.equal(handled.returned, 2);
|
|
assert.equal(handled.timeRange.startDate, "2026-07-31T16:00:00.000Z");
|
|
assert.equal(handled.timeRange.endDate, "2026-08-31T15:59:59.999Z");
|
|
assert.equal(handled.timeRange.defaultedToRecent30Days, false);
|
|
assert.equal(handled.items[0]?.source, "dingtalk", "combined history is globally ordered by completion/submission time");
|
|
assert.equal("selectionId" in handled, false);
|
|
assert.ok(handled.items.every((item) => item.readOnly === true && Array.isArray(item.availableActions) && item.availableActions.length === 0));
|
|
const dingId = String(handled.items.find((item) => item.source === "dingtalk")?.approvalId);
|
|
const xftId = String(handled.items.find((item) => item.source === "xft")?.approvalId);
|
|
assert.match(dingId, /^v4\./);
|
|
assert.match(xftId, /^v4\./);
|
|
await service.getDetail(user, dingId);
|
|
await service.getDetail(user, xftId);
|
|
assert.deepEqual(detailCalls[0], {
|
|
provider: "dingtalk", userId: "user-1", scope: "handled", processInstanceId: "handled-oa", taskId: "task-1",
|
|
});
|
|
assert.deepEqual(detailCalls[1], { provider: "xft", scope: "handled", billId: "2026081400000001" });
|
|
await assert.rejects(service.getActions(user, dingId), /历史审批引用仅用于读取/);
|
|
const blocked = await service.processBatch(user, "approve", "", [{ approvalId: dingId }]);
|
|
assert.equal(blocked.status, "preflight_failed");
|
|
assert.equal(blocked.writesStarted, false);
|
|
assert.equal(blocked.invalidItems[0]?.code, "history_reference_read_only");
|
|
|
|
const submitted = await service.listHistory(user, "submitted", "dingtalk", 20, "", "2026-08-01", "2026-08-31");
|
|
const submittedId = String(submitted.items[0]?.approvalId);
|
|
assert.ok(submittedId.length < dingId.length, "submitted history references do not need a task ID");
|
|
await service.getDetail(user, submittedId);
|
|
await assert.rejects(service.getDetail({ userId: "other" }, submittedId), /不属于当前用户|已损坏/);
|
|
await assert.rejects(service.listHistory(user, "handled", "all", 20, "", "2026-01-01", "2026-08-31"), /不能超过 120 天/);
|
|
await assert.rejects(service.listHistory(user, "handled", "all", 20, "", "2026-02-30", "2026-03-01"), /不是有效日期/);
|
|
|
|
const beforeDefault = Date.now();
|
|
const recent = await service.listHistory(user, "handled", "dingtalk", 20);
|
|
const afterDefault = Date.now();
|
|
const defaultStart = Date.parse(recent.timeRange.startDate);
|
|
const defaultEnd = Date.parse(recent.timeRange.endDate);
|
|
assert.equal(recent.timeRange.defaultedToRecent30Days, true);
|
|
assert.ok(defaultEnd >= beforeDefault && defaultEnd <= afterDefault);
|
|
assert.equal(defaultEnd - defaultStart, 30 * 86_400_000);
|
|
});
|
|
|
|
test("unified approval API returns opaque user-bound IDs and routes DingTalk actions", async () => {
|
|
const calls: Array<Record<string, unknown>> = [];
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{
|
|
source: "official_oa", processInstanceId: "instance-secret", taskId: "task-secret", title: "采购审批",
|
|
applicantName: "张三", applicantDepartment: "采购部", submittedAt: "2026-08-11T01:00:00.000Z",
|
|
amount: 100, currency: "CNY", raw: {},
|
|
}] };
|
|
},
|
|
async getDetail(_userId: string, item: Record<string, unknown>) {
|
|
calls.push({ kind: "detail", ...item });
|
|
return {
|
|
mobile: "13800138000",
|
|
amount: 100,
|
|
todo: {
|
|
applicantName: "张三",
|
|
applicantDepartment: "采购部",
|
|
submittedAt: "2026-08-11T01:00:00.000Z",
|
|
amount: 100,
|
|
currency: "CNY",
|
|
},
|
|
formComponentValues: [
|
|
{ name: "项目", value: "完整字段保留" },
|
|
{ name: "银行卡号", value: "6222000012345678" },
|
|
{
|
|
name: "报销明细",
|
|
componentType: "TableField",
|
|
value: JSON.stringify([{ label: "收款信息", value: { cardNo: "6214833686733752", payerAddress: "深圳市测试路13800138000" } }]),
|
|
},
|
|
{
|
|
name: "收款账户",
|
|
componentType: "RecipientAccountField",
|
|
value: "测试用户甲",
|
|
extValue: JSON.stringify({ cardNo: "6214833686733752", instName: "招商银行" }),
|
|
},
|
|
],
|
|
};
|
|
},
|
|
async assertOfficialTaskPending() {},
|
|
async processBatch(userId: string, action: string, remark: string, items: unknown[]) {
|
|
calls.push({ kind: "process", userId, action, remark, items });
|
|
return { results: [{ success: true }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1", name: "测试用户乙" };
|
|
const listed = await service.listTodos(user, "all", 50);
|
|
const approvalId = String(listed.items[0]!.approvalId);
|
|
assert.deepEqual(listed.items[0]!.amount, { value: 100, currency: "CNY" });
|
|
assert.equal(listed.items[0]!.submittedAt, "2026-08-11T09:00:00.000+08:00");
|
|
assert.equal("submittedAtLocal" in listed.items[0]!, false);
|
|
assert.equal("submittedTimeZone" in listed.items[0]!, false);
|
|
assert.equal("sourceType" in listed.items[0]!, false);
|
|
assert.equal("actionsHint" in listed.items[0]!, false);
|
|
assert.match(approvalId, /^v4\./);
|
|
assert.ok(approvalId.length <= 128, `common DingTalk approvalId should be compact, got ${approvalId.length} characters`);
|
|
assert.doesNotMatch(approvalId, /instance-secret|task-secret/);
|
|
assert.doesNotMatch(Buffer.from(approvalId.split(".")[1]!, "base64url").toString("utf8"), /instance-secret|task-secret/);
|
|
|
|
const detail = await service.getDetail(user, approvalId);
|
|
assert.equal("view" in detail ? detail.view : undefined, "summary");
|
|
assert.doesNotMatch(JSON.stringify(detail), /6222000012345678|6214833686733752|13800138000/);
|
|
const compact = "detail" in detail ? detail.detail as Record<string, unknown> : {};
|
|
assert.deepEqual(compact.applicant, { name: "张三", department: "采购部" });
|
|
assert.equal(compact.submittedAt, "2026-08-11T01:00:00.000Z");
|
|
assert.deepEqual((compact.formFields as Array<Record<string, unknown>>)[0], { label: "项目", value: "完整字段保留" });
|
|
assert.equal((compact.formFields as Array<Record<string, unknown>>).some((field) => field.label === "银行卡号"), false);
|
|
|
|
const raw = await service.getDetail(user, approvalId, { view: "raw", rawPath: "/formComponentValues", rawLimit: 10 });
|
|
assert.doesNotMatch(JSON.stringify(raw), /6222000012345678|6214833686733752|13800138000/);
|
|
const actions = await service.getActions(user, approvalId);
|
|
assert.deepEqual(actions.actions.map((item) => item.action), ["approve", "reject", "return"]);
|
|
assert.equal(actions.actions[0]!.allowed, true);
|
|
assert.equal(actions.actions[0]!.guaranteed, false);
|
|
assert.equal(actions.actions[0]!.submissionSupported, true);
|
|
assert.equal(actions.actions[2]!.allowed, false);
|
|
|
|
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
|
assert.equal(result.succeeded, 1);
|
|
assert.equal(calls.find((call) => call.kind === "process")?.action, "agree");
|
|
|
|
await assert.rejects(service.getDetail({ userId: "user-2", name: "其他人" }, approvalId), /不属于当前用户/);
|
|
const tampered = `${approvalId.slice(0, -1)}${approvalId.endsWith("A") ? "B" : "A"}`;
|
|
await assert.rejects(service.getDetail(user, tampered), /(?:格式不完整|已损坏).*重新列出/);
|
|
});
|
|
|
|
test("selectionId processes exactly the confirmed live result set and rejects drift", async () => {
|
|
let changed = false;
|
|
let writes = 0;
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [
|
|
{ source: "official_oa", processInstanceId: "instance-a", taskId: "101", title: "测试用户丙费用一", raw: {} },
|
|
...(!changed ? [{ source: "official_oa", processInstanceId: "instance-b", taskId: "102", title: "测试用户丙费用二", raw: {} }] : []),
|
|
] };
|
|
},
|
|
async processBatch(_userId: string, _action: string, _remark: string, items: unknown[]) {
|
|
writes += items.length;
|
|
return { results: [{ success: true }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listTodos(user, "dingtalk", 10, "测试用户丙");
|
|
const selectionId = String("selectionId" in listed ? listed.selectionId : "");
|
|
assert.match(selectionId, /^v3\./);
|
|
assert.ok(selectionId.length <= 512, `selectionId should stay portable, got ${selectionId.length} characters`);
|
|
|
|
const processed = await service.processBatch(user, "approve", "同意", [], selectionId);
|
|
assert.equal(processed.succeeded, 2);
|
|
assert.equal(writes, 2);
|
|
|
|
const stale = await service.listTodos(user, "dingtalk", 10, "测试用户丙");
|
|
changed = true;
|
|
await assert.rejects(
|
|
service.processBatch(user, "approve", "同意", [], String("selectionId" in stale ? stale.selectionId : "")),
|
|
/列表在确认后发生变化/,
|
|
);
|
|
assert.equal(writes, 2);
|
|
});
|
|
|
|
test("todo lists keep transport batch policy internal", async () => {
|
|
const user = { userId: "user-1" };
|
|
const manyDing = {
|
|
async listMyTodos() {
|
|
return { items: Array.from({ length: 21 }, (_, index) => ({
|
|
source: "official_oa", processInstanceId: `many-${index}`, taskId: String(index + 1), raw: {},
|
|
})) };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const overflow = await new UnifiedApprovalService({ ...config(), batchLimit: 99 }, manyDing).listTodos(user, "dingtalk", 30);
|
|
assert.equal(overflow.returned, 21);
|
|
assert.equal("selectionId" in overflow, false);
|
|
assert.equal("selectionUnavailableReason" in overflow, false);
|
|
assert.equal("selectionHint" in overflow, false);
|
|
assert.equal("batchPolicy" in overflow, false);
|
|
|
|
const emptyDing = { async listMyTodos() { return { items: [] }; } } as unknown as ApprovalService;
|
|
const empty = await new UnifiedApprovalService(config(), emptyDing).listTodos(user, "dingtalk", 10);
|
|
assert.equal("selectionId" in empty, false);
|
|
assert.equal("selectionUnavailableReason" in empty, false);
|
|
assert.equal("batchPolicy" in empty, false);
|
|
|
|
const xftUnavailable = { async listMyTodos() { throw new Error("XFT unavailable"); } };
|
|
const partial = await new UnifiedApprovalService(config(), manyDing, xftUnavailable as never).listTodos(user, "all", 1);
|
|
assert.equal("selectionId" in partial, false);
|
|
assert.equal("selectionUnavailableReason" in partial, false);
|
|
assert.equal("batchPolicy" in partial, false);
|
|
});
|
|
|
|
test("combined todo lists are deterministic regardless of source response timing", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
return { items: [{ source: "official_oa", processInstanceId: "oa-instance", taskId: "101", title: "OA", raw: {} }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "xft", billId: "2026081300000001", title: "XFT", applyTime: "2026-08-13T01:30:00.000Z" }] };
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, xft as never);
|
|
const listed = await service.listTodos({ userId: "user-1" }, "all", 2);
|
|
assert.deepEqual(listed.items.map((item) => item.source), ["dingtalk", "xft"]);
|
|
assert.equal(listed.items[1]!.submittedAt, "2026-08-13T09:30:00.000+08:00");
|
|
assert.equal("submittedAtLocal" in listed.items[1]!, false);
|
|
assert.equal("submittedTimeZone" in listed.items[1]!, false);
|
|
});
|
|
|
|
test("v4 references remain backward compatible with unexpired v2 and v3 approvals", async () => {
|
|
const ding = {
|
|
async getDetail() { return { title: "旧引用详情", status: "RUNNING", tasks: [{ taskId: 101, userId: "user-1", taskStatus: "RUNNING" }] }; },
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const legacyValue = {
|
|
kind: "approval", userId: "user-1", provider: "official_oa",
|
|
processInstanceId: "legacy-instance", taskId: "101", exp: Date.now() + 60_000,
|
|
};
|
|
for (const legacy of [legacyV2Reference(legacyValue), legacyV3Reference(legacyValue)]) {
|
|
const detail = await service.getDetail({ userId: "user-1" }, legacy);
|
|
assert.equal(detail.source, "dingtalk");
|
|
}
|
|
});
|
|
|
|
test("approvalId stays portable when upstream display metadata is very large", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{
|
|
source: "official_oa", processInstanceId: "instance-1", taskId: "101",
|
|
title: "超长标题".repeat(300), applicantName: "申请人".repeat(100),
|
|
clientUrl: `https://aflow.dingtalk.com/dingtalk/mobile/aflow.htm?payload=${randomBytes(1500).toString("base64url")}`,
|
|
raw: {},
|
|
}] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const listed = await new UnifiedApprovalService(config(), ding).listTodos({ userId: "user-1" }, "dingtalk", 10);
|
|
const approvalId = String(listed.items[0]!.approvalId);
|
|
assert.match(approvalId, /^v4\./);
|
|
assert.ok(approvalId.length <= 128, `approvalId should ignore display metadata, got ${approvalId.length} characters`);
|
|
});
|
|
|
|
test("v4 approval references stay compact for representative DingTalk, Yida, and XFT identifiers", async () => {
|
|
const ding = {
|
|
async listMyTodos(_userId: string, source: string) {
|
|
return { items: source === "all" || source === "official_oa" ? [{
|
|
source: "official_oa",
|
|
processInstanceId: "5VXimwfMSBSWhjzttVCyWA10041785394274",
|
|
taskId: "111111111111",
|
|
raw: {},
|
|
}, {
|
|
source: "yida",
|
|
processInstanceId: "FINST-7b7ffc7800d942b7b8f6e70e16efab34",
|
|
taskId: "987654321012",
|
|
appType: "APP_MOCK_EXPENSE_2026",
|
|
raw: {},
|
|
}] : [] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyTodos() { return { items: [{ source: "xft", billId: "2099010100000001", title: "薪福通费用" }] }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, xft as never);
|
|
const listed = await service.listTodos({ userId: "user-1" }, "all", 10);
|
|
const official = String(listed.items.find((item) => item.businessNo === "5VXimwfMSBSWhjzttVCyWA10041785394274")?.approvalId);
|
|
const yida = String(listed.items.find((item) => item.businessNo === "FINST-7b7ffc7800d942b7b8f6e70e16efab34")?.approvalId);
|
|
const xftId = String(listed.items.find((item) => item.source === "xft")?.approvalId);
|
|
assert.match(official, /^v4\./);
|
|
assert.ok(official.length <= 128, `DingTalk reference is ${official.length} characters`);
|
|
assert.ok(yida.length <= 160, `Yida reference is ${yida.length} characters`);
|
|
assert.ok(xftId.length <= 80, `XFT reference is ${xftId.length} characters`);
|
|
});
|
|
|
|
test("v4 approval references survive service restart and reject another secret", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "restart-instance", taskId: "101", raw: {} }] };
|
|
},
|
|
async getDetail() { return { title: "重启后详情" }; },
|
|
} as unknown as ApprovalService;
|
|
const user = { userId: "user-1" };
|
|
const first = new UnifiedApprovalService(config(), ding);
|
|
const approvalId = String((await first.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
|
|
const restarted = new UnifiedApprovalService(config(), ding);
|
|
assert.equal((await restarted.getDetail(user, approvalId)).source, "dingtalk");
|
|
const foreign = new UnifiedApprovalService({ ...config(), approvalRefSecret: "another-secret" }, ding);
|
|
await assert.rejects(foreign.getDetail(user, approvalId), /已损坏|不同密钥/);
|
|
});
|
|
|
|
test("a truncated approval reference aborts the entire batch before any write", async () => {
|
|
let writes = 0;
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: Array.from({ length: 6 }, (_, index) => ({
|
|
source: "official_oa",
|
|
processInstanceId: `instance-${index + 1}`,
|
|
taskId: String(100 + index),
|
|
raw: {},
|
|
})) };
|
|
},
|
|
async processBatch(_userId: string, _action: string, _remark: string, values: unknown[]) {
|
|
writes += values.length;
|
|
return { results: [{ success: true }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listTodos(user, "dingtalk", 10);
|
|
const items = listed.items.map((item) => ({ approvalId: String(item.approvalId) }));
|
|
items[5]!.approvalId = items[5]!.approvalId.slice(0, -8);
|
|
const result = await service.processBatch(user, "approve", "同意", items);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.writesStarted, false);
|
|
assert.equal(result.notAttempted, 6);
|
|
assert.equal(result.invalidItems?.[0]?.index, 6);
|
|
assert.equal(result.invalidItems?.[0]?.code, "reference_truncated");
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
test("XFT action restrictions are enforced during zero-write preflight", async () => {
|
|
let writes = 0;
|
|
const ding = { async listMyTodos() { return { items: [] }; } } as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "xft", billId: "2099010100000001", allowPass: false, allowReject: true }] };
|
|
},
|
|
async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const approvalId = String((await service.listTodos(user, "xft", 10)).items[0]?.approvalId);
|
|
const result = await service.processBatch(user, "approve", "", [{ approvalId }]);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.invalidItems?.[0]?.code, "action_not_allowed");
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
test("approval reference expiry is checked once before the batch starts", async () => {
|
|
const originalNow = Date.now;
|
|
let now = originalNow();
|
|
let writes = 0;
|
|
Date.now = () => now;
|
|
try {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [
|
|
{ source: "official_oa", processInstanceId: "expiry-a", taskId: "201", raw: {} },
|
|
{ source: "official_oa", processInstanceId: "expiry-b", taskId: "202", raw: {} },
|
|
] };
|
|
},
|
|
async processBatch() {
|
|
writes += 1;
|
|
if (writes === 1) now += 31 * 60_000;
|
|
return { results: [{ success: true }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const items = (await service.listTodos(user, "dingtalk", 10)).items
|
|
.map((item) => ({ approvalId: String(item.approvalId) }));
|
|
const result = await service.processBatch(user, "approve", "同意", items);
|
|
assert.equal(result.succeeded, 2);
|
|
assert.equal(writes, 2);
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
});
|
|
|
|
test("an already expired v4 approval reference fails preflight without writing", async () => {
|
|
const originalNow = Date.now;
|
|
let now = originalNow();
|
|
let writes = 0;
|
|
Date.now = () => now;
|
|
try {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "expired", taskId: "401", raw: {} }] };
|
|
},
|
|
async processBatch() { writes += 1; return { results: [{ success: true }] }; },
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
|
|
now += 31 * 60_000;
|
|
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.invalidItems?.[0]?.code, "reference_expired");
|
|
assert.equal(writes, 0);
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
});
|
|
|
|
function legacyV2Reference(value: object): string {
|
|
const key = createHash("sha256").update(config().approvalRefSecret).digest();
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
const payload = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
return `v2.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
|
}
|
|
|
|
function legacyV3Reference(value: object): string {
|
|
const key = createHash("sha256").update(config().approvalRefSecret).digest();
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
|
|
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
|
}
|
|
|
|
test("signature-required DingTalk templates return a client link and never call the approval API", async () => {
|
|
let processCalls = 0;
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{
|
|
source: "official_oa",
|
|
processInstanceId: "signed-instance",
|
|
taskId: "111111111111",
|
|
processCode: "PROC-SIGN",
|
|
clientUrl: "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?taskId=111111111111",
|
|
title: "差旅报销",
|
|
raw: {},
|
|
}] };
|
|
},
|
|
async processBatch() { processCalls += 1; return { results: [{ success: true }] }; },
|
|
} as unknown as ApprovalService;
|
|
const appConfig = { ...config(), officialClientOnlyProcessCodes: ["PROC-SIGN"] };
|
|
const service = new UnifiedApprovalService(appConfig, ding);
|
|
const user = { userId: "user-1", name: "测试用户乙" };
|
|
|
|
const listed = await service.listTodos(user, "dingtalk", 10);
|
|
assert.deepEqual(listed.items[0]!.availableActions, []);
|
|
assert.equal((listed.items[0]!.manualAction as Record<string, unknown>).reason, "signature_required");
|
|
const approvalId = String(listed.items[0]!.approvalId);
|
|
const actions = await service.getActions(user, approvalId) as unknown as {
|
|
actions: Array<Record<string, unknown>>;
|
|
manualAction: Record<string, unknown>;
|
|
};
|
|
assert.equal(actions.actions[0]!.allowed, false);
|
|
assert.equal(actions.actions[0]!.signatureRequired, true);
|
|
assert.match(String(actions.manualAction.url), /^https:\/\/aflow\.dingtalk\.com\//);
|
|
|
|
const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
|
assert.equal(processed.succeeded, 0);
|
|
assert.equal(processed.results[0]!.status, "manual_action_required");
|
|
assert.equal(processCalls, 0);
|
|
});
|
|
|
|
test("a signature error discovered at submit time becomes a client-action result", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{
|
|
source: "official_oa", processInstanceId: "signed-late", taskId: "123456789012",
|
|
title: "费用报销", clientUrl: "https://aflow.dingtalk.com/detail/signed-late", raw: {},
|
|
}] };
|
|
},
|
|
async processBatch() { return { results: [{ success: false, error: "该节点需要手写签名 signature required" }] }; },
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
|
|
const result = await service.processBatch(user, "approve", "", [{ approvalId }]);
|
|
assert.equal(result.results[0]?.status, "manual_action_required");
|
|
assert.equal((result.results[0]?.manualAction as Record<string, unknown>).reason, "signature_required");
|
|
assert.match(String((result.results[0]?.manualAction as Record<string, unknown>).url), /aflow\.dingtalk\.com/);
|
|
});
|
|
|
|
test("unified approval reports a newly assigned DingTalk node as follow-up instead of completion", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "task-1", title: "出差申请", raw: {} }] };
|
|
},
|
|
async processBatch() {
|
|
return { results: [{ success: true, completed: false, followUpRequired: true, nextTaskId: "task-2" }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
|
|
|
|
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
|
|
|
assert.equal(result.succeeded, 1);
|
|
assert.equal(result.failed, 0);
|
|
assert.equal(result.completed, 0);
|
|
assert.equal(result.followUpRequired, 1);
|
|
assert.equal(result.results[0]!.status, "follow_up_required");
|
|
assert.equal(result.results[0]!.completed, false);
|
|
});
|
|
|
|
test("unified approval preserves an accepted-but-unconfirmed write as uncertain", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "task-1", title: "采购审批", raw: {} }] };
|
|
},
|
|
async processBatch() {
|
|
return { results: [{ success: false, status: "outcome_uncertain", outcomeUncertain: true, message: "禁止自动重试" }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
|
|
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
|
|
assert.equal(result.succeeded, 0);
|
|
assert.equal(result.failed, 0);
|
|
assert.equal(result.uncertain, 1);
|
|
assert.equal(result.results[0]!.status, "outcome_uncertain");
|
|
assert.match(String(result.results[0]!.message), /禁止自动重试/);
|
|
});
|
|
|
|
test("unified OA return signs live targets, revalidates them, and confirms task completion", async () => {
|
|
let pending = true;
|
|
const calls: Array<Record<string, unknown>> = [];
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "101", title: "采购审批", raw: {} }] };
|
|
},
|
|
async assertOfficialTaskPending(userId: string, processInstanceId: string, taskId: string) {
|
|
calls.push({ kind: "assert", userId, processInstanceId, taskId });
|
|
if (!pending) throw new Error("not pending");
|
|
},
|
|
async isOfficialTaskPending(userId: string, processInstanceId: string, taskId: string) {
|
|
calls.push({ kind: "verify", userId, processInstanceId, taskId });
|
|
return pending;
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const returns = {
|
|
supportsUser(userId: string) { return userId === "user-1"; },
|
|
async listRevertActivities(userId: string, taskId: string) {
|
|
calls.push({ kind: "activities", userId, taskId });
|
|
return [
|
|
{ activityId: "sid-startevent", activityName: "发起人", revertAction: "REVERT_FOR_RESUBMIT" as const, approverNames: ["申请人"] },
|
|
{ activityId: "leader-node", activityName: "部门负责人", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: ["李经理"] },
|
|
];
|
|
},
|
|
async revertTask(userId: string, request: Record<string, unknown>) {
|
|
calls.push({ kind: "revert", userId, ...request });
|
|
pending = false;
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
|
|
const user = { userId: "user-1", name: "测试用户乙" };
|
|
const listed = await service.listTodos(user, "dingtalk", 10);
|
|
assert.deepEqual(listed.items[0]!.availableActions, ["approve", "reject"]);
|
|
assert.equal("actionsHint" in listed.items[0]!, false);
|
|
const approvalId = String(listed.items[0]!.approvalId);
|
|
const actions = await service.getActions(user, approvalId);
|
|
const returnAction = actions.actions[2]! as {
|
|
allowed: boolean;
|
|
targets?: Array<{ targetId: string }>;
|
|
};
|
|
assert.equal(returnAction.allowed, true);
|
|
assert.equal(returnAction.targets?.length, 2);
|
|
assert.match(String(returnAction.targets?.[0]?.targetId), /^v3\./);
|
|
|
|
const result = await service.processBatch(user, "return", "请补充附件", [{
|
|
approvalId,
|
|
targetId: String(returnAction.targets?.[1]?.targetId),
|
|
}]);
|
|
assert.equal(result.succeeded, 1);
|
|
assert.equal(calls.filter((call) => call.kind === "activities").length, 2);
|
|
const reverted = calls.find((call) => call.kind === "revert")!;
|
|
assert.equal(reverted.targetActivityId, "leader-node");
|
|
assert.equal(reverted.revertAction, "REVERT_FOR_APPROVAL");
|
|
assert.equal(reverted.remark, "请补充附件");
|
|
assert.equal(calls.at(-1)?.kind, "verify");
|
|
});
|
|
|
|
test("unified OA return rejects stale, tampered, cross-user, and unsupported targets", async () => {
|
|
let activities = [{ activityId: "node-1", activityName: "上一级", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
|
|
let writes = 0;
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "101", raw: {} }] };
|
|
},
|
|
async assertOfficialTaskPending() {},
|
|
async isOfficialTaskPending() { return false; },
|
|
} as unknown as ApprovalService;
|
|
const returns = {
|
|
supportsUser() { return true; },
|
|
async listRevertActivities() { return activities; },
|
|
async revertTask() { writes += 1; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
|
|
const user1 = { userId: "user-1" };
|
|
const listed1 = await service.listTodos(user1, "dingtalk", 10);
|
|
const approval1 = String(listed1.items[0]!.approvalId);
|
|
const targetAction1 = (await service.getActions(user1, approval1)).actions[2]! as { targets?: Array<{ targetId: string }> };
|
|
const target1 = String(targetAction1.targets?.[0]?.targetId);
|
|
|
|
const tampered = `${target1.slice(0, -1)}${target1.endsWith("A") ? "B" : "A"}`;
|
|
let result = await service.processBatch(user1, "return", "", [{ approvalId: approval1, targetId: tampered }]);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.writesStarted, false);
|
|
assert.equal(result.failed, 0);
|
|
assert.match(String(result.invalidItems?.[0]?.message), /(?:无效|格式不完整|已损坏)/);
|
|
|
|
activities = [];
|
|
result = await service.processBatch(user1, "return", "", [{ approvalId: approval1, targetId: target1 }]);
|
|
assert.equal(result.failed, 1);
|
|
assert.match(String(result.results[0]!.message), /已不在当前可退回节点/);
|
|
|
|
activities = [{ activityId: "node-1", activityName: "上一级", revertAction: "REVERT_FOR_APPROVAL", approverNames: [] }];
|
|
const user2 = { userId: "user-2" };
|
|
const approval2 = String((await service.listTodos(user2, "dingtalk", 10)).items[0]!.approvalId);
|
|
result = await service.processBatch(user2, "return", "", [{ approvalId: approval2, targetId: target1 }]);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.failed, 0);
|
|
assert.match(String(result.invalidItems?.[0]?.message), /不属于当前用户/);
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
test("one invalid return target prevents every write in the batch", async () => {
|
|
let writes = 0;
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [
|
|
{ source: "official_oa", processInstanceId: "return-a", taskId: "301", raw: {} },
|
|
{ source: "official_oa", processInstanceId: "return-b", taskId: "302", raw: {} },
|
|
] };
|
|
},
|
|
async assertOfficialTaskPending() {},
|
|
async isOfficialTaskPending() { return false; },
|
|
} as unknown as ApprovalService;
|
|
const returns = {
|
|
supportsUser() { return true; },
|
|
async listRevertActivities() {
|
|
return [{ activityId: "node-1", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
|
|
},
|
|
async revertTask() { writes += 1; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listTodos(user, "dingtalk", 10);
|
|
const items = await Promise.all(listed.items.map(async (item) => {
|
|
const approvalId = String(item.approvalId);
|
|
const action = (await service.getActions(user, approvalId)).actions[2] as { targets?: Array<{ targetId: string }> };
|
|
return { approvalId, targetId: String(action.targets?.[0]?.targetId) };
|
|
}));
|
|
items[1]!.targetId = items[1]!.targetId.slice(0, -9);
|
|
const result = await service.processBatch(user, "return", "请补充", items);
|
|
assert.equal(result.status, "preflight_failed");
|
|
assert.equal(result.writesStarted, false);
|
|
assert.equal(result.invalidItems?.[0]?.index, 2);
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
test("unified OA return batch is non-transactional and marks an unconfirmed item uncertain", async () => {
|
|
const completed = new Set<string>();
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [
|
|
{ source: "official_oa", processInstanceId: "good", taskId: "1", raw: {} },
|
|
{ source: "official_oa", processInstanceId: "still-pending", taskId: "2", raw: {} },
|
|
] };
|
|
},
|
|
async assertOfficialTaskPending() {},
|
|
async isOfficialTaskPending(_userId: string, processInstanceId: string) { return !completed.has(processInstanceId); },
|
|
} as unknown as ApprovalService;
|
|
const returns = {
|
|
supportsUser() { return true; },
|
|
async listRevertActivities() {
|
|
return [{ activityId: "node-1", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
|
|
},
|
|
async revertTask(_userId: string, request: Record<string, unknown>) {
|
|
if (request.processInstanceId === "good") completed.add("good");
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listTodos(user, "dingtalk", 10);
|
|
const actionTargets = await Promise.all(listed.items.map(async (item) => {
|
|
const approvalId = String(item.approvalId);
|
|
const action = (await service.getActions(user, approvalId)).actions[2]! as { targets?: Array<{ targetId: string }> };
|
|
return { approvalId, targetId: action.targets?.[0]!.targetId };
|
|
}));
|
|
const result = await service.processBatch(user, "return", "", actionTargets);
|
|
assert.equal(result.succeeded, 1);
|
|
assert.equal(result.failed, 0);
|
|
assert.equal(result.uncertain, 1);
|
|
assert.equal(result.results[0]!.success, true);
|
|
assert.equal(result.results[1]!.status, "outcome_uncertain");
|
|
assert.match(String(result.results[1]!.message), /禁止自动重试/);
|
|
});
|
|
|
|
test("unified OA actions expose return-provider failures as unavailable and keep Yida unsupported", async () => {
|
|
const ding = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "i1", taskId: "t1", raw: {} }] };
|
|
},
|
|
async assertOfficialTaskPending() {},
|
|
} as unknown as ApprovalService;
|
|
const returns = {
|
|
supportsUser() { return true; },
|
|
async listRevertActivities() { throw new Error("return provider unavailable"); },
|
|
async revertTask() {},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
|
|
const user = { userId: "user-1" };
|
|
const oaId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
|
|
const actions = await service.getActions(user, oaId);
|
|
const unavailable = actions.actions[2]! as { allowed: boolean; unavailable?: boolean; message?: string };
|
|
assert.equal(unavailable.allowed, false);
|
|
assert.equal(unavailable.unavailable, true);
|
|
assert.match(String(unavailable.message), /return provider unavailable/);
|
|
|
|
const yidaDing = {
|
|
async listMyTodos() { return { items: [{ source: "yida", processInstanceId: "y1", taskId: "yt1", appType: "app", raw: {} }] }; },
|
|
} as unknown as ApprovalService;
|
|
const yidaService = new UnifiedApprovalService(config(), yidaDing, undefined, undefined, returns);
|
|
const yidaId = String((await yidaService.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
|
|
const yidaResult = await yidaService.processBatch(user, "return", "", [{ approvalId: yidaId, targetId: "v2.placeholder.placeholder.placeholder" }]);
|
|
assert.equal(yidaResult.status, "preflight_failed");
|
|
assert.equal(yidaResult.failed, 0);
|
|
assert.match(String(yidaResult.invalidItems?.[0]?.message), /宜搭审批当前不支持退回/);
|
|
});
|
|
|
|
test("unified list preserves healthy sources when XFT requires authentication", async () => {
|
|
const ding = {
|
|
async listMyTodos() { return { items: [{ source: "official_oa", processInstanceId: "i1", taskId: "t1", title: "OA", raw: {} }] }; },
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyTodos() {
|
|
const { XftSessionExpiredError } = await import("../src/errors.js");
|
|
throw new XftSessionExpiredError();
|
|
},
|
|
};
|
|
const challenges = {
|
|
async getOrStart() {
|
|
return { provider: "xft" as const, type: "qr" as const, challengeId: "challenge", qrImageUrl: "https://mcp.test/qr", expiresAt: new Date(Date.now() + 60_000).toISOString(), pollAfterSeconds: 3 };
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, xft as never, challenges);
|
|
const result = await service.listTodos({ userId: "user-1", name: "测试用户乙" }, "all", 50);
|
|
assert.equal(result.items.length, 1);
|
|
assert.equal(result.sourceStatus.dingtalk?.status, "ok");
|
|
assert.equal(result.sourceStatus.xft?.status, "auth_required");
|
|
assert.doesNotMatch(JSON.stringify(result), /cookie|token/i);
|
|
});
|
|
|
|
test("DingTalk submission requires a content-bound one-time confirmation", async () => {
|
|
const writes: Array<Record<string, unknown>> = [];
|
|
const ding = {
|
|
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "业务招待申请_捷康", dirName: "捷康_费用报销" }]; },
|
|
async getSubmissionSchema() {
|
|
return {
|
|
template: { processCode: "PROC-1", name: "业务招待申请_捷康", dirName: "捷康_费用报销" },
|
|
schema: { content: JSON.stringify({ items: [
|
|
{ componentName: "TextField", props: { label: "事由", required: true } },
|
|
{ componentName: "MoneyField", props: { label: "金额", required: true } },
|
|
] }) },
|
|
};
|
|
},
|
|
async resolveSubmissionDepartment() { return 123; },
|
|
async forecastSubmission(userId: string, request: Record<string, unknown>) {
|
|
assert.equal(userId, "user-1");
|
|
assert.equal("originatorUserId" in request, false);
|
|
return {
|
|
forecastSuccess: true,
|
|
staticWorkflow: false,
|
|
workflowActivityRuleVOs: [{
|
|
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", targetSelect: true,
|
|
workflowActor: { actorKey: "manual-1", actorType: "approver", approvalMethod: "NONE" },
|
|
}],
|
|
};
|
|
},
|
|
async submitApproval(userId: string, request: Record<string, unknown>) {
|
|
writes.push({ userId, ...request });
|
|
return "instance-new";
|
|
},
|
|
async revokeSubmission(userId: string, processInstanceId: string, remark: string) {
|
|
assert.equal(userId, "user-1");
|
|
assert.equal(processInstanceId, "instance-new");
|
|
assert.equal(remark, "测试撤销");
|
|
return { status: "revoked", processInstanceId };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1", name: "测试用户" };
|
|
const listed = await service.listSubmissionTemplates(user, "dingtalk", 20);
|
|
const templateId = String(listed.items[0]?.templateId);
|
|
const incompleteInput = { templateId, values: { "事由": "购买耗材", "金额": "500" }, deptId: -1, targetSelectActioners: [] };
|
|
await assert.rejects(service.prepareSubmission(user, incompleteInput), /需要选择处理人/);
|
|
const input = {
|
|
...incompleteInput,
|
|
targetSelectActioners: [{ actionerKey: "manual-1", actionerStaffIds: ["manager-1"] }],
|
|
};
|
|
const prepared = await service.prepareSubmission(user, input);
|
|
assert.deepEqual(prepared.preview.selectionRequirements, [{
|
|
actionerKey: "manual-1", activityName: "主管审批", actorType: "approver", required: true,
|
|
}]);
|
|
assert.deepEqual(prepared.preview.processForecast, {
|
|
supported: true,
|
|
forecastSuccess: true,
|
|
staticWorkflow: false,
|
|
nodes: [{
|
|
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", targetSelect: true,
|
|
actorType: "approver", approvalMethod: "NONE", actioners: [],
|
|
}],
|
|
});
|
|
|
|
const restartedService = new UnifiedApprovalService(config(), ding);
|
|
await assert.rejects(
|
|
restartedService.submitApproval(user, prepared.confirmationId!, input),
|
|
/先前的服务进程/,
|
|
);
|
|
|
|
await assert.rejects(
|
|
service.submitApproval(user, prepared.confirmationId!, { ...input, values: { ...input.values, "金额": "501" } }),
|
|
/与用户确认时不一致/,
|
|
);
|
|
const preparedAgain = await service.prepareSubmission(user, input);
|
|
const submitted = await service.submitApproval(user, preparedAgain.confirmationId!, input);
|
|
assert.equal(submitted.status, "submitted");
|
|
assert.equal(submitted.processInstanceId, "instance-new");
|
|
assert.ok(submitted.revokeId);
|
|
assert.equal(writes[0]?.userId, "user-1");
|
|
const revoked = await service.revokeSubmission(user, submitted.revokeId, "测试撤销");
|
|
assert.equal(revoked.status, "revoked");
|
|
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, submitted.revokeId), /不属于当前登录用户|无效/);
|
|
await assert.rejects(service.submitApproval(user, preparedAgain.confirmationId!, input), /已使用/);
|
|
});
|
|
|
|
test("submission template listing deterministically keeps both sources", async () => {
|
|
const ding = {
|
|
async listSubmissionTemplates() {
|
|
return Array.from({ length: 10 }, (_, index) => ({
|
|
processCode: `PROC-${index}`, name: "外出_北极大",
|
|
dirName: index % 2 === 0 ? "北极大_人事假勤" : "其他分类",
|
|
}));
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listSubmissionTemplates() {
|
|
return [{ eventSeqId: "1", eventNumber: "XFT-1", eventName: "薪福通报销", eventType: "COMMON_REIM", kind: "common_reimburse" }];
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), ding, xft as never);
|
|
const listed = await service.listSubmissionTemplates({ userId: "user-1" }, "all", 4);
|
|
assert.deepEqual(listed.items.map((item) => item.source), ["dingtalk", "xft", "dingtalk", "dingtalk"]);
|
|
});
|
|
|
|
test("Xft submission confirmation expires when the live custom-field schema changes", async () => {
|
|
let schemaReads = 0;
|
|
let writes = 0;
|
|
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
async getSubmissionSchema() {
|
|
schemaReads += 1;
|
|
return {
|
|
template,
|
|
fields: [{ fieldNumber: "C1", name: "说明", type: schemaReads === 1 ? "INPUT" : "TEXTAREA", required: false }],
|
|
inputContract: {}, diagnostics: {},
|
|
};
|
|
},
|
|
async submitApproval() { writes += 1; return { billId: "1", status: "submitted", diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
|
|
const prepared = await service.prepareSubmission(user, input);
|
|
await assert.rejects(service.submitApproval(user, prepared.confirmationId!, input), /表单在确认后发生变化/);
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
test("Xft submission returns a user-bound revokeId and routes recall without exposing empId", async () => {
|
|
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
|
|
const recalls: Array<Record<string, unknown>> = [];
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
async submitApproval() { return { billId: "2026081200000001", status: "submitted", diagnostics: {} }; },
|
|
async recallSubmission(user: Record<string, unknown>, billId: string, reason: string) {
|
|
recalls.push({ user, billId, reason });
|
|
return { status: "revoked", billId, diagnostics: { applicantIdentity: "server_derived" } };
|
|
},
|
|
async getMySubmissionStatus() {
|
|
return {
|
|
billId: "2026081200000001", status: "1", statusName: "待审批",
|
|
revoked: false, revokeAllowed: true, diagnostics: { ownershipRevalidated: true },
|
|
};
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
|
|
const prepared = await service.prepareSubmission(user, input);
|
|
const submitted = await service.submitApproval(user, prepared.confirmationId!, input);
|
|
assert.equal(submitted.source, "xft");
|
|
assert.ok(submitted.revokeId);
|
|
const recovered = await service.getSubmissionStatus(user, "xft", "2026081200000001");
|
|
assert.ok(recovered.revokeId);
|
|
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, recovered.revokeId!), /不属于当前登录用户|无效/);
|
|
const revoked = await service.revokeSubmission(user, submitted.revokeId, "测试撤回");
|
|
assert.equal(revoked.status, "revoked");
|
|
assert.deepEqual(recalls, [{ user, billId: "2026081200000001", reason: "测试撤回" }]);
|
|
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, submitted.revokeId), /不属于当前登录用户|无效/);
|
|
});
|
|
|
|
test("Xft submission reports an immediately completed workflow without issuing revokeId", async () => {
|
|
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
async submitApproval() { return { billId: "2026081300000001", status: "submitted", diagnostics: {} }; },
|
|
async getMySubmissionStatus() {
|
|
return {
|
|
billId: "2026081300000001", status: "2", statusName: "审批通过",
|
|
revoked: false, revokeAllowed: false, flowHistory: [{ nodeName: "发起申请", actions: ["start"] }], diagnostics: {},
|
|
};
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
|
|
const prepared = await service.prepareSubmission(user, input);
|
|
const submitted = await service.submitApproval(user, prepared.confirmationId!, input);
|
|
assert.equal(submitted.status, "completed");
|
|
assert.equal(submitted.observedStatusName, "审批通过");
|
|
assert.equal(submitted.completionMode, "workflow_completed_immediately");
|
|
assert.equal("revokeId" in submitted, false);
|
|
assert.equal(submitted.diagnostics.automaticRetryAllowed, false);
|
|
});
|
|
|
|
test("Xft travel reimbursement preparation validates nested and custom required fields", async () => {
|
|
const template = { eventSeqId: "1", eventNumber: "XFT-T", eventName: "差旅报销单", eventType: "TRAVEL_REIM", kind: "travel_reimburse" as const };
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() {
|
|
return {
|
|
template,
|
|
fields: [{ fieldNumber: "-2", name: "承担部门", type: "ADV_DEPARTMENT", required: true }],
|
|
inputContract: {}, diagnostics: {},
|
|
};
|
|
},
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const base = {
|
|
templateId,
|
|
values: {
|
|
reimAmount: "10.00",
|
|
reimAmountCurrencyCode: "CNY",
|
|
expenseList: [{ expTypeCode: "TYPE-1", currencyCode: "CNY", originalCurrencyReimAmt: "10.00", wrTim: "2026-08-12" }],
|
|
businessTrip: { businessTripDetails: [{ departCityCode: "25", destinationCityCode: "1", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00" }] },
|
|
},
|
|
};
|
|
await assert.rejects(service.prepareSubmission(user, base), /承担部门/);
|
|
const prepared = await service.prepareSubmission(user, {
|
|
...base,
|
|
values: { ...base.values, customFieldValues: [{ fieldNumber: "-2", fieldValue: "[\"ORG-1\"]" }] },
|
|
});
|
|
assert.equal(prepared.status, "confirmation_required");
|
|
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
...base,
|
|
values: { ...base.values, customFieldValues: [{ fieldNumber: "-2", fieldValue: "[\"ORG-1\"]" }], businessTrip: { businessTripDetails: [{}] } },
|
|
}), /第 1 段行程缺少/);
|
|
});
|
|
|
|
test("DingTalk submission rejects required client-only generated fields", async () => {
|
|
const ding = {
|
|
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }]; },
|
|
async getSubmissionSchema() {
|
|
return { template: { processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }, schema: { content: JSON.stringify({ items: [
|
|
{ componentName: "SeqNumberField", props: { label: "流水号", required: true } },
|
|
] }) } };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
|
|
await assert.rejects(service.prepareSubmission(user, { templateId, values: {} }), /钉钉客户端发起/);
|
|
});
|
|
|
|
test("DingTalk table submission template is client-only after live enterprise API incompatibility", async () => {
|
|
const ding = {
|
|
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" }]; },
|
|
async getSubmissionSchema() {
|
|
return { template: { processCode: "PROC-1", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" }, schema: { schemaContent: {
|
|
items: [
|
|
{ componentName: "InnerContactField", props: { id: "applicant", label: "申请人", required: true } },
|
|
{ componentName: "DepartmentField", props: { id: "department", label: "部门", required: true } },
|
|
{ componentName: "TableField", props: { id: "entertainmentRows", label: "业务招待申请明细", required: true }, children: [
|
|
{ componentName: "TextField", props: { id: "reason", label: "招待事由", required: true } },
|
|
{ componentName: "NumberField", props: { id: "amount", label: "招待预估金额", required: true } },
|
|
] },
|
|
],
|
|
} } };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1", name: "测试用户" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
|
|
const schema = await service.getSubmissionSchema(user, templateId);
|
|
assert.equal(schema.fields.length, 5);
|
|
assert.equal(schema.submissionCapability.status, "client_only");
|
|
const schemaFields = schema.fields as Array<Record<string, unknown>>;
|
|
assert.equal(schemaFields.find((field) => field.name === "部门")?.serverManaged, true);
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
templateId, values: { 业务招待申请明细: [{ 招待事由: "客户拜访", 招待预估金额: "100" }] },
|
|
}), /钉钉客户端|DWS/);
|
|
});
|
|
|
|
test("DingTalk finance suites are marked client-only even when their schema has no label", async () => {
|
|
const ding = {
|
|
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" }]; },
|
|
async getSubmissionSchema() {
|
|
return { template: { processCode: "PROC-1", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" }, schema: { schemaContent: { items: [{
|
|
componentName: "DDBizSuite", props: { bizType: "dingtalk.businessFinance.reimbursement" }, children: [
|
|
{ componentName: "MoneyField", props: { label: "报销总额(元)" } },
|
|
],
|
|
}] } } };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
|
|
const schema = await service.getSubmissionSchema(user, templateId);
|
|
assert.equal(schema.submissionCapability?.status, "client_only");
|
|
await assert.rejects(service.prepareSubmission(user, { templateId, values: {} }), /费控业务套件/);
|
|
});
|
|
|
|
test("Xft application templates support API submission with live post-write status verification", async () => {
|
|
let template: XftSubmissionTemplate = { eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单", eventType: "INVITATION_APPLY", kind: "entertainment_apply" };
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
let templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
assert.equal((await service.getSubmissionSchema(user, templateId)).submissionCapability.status, "api_supported_with_constraints");
|
|
const entertainmentPrepared = await service.prepareSubmission(user, { templateId, values: { entertainmentApplyApiCreateDTO: {
|
|
entertainmentCityCode: "1", entertainmentPersonCount: 2, entertainmentStartTime: "2026-08-12", entertainmentEndTime: "2026-08-13",
|
|
} } });
|
|
assert.equal(entertainmentPrepared.status, "confirmation_required");
|
|
|
|
template = { eventSeqId: "2", eventNumber: "XFT-T", eventName: "出差费用申请单", eventType: "TRAVEL_APPLY", kind: "travel_apply" };
|
|
templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
assert.equal((await service.getSubmissionSchema(user, templateId)).submissionCapability.status, "api_supported_with_constraints");
|
|
const travelPrepared = await service.prepareSubmission(user, { templateId, values: { businessTrip: { businessTripDetails: [{
|
|
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
|
|
}] } } });
|
|
assert.equal(travelPrepared.status, "confirmation_required");
|
|
});
|
|
|
|
test("Xft preparation preview resolves every selection to user-readable business names", async () => {
|
|
const template: XftSubmissionTemplate = {
|
|
eventSeqId: "event-travel", eventNumber: "XFT-TRAVEL", eventName: "差旅报销单",
|
|
eventType: "TRAVEL_REIM", kind: "travel_reimburse",
|
|
};
|
|
const fields: XftSubmissionField[] = [
|
|
{
|
|
fieldNumber: "C-TOPIC", name: "申请事项", type: "SELECT", required: true,
|
|
options: [{ code: "PROCUREMENT", label: "采购事项" }],
|
|
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
|
|
},
|
|
{
|
|
fieldNumber: "C-TABLE", name: "补充明细", type: "TABLE", required: true,
|
|
itemFields: [{
|
|
titleCode: "T-TYPE", label: "明细类型", type: "SELECT", required: true,
|
|
options: [{ code: "MARKETING", label: "市场活动" }],
|
|
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
|
|
}],
|
|
},
|
|
];
|
|
const standardFields = [
|
|
{ key: "reimAmount", label: "报销总金额", type: "decimal" },
|
|
{ key: "reimAmountCurrencyCode", label: "报销币种", type: "string", enumLabels: { CNY: "人民币" } },
|
|
{
|
|
key: "expenseList", label: "费用明细", type: "array", itemFields: [
|
|
{ key: "expTypeCode", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
|
|
{ key: "currencyCode", label: "费用币种", type: "string", enumLabels: { CNY: "人民币" } },
|
|
{ key: "originalCurrencyReimAmt", label: "费用金额", type: "decimal" },
|
|
{ key: "payCityCode", label: "消费城市", type: "string", optionSource: { kind: "city" } },
|
|
],
|
|
},
|
|
{
|
|
key: "businessTrip", label: "差旅行程", type: "object", fields: [{
|
|
key: "businessTripDetails", label: "行程明细", type: "array", itemFields: [
|
|
{ key: "departCityCode", label: "出发城市", type: "string", optionSource: { kind: "city" } },
|
|
{ key: "destinationCityCode", label: "目的城市", type: "string", optionSource: { kind: "city" } },
|
|
{ key: "beginTime", label: "出发时间", type: "datetime" },
|
|
{ key: "endTime", label: "结束时间", type: "datetime" },
|
|
{ key: "travelMode", label: "出行方式", type: "comma_separated_enum", multiple: true, enumLabels: { AirPlane: "飞机", Train: "火车" } },
|
|
{ key: "roundTrip", label: "是否往返", type: "string", enumLabels: { Y: "往返", N: "单程" } },
|
|
],
|
|
}],
|
|
},
|
|
{ key: "customFieldValues", userVisible: false, type: "array" },
|
|
];
|
|
const optionCalls: Array<{ kind: string; query: string }> = [];
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() {
|
|
return { template, fields, inputContract: { values: { standardFields } }, diagnostics: {} };
|
|
},
|
|
async getSubmissionOptions(_user: unknown, _template: unknown, kind: string, query: string) {
|
|
optionCalls.push({ kind, query });
|
|
const labels: Record<string, string> = {
|
|
"440300": "深圳市", "310100": "上海市", MEAL: "餐饮费",
|
|
};
|
|
return {
|
|
kind,
|
|
items: labels[query] ? [{ code: query, label: labels[query] }] : [],
|
|
diagnostics: {},
|
|
};
|
|
},
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const prepared = await service.prepareSubmission(user, {
|
|
templateId,
|
|
values: {
|
|
reimAmount: "100.00",
|
|
reimAmountCurrencyCode: "CNY",
|
|
expenseList: [{
|
|
expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "100.00", payCityCode: "440300",
|
|
}],
|
|
businessTrip: { businessTripDetails: [{
|
|
departCityCode: "440300", destinationCityCode: "310100",
|
|
beginTime: "2026-08-20 09:00", endTime: "2026-08-21 18:00",
|
|
travelMode: "AirPlane,Train", roundTrip: "Y",
|
|
}] },
|
|
customFieldValues: [
|
|
{ fieldNumber: "C-TOPIC", fieldValue: "PROCUREMENT" },
|
|
{ fieldNumber: "C-TABLE", fieldValue: JSON.stringify([{ sequence: 1, columnValues: [{ titleCode: "T-TYPE", value: "MARKETING" }] }]) },
|
|
],
|
|
},
|
|
});
|
|
assert.equal(prepared.status, "confirmation_required");
|
|
const summary = JSON.stringify(prepared.preview.formSummary);
|
|
for (const label of ["深圳市", "上海市", "餐饮费", "人民币", "飞机", "火车", "往返", "采购事项", "市场活动"]) {
|
|
assert.match(summary, new RegExp(label));
|
|
}
|
|
for (const internal of ["440300", "310100", "MEAL", "C-TOPIC", "PROCUREMENT", "CNY", "T-TYPE", "MARKETING"]) {
|
|
assert.doesNotMatch(summary, new RegExp(internal));
|
|
}
|
|
assert.deepEqual(optionCalls, [
|
|
{ kind: "expense_type", query: "MEAL" },
|
|
{ kind: "city", query: "440300" },
|
|
{ kind: "city", query: "310100" },
|
|
]);
|
|
});
|
|
|
|
test("Xft preparation fails before confirmation when a selected code has no readable mapping", async () => {
|
|
const template: XftSubmissionTemplate = {
|
|
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
|
|
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
|
};
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() {
|
|
return {
|
|
template,
|
|
fields: [],
|
|
inputContract: { values: { standardFields: [{
|
|
key: "applyExpenses", label: "事前申请费用列表", type: "array", itemFields: [
|
|
{ key: "expenseNumber", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
|
|
{ key: "amount", label: "申请金额", type: "decimal" },
|
|
],
|
|
}] } },
|
|
diagnostics: {},
|
|
};
|
|
},
|
|
async getSubmissionOptions() { return { kind: "expense_type", items: [], diagnostics: {} }; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
await assert.rejects(
|
|
service.prepareSubmission(user, {
|
|
templateId, values: { applyExpenses: [{ expenseNumber: "UNKNOWN", amount: "10.00" }] },
|
|
}),
|
|
/无法映射为业务名称.*重新查询可用名称/,
|
|
);
|
|
});
|
|
|
|
test("Xft use-car application is omitted and old signed references are rejected", async () => {
|
|
const useCarTemplate: XftSubmissionTemplate = {
|
|
eventSeqId: "event-car", eventNumber: "XFT-CAR", eventName: "用车申请单",
|
|
eventType: "USE_CAR_APPLY", kind: "use_car_apply",
|
|
};
|
|
const expenseTemplate: XftSubmissionTemplate = {
|
|
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
|
|
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
|
};
|
|
let schemaReads = 0;
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [useCarTemplate, expenseTemplate]; },
|
|
async getSubmissionSchema() {
|
|
schemaReads += 1;
|
|
return { template: useCarTemplate, fields: [], inputContract: { values: { standardFields: [] } }, diagnostics: {} };
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listSubmissionTemplates(user, "xft", 10);
|
|
assert.deepEqual(listed.items.map((item) => item.name), ["费用申请单"]);
|
|
assert.equal(listed.sourceStatus.xft?.count, 1);
|
|
const templateId = (service as any).signer.sign({
|
|
kind: "submission_template", userId: user.userId, source: "xft",
|
|
xftTemplate: useCarTemplate, exp: Date.now() + 60_000,
|
|
});
|
|
await assert.rejects(service.getSubmissionSchema(user, templateId), /不在 MCP 支持的发起范围/);
|
|
await assert.rejects(
|
|
service.prepareSubmission(user, { templateId, values: { useCarInfo: { purpose: "客户拜访" } } }),
|
|
/不在 MCP 支持的发起范围/,
|
|
);
|
|
assert.equal(schemaReads, 0);
|
|
});
|
|
|
|
test("Xft preparation reports standard and live custom required fields together", async () => {
|
|
const template: XftSubmissionTemplate = {
|
|
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
|
|
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
|
};
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() {
|
|
return {
|
|
template,
|
|
fields: [
|
|
{ fieldNumber: "C1", name: "招待事由", type: "MULTI_INPUT", required: true },
|
|
{ fieldNumber: "C2", name: "招待日期", type: "DATE", required: true },
|
|
],
|
|
inputContract: {}, diagnostics: {},
|
|
};
|
|
},
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
await assert.rejects(
|
|
service.prepareSubmission(user, { templateId, values: {} }),
|
|
/业务字段:事前申请费用;自定义字段:招待事由、招待日期/,
|
|
);
|
|
});
|
|
|
|
test("Xft preparation blocks fields whose user-readable choices cannot be resolved", async () => {
|
|
const template: XftSubmissionTemplate = {
|
|
eventSeqId: "1", eventNumber: "XFT-E", eventName: "费用申请单",
|
|
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
|
};
|
|
let fields: XftSubmissionField[] = [{
|
|
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true,
|
|
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
|
}];
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() { return { template, fields, inputContract: {}, diagnostics: {} }; },
|
|
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
const schema = await service.getSubmissionSchema(user, templateId);
|
|
assert.equal(schema.submissionCapability.status, "client_only");
|
|
assert.match(String(schema.submissionCapability.reason), /参与人员/);
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }] },
|
|
}), /无法安全填写的必填项:参与人员.*薪福通客户端/);
|
|
|
|
fields = [{
|
|
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: false,
|
|
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
|
}];
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
templateId,
|
|
values: {
|
|
applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }],
|
|
customFieldValues: [{ fieldNumber: "P1", fieldValue: "[\"EMP-1\"]" }],
|
|
},
|
|
}), /参与人员.*无法通过 MCP.*薪福通客户端/);
|
|
});
|
|
|
|
test("Xft preparation refuses optional internal-value fields instead of asking the user for codes", async () => {
|
|
const template: XftSubmissionTemplate = {
|
|
eventSeqId: "1", eventNumber: "XFT-T", eventName: "出差申请单",
|
|
eventType: "TRAVEL_APPLY", kind: "travel_apply",
|
|
};
|
|
const xft = {
|
|
async listSubmissionTemplates() { return [template]; },
|
|
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
|
};
|
|
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
templateId,
|
|
values: {
|
|
businessTrip: { businessTripDetails: [{
|
|
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
|
|
}] },
|
|
peerEmpNumbers: ["EMP-1"],
|
|
},
|
|
}), /同行人员.*不要提供内部编码或 ID/);
|
|
});
|
|
|
|
test("submission rejects caller-supplied applicant identity", async () => {
|
|
const ding = {
|
|
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; },
|
|
async getSubmissionSchema() {
|
|
return { template: { processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }, schema: { content: JSON.stringify({ items: [] }) } };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
|
|
await assert.rejects(service.prepareSubmission(user, {
|
|
templateId, values: { originatorUserId: "victim" },
|
|
}), /不得包含发起人身份/);
|
|
});
|
|
|
|
test("DingTalk submission scope filters names and revalidates after template listing", async () => {
|
|
let category = "捷康_费用报销";
|
|
let name = "业务招待费用报销_捷康";
|
|
const ding = {
|
|
async listSubmissionTemplates() {
|
|
return [
|
|
{ processCode: "ALLOWED", name, dirName: category },
|
|
{ processCode: "BJ-TRAVEL", name: "出差申请单_北极大", dirName: "北极大_人事假勤" },
|
|
{ processCode: "BJ-OUT", name: "外出_北极大", dirName: "北极大_人事假勤" },
|
|
{ processCode: "BJ-ENT", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" },
|
|
{ processCode: "JK-TRAVEL", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" },
|
|
{ processCode: "JK-ENT-APPLY", name: "业务招待申请_捷康", dirName: "捷康_费用报销" },
|
|
{ processCode: "SAME-CATEGORY-BLOCKED", name: "日常费用报销_捷康", dirName: "捷康_费用报销" },
|
|
{ processCode: "SAME-CATEGORY-BLOCKED-2", name: "请假_北极大", dirName: "北极大_人事假勤" },
|
|
{ processCode: "OTHER-CATEGORY-BLOCKED", name: "其他申请", dirName: "其他分类" },
|
|
];
|
|
},
|
|
async getSubmissionSchema(processCode: string) {
|
|
return { template: { processCode, name, dirName: category }, schema: { content: JSON.stringify({ items: [] }) } };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const service = new UnifiedApprovalService(config(), ding);
|
|
const user = { userId: "user-1" };
|
|
const listed = await service.listSubmissionTemplates(user, "dingtalk", 10);
|
|
assert.deepEqual(listed.items.map((item) => item.name), [
|
|
"业务招待费用报销_捷康",
|
|
"出差申请单_北极大",
|
|
"外出_北极大",
|
|
"业务招待申请_北极大",
|
|
"差旅费用报销_捷康",
|
|
"业务招待申请_捷康",
|
|
]);
|
|
const templateId = String(listed.items[0]?.templateId);
|
|
name = "日常费用报销_捷康";
|
|
await assert.rejects(service.getSubmissionSchema(user, templateId), /不在允许发起范围/);
|
|
});
|