from __future__ import annotations from datetime import datetime from urllib.parse import urlencode import pytest import yaml from sqlalchemy import event, func, select from dms.common.errors import InvalidArgumentError from dms.common.time_range import parse_datetime, parse_updated_range def auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def payload(response): return response.get_json()["data"] TIMES = { "B4_TEST_草稿主案": datetime(2026, 1, 5, 0, 0), "B4_TEST_绝密主案": datetime(2026, 1, 6, 0, 0), "B4_TEST_无授权主案": datetime(2026, 1, 7, 0, 0), "B4_TEST_全部主案": datetime(2026, 1, 10, 0, 0), "B4_TEST_规范附件": datetime(2026, 1, 15, 0, 0), "B4_TEST_组织主案": datetime(2026, 1, 20, 0, 0), "B4_TEST_动态继承子案": datetime(2026, 1, 25, 0, 0), "B4_TEST_自定义主案": datetime(2026, 1, 30, 0, 0), "B4_TEST_表格附件": datetime(2026, 2, 5, 0, 0), } @pytest.fixture() def q2b_app(b4_app): from dms.extensions import db from dms.models import AttachmentBinding, Document with b4_app.app_context(): documents = db.session.scalars( select(Document).where(Document.document_name.in_(TIMES)) ).all() assert len(documents) == len(TIMES) for document in documents: document.updated_at = TIMES[document.document_name] # 故意让挂载关系时间全部晚于筛选范围,证明筛选依据是附件文档。 for binding in db.session.scalars(select(AttachmentBinding)).all(): binding.updated_at = datetime(2035, 1, 1, 0, 0) db.session.commit() return b4_app @pytest.fixture() def q2b_client(q2b_app): return q2b_app.test_client() def query(client, path: str, token: str, **params): return client.get( f"{path}?{urlencode(params)}", headers=auth(token), ) def names(response) -> list[str]: return [item["documentName"] for item in payload(response)["items"]] def test_common_parser_requires_timezone_and_normalizes_to_utc(): assert parse_datetime( "2026-01-20T08:00:00+08:00", name="updatedFrom" ) == datetime(2026, 1, 20, 0, 0) assert parse_datetime( "2026-01-20T00:00:00Z", name="updatedFrom" ) == datetime(2026, 1, 20, 0, 0) with pytest.raises(InvalidArgumentError): parse_datetime("2026-01-20T00:00:00", name="updatedFrom") with pytest.raises(InvalidArgumentError): parse_updated_range( { "updatedFrom": "2026-01-21T00:00:00Z", "updatedTo": "2026-01-20T00:00:00Z", } ) def test_documents_updated_range_single_and_closed_boundaries( q2b_client, token_for ): token = token_for("admin") start = query( q2b_client, "/api/v1/documents", token, documentType="MAIN", updatedFrom="2026-01-30T00:00:00Z", pageSize=100, ) end = query( q2b_client, "/api/v1/documents", token, documentType="MAIN", updatedTo="2026-01-10T00:00:00Z", pageSize=100, ) closed = query( q2b_client, "/api/v1/documents", token, documentType="MAIN", updatedFrom="2026-01-20T00:00:00Z", updatedTo="2026-01-30T00:00:00Z", sortBy="updatedAt", sortDirection="asc", pageSize=100, ) assert names(start) == ["B4_TEST_自定义主案"] assert "B4_TEST_全部主案" in names(end) assert "B4_TEST_组织主案" not in names(end) assert names(closed) == ["B4_TEST_组织主案", "B4_TEST_自定义主案"] assert all(item["id"].isdecimal() for item in payload(closed)["items"]) def test_documents_keyword_offset_timezone_and_filter_before_pagination( q2b_client, token_for ): token = token_for("admin") combined = query( q2b_client, "/api/v1/documents", token, documentType="MAIN", keyword="组织", updatedFrom="2026-01-20T08:00:00+08:00", updatedTo="2026-01-20T08:00:00+08:00", pageSize=100, ) second_page = query( q2b_client, "/api/v1/documents", token, documentType="MAIN", updatedFrom="2026-01-20T00:00:00Z", updatedTo="2026-01-30T00:00:00Z", sortBy="updatedAt", sortDirection="asc", page=2, pageSize=1, ) assert names(combined) == ["B4_TEST_组织主案"] assert payload(second_page)["total"] == 2 assert names(second_page) == ["B4_TEST_自定义主案"] def test_direct_sub_plan_range_exact_boundary_and_status( q2b_app, q2b_client, token_for ): main_id = q2b_app.config["B4_IDS"]["main_custom"] response = query( q2b_client, f"/api/v1/main-plans/{main_id}/sub-plans", token_for("user"), keyword="动态", status="PUBLISHED", updatedFrom="2026-01-25T00:00:00Z", updatedTo="2026-01-25T00:00:00Z", pageSize=100, ) outside = query( q2b_client, f"/api/v1/main-plans/{main_id}/sub-plans", token_for("admin"), status="PUBLISHED", updatedFrom="2026-01-25T00:00:00.001Z", pageSize=100, ) assert response.status_code == 200 assert names(response) == ["B4_TEST_动态继承子案"] assert names(outside) == [] def test_mounted_attachment_range_uses_attachment_not_binding_time( q2b_app, q2b_client, token_for ): main_id = q2b_app.config["B4_IDS"]["main_all"] first = query( q2b_client, f"/api/v1/main-plans/{main_id}/attachments", token_for("user"), keyword="规范", attachmentType="WORK_STANDARD", updatedFrom="2026-01-15T00:00:00Z", updatedTo="2026-01-15T00:00:00Z", pageSize=100, ) second = query( q2b_client, f"/api/v1/main-plans/{main_id}/attachments", token_for("admin"), attachmentType="TABLE", updatedFrom="2026-02-01T00:00:00Z", pageSize=100, ) excluded = query( q2b_client, f"/api/v1/main-plans/{main_id}/attachments", token_for("admin"), updatedTo="2026-01-14T23:59:59Z", pageSize=100, ) assert names(first) == ["B4_TEST_规范附件"] assert names(second) == ["B4_TEST_表格附件"] assert names(excluded) == [] assert payload(first)["items"][0]["bindingId"].isdecimal() @pytest.mark.parametrize( "params", [ {"updatedFrom": ""}, {"updatedTo": "not-a-time"}, {"updatedFrom": "2026-01-01T00:00:00"}, { "updatedFrom": "2026-02-01T00:00:00Z", "updatedTo": "2026-01-01T00:00:00Z", }, ], ) @pytest.mark.parametrize("endpoint", ["documents", "sub-plans", "attachments"]) def test_invalid_ranges_return_uniform_error( q2b_app, q2b_client, token_for, endpoint, params ): ids = q2b_app.config["B4_IDS"] paths = { "documents": "/api/v1/documents", "sub-plans": f"/api/v1/main-plans/{ids['main_custom']}/sub-plans", "attachments": f"/api/v1/main-plans/{ids['main_all']}/attachments", } response = query(q2b_client, paths[endpoint], token_for("admin"), **params) assert response.status_code == 400 body = response.get_json() assert body["code"] == "INVALID_ARGUMENT" assert body["requestId"] == response.headers["X-Request-Id"] def test_role_boundaries_and_queries_do_not_increment_counts_or_audit( q2b_app, q2b_client, token_for ): from dms.extensions import db from dms.models import AuditLog, Document ids = q2b_app.config["B4_IDS"] tokens = {name: token_for(name) for name in ("admin", "user")} with q2b_app.app_context(): before_counts = { item.id: (item.view_count, item.download_count) for item in db.session.scalars(select(Document)).all() } before_audits = db.session.scalar(select(func.count()).select_from(AuditLog)) admin = query( q2b_client, "/api/v1/documents", tokens["admin"], updatedFrom="2026-01-01T00:00:00Z", ) user = query( q2b_client, f"/api/v1/main-plans/{ids['main_custom']}/sub-plans", tokens["user"], updatedTo="2026-12-31T23:59:59Z", ) assert admin.status_code == 200 assert user.status_code == 200 with q2b_app.app_context(): after_counts = { item.id: (item.view_count, item.download_count) for item in db.session.scalars(select(Document)).all() } after_audits = db.session.scalar(select(func.count()).select_from(AuditLog)) assert after_counts == before_counts assert after_audits == before_audits def test_time_filters_are_present_in_database_queries( q2b_app, q2b_client, token_for ): from dms.extensions import db ids = q2b_app.config["B4_IDS"] statements: list[str] = [] def record(_conn, _cursor, statement, _params, _context, _many): statements.append(statement.lower()) with q2b_app.app_context(): event.listen(db.engine, "before_cursor_execute", record) try: token = token_for("admin") for path in ( "/api/v1/documents", f"/api/v1/main-plans/{ids['main_custom']}/sub-plans", f"/api/v1/main-plans/{ids['main_all']}/attachments", ): response = query( q2b_client, path, token, updatedFrom="2026-01-01T00:00:00Z", updatedTo="2026-12-31T23:59:59Z", ) assert response.status_code == 200 finally: event.remove(db.engine, "before_cursor_execute", record) filtered = [ statement for statement in statements if "doc_document.updated_at >=" in statement and "doc_document.updated_at <=" in statement ] assert len(filtered) >= 3 mounted = next( statement for statement in filtered if "doc_attachment_binding" in statement ) normalized_sql = " ".join(mounted.split()) where_clause = normalized_sql.split(" where ", maxsplit=1)[1] assert "doc_attachment_binding.updated_at" not in where_clause def test_openapi_declares_q2b_parameters_without_new_paths(): from pathlib import Path root = Path(__file__).resolve().parents[2] / "openapi" document = yaml.safe_load((root / "openapi.yaml").read_text(encoding="utf-8")) paths = yaml.safe_load( (root / "paths" / "documents.yaml").read_text(encoding="utf-8") ) assert document["info"]["version"] == "1.6.0" for operation in ("Documents", "SubPlans", "MainPlanAttachments"): references = { parameter.get("$ref") for parameter in paths[operation]["get"]["parameters"] if "$ref" in parameter } assert "../components/parameters.yaml#/UpdatedFrom" in references assert "../components/parameters.yaml#/UpdatedTo" in references assert "/api/v1/documents/search" not in document["paths"]