Files
flow-center-front/tests/components/assign-embed.component.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

289 lines
13 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus from "view-ui-plus";
import { nextTick } from "vue";
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
/**
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
*/
enableAutoUnmount(afterEach);
const PROTO = "flow";
const VERSION = 1;
const APP_ORIGIN = window.location.origin;
function baseData(over: Record<string, unknown> = {}) {
return {
proto: PROTO,
v: VERSION,
type: "assign-confirmed",
contextId: "",
flowGuid: "g-embed",
defVersion: 3,
assigns: [{ nodeId: "u2", userIds: [101] }],
...over,
};
}
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
function frameEl(): HTMLIFrameElement | null {
return document.querySelector("iframe") as HTMLIFrameElement | null;
}
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
const frame = frameEl() as HTMLIFrameElement;
const ev = new MessageEvent("message", {
origin: opts.origin ?? APP_ORIGIN,
source: (opts.source ?? frame.contentWindow) as Window | null,
data,
});
window.dispatchEvent(ev);
return nextTick();
}
function mountHost(props: Record<string, unknown> = {}) {
return mount(FlowAssignEmbed, {
props: { flowGuid: "g-embed", ...props },
global: { plugins: [ViewUIPlus] },
attachTo: document.body,
});
}
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
async function openHost(wrapper: VueWrapper) {
(wrapper.vm as any).open();
await nextTick();
await flushPromises();
const frame = frameEl() as HTMLIFrameElement;
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const src = frameEl()!.src;
expect(src.split("#").length - 1).toBe(1);
expect(src).toContain("#/assign-preview");
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
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 () => {
const addSpy = vi.spyOn(window, "addEventListener");
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")).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);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
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;
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();
});
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
const wrapper = mountHost();
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();
});
});
describe("FlowAssignEmbed 生命周期与选择语义", () => {
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
expect(wrapper.emitted("cancelled")).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
const wrapper = mountHost();
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: [999] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
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);
(wrapper.vm as any).close();
await nextTick();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
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 }));
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
await openHost(wrapper);
const cid2 = (wrapper.vm as any).contextId;
expect(cid2).not.toEqual(cid1);
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(2);
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
});
it("卸载后监听器摘掉,后续消息不再回传", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
const frame = frameEl() as HTMLIFrameElement;
wrapper.unmount();
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
window.dispatchEvent(new MessageEvent("message", {
origin: APP_ORIGIN,
source: frame.contentWindow as Window,
data: baseData({ contextId: cid }),
}));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
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);
});
});