test_q2b_updated_range.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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")}
  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. assert admin.status_code == 200
  242. assert user.status_code == 200
  243. with q2b_app.app_context():
  244. after_counts = {
  245. item.id: (item.view_count, item.download_count)
  246. for item in db.session.scalars(select(Document)).all()
  247. }
  248. after_audits = db.session.scalar(select(func.count()).select_from(AuditLog))
  249. assert after_counts == before_counts
  250. assert after_audits == before_audits
  251. def test_time_filters_are_present_in_database_queries(
  252. q2b_app, q2b_client, token_for
  253. ):
  254. from dms.extensions import db
  255. ids = q2b_app.config["B4_IDS"]
  256. statements: list[str] = []
  257. def record(_conn, _cursor, statement, _params, _context, _many):
  258. statements.append(statement.lower())
  259. with q2b_app.app_context():
  260. event.listen(db.engine, "before_cursor_execute", record)
  261. try:
  262. token = token_for("admin")
  263. for path in (
  264. "/api/v1/documents",
  265. f"/api/v1/main-plans/{ids['main_custom']}/sub-plans",
  266. f"/api/v1/main-plans/{ids['main_all']}/attachments",
  267. ):
  268. response = query(
  269. q2b_client,
  270. path,
  271. token,
  272. updatedFrom="2026-01-01T00:00:00Z",
  273. updatedTo="2026-12-31T23:59:59Z",
  274. )
  275. assert response.status_code == 200
  276. finally:
  277. event.remove(db.engine, "before_cursor_execute", record)
  278. filtered = [
  279. statement
  280. for statement in statements
  281. if "doc_document.updated_at >=" in statement
  282. and "doc_document.updated_at <=" in statement
  283. ]
  284. assert len(filtered) >= 3
  285. mounted = next(
  286. statement for statement in filtered if "doc_attachment_binding" in statement
  287. )
  288. normalized_sql = " ".join(mounted.split())
  289. where_clause = normalized_sql.split(" where ", maxsplit=1)[1]
  290. assert "doc_attachment_binding.updated_at" not in where_clause
  291. def test_openapi_declares_q2b_parameters_without_new_paths():
  292. from pathlib import Path
  293. root = Path(__file__).resolve().parents[2] / "openapi"
  294. document = yaml.safe_load((root / "openapi.yaml").read_text(encoding="utf-8"))
  295. paths = yaml.safe_load(
  296. (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
  297. )
  298. assert document["info"]["version"] == "1.5.0"
  299. for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
  300. references = {
  301. parameter.get("$ref")
  302. for parameter in paths[operation]["get"]["parameters"]
  303. if "$ref" in parameter
  304. }
  305. assert "../components/parameters.yaml#/UpdatedFrom" in references
  306. assert "../components/parameters.yaml#/UpdatedTo" in references
  307. assert "/api/v1/documents/search" not in document["paths"]