| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528 |
- """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, OaTask
- self.engine = create_engine("sqlite:///:memory:")
- # 仅建引擎相关表
- OaAppVersion.__table__.create(self.engine)
- OaInstance.__table__.create(self.engine)
- OaTask.__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,
- 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,
- create_time=ts,
- update_time=ts,
- comment_text=None,
- resolve_source="users",
- )
- inst = MagicMock(
- status="running",
- initiator_user_id="u0",
- 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,
- create_time=ts,
- update_time=ts,
- comment_text="不同意",
- resolve_source="users",
- )
- for status in ("rejected", "completed", "cancelled"):
- inst = MagicMock(
- status=status,
- initiator_user_id="u0",
- 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")
- if __name__ == "__main__":
- unittest.main()
|