| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- 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.5.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"]
|