test_global_search.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. """全局搜索与高级检索测试。
  2. 覆盖检索条件矩阵(四条件 AND 叠加):
  3. - 简单关键词:仅匹配标题;主案聚合模式下命中子案/附件标题也召回主案
  4. - 正文关键词:匹配 contentText / searchText,命中时输出 contentSnippet
  5. - 密级多选:勾选项内 OR
  6. - 部门关键词:模糊匹配 organization_name
  7. - 聚合命中徽章:titleHit / subHitCount / attachmentHitCount(不可见子案不计入)
  8. """
  9. from __future__ import annotations
  10. import pytest
  11. def auth(token: str) -> dict[str, str]:
  12. return {"Authorization": f"Bearer {token}"}
  13. def data(response):
  14. return response.get_json()["data"]
  15. def names(response) -> list[str]:
  16. return [item["documentName"] for item in data(response)["items"]]
  17. def by_name(response, name: str):
  18. return next(item for item in data(response)["items"] if item["documentName"] == name)
  19. @pytest.fixture()
  20. def gs_app(b4_app):
  21. from dms.extensions import db
  22. from dms.models import AttachmentBinding, Document, Organization
  23. with b4_app.app_context():
  24. root = db.session.scalar(
  25. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  26. )
  27. assert root is not None
  28. # 追加三个 GS 专用组织,便于用 organizationKeyword=GS 圈定数据集。
  29. gs_ops = Organization(
  30. org_code="ORG_GS_OPS",
  31. org_name="GS作战组",
  32. org_path=f"{root.org_path}/GS作战组",
  33. parent_id=root.id,
  34. sort_no=50,
  35. status="ENABLED",
  36. )
  37. gs_comms = Organization(
  38. org_code="ORG_GS_COMMS",
  39. org_name="GS通信组",
  40. org_path=f"{root.org_path}/GS通信组",
  41. parent_id=root.id,
  42. sort_no=60,
  43. status="ENABLED",
  44. )
  45. gs_base = Organization(
  46. org_code="ORG_GS_BASE",
  47. org_name="GS检索部",
  48. org_path=f"{root.org_path}/GS检索部",
  49. parent_id=root.id,
  50. sort_no=70,
  51. status="ENABLED",
  52. )
  53. db.session.add_all([gs_ops, gs_comms, gs_base])
  54. db.session.flush()
  55. def document(
  56. name: str,
  57. document_type: str,
  58. *,
  59. security: str = "INTERNAL",
  60. visibility: str = "ALL_AUTHENTICATED",
  61. parent: Document | None = None,
  62. organization: Organization,
  63. content_text: str | None = None,
  64. attachment_type: str | None = None,
  65. ) -> Document:
  66. item = Document(
  67. document_name=name,
  68. summary=f"{name}摘要",
  69. document_type=document_type,
  70. document_status="PUBLISHED",
  71. security_level=security,
  72. visibility_type=visibility,
  73. attachment_type=attachment_type,
  74. organization_id=organization.id,
  75. organization_name=organization.org_name,
  76. parent_document_id=parent.id if parent else None,
  77. root_document_id=parent.id if parent else None,
  78. tags=["GS"],
  79. original_file_name=f"{name}.docx",
  80. file_relative_path=f"original/{name}.docx",
  81. file_extension="docx",
  82. mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  83. file_size=64,
  84. file_hash="b" * 64,
  85. search_text=name,
  86. content_text=content_text,
  87. created_by_name="系统管理员",
  88. updated_by_name="系统管理员",
  89. )
  90. db.session.add(item)
  91. db.session.flush()
  92. return item
  93. alpha = document(
  94. "GS_TEST_阿尔法主案",
  95. "MAIN",
  96. security="INTERNAL",
  97. organization=gs_ops,
  98. content_text="阿尔法主案正文,包含全域搜索唯一词。",
  99. )
  100. alpha_sub = document(
  101. "GS_TEST_阿尔法雷达子案",
  102. "SUB_PLAN",
  103. security="INTERNAL",
  104. parent=alpha,
  105. organization=gs_ops,
  106. content_text="阿尔法子案正文专属词。",
  107. )
  108. document(
  109. "GS_TEST_阿尔法隐藏子案",
  110. "SUB_PLAN",
  111. security="SECRET",
  112. visibility="CUSTOM",
  113. parent=alpha,
  114. organization=gs_ops,
  115. )
  116. alpha_att = document(
  117. "GS_TEST_阿尔法挂载附件",
  118. "ATTACHMENT",
  119. security="PUBLIC",
  120. organization=gs_ops,
  121. content_text="附件正文检索词。",
  122. attachment_type="OTHER",
  123. )
  124. beta = document(
  125. "GS_TEST_贝塔主案",
  126. "MAIN",
  127. security="SECRET",
  128. organization=gs_comms,
  129. )
  130. document(
  131. "GS_TEST_贝塔雷达子案",
  132. "SUB_PLAN",
  133. security="SECRET",
  134. parent=beta,
  135. organization=gs_comms,
  136. content_text="雷达正文唯一词。",
  137. )
  138. gamma = document(
  139. "GS_TEST_伽马主案",
  140. "MAIN",
  141. security="TOP_SECRET",
  142. organization=gs_base,
  143. )
  144. document(
  145. "GS_TEST_伽马雷达子案",
  146. "SUB_PLAN",
  147. security="TOP_SECRET",
  148. parent=gamma,
  149. organization=gs_base,
  150. )
  151. db.session.add(
  152. AttachmentBinding(
  153. main_document_id=alpha.id,
  154. attachment_document_id=alpha_att.id,
  155. sort_no=10,
  156. )
  157. )
  158. alpha.attachment_count = 1
  159. alpha.child_count = 2
  160. beta.child_count = 1
  161. gamma.child_count = 1
  162. db.session.commit()
  163. b4_app.config["GS_IDS"] = {
  164. "alpha": alpha.id,
  165. "alpha_sub": alpha_sub.id,
  166. "alpha_att": alpha_att.id,
  167. "beta": beta.id,
  168. "gamma": gamma.id,
  169. "gs_ops": gs_ops.id,
  170. }
  171. return b4_app
  172. @pytest.fixture()
  173. def gs_client(gs_app):
  174. return gs_app.test_client()
  175. def test_aggregate_main_title_hit(gs_client, token_for):
  176. response = gs_client.get(
  177. "/api/v1/documents?aggregateHits=true&keyword=阿尔法主案",
  178. headers=auth(token_for("user")),
  179. )
  180. assert response.status_code == 200
  181. payload = data(response)
  182. assert [item["documentName"] for item in payload["items"]] == [
  183. "GS_TEST_阿尔法主案"
  184. ]
  185. item = payload["items"][0]
  186. assert item["titleHit"] is True
  187. assert item["subHitCount"] == 0
  188. assert item["attachmentHitCount"] == 0
  189. def test_aggregate_sub_hit_with_acl_filtered_count(gs_client, token_for):
  190. user_response = gs_client.get(
  191. "/api/v1/documents?aggregateHits=true&keyword=雷达",
  192. headers=auth(token_for("user")),
  193. )
  194. user_names = names(user_response)
  195. # 伽马主案 TOP_SECRET 超出 user 密级,不召回。
  196. assert user_names == ["GS_TEST_阿尔法主案", "GS_TEST_贝塔主案"]
  197. alpha_user = by_name(user_response, "GS_TEST_阿尔法主案")
  198. # 隐藏子案 CUSTOM 无授权,对 user 不可见,不计入计数。
  199. assert alpha_user["subHitCount"] == 1
  200. assert alpha_user["titleHit"] is False
  201. admin_response = gs_client.get(
  202. "/api/v1/documents?aggregateHits=true&keyword=雷达",
  203. headers=auth(token_for("admin")),
  204. )
  205. assert names(admin_response) == [
  206. "GS_TEST_阿尔法主案",
  207. "GS_TEST_贝塔主案",
  208. "GS_TEST_伽马主案",
  209. ]
  210. alpha_admin = by_name(admin_response, "GS_TEST_阿尔法主案")
  211. assert alpha_admin["subHitCount"] == 2
  212. def test_aggregate_keyword_hits_only_one_sub_plan_title(gs_client, token_for):
  213. """回归:子案标题命中只召回所属主案,其余主案不得入列表。"""
  214. response = gs_client.get(
  215. "/api/v1/documents?aggregateHits=true&keyword=贝塔雷达",
  216. headers=auth(token_for("user")),
  217. )
  218. assert names(response) == ["GS_TEST_贝塔主案"]
  219. beta = data(response)["items"][0]
  220. assert beta["titleHit"] is False
  221. assert beta["subHitCount"] == 1
  222. assert beta["attachmentHitCount"] == 0
  223. def test_aggregate_attachment_hit(gs_client, token_for):
  224. response = gs_client.get(
  225. "/api/v1/documents?aggregateHits=true&keyword=挂载附件",
  226. headers=auth(token_for("user")),
  227. )
  228. assert names(response) == ["GS_TEST_阿尔法主案"]
  229. item = data(response)["items"][0]
  230. assert item["titleHit"] is False
  231. assert item["subHitCount"] == 0
  232. assert item["attachmentHitCount"] == 1
  233. def test_aggregate_mixed_hit(gs_client, token_for):
  234. response = gs_client.get(
  235. "/api/v1/documents?aggregateHits=true&keyword=阿尔法",
  236. headers=auth(token_for("user")),
  237. )
  238. assert names(response) == ["GS_TEST_阿尔法主案"]
  239. item = data(response)["items"][0]
  240. assert item["titleHit"] is True
  241. assert item["subHitCount"] == 1
  242. assert item["attachmentHitCount"] == 1
  243. def test_aggregate_no_hit_returns_empty(gs_client, token_for):
  244. response = gs_client.get(
  245. "/api/v1/documents?aggregateHits=true&keyword=GS_TEST_不存在的词",
  246. headers=auth(token_for("admin")),
  247. )
  248. assert response.status_code == 200
  249. assert data(response)["total"] == 0
  250. assert data(response)["items"] == []
  251. def test_aggregate_without_keyword_keeps_all(gs_client, token_for):
  252. response = gs_client.get(
  253. "/api/v1/documents?aggregateHits=true&organizationKeyword=GS",
  254. headers=auth(token_for("user")),
  255. )
  256. assert set(names(response)) == {
  257. "GS_TEST_阿尔法主案",
  258. "GS_TEST_贝塔主案",
  259. }
  260. item = by_name(response, "GS_TEST_贝塔主案")
  261. assert item["titleHit"] is True
  262. assert item["subHitCount"] == 0
  263. assert item["attachmentHitCount"] == 0
  264. def test_advanced_content_keyword_filters_main_itself(gs_client, token_for):
  265. response = gs_client.get(
  266. "/api/v1/documents?aggregateHits=true"
  267. "&organizationKeyword=GS&contentKeyword=全域搜索唯一词",
  268. headers=auth(token_for("user")),
  269. )
  270. assert names(response) == ["GS_TEST_阿尔法主案"]
  271. # 高级正文命中时输出片段。
  272. item = data(response)["items"][0]
  273. assert "全域搜索唯一词" in item["contentSnippet"]
  274. def test_advanced_content_keyword_excludes_unmatched(gs_client, token_for):
  275. response = gs_client.get(
  276. "/api/v1/documents?aggregateHits=true"
  277. "&organizationKeyword=GS&contentKeyword=雷达正文唯一词",
  278. headers=auth(token_for("user")),
  279. )
  280. # 正文词只存在于贝塔子案:高级条件只过滤主案本身,子案命中不召回主案。
  281. assert data(response)["total"] == 0
  282. def test_advanced_security_levels_multiselect(gs_client, token_for):
  283. response = gs_client.get(
  284. "/api/v1/documents?organizationKeyword=GS"
  285. "&securityLevels=SECRET,TOP_SECRET",
  286. headers=auth(token_for("admin")),
  287. )
  288. assert set(names(response)) == {"GS_TEST_贝塔主案", "GS_TEST_伽马主案"}
  289. internal = gs_client.get(
  290. "/api/v1/documents?organizationKeyword=GS&securityLevels=INTERNAL",
  291. headers=auth(token_for("admin")),
  292. )
  293. assert names(internal) == ["GS_TEST_阿尔法主案"]
  294. def test_advanced_organization_keyword(gs_client, token_for):
  295. response = gs_client.get(
  296. "/api/v1/documents?organizationKeyword=GS作战",
  297. headers=auth(token_for("user")),
  298. )
  299. assert names(response) == ["GS_TEST_阿尔法主案"]
  300. def test_simple_and_advanced_are_anded(gs_client, token_for):
  301. mismatch = gs_client.get(
  302. "/api/v1/documents?aggregateHits=true"
  303. "&keyword=贝塔&organizationKeyword=GS作战",
  304. headers=auth(token_for("user")),
  305. )
  306. assert data(mismatch)["total"] == 0
  307. match = gs_client.get(
  308. "/api/v1/documents?aggregateHits=true"
  309. "&keyword=贝塔&securityLevels=SECRET&organizationKeyword=GS通信",
  310. headers=auth(token_for("user")),
  311. )
  312. assert names(match) == ["GS_TEST_贝塔主案"]
  313. def test_keyword_matches_title_only_not_content(gs_client, token_for):
  314. response = gs_client.get(
  315. "/api/v1/documents?keyword=全域搜索唯一词",
  316. headers=auth(token_for("admin")),
  317. )
  318. assert data(response)["total"] == 0
  319. def test_sub_plans_tab_applies_all_conditions(
  320. gs_app, gs_client, token_for
  321. ):
  322. alpha_id = gs_app.config["GS_IDS"]["alpha"]
  323. base = f"/api/v1/main-plans/{alpha_id}/sub-plans"
  324. user_all = gs_client.get(
  325. f"{base}?pageSize=100", headers=auth(token_for("user"))
  326. )
  327. assert names(user_all) == ["GS_TEST_阿尔法雷达子案"]
  328. admin_all = gs_client.get(
  329. f"{base}?pageSize=100", headers=auth(token_for("admin"))
  330. )
  331. assert set(names(admin_all)) == {
  332. "GS_TEST_阿尔法雷达子案",
  333. "GS_TEST_阿尔法隐藏子案",
  334. }
  335. by_keyword = gs_client.get(f"{base}?keyword=雷达", headers=auth(token_for("user")))
  336. assert names(by_keyword) == ["GS_TEST_阿尔法雷达子案"]
  337. by_content = gs_client.get(
  338. f"{base}?contentKeyword=专属词", headers=auth(token_for("user"))
  339. )
  340. assert names(by_content) == ["GS_TEST_阿尔法雷达子案"]
  341. item = data(by_content)["items"][0]
  342. assert "专属词" in item["contentSnippet"]
  343. by_levels = gs_client.get(
  344. f"{base}?securityLevels=SECRET", headers=auth(token_for("user"))
  345. )
  346. # 唯一 SECRET 子案对 user 不可见(CUSTOM 无授权)→ 完全空白。
  347. assert data(by_levels)["total"] == 0
  348. by_org = gs_client.get(
  349. f"{base}?organizationKeyword=GS通信", headers=auth(token_for("user"))
  350. )
  351. assert data(by_org)["total"] == 0
  352. def test_mounted_attachments_tab_applies_all_conditions(
  353. gs_app, gs_client, token_for
  354. ):
  355. alpha_id = gs_app.config["GS_IDS"]["alpha"]
  356. base = f"/api/v1/main-plans/{alpha_id}/attachments"
  357. by_keyword = gs_client.get(f"{base}?keyword=挂载附件", headers=auth(token_for("user")))
  358. assert names(by_keyword) == ["GS_TEST_阿尔法挂载附件"]
  359. by_content = gs_client.get(
  360. f"{base}?contentKeyword=附件正文检索词", headers=auth(token_for("user"))
  361. )
  362. assert names(by_content) == ["GS_TEST_阿尔法挂载附件"]
  363. item = data(by_content)["items"][0]
  364. assert "附件正文检索词" in item["contentSnippet"]
  365. assert item["organizationName"] == "GS作战组"
  366. by_levels = gs_client.get(
  367. f"{base}?securityLevels=SECRET", headers=auth(token_for("user"))
  368. )
  369. assert data(by_levels)["total"] == 0
  370. by_org = gs_client.get(
  371. f"{base}?organizationKeyword=GS通信", headers=auth(token_for("user"))
  372. )
  373. assert data(by_org)["total"] == 0
  374. @pytest.mark.parametrize(
  375. "query",
  376. [
  377. "securityLevels=BOGUS",
  378. "securityLevels=SECRET,BOGUS",
  379. "securityLevels=SECRET,SECRET",
  380. "aggregateHits=true&documentType=MAIN,SUB_PLAN",
  381. "aggregateHits=maybe",
  382. ],
  383. )
  384. def test_invalid_advanced_params(gs_client, token_for, query):
  385. response = gs_client.get(
  386. f"/api/v1/documents?{query}", headers=auth(token_for("admin"))
  387. )
  388. assert response.status_code == 400
  389. assert response.get_json()["code"] == "INVALID_ARGUMENT"