Files
flow-center-front/tests/flow2-contract.test.ts
T
chenxing bed57846f4
build / build (push) Successful in 1m31s
feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
在 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)与依赖目录
2026-10-30 10:15:47 +08:00

210 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});