feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
build / build (push) Successful in 1m31s

在 c93313c 之上增量落地原型与前端规范,保留既有流程图入口/失败提示/指派预览/线级停发:

- 审批/抄送抽屉按原型重构(422→1026 / 239→742 行):科室树+成员多选、SYSTEM/CUSTOM 分支、
  CUSTOM 分类导航自动定位、跨节点导航重置、保存后重开回显
- 新增授权管理页 /admin/usage-grants(UsageGrants.vue,417 行):校域清单、按流程线筛选、
  授予/撤销后回读,失败不宣称成功
- 路由与 API:新增 usage-grant(schoolList/summary/grant/revoke)、department(groups/options/resolve/members)、
  instance/resubmit;TaskHandle 增加「退回待修改」入口(仅 status=50 显示)
- 错误处理沿用 flowError 统一出口,不透出后端原文
- 组件/契约测试:flow-error、flow2-contract、department-roundtrip、task-result

不含构建产物(dist-verify)与依赖目录
This commit is contained in:
chenxing
2026-10-30 10:15:47 +08:00
parent c93313cdb7
commit bed57846f4
28 changed files with 2829 additions and 253 deletions
+2 -1
View File
@@ -143,7 +143,8 @@ test("已发布版本有本节点:候选人按服务端解析结果展示(
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
const api = stubApi({
applyPreview: async () => {
throw new Error("流程线已停发,不可发起新办件");
// 与 alova 层一致:业务信封 code≠0 的拒绝才带 bizCode,安全呈现层据此判定可信原文
throw Object.assign(new Error("流程线已停发,不可发起新办件"), { bizCode: 2 });
},
});
const out = await resolveNodePreview(cfg(), depts, api, "g1");
+110 -26
View File
@@ -71,12 +71,13 @@ describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
const src = frameEl()!.src;
expect(src.split("#").length - 1).toBe(1);
expect(src).toContain("#/assign-preview");
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain("contextId=");
expect((wrapper.vm as any).contextId).toBeTruthy();
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain(`contextId=${(wrapper.vm as any).contextId}`);
});
it("缺少 flowGuid 时不开框、不监听", async () => {
@@ -84,13 +85,22 @@ describe("FlowAssignEmbed 地址与会话", () => {
const wrapper = mountHost({ flowGuid: "" });
(wrapper.vm as any).open();
await nextTick();
await flushPromises();
expect(frameEl()).toBeNull();
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
expect(addSpy.mock.calls.filter((c) => c[0] === "message")).toHaveLength(0);
addSpy.mockRestore();
});
});
describe("FlowAssignEmbed 消息校验", () => {
it("换线立即结束旧会话,旧 iframe 不能借新 props 回传选择", async () => {
const wrapper = mountHost(); await openHost(wrapper);
const contextId = (wrapper.vm as any).contextId;
await wrapper.setProps({ flowGuid: 'different-flow' });
await post(wrapper, baseData({ contextId, flowGuid: 'different-flow' }));
expect(wrapper.emitted('confirmed')).toBeUndefined();
expect(wrapper.emitted('cancelled')).toHaveLength(1);
});
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
const wrapper = mountHost();
await openHost(wrapper);
@@ -98,25 +108,25 @@ describe("FlowAssignEmbed 消息校验", () => {
await post(wrapper, baseData({ contextId: cid }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
expect((events[0][0] as any).defVersion).toBe(3);
expect((events[0][0] as any).flowGuid).toBe("g-embed");
const payload = events![0][0] as any;
expect(payload.flowGuid).toBe("g-embed");
expect(payload.defVersion).toBe(3);
expect(payload.assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
const fakeOtherWindow = { __other: true };
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" });
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow });
await post(wrapper, baseData({ contextId: "stale-from-prev-session" }));
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid });
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid });
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid });
await post(wrapper, "plain-string-from-page-script");
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example" });
await post(wrapper, baseData({ contextId: cid }), { source: { __other: true } });
await post(wrapper, baseData({ contextId: "stale-ctx" }));
await post(wrapper, { proto: "fc", v: 1, type: "assign-confirmed", contextId: cid });
await post(wrapper, baseData({ contextId: cid, v: 2 }));
await post(wrapper, baseData({ contextId: cid, type: "assign-mystery" }));
await post(wrapper, { nope: true });
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
@@ -126,6 +136,9 @@ describe("FlowAssignEmbed 消息校验", () => {
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
await post(wrapper, baseData({ contextId: cid, defVersion: undefined }));
await post(wrapper, baseData({ contextId: cid, flowGuid: undefined }));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
});
@@ -145,36 +158,33 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [777] }] }));
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [999] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
expect((events![0][0] as any).assigns[0].userIds).toEqual([101]);
});
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
(wrapper.vm as any).close();
await nextTick();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
removeSpy.mockRestore();
});
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid1 = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] }));
await post(wrapper, baseData({ contextId: cid1 }));
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
// 宿主把 A 记在内存里,重开选 B
await openHost(wrapper);
const cid2 = (wrapper.vm as any).contextId;
expect(cid2).not.toEqual(cid1);
// 上一轮残帧补发一条:按 context 失效
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
@@ -202,3 +212,77 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
removeSpy.mockRestore();
});
});
describe("FlowAssignEmbed 载荷逐字段校验与真实关闭(F03/F07)", () => {
/** F03:宿主必须按打开时锁定的流程与字段类型核载荷,畸形载荷不得覆盖节点配置 */
it.each([
["异流程回传", () => ({ flowGuid: "g-other" })],
["字符串 defVersion", () => ({ defVersion: "3" })],
["零 defVersion", () => ({ defVersion: 0 })],
["小数 defVersion", () => ({ defVersion: 1.5 })],
["空 nodeId", () => ({ assigns: [{ nodeId: "", userIds: [101] }] })],
["字符串型 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: ["101"] }] })],
["空 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: [] }] })],
["assigns 不是数组", () => ({ assigns: "u2=101" })],
])("畸形/异源载荷被丢弃:%s", async (_label, mutate) => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, ...(mutate() as object) }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("缺少可核验的窗口引用时不处理消息(不再是「缺条件就跳过校验」)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }), { source: {} as Window });
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
/** F07:点 X 是真实用户路径,必须走同一 settle/close 并通知宿主取消 */
it("点 Modal 的 X:宿主收到一次 cancelled,原有选择未被覆盖,监听器已摘", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const closeBtn = document.querySelector(".ivu-modal-close");
expect(closeBtn).not.toBeNull();
(closeBtn as HTMLElement).click();
await nextTick();
await flushPromises();
const cancelled = wrapper.emitted("cancelled");
expect(cancelled).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("程序化 close 与 X 走同一路径:重复关闭只 emit 一次 cancelled", async () => {
const wrapper = mountHost();
await openHost(wrapper);
(wrapper.vm as any).close();
(wrapper.vm as any).close();
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("确认后不得再补发 cancelled(iframe 已答复,Modal 收起只是收尾)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toBeUndefined();
expect(wrapper.emitted("confirmed")).toHaveLength(1);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import { reactive, nextTick } from 'vue';
import ViewUIPlus from 'view-ui-plus';
import ApprovalDrawer from '@/views/flow/admin/def-editor/components/ApprovalDrawer.vue';
import NotifyDrawer from '@/views/flow/admin/def-editor/components/NotifyDrawer.vue';
import { EDITOR_CTX, createApproval, createNotify } from '@/views/flow/admin/def-editor/model';
const { api } = vi.hoisted(() => ({ api: { deptGroups: vi.fn(), deptResolve: vi.fn(), deptOptions: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api }));
enableAutoUnmount(afterEach);
const ok = (data: unknown) => ({ send: async () => data });
describe('CUSTOM 引用真实抽屉编辑往返', () => {
for (const mode of ['approval', 'notify'] as const) {
it(`${mode}:无 categoryId 的历史解析仍可反查分类,保存重开再保存不丢引用`, async () => {
document.body.innerHTML = '';
const ref = { departmentType: 'CUSTOM', departmentId: 9 };
api.deptGroups.mockImplementation(() => ok([
{ departmentType: 'SYSTEM', name: '系统组织' },
{ departmentType: 'CUSTOM', categoryId: 4, name: '教研组' },
]));
api.deptResolve.mockImplementation(() => ok([{ ...ref, name: '科学组', memberCount: 2, selectable: true }]));
api.deptOptions.mockImplementation(() => ok([{ ...ref, categoryId: 4, name: '科学组', memberCount: 2, selectable: true }]));
const node: any = mode === 'approval' ? createApproval('审批') : createNotify('抄送');
const key = mode === 'approval' ? 'assignee' : 'receivers';
node[key] = { type: 'DEPT', deptRefs: [ref] };
const ctx = { model: reactive({ list: [node] }), depts: [], userNames: {}, routers: [], availableVars: [], issueOf: () => [] };
const wrapper = mount(mode === 'approval' ? ApprovalDrawer : NotifyDrawer, {
props: { node, modelValue: true }, attachTo: document.body,
global: { plugins: [ViewUIPlus], provide: { [EDITOR_CTX as unknown as symbol]: ctx } },
});
await flushPromises(); await nextTick();
expect(document.body.textContent).toContain('科学组');
expect(api.deptOptions).toHaveBeenCalledWith({ type: 'CUSTOM' });
for (let round = 0; round < 2; round++) {
if (round) { await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises(); }
const input = document.querySelector('input.ivu-input') as HTMLInputElement;
expect(input).toBeTruthy(); input.value = `修改名称${round}`; input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
const confirm = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '确定');
expect(confirm).toBeTruthy(); confirm!.click(); await nextTick();
expect(node[key]).toEqual({ type: 'DEPT', deptRefs: [ref] });
expect(node.name).toBe(`修改名称${round}`);
}
});
}
});
@@ -0,0 +1,27 @@
import { it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import ViewUIPlus from 'view-ui-plus';
import TaskHandle from '@/views/flow/TaskHandle.vue';
const { api } = vi.hoisted(() => ({ api: { taskDetail: vi.fn(), approve: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api, INSTANCE_STATUS: { 10: '审批中' } }));
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { taskId: '1' }, path: '/task/1', query: { embed: '1' } }), useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@/utils/taskEmbed', () => ({ isBareTaskRoute: () => true, postToParent: vi.fn() }));
enableAutoUnmount(afterEach);
const detail = { instanceId: 9, status: 10, myPendingTaskIds: [1], trace: [{ taskId: 1, status: 0 }], outlets: [] };
async function click(text: string) {
const b = Array.from(document.querySelectorAll('button')).find(x => x.textContent?.trim() === text);
expect(b).toBeTruthy(); b!.click(); await flushPromises();
}
for (const lost of [true, false]) it(lost ? 'POST 丢响应不声称未生效' : 'POST 成功但刷新失败保留成功并关闭办理入口', async () => {
document.body.innerHTML = '';
let reads = 0;
api.taskDetail.mockImplementation(() => ({ send: async () => { if (++reads > 1) throw new Error('refresh failed'); return detail; } }));
api.approve.mockImplementation(() => ({ send: async () => { if (lost) throw new TypeError('Failed to fetch'); return {}; } }));
mount(TaskHandle, { global: { plugins: [ViewUIPlus], stubs: { TraceTimeline: true } }, attachTo: document.body });
await flushPromises(); await click('同意'); await click('确定');
const text = document.body.textContent || '';
expect(text).not.toContain('本次办理未生效');
expect(text).not.toContain('任务仍是你的待办');
if (lost) expect(text).toContain('结果待确认');
else { expect(text).toContain('已提交成功'); expect(document.querySelector('.actions')).toBeNull(); }
});
+81 -26
View File
@@ -1,55 +1,110 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError";
import {
classifyFlowError,
describeFlowError,
flowFailTip,
flowReloadFailTip,
flowSubmitFailTip,
isDefiniteBusinessRejection,
FLOW_GENERIC_FALLBACK,
FLOW_NETWORK_FALLBACK,
FLOW_UNKNOWN_FALLBACK,
} from "../src/utils/flowError";
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */
/**
* 错误呈现与结果可信度单测(P0-1 附带项 + Codex 终审 F04/F05,2026-10-29)。
*
* F05 的口径不是"命中黑名单才挡",而是"拿不到可信凭据就不放行":
* 只有带 bizCode 的业务信封原文才可能进 DOM;HTTP 状态文本、异常/SQL/Token 形态、
* 以及无法判定来源的裸文本一律换成通用安全提示。
*
* F04 的口径是"失败 ≠ 未生效":服务端明确拒绝与"请求已发出但结果未知"必须分开说。
*/
test("后端给出通用 code=6 占位文案:换成不误导的兜底话术,不猜原因", () => {
assert.equal(describeFlowError("内部服务错误"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError("内部服务错误!"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError("业务错误"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(""), FLOW_GENERIC_FALLBACK);
assert.ok(!FLOW_GENERIC_FALLBACK.includes("成功"));
});
function bizError(message: string, bizCode = 2): Error {
return Object.assign(new Error(message), { bizCode });
}
test("契约内的具体业务 msg 原样透出,不改写不误导", () => {
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049");
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理");
function httpError(message: string, httpStatus = 500): Error {
return Object.assign(new Error(message), { httpStatus });
}
test("业务信封明确拒绝:具体业务话原样透出,不改写不误导", () => {
assert.equal(describeFlowError(bizError("流程线不存在:8d07f049")), "流程线不存在:8d07f049");
assert.equal(describeFlowError(bizError("该任务已被他人办理")), "该任务已被他人办理");
assert.equal(
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"),
describeFlowError(bizError("该待办缺少流程推进记录,需管理员回填后办理")),
"该待办缺少流程推进记录,需管理员回填后办理",
);
assert.equal(isDefiniteBusinessRejection(bizError("流程线不存在:g1")), true);
});
test("异常/SQL/令牌形态不回显:堆栈与凭据不进页面", () => {
test("F05:HTTP 500 的响应原文不回显(旧实现把任意非黑名单文本都当业务话)", () => {
assert.equal(describeFlowError(Object.assign(httpError('private backend text'), { bizCode: 2 })), FLOW_GENERIC_FALLBACK);
assert.equal(isDefiniteBusinessRejection(Object.assign(httpError('private backend text'), { bizCode: 2 })), false);
assert.equal(describeFlowError(bizError('private backend text', 999)), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(bizError('private backend text', 6)), FLOW_GENERIC_FALLBACK);
for (const raw of ["Internal Server Error", "Bad Gateway", ""]) {
const out = describeFlowError(httpError(raw));
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.equal(isDefiniteBusinessRejection(httpError(raw)), false);
}
// 网关超时属网络口径:说"刷新重试"而不是把 "Gateway Timeout" 原文贴进页面
assert.equal(describeFlowError(httpError("Gateway Timeout", 504)), FLOW_NETWORK_FALLBACK);
});
test("F05:无法判定来源的裸文本走通用提示,不做黑名单后原样放行", () => {
// 没有 bizCode 的字符串/Error 不可能是"可信业务文案"
assert.equal(describeFlowError("Something went wrong at node 7"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(new Error("流程线不存在:g1")), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(undefined), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(null), FLOW_GENERIC_FALLBACK);
});
test("F05:即使来自业务信封,异常/SQL/令牌形态也不回显", () => {
const leaks = [
"java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required",
"at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)",
"Caused by: SQLException error code [1064]",
"refresh token=Bearer abcdef123456",
"<html><body>500 Internal Server Error</body></html>",
"内部服务错误",
];
for (const raw of leaks) {
const out = describeFlowError(raw);
const out = describeFlowError(bizError(raw, 500));
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out));
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out), raw);
}
});
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("Failed to fetch"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("timeout of 20000ms exceeded"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError(new Error("Failed to fetch")), FLOW_NETWORK_FALLBACK);
assert.equal(classifyFlowError(httpError("timeout of 20000ms exceeded", 0)).kind, "network");
});
test("Error 对象可直接传入(取 message)", () => {
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1");
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK);
test("F04:明确拒绝说拒绝;超时/丢响应只说结果待确认,不断言未生效", () => {
const rejected = flowSubmitFailTip("同意", bizError("仅任务办理人本人可办理"));
assert.ok(rejected.startsWith("同意被拒绝:"), rejected);
assert.ok(rejected.includes("仅任务办理人本人可办理"), rejected);
const lost = flowSubmitFailTip("同意", new TypeError("network failure"));
assert.ok(lost.startsWith("同意结果待确认:"), lost);
assert.ok(lost.includes(FLOW_UNKNOWN_FALLBACK) || lost.includes(FLOW_NETWORK_FALLBACK), lost);
assert.ok(!lost.includes("未生效"), lost);
assert.ok(!lost.includes("仍是你的待办"), lost);
});
test("页面内联提示带动作名,明确「未生效」而不是成功", () => {
const tip = flowFailTip("同意", new Error("内部服务错误"));
assert.ok(tip.startsWith("同意未完成:"));
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK));
assert.ok(!tip.includes("已同意"));
test("F04:办理成功但后续刷新失败 → 说明已成功、只是没刷新,不改口成失败", () => {
const tip = flowReloadFailTip("同意");
assert.ok(tip.includes("已提交成功"), tip);
assert.ok(tip.includes("刷新"), tip);
});
test("只读动作失败的内联提示带动作名,不谎报成功", () => {
const tip = flowFailTip("催办", bizError("已达催办次数上限(3 次)"));
assert.ok(tip.startsWith("催办未完成:"), tip);
assert.ok(tip.includes("已达催办次数上限(3 次)"), tip);
assert.ok(!tip.includes("成功"), tip);
});
+209
View File
@@ -0,0 +1,209 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fromContentJson, toContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createEmptyModel } from "../src/views/flow/admin/def-editor/model";
import { isValidAssignMessage, buildMessage } from "../src/utils/assignEmbed";
/**
* FL 2.0 缺口补足的定义契约往返 + 选人载荷校验单测(2026-10-29)。
*
* 覆盖三件"换皮不能破"的事:
* 1. C1/C3:UserBase 类型化部门引用与节点展示方式在 保存→重新加载→再次保存 中逐字段存活;
* 2. D1:变量契约持久化/回显,且编辑态专用字段(enumText)不进 contentJson;
* 3. F03/F07:选人 iframe 宿主对畸形/异源/异流程载荷一律拒绝。
*
* 注意:并行容器派生节点 id 重编号(Codex F08)是基线既有行为,本文件刻意不把它写成"已修复"。
*/
const ORIGINAL = JSON.stringify({
code: "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
name: "缺口补足线",
start: "start",
nodes: [
{ id: "start", type: "START" },
{
id: "n1",
type: "APPROVAL",
name: "一级审批",
assignee: { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] },
countersign: "ANY",
displayMode: "DEPT",
displayLabel: "校长室",
},
{
id: "n2",
type: "APPROVAL",
name: "二级审批",
assignee: { type: "USERS", userIds: [101, 102] },
countersign: "ALL",
assignPolicy: { mode: "SINGLE", required: true },
},
{
id: "n3",
type: "NOTIFY",
name: "抄送",
receivers: {
type: "DEPT",
deptRefs: [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
],
},
},
{ id: "end", type: "END" },
],
edges: [
{ from: "start", to: "n1" },
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
{ from: "n3", to: "end" },
],
variables: [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
],
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
});
test("部门引用与展示方式:载入→保存→再载入逐字段存活,且不写入名称快照", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
const json = toContentJson({
code: parsed.code,
name: parsed.name,
list: parsed.list,
settings: parsed.settings,
variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.deepEqual(n1.assignee, { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] });
assert.equal(n1.displayMode, "DEPT");
assert.equal(n1.displayLabel, "校长室");
assert.equal(n1.countersign, "ANY");
const n3: any = json.nodes.find((n) => n.id === "n3");
assert.deepEqual(n3.receivers.deptRefs, [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
]);
// 第二次往返不变形(保存-重载-再保存)
const reparsed = fromContentJson(JSON.parse(JSON.stringify(json)));
const again = toContentJson({
code: reparsed.code,
name: reparsed.name,
list: reparsed.list,
settings: reparsed.settings,
variables: reparsed.variables,
});
assert.deepEqual(again.nodes, json.nodes);
});
test("未配置展示方式的历史节点不写出 displayMode(旧定义字节兼容)", () => {
const legacy = JSON.parse(ORIGINAL);
delete legacy.nodes[1].displayMode;
delete legacy.nodes[1].displayLabel;
const parsed = fromContentJson(legacy);
const json = toContentJson({
code: parsed.code, name: parsed.name, list: parsed.list,
settings: parsed.settings, variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.equal("displayMode" in n1, false);
assert.equal("displayLabel" in n1, false);
});
test("D1 变量契约:required/enum 持久化,编辑态 enumText 不进 contentJson", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
assert.deepEqual(parsed.variables?.map((v) => v.key), ["days", "grade"]);
assert.equal(parsed.variables?.[0].required, true);
assert.deepEqual(parsed.variables?.[1].enum, ["g1", "g2"]);
const json = toContentJson({
code: "x", name: "x", list: parsed.list, settings: parsed.settings, variables: parsed.variables,
});
assert.deepEqual((json as any).variables, [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
]);
assert.equal("enumText" in ((json as any).variables[1]), false);
});
test("D1 新建流程显式声明空变量集(后端要求新发布版本必须声明)", () => {
const model = createEmptyModel();
const json = toContentJson(model);
assert.deepEqual((json as any).variables, []);
});
test("D1 逗号输入的值域归一为字符串数组,中文逗号同样切开", () => {
const model = createEmptyModel();
model.variables = [{ key: "grade", type: "string", required: false, enumText: "优,良, 中 ," }];
const json = toContentJson(model);
assert.deepEqual((json as any).variables, [{ key: "grade", type: "string", enum: ["优", "良", "中"] }]);
});
// ==================== F03 选人 iframe 载荷校验 ====================
const ORIGIN = "https://notice.example.edu.cn";
const WINDOW = {} as Window;
const CONTEXT = "ctx-1";
const FLOW = "fg-locked";
function frameEvent(data: unknown, origin = ORIGIN, source: Window | null = WINDOW) {
return { origin, source, data } as unknown as MessageEvent;
}
function confirmed(overrides: Record<string, unknown> = {}) {
return {
proto: "flow", v: 1, type: "assign-confirmed", contextId: CONTEXT,
flowGuid: FLOW, defVersion: 3,
assigns: [{ nodeId: "n1", userIds: [101] }],
...overrides,
};
}
function valid(event: MessageEvent) {
return isValidAssignMessage(event, ORIGIN, WINDOW, CONTEXT, FLOW);
}
test("F03:正向消息通过,且逐字段带出", () => {
const msg = valid(frameEvent(confirmed()));
assert.ok(msg);
assert.equal(msg?.flowGuid, FLOW);
assert.equal(msg?.defVersion, 3);
});
test("F03:origin/source/context 任一缺失或不符即丢弃(不再是「缺条件就跳过校验」)", () => {
assert.equal(valid(frameEvent(confirmed(), "https://evil.example")), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), ORIGIN, null, CONTEXT, FLOW), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), "", WINDOW, CONTEXT, FLOW), null);
assert.equal(valid(frameEvent(confirmed({ contextId: "stale-ctx" }))), null);
});
test("F03:异流程回传丢弃(消息 flowGuid 必须等于打开时锁定的流程)", () => {
assert.equal(valid(frameEvent(confirmed({ flowGuid: "fg-other" }))), null);
});
test("F03:畸形载荷整条拒绝——字符串版本、空节点、非数字 ID、缺 assigns", () => {
assert.equal(valid(frameEvent(confirmed({ defVersion: "3" }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 0 }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 1.5 }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "", userIds: [101] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: ["101"] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: [] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: "n1=101" }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: undefined }))), null);
});
test("F03:协议版本与类型闭集不被放宽", () => {
assert.equal(valid(frameEvent(confirmed({ v: 2 }))), null);
assert.equal(valid(frameEvent(confirmed({ proto: "fc" }))), null);
assert.equal(valid(frameEvent(confirmed({ type: "assign-mystery" }))), null);
assert.equal(valid(frameEvent({ nope: true })), null);
});
test("取消消息只需 context,不携带选择也不被要求带 flowGuid", () => {
const cancelled = { proto: "flow", v: 1, type: "assign-cancelled", contextId: CONTEXT };
const msg = valid(frameEvent(cancelled));
assert.equal(msg?.type, "assign-cancelled");
assert.equal(buildMessage(cancelled as any)?.type, "assign-cancelled");
});