test_institution.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """
  2. 费控制度模块专题测试。
  3. 解决策略:
  4. 1. 函数内 lazy import 避免模块级依赖
  5. 2. patch.object 对已导入类的实例方法打桩
  6. 3. patch.object 对导入后的模块全局变量打桩(绕过 @patch 字符串路径限制)
  7. """
  8. import json
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. pytestmark = pytest.mark.asyncio
  12. # ====================== 测试数据 ======================
  13. MOCK_INSTITUTION_ID = "inst_202605150001"
  14. MOCK_ENTERPRISE_ID = "ent_202605150001"
  15. MOCK_ISSUE_RULE_ID = "rule_202605150001"
  16. @pytest.fixture
  17. def mock_auth():
  18. """模拟认证上下文"""
  19. auth = MagicMock()
  20. auth.user.id = 1
  21. auth.tenant_id = "tenant_001"
  22. auth.db = AsyncMock()
  23. return auth
  24. # ====================== 费控制度 Service 测试 ======================
  25. class TestInstitutionCreate:
  26. """测试创建费控制度的串联流程"""
  27. async def test_create_full_flow_success(self, mock_auth):
  28. """正向场景:完整的创建流程(create + scope + issuerule + 本地保存)"""
  29. import app.plugin.module_payment.expense.institution.service as svc
  30. inst = svc.InstitutionService
  31. scope_svc = svc.InstitutionScopeService
  32. issuerule_svc = svc.IssueruleService
  33. with (
  34. patch.object(inst, "create_institution_service") as mock_create_inst,
  35. patch.object(scope_svc, "scope_modify_service") as mock_scope,
  36. patch.object(issuerule_svc, "create_issuerule_service") as mock_issuerule,
  37. patch.object(svc, "InstitutionCRUD") as mock_crud_cls,
  38. ):
  39. mock_create_inst.return_value = MagicMock(institution_id=MOCK_INSTITUTION_ID)
  40. mock_scope.return_value = {"result": True}
  41. mock_issuerule.return_value = {"issue_rule_id": MOCK_ISSUE_RULE_ID}
  42. mock_crud_instance = AsyncMock()
  43. mock_crud_cls.return_value = mock_crud_instance
  44. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionCreateModel import (
  45. AlipayEbppInvoiceInstitutionCreateModel,
  46. )
  47. model = AlipayEbppInvoiceInstitutionCreateModel()
  48. model.enterprise_id = MOCK_ENTERPRISE_ID
  49. model.institution_name = "测试制度"
  50. model.effective = "1"
  51. result = await inst.create_institution_full_flow(
  52. auth=mock_auth,
  53. institution_model=model,
  54. enterprise_id=MOCK_ENTERPRISE_ID,
  55. scope_data={"adapter_type": "EMPLOYEE_ALL"},
  56. issuerule_data={
  57. "quota_type": "CAP",
  58. "issue_type": "ISSUE_MONTH",
  59. "issue_amount_value": "1000",
  60. "issue_rule_name": "测试制度-发放规则",
  61. },
  62. )
  63. assert result["institution_id"] == MOCK_INSTITUTION_ID
  64. assert result["scope_modified"] is True
  65. assert result["issue_rule_id"] == MOCK_ISSUE_RULE_ID
  66. mock_create_inst.assert_called_once()
  67. mock_scope.assert_called_once()
  68. mock_issuerule.assert_called_once()
  69. mock_crud_instance.create.assert_called_once()
  70. async def test_create_without_scope_and_issuerule(self, mock_auth):
  71. """边界场景:不传 scope 和 issuerule 时只调 create"""
  72. import app.plugin.module_payment.expense.institution.service as svc
  73. inst = svc.InstitutionService
  74. scope_svc = svc.InstitutionScopeService
  75. issuerule_svc = svc.IssueruleService
  76. with (
  77. patch.object(inst, "create_institution_service") as mock_create_inst,
  78. patch.object(scope_svc, "scope_modify_service") as mock_scope,
  79. patch.object(issuerule_svc, "create_issuerule_service") as mock_issuerule,
  80. patch.object(svc, "InstitutionCRUD") as mock_crud_cls,
  81. ):
  82. mock_create_inst.return_value = MagicMock(institution_id=MOCK_INSTITUTION_ID)
  83. mock_crud_instance = AsyncMock()
  84. mock_crud_cls.return_value = mock_crud_instance
  85. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionCreateModel import (
  86. AlipayEbppInvoiceInstitutionCreateModel,
  87. )
  88. model = AlipayEbppInvoiceInstitutionCreateModel()
  89. model.enterprise_id = MOCK_ENTERPRISE_ID
  90. model.institution_name = "无成员无规则制度"
  91. result = await inst.create_institution_full_flow(
  92. auth=mock_auth,
  93. institution_model=model,
  94. enterprise_id=MOCK_ENTERPRISE_ID,
  95. scope_data=None,
  96. issuerule_data=None,
  97. )
  98. assert result["institution_id"] == MOCK_INSTITUTION_ID
  99. assert result["scope_modified"] is False
  100. assert result["issue_rule_id"] is None
  101. mock_scope.assert_not_called()
  102. mock_issuerule.assert_not_called()
  103. async def test_create_rollback_on_failure(self, mock_auth):
  104. """异常场景:scope 失败后触发回滚(删除已创建的制度)"""
  105. import app.plugin.module_payment.expense.institution.service as svc
  106. inst = svc.InstitutionService
  107. scope_svc = svc.InstitutionScopeService
  108. with (
  109. patch.object(inst, "create_institution_service") as mock_create_inst,
  110. patch.object(scope_svc, "scope_modify_service") as mock_scope,
  111. ):
  112. mock_create_inst.return_value = MagicMock(institution_id=MOCK_INSTITUTION_ID)
  113. mock_scope.side_effect = Exception("支付宝接口异常")
  114. with patch("asyncio.to_thread") as mock_to_thread:
  115. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionCreateModel import (
  116. AlipayEbppInvoiceInstitutionCreateModel,
  117. )
  118. model = AlipayEbppInvoiceInstitutionCreateModel()
  119. model.enterprise_id = MOCK_ENTERPRISE_ID
  120. model.institution_name = "测试回滚制度"
  121. with pytest.raises(Exception, match="支付宝接口异常"):
  122. await inst.create_institution_full_flow(
  123. auth=mock_auth,
  124. institution_model=model,
  125. enterprise_id=MOCK_ENTERPRISE_ID,
  126. scope_data={"adapter_type": "EMPLOYEE_ALL"},
  127. issuerule_data=None,
  128. )
  129. assert mock_to_thread.called, "回滚时应该调用了 asyncio.to_thread"
  130. class TestInstitutionDelete:
  131. """测试删除费控制度"""
  132. async def test_delete_sync_local_db(self, mock_auth):
  133. """正向场景:删除成功后同步删除本地记录"""
  134. import app.plugin.module_payment.expense.institution.service as svc
  135. from alipay.aop.api.domain.AlipayEbppInvoiceInstitutionDeleteModel import (
  136. AlipayEbppInvoiceInstitutionDeleteModel,
  137. )
  138. inst = svc.InstitutionService
  139. with (
  140. patch.object(inst, "_execute_alipay") as mock_execute,
  141. patch.object(svc, "InstitutionCRUD") as mock_crud_cls,
  142. ):
  143. mock_execute.return_value = json.dumps({
  144. "alipay_ebpp_invoice_institution_delete_response": {
  145. "code": "10000", "msg": "Success",
  146. }
  147. })
  148. mock_obj = MagicMock()
  149. mock_obj.id = 1
  150. mock_crud_instance = AsyncMock()
  151. mock_crud_instance.get = AsyncMock(return_value=mock_obj)
  152. mock_crud_cls.return_value = mock_crud_instance
  153. delete_model = AlipayEbppInvoiceInstitutionDeleteModel()
  154. delete_model.institution_id = MOCK_INSTITUTION_ID
  155. delete_model.enterprise_id = MOCK_ENTERPRISE_ID
  156. result = await inst.delete_institution_service(auth=mock_auth, data=delete_model)
  157. assert result is not None
  158. mock_crud_instance.get.assert_called_with(institution_id=MOCK_INSTITUTION_ID)
  159. mock_crud_instance.delete.assert_called_with(ids=[mock_obj.id])
  160. class TestIssueruleConstraint:
  161. """测试自动发放规则的参数约束"""
  162. async def test_cap_must_be_accumulative(self, mock_auth):
  163. """余额类型(CAP)必须为可累计(invalid_mode=1)"""
  164. from app.core.exceptions import CustomException
  165. import app.plugin.module_payment.expense.institution.service as svc
  166. with patch.object(svc.IssueruleService, "_execute_alipay") as mock_execute:
  167. with pytest.raises(CustomException, match="余额类型.*必须为可累计"):
  168. await svc.IssueruleService.create_issuerule_service(
  169. auth=mock_auth,
  170. institution_id=MOCK_INSTITUTION_ID,
  171. enterprise_id=MOCK_ENTERPRISE_ID,
  172. quota_type="CAP",
  173. issue_type="ISSUE_MONTH",
  174. issue_amount_value="500",
  175. invalid_mode=0,
  176. )
  177. mock_execute.assert_not_called()
  178. async def test_count_cannot_transfer(self, mock_auth):
  179. """次卡类型(COUNT)不可转赠(share_mode=0)"""
  180. from app.core.exceptions import CustomException
  181. import app.plugin.module_payment.expense.institution.service as svc
  182. with patch.object(svc.IssueruleService, "_execute_alipay") as mock_execute:
  183. with pytest.raises(CustomException, match="次卡类型.*不可转赠"):
  184. await svc.IssueruleService.create_issuerule_service(
  185. auth=mock_auth,
  186. institution_id=MOCK_INSTITUTION_ID,
  187. enterprise_id=MOCK_ENTERPRISE_ID,
  188. quota_type="COUNT",
  189. issue_type="ISSUE_MONTH",
  190. issue_amount_value="10",
  191. share_mode=1,
  192. )
  193. mock_execute.assert_not_called()
  194. # ====================== Scope Sync 测试 ======================
  195. class TestScopeSync:
  196. """测试员工/部门停用联动"""
  197. async def test_remove_employee(self, mock_auth):
  198. """解约员工时:从所有引用该员工的制度中移除"""
  199. import app.plugin.module_payment.expense.institution.scope_sync as sync
  200. import app.plugin.module_payment.expense.institution.service as svc
  201. with (
  202. patch.object(sync, "InstitutionCRUD") as mock_crud_cls,
  203. patch.object(svc.InstitutionScopeService, "scope_modify_service") as mock_scope_modify,
  204. ):
  205. mock_inst_1 = MagicMock()
  206. mock_inst_1.institution_id = "inst_001"
  207. mock_inst_2 = MagicMock()
  208. mock_inst_2.institution_id = "inst_002"
  209. mock_crud_instance = AsyncMock()
  210. mock_crud_instance.list = AsyncMock(return_value=[mock_inst_1, mock_inst_2])
  211. mock_crud_cls.return_value = mock_crud_instance
  212. await sync.remove_employee_from_institution_scopes(
  213. auth=mock_auth,
  214. enterprise_id=MOCK_ENTERPRISE_ID,
  215. employee_id="emp_001",
  216. )
  217. assert mock_scope_modify.call_count == 2
  218. mock_scope_modify.assert_any_call(
  219. auth=mock_auth,
  220. institution_id="inst_001",
  221. data={
  222. "enterprise_id": MOCK_ENTERPRISE_ID,
  223. "adapter_type": "EMPLOYEE_SELECT",
  224. "delete_owner_id_list": ["emp_001"],
  225. },
  226. )
  227. async def test_remove_department(self, mock_auth):
  228. """停用部门时:从所有引用该部门的制度中移除"""
  229. import app.plugin.module_payment.expense.institution.scope_sync as sync
  230. import app.plugin.module_payment.expense.institution.service as svc
  231. with (
  232. patch.object(sync, "InstitutionCRUD") as mock_crud_cls,
  233. patch.object(svc.InstitutionScopeService, "scope_modify_service") as mock_scope_modify,
  234. ):
  235. mock_inst = MagicMock()
  236. mock_inst.institution_id = "inst_001"
  237. mock_crud_instance = AsyncMock()
  238. mock_crud_instance.list = AsyncMock(return_value=[mock_inst])
  239. mock_crud_cls.return_value = mock_crud_instance
  240. await sync.remove_department_from_institution_scopes(
  241. auth=mock_auth,
  242. enterprise_id=MOCK_ENTERPRISE_ID,
  243. department_id="dept_001",
  244. )
  245. mock_scope_modify.assert_called_once_with(
  246. auth=mock_auth,
  247. institution_id="inst_001",
  248. data={
  249. "enterprise_id": MOCK_ENTERPRISE_ID,
  250. "adapter_type": "EMPLOYEE_DEPARTMENT",
  251. "delete_owner_id_list": ["dept_001"],
  252. },
  253. )