test_q2b_updated_range.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from urllib.parse import urlencode
  4. import pytest
  5. import yaml
  6. from sqlalchemy import event, func, select
  7. from dms.common.errors import InvalidArgumentError
  8. from dms.common.time_range import parse_datetime, parse_updated_range
  9. def auth(token: str) -> dict[str, str]:
  10. return {"Authorization": f"Bearer {token}"}
  11. def payload(response):
  12. return response.get_json()["data"]
  13. TIMES = {
  14. "B4_TEST_草稿主案": datetime(2026, 1, 5, 0, 0),
  15. "B4_TEST_绝密主案": datetime(2026, 1, 6, 0, 0),
  16. "B4_TEST_无授权主案": datetime(2026, 1, 7, 0, 0),
  17. "B4_TEST_全部主案": datetime(2026, 1, 10, 0, 0),
  18. "B4_TEST_规范附件": datetime(2026, 1, 15, 0, 0),
  19. "B4_TEST_组织主案": datetime(2026, 1, 20, 0, 0),
  20. "B4_TEST_动态继承子案": datetime(2026, 1, 25, 0, 0),
  21. "B4_TEST_自定义主案": datetime(2026, 1, 30, 0, 0),
  22. "B4_TEST_表格附件": datetime(2026, 2, 5, 0, 0),
  23. }
  24. @pytest.fixture()
  25. def q2b_app(b4_app):
  26. from dms.extensions import db
  27. from dms.models import AttachmentBinding, Document
  28. with b4_app.app_context():
  29. documents = db.session.scalars(
  30. select(Document).where(Document.document_name.in_(TIMES))
  31. ).all()
  32. assert len(documents) == len(TIMES)
  33. for document in documents:
  34. document.updated_at = TIMES[document.document_name]
  35. # 故意让挂载关系时间全部晚于筛选范围,证明筛选依据是附件文档。
  36. for binding in db.session.scalars(select(AttachmentBinding)).all():
  37. binding.updated_at = datetime(2035, 1, 1, 0, 0)
  38. db.session.commit()
  39. return b4_app
  40. @pytest.fixture()
  41. def q2b_client(q2b_app):
  42. return q2b_app.test_client()
  43. def query(client, path: str, token: str, **params):
  44. return client.get(
  45. f"{path}?{urlencode(params)}",
  46. headers=auth(token),
  47. )
  48. def names(response) -> list[str]:
  49. return [item["documentName"] for item in payload(response)["items"]]
  50. def test_common_parser_requires_timezone_and_normalizes_to_utc():
  51. assert parse_datetime(
  52. "2026-01-20T08:00:00+08:00", name="updatedFrom"
  53. ) == datetime(2026, 1, 20, 0, 0)
  54. assert parse_datetime(
  55. "2026-01-20T00:00:00Z", name="updatedFrom"
  56. ) == datetime(2026, 1, 20, 0, 0)
  57. with pytest.raises(InvalidArgumentError):
  58. parse_datetime("2026-01-20T00:00:00", name="updatedFrom")
  59. with pytest.raises(InvalidArgumentError):
  60. parse_updated_range(
  61. {
  62. "updatedFrom": "2026-01-21T00:00:00Z",
  63. "updatedTo": "2026-01-20T00:00:00Z",
  64. }
  65. )
  66. def test_documents_updated_range_single_and_closed_boundaries(
  67. q2b_client, token_for
  68. ):
  69. token = token_for("admin")
  70. start = query(
  71. q2b_client,
  72. "/api/v1/documents",
  73. token,
  74. documentType="MAIN",
  75. updatedFrom="2026-01-30T00:00:00Z",
  76. pageSize=100,
  77. )
  78. end = query(
  79. q2b_client,
  80. "/api/v1/documents",
  81. token,
  82. documentType="MAIN",
  83. updatedTo="2026-01-10T00:00:00Z",
  84. pageSize=100,
  85. )
  86. closed = query(
  87. q2b_client,
  88. "/api/v1/documents",
  89. token,
  90. documentType="MAIN",
  91. updatedFrom="2026-01-20T00:00:00Z",
  92. updatedTo="2026-01-30T00:00:00Z",
  93. sortBy="updatedAt",
  94. sortDirection="asc",
  95. pageSize=100,
  96. )
  97. assert names(start) == ["B4_TEST_自定义主案"]
  98. assert "B4_TEST_全部主案" in names(end)
  99. assert "B4_TEST_组织主案" not in names(end)
  100. assert names(closed) == ["B4_TEST_组织主案", "B4_TEST_自定义主案"]
  101. assert all(item["id"].isdecimal() for item in payload(closed)["items"])
  102. def test_documents_keyword_offset_timezone_and_filter_before_pagination(
  103. q2b_client, token_for
  104. ):
  105. token = token_for("admin")
  106. combined = query(
  107. q2b_client,
  108. "/api/v1/documents",
  109. token,
  110. documentType="MAIN",
  111. keyword="组织",
  112. updatedFrom="2026-01-20T08:00:00+08:00",
  113. updatedTo="2026-01-20T08:00:00+08:00",
  114. pageSize=100,
  115. )
  116. second_page = query(
  117. q2b_client,
  118. "/api/v1/documents",
  119. token,
  120. documentType="MAIN",
  121. updatedFrom="2026-01-20T00:00:00Z",
  122. updatedTo="2026-01-30T00:00:00Z",
  123. sortBy="updatedAt",
  124. sortDirection="asc",
  125. page=2,
  126. pageSize=1,
  127. )
  128. assert names(combined) == ["B4_TEST_组织主案"]
  129. assert payload(second_page)["total"] == 2
  130. assert names(second_page) == ["B4_TEST_自定义主案"]
  131. def test_direct_sub_plan_range_exact_boundary_and_status(
  132. q2b_app, q2b_client, token_for
  133. ):
  134. main_id = q2b_app.config["B4_IDS"]["main_custom"]
  135. response = query(
  136. q2b_client,
  137. f"/api/v1/main-plans/{main_id}/sub-plans",
  138. token_for("user"),
  139. keyword="动态",
  140. status="PUBLISHED",
  141. updatedFrom="2026-01-25T00:00:00Z",
  142. updatedTo="2026-01-25T00:00:00Z",
  143. pageSize=100,
  144. )
  145. outside = query(
  146. q2b_client,
  147. f"/api/v1/main-plans/{main_id}/sub-plans",
  148. token_for("admin"),
  149. status="PUBLISHED",
  150. updatedFrom="2026-01-25T00:00:00.001Z",
  151. pageSize=100,
  152. )
  153. assert response.status_code == 200
  154. assert names(response) == ["B4_TEST_动态继承子案"]
  155. assert names(outside) == []
  156. def test_mounted_attachment_range_uses_attachment_not_binding_time(
  157. q2b_app, q2b_client, token_for
  158. ):
  159. main_id = q2b_app.config["B4_IDS"]["main_all"]
  160. first = query(
  161. q2b_client,
  162. f"/api/v1/main-plans/{main_id}/attachments",
  163. token_for("user"),
  164. keyword="规范",
  165. attachmentType="WORK_STANDARD",
  166. updatedFrom="2026-01-15T00:00:00Z",
  167. updatedTo="2026-01-15T00:00:00Z",
  168. pageSize=100,
  169. )
  170. second = query(
  171. q2b_client,
  172. f"/api/v1/main-plans/{main_id}/attachments",
  173. token_for("admin"),
  174. attachmentType="TABLE",
  175. updatedFrom="2026-02-01T00:00:00Z",
  176. pageSize=100,
  177. )
  178. excluded = query(
  179. q2b_client,
  180. f"/api/v1/main-plans/{main_id}/attachments",
  181. token_for("admin"),
  182. updatedTo="2026-01-14T23:59:59Z",
  183. pageSize=100,
  184. )
  185. assert names(first) == ["B4_TEST_规范附件"]
  186. assert names(second) == ["B4_TEST_表格附件"]
  187. assert names(excluded) == []
  188. assert payload(first)["items"][0]["bindingId"].isdecimal()
  189. @pytest.mark.parametrize(
  190. "params",
  191. [
  192. {"updatedFrom": ""},
  193. {"updatedTo": "not-a-time"},
  194. {"updatedFrom": "2026-01-01T00:00:00"},
  195. {
  196. "updatedFrom": "2026-02-01T00:00:00Z",
  197. "updatedTo": "2026-01-01T00:00:00Z",
  198. },
  199. ],
  200. )
  201. @pytest.mark.parametrize("endpoint", ["documents", "sub-plans", "attachments"])
  202. def test_invalid_ranges_return_uniform_error(
  203. q2b_app, q2b_client, token_for, endpoint, params
  204. ):
  205. ids = q2b_app.config["B4_IDS"]
  206. paths = {
  207. "documents": "/api/v1/documents",
  208. "sub-plans": f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
  209. "attachments": f"/api/v1/main-plans/{ids['main_all']}/attachments",
  210. }
  211. response = query(q2b_client, paths[endpoint], token_for("admin"), **params)
  212. assert response.status_code == 400
  213. body = response.get_json()
  214. assert body["code"] == "INVALID_ARGUMENT"
  215. assert body["requestId"] == response.headers["X-Request-Id"]
  216. def test_role_boundaries_and_queries_do_not_increment_counts_or_audit(
  217. q2b_app, q2b_client, token_for
  218. ):
  219. from dms.extensions import db
  220. from dms.models import AuditLog, Document
  221. ids = q2b_app.config["B4_IDS"]
  222. tokens = {name: token_for(name) for name in ("admin", "user", "auditor")}
  223. with q2b_app.app_context():
  224. before_counts = {
  225. item.id: (item.view_count, item.download_count)
  226. for item in db.session.scalars(select(Document)).all()
  227. }
  228. before_audits = db.session.scalar(select(func.count()).select_from(AuditLog))
  229. admin = query(
  230. q2b_client,
  231. "/api/v1/documents",
  232. tokens["admin"],
  233. updatedFrom="2026-01-01T00:00:00Z",
  234. )
  235. user = query(
  236. q2b_client,
  237. f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
  238. tokens["user"],
  239. updatedTo="2026-12-31T23:59:59Z",
  240. )
  241. auditor = query(
  242. q2b_client,
  243. "/api/v1/documents",
  244. tokens["auditor"],
  245. updatedFrom="2026-01-01T00:00:00Z",
  246. )
  247. auditor_mounted = query(
  248. q2b_client,
  249. f"/api/v1/main-plans/{ids['main_all']}/attachments",
  250. tokens["auditor"],
  251. updatedTo="2026-12-31T23:59:59Z",
  252. )
  253. assert admin.status_code == 200
  254. assert user.status_code == 200
  255. assert auditor.status_code == 403
  256. assert auditor_mounted.status_code == 403
  257. with q2b_app.app_context():
  258. after_counts = {
  259. item.id: (item.view_count, item.download_count)
  260. for item in db.session.scalars(select(Document)).all()
  261. }
  262. after_audits = db.session.scalar(select(func.count()).select_from(AuditLog))
  263. assert after_counts == before_counts
  264. assert after_audits == before_audits
  265. def test_time_filters_are_present_in_database_queries(
  266. q2b_app, q2b_client, token_for
  267. ):
  268. from dms.extensions import db
  269. ids = q2b_app.config["B4_IDS"]
  270. statements: list[str] = []
  271. def record(_conn, _cursor, statement, _params, _context, _many):
  272. statements.append(statement.lower())
  273. with q2b_app.app_context():
  274. event.listen(db.engine, "before_cursor_execute", record)
  275. try:
  276. token = token_for("admin")
  277. for path in (
  278. "/api/v1/documents",
  279. f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
  280. f"/api/v1/main-plans/{ids['main_all']}/attachments",
  281. ):
  282. response = query(
  283. q2b_client,
  284. path,
  285. token,
  286. updatedFrom="2026-01-01T00:00:00Z",
  287. updatedTo="2026-12-31T23:59:59Z",
  288. )
  289. assert response.status_code == 200
  290. finally:
  291. event.remove(db.engine, "before_cursor_execute", record)
  292. filtered = [
  293. statement
  294. for statement in statements
  295. if "doc_document.updated_at >=" in statement
  296. and "doc_document.updated_at <=" in statement
  297. ]
  298. assert len(filtered) >= 3
  299. mounted = next(
  300. statement for statement in filtered if "doc_attachment_binding" in statement
  301. )
  302. normalized_sql = " ".join(mounted.split())
  303. where_clause = normalized_sql.split(" where ", maxsplit=1)[1]
  304. assert "doc_attachment_binding.updated_at" not in where_clause
  305. def test_openapi_declares_q2b_parameters_without_new_paths():
  306. from pathlib import Path
  307. root = Path(__file__).resolve().parents[2] / "openapi"
  308. document = yaml.safe_load((root / "openapi.yaml").read_text(encoding="utf-8"))
  309. paths = yaml.safe_load(
  310. (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
  311. )
  312. assert document["info"]["version"] == "1.3.0"
  313. for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
  314. references = {
  315. parameter.get("$ref")
  316. for parameter in paths[operation]["get"]["parameters"]
  317. if "$ref" in parameter
  318. }
  319. assert "../components/parameters.yaml#/UpdatedFrom" in references
  320. assert "../components/parameters.yaml#/UpdatedTo" in references
  321. assert "/api/v1/documents/search" not in document["paths"]