test_duty_schedule.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """值班排班服务单测"""
  2. from datetime import date
  3. from types import SimpleNamespace
  4. from unittest.mock import MagicMock
  5. import pytest
  6. from app.models.duty_models import (
  7. LINK_CLOSED,
  8. LINK_OPEN,
  9. SHIFT_EVENING,
  10. SHIFT_LABELS,
  11. SHIFT_MORNING,
  12. SOURCE_GRAB,
  13. )
  14. from app.schemas.duty import DutyAssignmentIn, DutyDaySaveBody, DutySlotSaveIn
  15. from app.services.duty_schedule import (
  16. DutyBizError,
  17. grab,
  18. normalize_person_name,
  19. save_day,
  20. )
  21. def test_normalize_person_name_ok():
  22. assert normalize_person_name(" 张三 ") == "张三"
  23. def test_normalize_person_name_empty():
  24. with pytest.raises(DutyBizError, match="姓名"):
  25. normalize_person_name(" ")
  26. def test_normalize_person_name_too_long():
  27. with pytest.raises(DutyBizError, match="姓名"):
  28. normalize_person_name("啊" * 21)
  29. def test_shift_labels():
  30. assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
  31. def test_grab_rejects_when_full():
  32. db = MagicMock()
  33. day = SimpleNamespace(
  34. id="day1",
  35. tenant_no="t1",
  36. link_status=LINK_OPEN,
  37. duty_date=date(2026, 8, 20),
  38. access_token="tok",
  39. del_flag=0,
  40. )
  41. slot = SimpleNamespace(
  42. id="slot1",
  43. duty_day_id="day1",
  44. shift_type=SHIFT_EVENING,
  45. capacity=1,
  46. del_flag=0,
  47. )
  48. user = SimpleNamespace(
  49. id="u1",
  50. tenant_no="t1",
  51. nick_name="李四",
  52. user_no="lisi",
  53. status="1",
  54. del_flag=0,
  55. )
  56. day_q = MagicMock()
  57. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  58. slot_q = MagicMock()
  59. slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
  60. user_q = MagicMock()
  61. user_q.filter.return_value.first.return_value = user
  62. def query_side_effect(model):
  63. name = getattr(model, "__name__", str(model))
  64. if name == "DutyDay":
  65. return day_q
  66. if name == "DutySlot":
  67. return slot_q
  68. if name == "SysUser":
  69. return user_q
  70. # DutyAssignment count / duplicate
  71. aq = MagicMock()
  72. aq.filter.return_value.count.return_value = 1
  73. aq.filter.return_value.first.return_value = None
  74. return aq
  75. db.query.side_effect = query_side_effect
  76. with pytest.raises(DutyBizError, match="名额已满"):
  77. grab(db, "tok", SHIFT_EVENING, "u1")
  78. def test_grab_rejects_when_closed():
  79. db = MagicMock()
  80. day = SimpleNamespace(
  81. id="day1",
  82. tenant_no="t1",
  83. link_status=LINK_CLOSED,
  84. duty_date=date(2026, 8, 20),
  85. access_token="tok",
  86. del_flag=0,
  87. )
  88. day_q = MagicMock()
  89. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  90. db.query.return_value = day_q
  91. with pytest.raises(DutyBizError, match="关闭"):
  92. grab(db, "tok", SHIFT_EVENING, "u1")
  93. def test_grab_rejects_invalid_user():
  94. db = MagicMock()
  95. day = SimpleNamespace(
  96. id="day1",
  97. tenant_no="t1",
  98. link_status=LINK_OPEN,
  99. duty_date=date(2026, 8, 20),
  100. access_token="tok",
  101. del_flag=0,
  102. )
  103. day_q = MagicMock()
  104. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  105. user_q = MagicMock()
  106. user_q.filter.return_value.first.return_value = None
  107. def query_side_effect(model):
  108. name = getattr(model, "__name__", str(model))
  109. if name == "DutyDay":
  110. return day_q
  111. if name == "SysUser":
  112. return user_q
  113. return MagicMock()
  114. db.query.side_effect = query_side_effect
  115. with pytest.raises(DutyBizError, match="用户|无效|不存在"):
  116. grab(db, "tok", SHIFT_EVENING, "bad-user")
  117. def test_grab_rejects_duplicate_same_shift():
  118. db = MagicMock()
  119. day = SimpleNamespace(
  120. id="day1",
  121. tenant_no="t1",
  122. link_status=LINK_OPEN,
  123. duty_date=date(2026, 8, 20),
  124. access_token="tok",
  125. del_flag=0,
  126. )
  127. slot = SimpleNamespace(
  128. id="slot1",
  129. duty_day_id="day1",
  130. shift_type=SHIFT_EVENING,
  131. capacity=2,
  132. del_flag=0,
  133. )
  134. user = SimpleNamespace(
  135. id="u1",
  136. tenant_no="t1",
  137. nick_name="李四",
  138. user_no="lisi",
  139. status="1",
  140. del_flag=0,
  141. )
  142. existing = SimpleNamespace(id="a1", user_id="u1", del_flag=0)
  143. day_q = MagicMock()
  144. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  145. slot_q = MagicMock()
  146. slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
  147. user_q = MagicMock()
  148. user_q.filter.return_value.first.return_value = user
  149. assign_calls = {"n": 0}
  150. def query_side_effect(model):
  151. name = getattr(model, "__name__", str(model))
  152. if name == "DutyDay":
  153. return day_q
  154. if name == "DutySlot":
  155. return slot_q
  156. if name == "SysUser":
  157. return user_q
  158. aq = MagicMock()
  159. # first: duplicate check .first(); later: count
  160. assign_calls["n"] += 1
  161. aq.filter.return_value.first.return_value = existing
  162. aq.filter.return_value.count.return_value = 1
  163. return aq
  164. db.query.side_effect = query_side_effect
  165. with pytest.raises(DutyBizError, match="已报名"):
  166. grab(db, "tok", SHIFT_EVENING, "u1")
  167. def test_save_day_rejects_shrink_below_grab_count():
  168. """缩容:已有 1 人抢班时 capacity=1 且再指定 1 人应失败。"""
  169. db = MagicMock()
  170. day = SimpleNamespace(
  171. id="day1",
  172. duty_date=date(2026, 8, 20),
  173. access_token="tok",
  174. link_status=LINK_OPEN,
  175. tenant_no="t1",
  176. del_flag=0,
  177. update_by=None,
  178. update_time=None,
  179. )
  180. slot = SimpleNamespace(
  181. id="slot1",
  182. duty_day_id="day1",
  183. shift_type=SHIFT_EVENING,
  184. capacity=2,
  185. del_flag=0,
  186. order_num=1,
  187. update_by=None,
  188. update_time=None,
  189. )
  190. grab_row = SimpleNamespace(
  191. id="a1",
  192. duty_slot_id="slot1",
  193. source=SOURCE_GRAB,
  194. del_flag=0,
  195. update_by=None,
  196. update_time=None,
  197. )
  198. day_q = MagicMock()
  199. day_q.filter.return_value.first.return_value = day
  200. slot_list_q = MagicMock()
  201. slot_list_q.filter.return_value.all.return_value = [slot]
  202. assign_q = MagicMock()
  203. assign_q.filter.return_value.all.return_value = [grab_row]
  204. count_q = MagicMock()
  205. count_q.filter.return_value.count.return_value = 1
  206. def query_side_effect(model):
  207. name = getattr(model, "__name__", "")
  208. if name == "DutyDay":
  209. return day_q
  210. if name == "DutySlot":
  211. return slot_list_q
  212. if name == "DutyAssignment":
  213. # first call in loop may be count, then all
  214. m = MagicMock()
  215. m.filter.return_value.count.return_value = 1
  216. m.filter.return_value.all.return_value = [grab_row]
  217. return m
  218. return MagicMock()
  219. db.query.side_effect = query_side_effect
  220. body = DutyDaySaveBody(
  221. dutyDate=date(2026, 8, 20),
  222. slots=[
  223. DutySlotSaveIn(
  224. shiftType=SHIFT_EVENING,
  225. capacity=1,
  226. assignments=[
  227. DutyAssignmentIn(source="assign", userId="u1", personName="张三")
  228. ],
  229. )
  230. ],
  231. )
  232. with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
  233. save_day(db, "t1", {"user_id": "op1"}, body)