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)与依赖目录
210 lines
8.7 KiB
TypeScript
210 lines
8.7 KiB
TypeScript
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");
|
||
});
|