test_global_search.py 15 KB

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