test_document_organization.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. """文档归属部门行为测试(T1.2)。
  2. 覆盖:
  3. - 上传默认取创建者当前部门;显式 organizationId 可指定其他启用部门
  4. - 无效/停用部门 → 404 RESOURCE_NOT_FOUND
  5. - PUT /documents/{id} 修改归属部门
  6. - 编辑缺省 organizationId 沿用现有归属,不被编辑人部门覆盖
  7. - 非管理员原样回传其他部门归属视为未变更;改挂其他部门 → 400 INVALID_ARGUMENT
  8. - 组织改名级联 Document.organization_name 冗余字段
  9. - 用户调岗不联动历史文档归属
  10. - 附件库条目输出 organizationName
  11. """
  12. from __future__ import annotations
  13. import io
  14. import json
  15. import pytest
  16. from docx import Document as WordDocument
  17. from sqlalchemy import update
  18. from dms.extensions import db
  19. from dms.models import Document, Organization, Role, RolePermission, User
  20. def _auth(token: str) -> dict[str, str]:
  21. return {"Authorization": f"Bearer {token}"}
  22. def _docx_bytes() -> bytes:
  23. stream = io.BytesIO()
  24. document = WordDocument()
  25. document.add_paragraph("DO组织行为验证")
  26. document.save(stream)
  27. return stream.getvalue()
  28. def _upload(client, token: str, path: str, metadata: dict):
  29. name = metadata["documentName"]
  30. return client.post(
  31. f"/api/v1/{path}",
  32. data={
  33. "file": (io.BytesIO(_docx_bytes()), f"{name}.docx"),
  34. "metadata": json.dumps(metadata, ensure_ascii=False),
  35. },
  36. headers=_auth(token),
  37. content_type="multipart/form-data",
  38. )
  39. def _leaf_category_id(app) -> str:
  40. from dms.models import Category
  41. with app.app_context():
  42. category = db.session.scalar(
  43. db.select(Category).where(Category.category_code == "CHILD_A1")
  44. )
  45. assert category is not None
  46. return str(category.id)
  47. def _org_by_code(app, code: str):
  48. with app.app_context():
  49. org = db.session.scalar(
  50. db.select(Organization).where(Organization.org_code == code)
  51. )
  52. assert org is not None
  53. return org.id, org.org_name, org.parent_id, org.row_version
  54. def _main_metadata(leaf_id: str, name: str, **extra) -> dict:
  55. return {
  56. "documentName": name,
  57. "documentType": "MAIN",
  58. "summary": "DO摘要",
  59. "categoryId": leaf_id,
  60. "securityLevel": "INTERNAL",
  61. "tags": ["DO"],
  62. **extra,
  63. }
  64. @pytest.fixture()
  65. def do_app(b3_app):
  66. with b3_app.app_context():
  67. root = db.session.scalar(
  68. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  69. )
  70. assert root is not None
  71. org_a = Organization(
  72. org_code="ORG_DO_A",
  73. org_name="DO甲部门",
  74. org_path=f"{root.org_path}/DO甲部门",
  75. parent_id=root.id,
  76. sort_no=80,
  77. status="ENABLED",
  78. )
  79. org_disabled = Organization(
  80. org_code="ORG_DO_DISABLED",
  81. org_name="DO停用部门",
  82. org_path=f"{root.org_path}/DO停用部门",
  83. parent_id=root.id,
  84. sort_no=81,
  85. status="DISABLED",
  86. )
  87. db.session.add_all([org_a, org_disabled])
  88. db.session.commit()
  89. b3_app.config["DO_IDS"] = {
  90. "org_a": org_a.id,
  91. "org_disabled": org_disabled.id,
  92. }
  93. return b3_app
  94. @pytest.fixture()
  95. def do_client(do_app):
  96. return do_app.test_client()
  97. def test_upload_defaults_to_creator_organization(do_app, do_client, token_for):
  98. response = _upload(
  99. do_client,
  100. token_for("admin"),
  101. "documents",
  102. _main_metadata(_leaf_category_id(do_app), "DO_TEST_默认部门主案"),
  103. )
  104. assert response.status_code == 201
  105. item = response.get_json()["data"]
  106. with do_app.app_context():
  107. admin = db.session.scalar(db.select(User).where(User.username == "admin"))
  108. assert item["organizationId"] == str(admin.organization_id)
  109. assert item["organizationName"] == admin.organization_name
  110. def test_upload_with_explicit_enabled_organization(do_app, do_client, token_for):
  111. org_a_id = do_app.config["DO_IDS"]["org_a"]
  112. response = _upload(
  113. do_client,
  114. token_for("admin"),
  115. "documents",
  116. _main_metadata(
  117. _leaf_category_id(do_app),
  118. "DO_TEST_指定部门主案",
  119. organizationId=str(org_a_id),
  120. ),
  121. )
  122. assert response.status_code == 201
  123. item = response.get_json()["data"]
  124. assert item["organizationId"] == str(org_a_id)
  125. assert item["organizationName"] == "DO甲部门"
  126. @pytest.mark.parametrize(
  127. "org_code",
  128. ["missing", "ORG_DO_DISABLED"],
  129. )
  130. def test_upload_rejects_invalid_or_disabled_organization(
  131. do_app, do_client, token_for, org_code
  132. ):
  133. raw_id = (
  134. "99999999"
  135. if org_code == "missing"
  136. else str(do_app.config["DO_IDS"][org_code])
  137. )
  138. response = _upload(
  139. do_client,
  140. token_for("admin"),
  141. "documents",
  142. _main_metadata(
  143. _leaf_category_id(do_app),
  144. f"DO_TEST_非法部门主案_{org_code}",
  145. organizationId=raw_id,
  146. ),
  147. )
  148. assert response.status_code == 404
  149. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  150. def test_update_document_organization(do_app, do_client, token_for):
  151. token = token_for("admin")
  152. org_a_id = do_app.config["DO_IDS"]["org_a"]
  153. created = _upload(
  154. do_client,
  155. token,
  156. "documents",
  157. _main_metadata(_leaf_category_id(do_app), "DO_TEST_修改部门主案"),
  158. ).get_json()["data"]
  159. assert created["organizationName"] != "DO甲部门"
  160. updated = do_client.put(
  161. f"/api/v1/documents/{created['id']}",
  162. json={
  163. "documentName": created["documentName"],
  164. "summary": created["summary"],
  165. "categoryId": created["categoryId"],
  166. "securityLevel": created["securityLevel"],
  167. "tags": created["tags"],
  168. "rowVersion": created["rowVersion"],
  169. "organizationId": str(org_a_id),
  170. },
  171. headers=_auth(token),
  172. )
  173. assert updated.status_code == 200
  174. assert updated.get_json()["data"]["organizationName"] == "DO甲部门"
  175. listed = do_client.get(
  176. "/api/v1/documents",
  177. query_string={
  178. "keyword": "DO_TEST_修改部门主案",
  179. "organizationKeyword": "DO甲部",
  180. },
  181. headers=_auth(token),
  182. ).get_json()["data"]
  183. assert [item["documentName"] for item in listed["items"]] == [
  184. "DO_TEST_修改部门主案"
  185. ]
  186. def _edit_payload(item: dict, **extra) -> dict:
  187. return {
  188. "documentName": item["documentName"],
  189. "summary": item["summary"],
  190. "categoryId": item["categoryId"],
  191. "securityLevel": item["securityLevel"],
  192. "tags": item["tags"],
  193. "rowVersion": item["rowVersion"],
  194. **extra,
  195. }
  196. def test_update_without_organization_keeps_current(
  197. do_app, do_client, token_for
  198. ):
  199. """编辑未携带 organizationId 时沿用文档现有归属,不被编辑人部门覆盖。"""
  200. token = token_for("admin")
  201. org_a_id = do_app.config["DO_IDS"]["org_a"]
  202. created = _upload(
  203. do_client,
  204. token,
  205. "documents",
  206. _main_metadata(
  207. _leaf_category_id(do_app),
  208. "DO_TEST_编辑不改部门主案",
  209. organizationId=str(org_a_id),
  210. ),
  211. ).get_json()["data"]
  212. updated = do_client.put(
  213. f"/api/v1/documents/{created['id']}",
  214. json=_edit_payload(created),
  215. headers=_auth(token),
  216. )
  217. assert updated.status_code == 200
  218. data = updated.get_json()["data"]
  219. assert data["organizationId"] == str(org_a_id)
  220. assert data["organizationName"] == "DO甲部门"
  221. def test_non_admin_can_resubmit_other_organization_but_not_reassign(
  222. do_app, do_client, token_for
  223. ):
  224. """非管理员原样回传其他部门归属视为未变更;改挂其他部门被拒绝。"""
  225. admin_token = token_for("admin")
  226. user_token = token_for("user")
  227. org_a_id = do_app.config["DO_IDS"]["org_a"]
  228. comms_id, _, _, _ = _org_by_code(do_app, "ORG_COMMS")
  229. created = _upload(
  230. do_client,
  231. admin_token,
  232. "documents",
  233. _main_metadata(
  234. _leaf_category_id(do_app),
  235. "DO_TEST_普通用户编辑他部门主案",
  236. organizationId=str(org_a_id),
  237. ),
  238. ).get_json()["data"]
  239. with do_app.app_context():
  240. user_id = db.session.scalar(
  241. db.select(User.id).where(User.username == "user")
  242. )
  243. # 借阅语义下EDIT仅来自角色权限矩阵:为用户挂载INTERNAL×OTHER
  244. # 可查看可编辑的角色(该主案归属DO甲部门,对用户属于OTHER范围)。
  245. with do_app.app_context():
  246. role = Role(role_name="DO编辑角色", sort_no=90)
  247. db.session.add(role)
  248. db.session.flush()
  249. db.session.add(
  250. RolePermission(
  251. role_id=role.id,
  252. security_level="INTERNAL",
  253. dept_scope="OTHER",
  254. can_view=True,
  255. can_edit=True,
  256. )
  257. )
  258. db.session.execute(
  259. update(User).where(User.id == user_id).values(role_id=role.id)
  260. )
  261. db.session.commit()
  262. # 保存权限会推进rowVersion,重新读取详情
  263. latest = do_client.get(
  264. f"/api/v1/documents/{created['id']}", headers=_auth(user_token)
  265. ).get_json()["data"]
  266. assert "EDIT" in latest["allowedActions"]
  267. # 原样回传其他部门(DO甲部门)归属:视为未变更,允许保存
  268. unchanged = do_client.put(
  269. f"/api/v1/documents/{created['id']}",
  270. json=_edit_payload(latest, organizationId=str(org_a_id)),
  271. headers=_auth(user_token),
  272. )
  273. assert unchanged.status_code == 200, unchanged.get_json()
  274. assert unchanged.get_json()["data"]["organizationId"] == str(org_a_id)
  275. # 改挂到通信部:非管理员被拒绝
  276. reassigned = do_client.put(
  277. f"/api/v1/documents/{created['id']}",
  278. json=_edit_payload(
  279. unchanged.get_json()["data"], organizationId=str(comms_id)
  280. ),
  281. headers=_auth(user_token),
  282. )
  283. assert reassigned.status_code == 400
  284. body = reassigned.get_json()
  285. assert body["code"] == "INVALID_ARGUMENT"
  286. assert body["message"] == "仅管理员可将文档调整到其他部门"
  287. def test_organization_rename_cascades_document_name(do_app, do_client, token_for):
  288. token = token_for("admin")
  289. org_a_id, _, parent_id, row_version = _org_by_code(do_app, "ORG_DO_A")
  290. created = _upload(
  291. do_client,
  292. token,
  293. "documents",
  294. _main_metadata(
  295. _leaf_category_id(do_app),
  296. "DO_TEST_改名级联主案",
  297. organizationId=str(org_a_id),
  298. ),
  299. ).get_json()["data"]
  300. assert created["organizationName"] == "DO甲部门"
  301. def rename(name: str, version: int) -> int:
  302. response = do_client.put(
  303. f"/api/v1/organizations/{org_a_id}",
  304. json={
  305. "orgName": name,
  306. "parentId": str(parent_id),
  307. "sortNo": 80,
  308. "status": "ENABLED",
  309. "rowVersion": version,
  310. },
  311. headers=_auth(token),
  312. )
  313. assert response.status_code == 200, response.get_json()
  314. return response.get_json()["data"]["rowVersion"]
  315. try:
  316. row_version = rename("DO甲部门新名", row_version)
  317. listed = do_client.get(
  318. "/api/v1/documents",
  319. query_string={"keyword": "DO_TEST_改名级联主案"},
  320. headers=_auth(token),
  321. ).get_json()["data"]
  322. assert listed["items"][0]["organizationName"] == "DO甲部门新名"
  323. finally:
  324. rename("DO甲部门", row_version)
  325. def test_user_transfer_does_not_cascade_documents(do_app, do_client, token_for):
  326. token = token_for("admin")
  327. org_a_id = do_app.config["DO_IDS"]["org_a"]
  328. comms_id, comms_name, _, _ = _org_by_code(do_app, "ORG_COMMS")
  329. created_user = do_client.post(
  330. "/api/v1/users",
  331. json={
  332. "username": "do_transfer_user",
  333. "realName": "调岗验证用户",
  334. "initialPassword": "Initial@2026",
  335. "organizationId": str(org_a_id),
  336. "roleCode": "USER",
  337. "securityLevel": "INTERNAL",
  338. "status": "ENABLED",
  339. },
  340. headers=_auth(token),
  341. )
  342. assert created_user.status_code == 201, created_user.get_json()
  343. user_id = created_user.get_json()["data"]["id"]
  344. with do_app.app_context():
  345. leaf = db.session.scalar(
  346. db.select(Document).where(
  347. Document.document_name == "DO_TEST_修改部门主案",
  348. Document.is_deleted.is_(False),
  349. )
  350. )
  351. assert leaf is not None
  352. document = Document(
  353. document_name="DO_TEST_调岗不联动主案",
  354. summary="调岗不联动",
  355. document_type="MAIN",
  356. document_status="PUBLISHED",
  357. security_level="INTERNAL",
  358. organization_id=org_a_id,
  359. organization_name="DO甲部门",
  360. tags=["DO"],
  361. original_file_name="DO_TEST_调岗不联动主案.docx",
  362. file_relative_path="original/DO_TEST_调岗不联动主案.docx",
  363. file_extension="docx",
  364. mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  365. file_size=64,
  366. file_hash="d" * 64,
  367. search_text="DO_TEST_调岗不联动主案",
  368. created_by=int(user_id),
  369. updated_by=int(user_id),
  370. created_by_name="调岗验证用户",
  371. updated_by_name="调岗验证用户",
  372. category_id=leaf.category_id,
  373. )
  374. db.session.add(document)
  375. user = db.session.get(User, int(user_id))
  376. row_version = user.row_version
  377. db.session.commit()
  378. transferred = do_client.put(
  379. f"/api/v1/users/{user_id}",
  380. json={
  381. "realName": "调岗验证用户",
  382. "organizationId": str(comms_id),
  383. "roleCode": "USER",
  384. "securityLevel": "INTERNAL",
  385. "status": "ENABLED",
  386. "rowVersion": row_version,
  387. },
  388. headers=_auth(token),
  389. )
  390. assert transferred.status_code == 200, transferred.get_json()
  391. assert transferred.get_json()["data"]["organizationName"] == comms_name
  392. with do_app.app_context():
  393. current = db.session.scalar(
  394. db.select(Document).where(
  395. Document.document_name == "DO_TEST_调岗不联动主案",
  396. Document.is_deleted.is_(False),
  397. )
  398. )
  399. assert current.organization_id == org_a_id
  400. assert current.organization_name == "DO甲部门"
  401. def test_attachment_library_exposes_organization_name(
  402. do_app, do_client, token_for
  403. ):
  404. token = token_for("admin")
  405. created = _upload(
  406. do_client,
  407. token,
  408. "attachments",
  409. {
  410. "documentName": "DO_TEST_部门附件",
  411. "attachmentType": "OTHER",
  412. "summary": "DO附件",
  413. "tags": ["DO"],
  414. },
  415. )
  416. assert created.status_code == 201
  417. listed = do_client.get(
  418. "/api/v1/attachments",
  419. query_string={"keyword": "DO_TEST_部门附件"},
  420. headers=_auth(token),
  421. ).get_json()["data"]
  422. assert len(listed["items"]) == 1
  423. with do_app.app_context():
  424. admin = db.session.scalar(db.select(User).where(User.username == "admin"))
  425. assert listed["items"][0]["organizationId"] == str(admin.organization_id)
  426. assert listed["items"][0]["organizationName"] == admin.organization_name