Files
zkxw/backend/tests/test_oa_engine.py
T
zlwuandCursor c9711d0dce feat(oa): terminate on return and copy form to start a new instance
Align with DingTalk: returned/rejected/cancelled are terminal; progress
shows return on the approver task only; done list includes returned;
deprecate same-instance resubmit and source-instance linking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 15:19:20 +08:00

745 lines
24 KiB
Python
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.
"""OA 引擎单元测试(纯逻辑 + 轻量 mock)"""
import json
import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch
from app.services.oa_engine import (
OaEngineError,
_find_node,
_flow_node_key_order,
_match_condition_group,
_sort_tasks_for_detail,
advance_from,
)
class TestOaEngineHelpers(unittest.TestCase):
def test_find_node(self):
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
}
self.assertEqual(_find_node(root, "a1")["id"], "a1")
self.assertEqual(_find_node(root, "e1")["id"], "e1")
self.assertIsNone(_find_node(root, "x"))
def test_condition_match(self):
groups = [
{
"name": "gt3",
"when": "{days} > 3",
"child": {"id": "n1", "type": "end"},
},
{"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
]
g = _match_condition_group(groups, {"days": 5})
self.assertEqual(g["name"], "gt3")
g2 = _match_condition_group(groups, {"days": 1})
self.assertEqual(g2["name"], "default")
def test_flow_node_key_order_cc_before_approval(self):
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
},
}
order = _flow_node_key_order(root)
self.assertLess(order["c1"], order["a1"])
def test_sort_tasks_by_flow_when_same_create_time(self):
"""抄送先于审批设计时,即使 create_time / order_num 相同也应按树序展示。"""
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
approval = MagicMock(node_key="a1", order_num=0, create_time=ts, id="AAAA")
cc = MagicMock(node_key="c1", order_num=0, create_time=ts, id="BBBB")
# 故意把审批放在列表前面,模拟 create_time 同秒时 PK 乱序
sorted_tasks = _sort_tasks_for_detail([approval, cc], flow_root=root)
self.assertEqual([t.node_key for t in sorted_tasks], ["c1", "a1"])
class TestAdvanceFrom(unittest.TestCase):
def test_linear_creates_approval_then_stops(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "负责人",
"approverConfig": {"assigneeType": "dept_leader"},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
)
ver = MagicMock(
flow_tree_json=json.dumps(flow),
advanced_settings_json=json.dumps({"supervisorFallbackToDeptLeader": True}),
)
fake_task = MagicMock()
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch(
"app.services.oa_engine._create_tasks_for_node",
return_value=[fake_task],
) as mock_create:
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
mock_create.assert_called_once()
self.assertEqual(inst.status, "running")
self.assertEqual(mock_create.call_args.kwargs.get("order_num_start"), 1)
def test_complete_path_to_end_via_cc(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"name": "抄送",
"approverConfig": {"assigneeType": "users", "userIds": ["u9"]},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
update_time=datetime.now(),
)
ver = MagicMock(
flow_tree_json=json.dumps(flow),
advanced_settings_json="{}",
)
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch(
"app.services.oa_engine._create_tasks_for_node",
return_value=[MagicMock()],
):
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
self.assertEqual(inst.status, "completed")
def test_empty_assignee_raises(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"approverConfig": {"assigneeType": "dept_leader"},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
)
ver = MagicMock(flow_tree_json=json.dumps(flow), advanced_settings_json="{}")
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch("app.services.oa_engine._create_tasks_for_node", return_value=[]):
with self.assertRaises(OaEngineError):
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
class TestAddSign(unittest.TestCase):
def test_add_sign_rejects_when_disabled(self):
from app.services.oa_engine import add_sign
db = MagicMock()
task = MagicMock(
id="t1",
assignee_user_id="u1",
task_type="approval",
status="pending",
instance_id="i1",
node_key="a1",
node_name="审批",
sign_mode="or",
comment_text=None,
)
inst = MagicMock(id="i1", status="running", initiator_dept_id="d1")
ver = MagicMock(
id="v1",
advanced_settings_json=json.dumps({"allowAddSign": False}),
del_flag=0,
)
db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
with self.assertRaises(OaEngineError) as ctx:
add_sign(
db,
"t1",
tenant_no="zkxw",
user_id="u1",
user_ids=["u2"],
)
self.assertIn("不允许加签", str(ctx.exception))
def test_add_sign_requires_users(self):
from app.services.oa_engine import add_sign
db = MagicMock()
task = MagicMock(
id="t1",
assignee_user_id="u1",
task_type="approval",
status="pending",
instance_id="i1",
)
inst = MagicMock(id="i1", status="running")
ver = MagicMock(advanced_settings_json="{}", del_flag=0)
db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
with self.assertRaises(OaEngineError) as ctx:
add_sign(db, "t1", tenant_no="zkxw", user_id="u1", user_ids=[])
self.assertIn("加签人", str(ctx.exception))
class TestCompleteTaskOrSign(unittest.TestCase):
"""回归:Session autoflush=False 时,或签取消同节点待办不可覆盖本人已同意状态。"""
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
self.engine = create_engine("sqlite:///:memory:")
# 仅建引擎相关表
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaTask.__table__.create(self.engine)
OaInstanceEvent.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.Base = Base
self.OaAppVersion = OaAppVersion
self.OaInstance = OaInstance
self.OaTask = OaTask
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"approverConfig": {"signMode": "or"},
"child": {"id": "e1", "type": "end"},
},
}
now = datetime.now()
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json=json.dumps(flow),
advanced_settings_json="{}",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaInstance(
id="i1",
app_id="app1",
app_version_id="v1",
title="单人审批",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaTask(
id="t1",
instance_id="i1",
node_key="a1",
node_name="审批人",
task_type="approval",
assignee_user_id="u1",
status="pending",
sign_mode="or",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_approve_keeps_approved_not_done(self):
from app.services.oa_engine import complete_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "completed"},
):
complete_task(
self.db,
"t1",
tenant_no="zkxw",
user_id="u1",
action="approve",
comment="同意",
)
task = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
self.assertEqual(task.status, "approved")
self.assertEqual(task.comment_text, "同意")
# 已处理列表应能查到
from app.services.oa_engine import list_tasks
done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")
self.assertEqual(len(done), 1)
self.assertEqual(done[0]["status"], "approved")
def test_or_sign_cancels_siblings_only(self):
from app.services.oa_engine import complete_task
now = datetime.now()
self.db.add(
self.OaTask(
id="t2",
instance_id="i1",
node_key="a1",
node_name="审批人",
task_type="approval",
assignee_user_id="u2",
status="pending",
sign_mode="or",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.commit()
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "completed"},
):
complete_task(
self.db,
"t1",
tenant_no="zkxw",
user_id="u1",
action="approve",
comment="同意",
)
t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
t2 = self.db.query(self.OaTask).filter(self.OaTask.id == "t2").first()
self.assertEqual(t1.status, "approved")
self.assertEqual(t2.status, "done")
class TestBuildProgress(unittest.TestCase):
def test_cc_before_approval_and_pending_ahead(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"name": "抄送人",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {
"id": "a2",
"type": "approval",
"name": "总经理",
"child": {"id": "e1", "type": "end"},
},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
# 审批任务 create 同秒但列表故意审批在前
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="pending",
assignee_user_id="u2",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
t_cc = MagicMock(
id="tC",
node_key="c1",
node_name="抄送人",
task_type="cc",
status="done",
assignee_user_id="u9",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
inst = MagicMock(
status="running",
initiator_user_id="u0",
current_round=1,
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval, t_cc],
name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"},
events=[], # P0 可空
)
kinds = [x["kind"] for x in items]
self.assertEqual(kinds[0], "submit")
# 已产生:cc 再 approval
self.assertEqual(
[x["nodeKey"] for x in items if x["kind"] == "task"],
["c1", "a1"],
)
# running 时未到达 a2
pending = [x for x in items if x["kind"] == "pending_node"]
self.assertTrue(any(p["nodeKey"] == "a2" for p in pending))
def test_terminal_status_excludes_pending_nodes(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {
"id": "a2",
"type": "approval",
"name": "总经理",
"child": {"id": "e1", "type": "end"},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="rejected",
assignee_user_id="u2",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text="不同意",
resolve_source="users",
)
for status in ("rejected", "completed", "cancelled", "returned"):
inst = MagicMock(
status=status,
initiator_user_id="u0",
current_round=1,
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval],
name_map={"u0": "发起人甲", "u2": "审批乙"},
events=[],
)
pending = [x for x in items if x["kind"] == "pending_node"]
self.assertEqual(pending, [], f"status={status} should not have pending_node")
terminal = [x for x in items if x["kind"] == "terminal"]
self.assertEqual(len(terminal), 1, f"status={status} should have one terminal")
def test_progress_returned_has_no_kind_return_node(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {"id": "e1", "type": "end"},
},
}
ts = datetime(2026, 7, 15, 14, 58, 8)
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="returned",
assignee_user_id="u2",
order_num=1,
round_no=1,
create_time=ts,
update_time=ts,
comment_text="打回",
resolve_source="users",
)
ret_ev = MagicMock(
event_type="return",
round_no=1,
actor_user_id="u2",
create_time=ts,
comment_text="打回",
node_key="a1",
task_id="tA",
)
inst = MagicMock(
status="returned",
initiator_user_id="u0",
current_round=1,
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval],
name_map={"u0": "发起人", "u2": "审批人"},
events=[ret_ev],
)
self.assertFalse(any(x["kind"] == "return" for x in items))
tasks = [x for x in items if x["kind"] == "task"]
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0]["status"], "returned")
self.assertEqual(tasks[0]["commentText"], "打回")
terminals = [x for x in items if x["kind"] == "terminal"]
self.assertEqual(len(terminals), 1)
self.assertEqual(terminals[0]["status"], "returned")
class TestReturnAndResubmit(unittest.TestCase):
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
self.engine = create_engine("sqlite:///:memory:")
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaTask.__table__.create(self.engine)
OaInstanceEvent.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.OaInstance = OaInstance
self.OaTask = OaTask
self.OaInstanceEvent = OaInstanceEvent
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"approverConfig": {
"assigneeType": "users",
"userIds": ["u1"],
"signMode": "or",
},
"child": {"id": "e1", "type": "end"},
},
}
now = datetime.now()
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json=json.dumps(flow),
advanced_settings_json=json.dumps({"allowReturn": True}),
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaInstance(
id="i1",
app_id="app1",
app_version_id="v1",
title="打回测",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
current_round=1,
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaTask(
id="t1",
instance_id="i1",
node_key="a1",
node_name="审批人",
task_type="approval",
assignee_user_id="u1",
status="pending",
sign_mode="or",
round_no=1,
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_return_sets_returned_and_event(self):
from app.services.oa_engine import return_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "returned"},
):
return_task(
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
)
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
task = self.db.query(self.OaTask).filter_by(id="t1").first()
ev = (
self.db.query(self.OaInstanceEvent)
.filter_by(instance_id="i1", event_type="return")
.first()
)
self.assertEqual(inst.status, "returned")
self.assertEqual(task.status, "returned")
self.assertIsNotNone(ev)
self.assertEqual(ev.comment_text, "请补充材料")
def test_resubmit_raises_deprecated(self):
from app.services.oa_engine import OaEngineError, resubmit_instance, return_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "returned"},
):
return_task(
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
)
with self.assertRaises(OaEngineError) as ctx:
resubmit_instance(
self.db,
"i1",
tenant_no="zkxw",
user_id="u0",
form_data={"x": 1},
)
self.assertIn("废弃", str(ctx.exception))
def test_done_list_includes_returned(self):
from app.services.oa_engine import list_tasks, return_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "returned"},
):
return_task(
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
)
done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")
self.assertEqual(len(done), 1)
self.assertEqual(done[0]["status"], "returned")
if __name__ == "__main__":
unittest.main()