test_document_relations.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """方案关联关系:预置配置/载荷校验单元测试 + HTTP 集成测试。"""
  2. from __future__ import annotations
  3. import io
  4. import json
  5. import pytest
  6. from dms.common.errors import InvalidArgumentError
  7. from dms.config import DEFAULT_DOCUMENT_RELATION_PRESET_CONFIG_PATH
  8. from dms.services.document_relation_service import (
  9. DocumentRelationPresetError,
  10. _relations_payload,
  11. load_relation_presets,
  12. )
  13. # ---------------------------------------------------------------------------
  14. # 纯单元测试(无数据库依赖)
  15. # ---------------------------------------------------------------------------
  16. def test_default_relation_presets_load_in_sorted_order():
  17. presets = load_relation_presets(DEFAULT_DOCUMENT_RELATION_PRESET_CONFIG_PATH)
  18. assert [(p.code, p.label, p.sort_no) for p in presets] == [
  19. ("BASIS", "依据", 10),
  20. ("SUPPORT", "支撑", 20),
  21. ("REFERENCE", "参照", 30),
  22. ]
  23. def _write(tmp_path, content: str):
  24. path = tmp_path / "presets.json"
  25. path.write_text(content, encoding="utf-8")
  26. return path
  27. @pytest.mark.parametrize(
  28. "content",
  29. [
  30. '{"presets": []}', # 空清单
  31. '{"presets": [{"code": "A", "label": "依据"}]}', # 缺sortNo
  32. '{"presets": [{"code": "A", "label": "依据", "sortNo": 1, "extra": 1}]}', # 多余字段
  33. '{"presets": [{"code": "", "label": "依据", "sortNo": 1}]}', # 空code
  34. '{"presets": [{"code": "A", "label": " ", "sortNo": 1}]}', # 空label
  35. '{"presets": [{"code": "A", "label": "依据", "sortNo": "10"}]}', # 非整数
  36. '{"items": []}', # 根节点形状不符
  37. "not-json", # 非JSON
  38. ],
  39. )
  40. def test_load_relation_presets_rejects_invalid(tmp_path, content):
  41. with pytest.raises(DocumentRelationPresetError):
  42. load_relation_presets(_write(tmp_path, content))
  43. def test_load_relation_presets_rejects_duplicate_code_or_label(tmp_path):
  44. content = json.dumps(
  45. {
  46. "presets": [
  47. {"code": "A", "label": "依据", "sortNo": 1},
  48. {"code": "A", "label": "支撑", "sortNo": 2},
  49. ]
  50. },
  51. ensure_ascii=False,
  52. )
  53. with pytest.raises(DocumentRelationPresetError):
  54. load_relation_presets(_write(tmp_path, content))
  55. content = json.dumps(
  56. {
  57. "presets": [
  58. {"code": "A", "label": "依据", "sortNo": 1},
  59. {"code": "B", "label": "依据", "sortNo": 2},
  60. ]
  61. },
  62. ensure_ascii=False,
  63. )
  64. with pytest.raises(DocumentRelationPresetError):
  65. load_relation_presets(_write(tmp_path, content))
  66. def test_load_relation_presets_missing_file(tmp_path):
  67. with pytest.raises(DocumentRelationPresetError):
  68. load_relation_presets(tmp_path / "absent.json")
  69. @pytest.mark.parametrize(
  70. "payload",
  71. [
  72. None,
  73. [],
  74. {"relations": []}, # 缺rowVersion
  75. {"rowVersion": 0}, # 缺relations
  76. {"relations": [], "rowVersion": 0, "extra": 1}, # 多余字段
  77. {"relations": {}, "rowVersion": 0},
  78. {"relations": [{"relatedDocumentId": "1"}], "rowVersion": 0}, # 缺relationType
  79. {"relations": [{"relationType": "依据"}], "rowVersion": 0}, # 缺relatedDocumentId
  80. {"relations": [{"relatedDocumentId": 1, "relationType": "依据"}], "rowVersion": 0},
  81. {"relations": [{"relatedDocumentId": "x", "relationType": "依据"}], "rowVersion": 0},
  82. {"relations": [{"relatedDocumentId": "1", "relationType": ""}], "rowVersion": 0},
  83. {"relations": [{"relatedDocumentId": "1", "relationType": " "}], "rowVersion": 0},
  84. {"relations": [{"relatedDocumentId": "1", "relationType": "依" * 65}], "rowVersion": 0},
  85. {"relations": [{"relatedDocumentId": "1", "relationType": "依据"}, {"relatedDocumentId": "1", "relationType": "支撑"}], "rowVersion": 0}, # 重复目标
  86. {"relations": [{"relatedDocumentId": "1", "relationType": "依据"}], "rowVersion": -1},
  87. {"relations": [{"relatedDocumentId": "1", "relationType": "依据"}], "rowVersion": "0"},
  88. ],
  89. )
  90. def test_relations_payload_rejects_invalid(payload):
  91. with pytest.raises(InvalidArgumentError):
  92. _relations_payload(payload)
  93. def test_relations_payload_accepts_empty_and_custom_text():
  94. assert _relations_payload({"relations": [], "rowVersion": 3}) == ([], 3)
  95. pairs, version = _relations_payload(
  96. {
  97. "relations": [
  98. {"relatedDocumentId": "12", "relationType": " 依据 "},
  99. {"relatedDocumentId": "34", "relationType": "前置输入条件"},
  100. ],
  101. "rowVersion": 0,
  102. }
  103. )
  104. assert pairs == [(12, "依据"), (34, "前置输入条件")]
  105. assert version == 0
  106. # ---------------------------------------------------------------------------
  107. # HTTP 集成测试(依赖独立测试库,本机无MySQL时自动跳过)
  108. # ---------------------------------------------------------------------------
  109. def auth(token: str) -> dict[str, str]:
  110. return {"Authorization": f"Bearer {token}"}
  111. def _put_relations(client, document_id, relations, row_version, headers):
  112. return client.put(
  113. f"/api/v1/documents/{document_id}/relations",
  114. json={"relations": relations, "rowVersion": row_version},
  115. headers=headers,
  116. )
  117. def _get_relations(client, document_id, headers):
  118. response = client.get(
  119. f"/api/v1/documents/{document_id}/relations", headers=headers
  120. )
  121. assert response.status_code == 200, response.get_json()
  122. return response.get_json()["data"]["items"]
  123. def test_relation_presets_endpoint(b4_client, token_for):
  124. response = b4_client.get(
  125. "/api/v1/documents/relation-presets", headers=auth(token_for("user"))
  126. )
  127. assert response.status_code == 200
  128. presets = response.get_json()["data"]["presets"]
  129. assert [item["code"] for item in presets] == ["BASIS", "SUPPORT", "REFERENCE"]
  130. assert presets[0]["label"] == "依据"
  131. def test_save_list_replace_and_clear_relations(b4_client, b4_app, token_for):
  132. ids = b4_app.config["B4_IDS"]
  133. main_id, target_id = ids["main_all"], ids["main_org"]
  134. admin = auth(token_for("admin"))
  135. # 初始为空
  136. assert _get_relations(b4_client, main_id, admin) == []
  137. # 保存一条预置关系 + 一条自定义关系
  138. saved = _put_relations(
  139. b4_client,
  140. main_id,
  141. [
  142. {"relatedDocumentId": str(target_id), "relationType": "依据"},
  143. {"relatedDocumentId": str(ids["main_custom"]), "relationType": "前置输入"},
  144. ],
  145. 0,
  146. admin,
  147. )
  148. assert saved.status_code == 200, saved.get_json()
  149. data = saved.get_json()["data"]
  150. assert data["rowVersion"] == 1
  151. assert [
  152. (item["relatedDocumentId"], item["relationType"])
  153. for item in data["relations"]
  154. ] == [(str(target_id), "依据"), (str(ids["main_custom"]), "前置输入")]
  155. # GET 返回被关联方案名称
  156. items = _get_relations(b4_client, main_id, admin)
  157. assert items[0]["relatedDocumentName"] == "B4_TEST_组织主案"
  158. # 整体替换为空 = 清空;rowVersion 再次自增
  159. cleared = _put_relations(b4_client, main_id, [], data["rowVersion"], admin)
  160. assert cleared.status_code == 200
  161. assert cleared.get_json()["data"]["relations"] == []
  162. assert cleared.get_json()["data"]["rowVersion"] == 2
  163. assert _get_relations(b4_client, main_id, admin) == []
  164. def test_save_relations_stale_row_version_conflicts(b4_client, b4_app, token_for):
  165. ids = b4_app.config["B4_IDS"]
  166. admin = auth(token_for("admin"))
  167. response = _put_relations(
  168. b4_client,
  169. ids["main_all"],
  170. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "依据"}],
  171. 0,
  172. admin,
  173. )
  174. assert response.status_code == 200
  175. stale = _put_relations(
  176. b4_client,
  177. ids["main_all"],
  178. [],
  179. 0, # 旧版本号
  180. admin,
  181. )
  182. assert stale.status_code == 409
  183. def test_save_relations_target_must_be_main_and_not_self(
  184. b4_client, b4_app, token_for
  185. ):
  186. ids = b4_app.config["B4_IDS"]
  187. admin = auth(token_for("admin"))
  188. base = _get_relations(b4_client, ids["main_all"], admin)
  189. version = 0
  190. # 自关联被拒
  191. self_relation = _put_relations(
  192. b4_client,
  193. ids["main_all"],
  194. [{"relatedDocumentId": str(ids["main_all"]), "relationType": "依据"}],
  195. version,
  196. admin,
  197. )
  198. assert self_relation.status_code == 400
  199. # 子方案/附件不能作为被关联对象
  200. sub_target = _put_relations(
  201. b4_client,
  202. ids["main_all"],
  203. [{"relatedDocumentId": str(ids["sub"]), "relationType": "依据"}],
  204. version,
  205. admin,
  206. )
  207. assert sub_target.status_code == 404
  208. attachment_target = _put_relations(
  209. b4_client,
  210. ids["main_all"],
  211. [{"relatedDocumentId": str(ids["attachment_one"]), "relationType": "依据"}],
  212. version,
  213. admin,
  214. )
  215. assert attachment_target.status_code == 404
  216. assert _get_relations(b4_client, ids["main_all"], admin) == base
  217. def test_sub_plan_may_declare_relations_but_attachment_may_not(
  218. b4_client, b4_app, token_for
  219. ):
  220. ids = b4_app.config["B4_IDS"]
  221. admin = auth(token_for("admin"))
  222. # 子方案可作为发起方
  223. ok = _put_relations(
  224. b4_client,
  225. ids["sub"],
  226. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "支撑"}],
  227. 0,
  228. admin,
  229. )
  230. assert ok.status_code == 200, ok.get_json()
  231. # 附件不可作为发起方
  232. rejected = _put_relations(
  233. b4_client,
  234. ids["attachment_one"],
  235. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "支撑"}],
  236. 0,
  237. admin,
  238. )
  239. assert rejected.status_code == 404
  240. def test_save_relations_requires_admin(b4_client, b4_app, token_for):
  241. ids = b4_app.config["B4_IDS"]
  242. response = _put_relations(
  243. b4_client,
  244. ids["main_all"],
  245. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "依据"}],
  246. 0,
  247. auth(token_for("user")),
  248. )
  249. assert response.status_code == 403
  250. def test_user_with_view_access_can_list_relations(b4_client, b4_app, token_for):
  251. ids = b4_app.config["B4_IDS"]
  252. admin = auth(token_for("admin"))
  253. _put_relations(
  254. b4_client,
  255. ids["main_all"],
  256. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "依据"}],
  257. 0,
  258. admin,
  259. )
  260. items = _get_relations(b4_client, ids["main_all"], auth(token_for("user")))
  261. assert len(items) == 1
  262. assert items[0]["relationType"] == "依据"
  263. def test_locked_main_relations_require_edit_password(
  264. b4_client, b4_app, token_for
  265. ):
  266. from dms.extensions import db
  267. from dms.models import Category
  268. with b4_app.app_context():
  269. category = db.session.scalar(
  270. db.select(Category).where(Category.category_code == "CHILD_A1")
  271. )
  272. category_id = str(category.id)
  273. name = "REL_TEST_锁定主案"
  274. metadata = {
  275. "documentName": name,
  276. "documentType": "MAIN",
  277. "summary": "关联关系密码锁测试",
  278. "categoryId": category_id,
  279. "securityLevel": "INTERNAL",
  280. "tags": ["REL"],
  281. "password": "rel-pw-123",
  282. "protectedActions": ["EDIT"],
  283. }
  284. created = b4_client.post(
  285. "/api/v1/documents",
  286. data={
  287. "file": (io.BytesIO(b"%PDF-1.4\n%%EOF"), name + ".pdf"),
  288. "metadata": json.dumps(metadata, ensure_ascii=False),
  289. },
  290. headers=auth(token_for("admin")),
  291. content_type="multipart/form-data",
  292. )
  293. assert created.status_code == 201, created.get_json()
  294. locked_id = created.get_json()["data"]["id"]
  295. ids = b4_app.config["B4_IDS"]
  296. blocked = _put_relations(
  297. b4_client,
  298. locked_id,
  299. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "依据"}],
  300. 0,
  301. auth(token_for("admin")),
  302. )
  303. assert blocked.status_code == 403
  304. assert blocked.get_json()["code"] == "DOCUMENT_PASSWORD_REQUIRED"
  305. headers = auth(token_for("admin"))
  306. headers["X-DMS-Document-Password"] = "rel-pw-123"
  307. ok = _put_relations(
  308. b4_client,
  309. locked_id,
  310. [{"relatedDocumentId": str(ids["main_org"]), "relationType": "依据"}],
  311. 0,
  312. headers,
  313. )
  314. assert ok.status_code == 200, ok.get_json()