| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- """跨页移动排序:文档 move 与挂载附件 move 的行为契约测试。"""
- from __future__ import annotations
- import pytest
- from dms.extensions import db
- from dms.models import AuditLog, AttachmentBinding, Category, Document, User
- def _auth(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- def _ids(app):
- return app.config["B4_IDS"]
- def _category(app, code: str) -> Category:
- with app.app_context():
- category = db.session.scalar(
- db.select(Category).where(Category.category_code == code)
- )
- assert category is not None
- db.session.expunge(category)
- return category
- def _main_document(name: str, category: Category) -> Document:
- return Document(
- document_name=name,
- document_type="MAIN",
- document_status="PUBLISHED",
- security_level="INTERNAL",
- category_id=category.id,
- category_name=category.category_name,
- category_path=category.category_path,
- original_file_name=f"{name}.docx",
- file_relative_path=f"original/MOVE_{name}.docx",
- file_extension="docx",
- file_hash="b" * 64,
- )
- def _attachment_document(name: str, attachment_type: str) -> Document:
- return Document(
- document_name=name,
- document_type="ATTACHMENT",
- document_status="PUBLISHED",
- security_level="PUBLIC",
- attachment_type=attachment_type,
- original_file_name=f"{name}.pdf",
- file_relative_path=f"original/MOVE_{name}.pdf",
- file_extension="pdf",
- file_hash="c" * 64,
- )
- def _move(client, document_id, payload, token):
- return client.put(
- f"/api/v1/documents/{document_id}/move",
- json=payload,
- headers=_auth(token),
- )
- def _main_order(app) -> list[int]:
- """全量主案按 (sort_no, id) 升序的 id 序列。"""
- with app.app_context():
- rows = db.session.scalars(
- db.select(Document).where(
- Document.document_type == "MAIN",
- Document.is_deleted.is_(False),
- )
- ).all()
- return sorted(rows, key=lambda item: (item.sort_no, item.id))
- def test_move_requires_authentication(b6_client):
- response = b6_client.put("/api/v1/documents/1/move", json={"target": "TOP"})
- assert response.status_code == 401
- @pytest.mark.parametrize(
- "payload",
- [
- {},
- {"targetPosition": 1, "target": "TOP"},
- {"targetPosition": 0},
- {"target": "MIDDLE"},
- {"targetPosition": "3"},
- {"targetPosition": 1, "unknown": 1},
- ],
- )
- def test_move_rejects_invalid_payload(b6_client, login_token, payload):
- response = _move(b6_client, "1", payload, login_token)
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_move_missing_document_returns_404(b6_client, login_token):
- response = _move(b6_client, "999999", {"target": "TOP"}, login_token)
- assert response.status_code == 404
- assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
- def test_move_main_plan_to_top_rewrites_sort_numbers(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- # 种子主案 sort_no 全 0:顺序按 id 即 全部/组织/自定义/草稿/绝密/无授权
- assert [item.id for item in _main_order(b6_app)].index(ids["main_custom"]) == 2
- response = _move(
- b6_client, ids["main_custom"], {"target": "TOP"}, login_token
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 1, "total": 6}
- order = [item.id for item in _main_order(b6_app)]
- assert order.index(ids["main_custom"]) == 0
- # 其余主案相对顺序保持,且全量按步长10重编号
- assert [item.sort_no for item in _main_order(b6_app)] == [
- 10, 20, 30, 40, 50, 60,
- ]
- with b6_app.app_context():
- audit = db.session.scalar(
- db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
- )
- assert audit is not None
- assert audit.target_id == ids["main_custom"]
- detail = audit.operation_detail
- assert detail["fromPosition"] == 3
- assert detail["toPosition"] == 1
- assert detail["scope"] == "GLOBAL"
- assert detail["scopeTotal"] == 6
- def test_move_clamps_out_of_range_position_to_bottom(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- response = _move(
- b6_client, ids["main_all"], {"targetPosition": 999}, login_token
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 6, "total": 6}
- assert [item.id for item in _main_order(b6_app)].index(ids["main_all"]) == 5
- def test_move_to_current_position_is_noop_without_audit(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- response = _move(
- b6_client, ids["main_custom"], {"targetPosition": 3}, login_token
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 3, "total": 6}
- with b6_app.app_context():
- assert (
- db.session.scalar(
- db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
- )
- is None
- )
- assert [item.sort_no for item in _main_order(b6_app)] == [0] * 6
- def test_move_with_category_scope_keeps_other_category_relative_order(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- root_a = _category(b6_app, "ROOT_A")
- root_b = _category(b6_app, "ROOT_B")
- with b6_app.app_context():
- admin = db.session.scalar(db.select(User).where(User.username == "admin"))
- extra_b1 = _main_document("MOVE_TEST_范围B甲", root_b)
- extra_b2 = _main_document("MOVE_TEST_范围B乙", root_b)
- db.session.add_all([extra_b1, extra_b2])
- db.session.flush()
- binding_before = [
- item.id for item in _main_order(b6_app)
- if item.id in {extra_b1.id, extra_b2.id}
- ]
- assert binding_before == [extra_b1.id, extra_b2.id]
- response = _move(
- b6_client,
- ids["main_custom"],
- {"target": "TOP", "scopeCategoryId": str(root_a.id)},
- login_token,
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 1, "total": 6}
- order = [item.id for item in _main_order(b6_app)]
- assert order.index(ids["main_custom"]) == 0
- # 范围外(ROOT_B)主案相对顺序保持
- assert order.index(extra_b1.id) < order.index(extra_b2.id)
- with b6_app.app_context():
- audit = db.session.scalar(
- db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
- )
- assert audit.operation_detail["scopeCategoryId"] == str(root_a.id)
- def test_move_rejects_document_outside_category_scope(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- root_b = _category(b6_app, "ROOT_B")
- response = _move(
- b6_client,
- ids["main_custom"],
- {"target": "TOP", "scopeCategoryId": str(root_b.id)},
- login_token,
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "DOCUMENT_NOT_IN_MOVE_SCOPE"
- def test_move_rejects_scope_category_on_sub_plan(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- root_a = _category(b6_app, "ROOT_A")
- response = _move(
- b6_client,
- ids["sub"],
- {"target": "TOP", "scopeCategoryId": str(root_a.id)},
- login_token,
- )
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_move_sub_plan_stays_within_parent_scope(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- root_a = _category(b6_app, "ROOT_A")
- with b6_app.app_context():
- parent = db.session.get(Document, ids["main_custom"])
- second_sub = Document(
- document_name="MOVE_TEST_第二子案",
- document_type="SUB_PLAN",
- document_status="PUBLISHED",
- security_level="SECRET",
- category_id=root_a.id,
- category_name=root_a.category_name,
- category_path=root_a.category_path,
- parent_document_id=parent.id,
- root_document_id=parent.id,
- original_file_name="MOVE_TEST_第二子案.docx",
- file_relative_path="original/MOVE_TEST_第二子案.docx",
- file_extension="docx",
- file_hash="d" * 64,
- )
- db.session.add(second_sub)
- db.session.commit()
- second_sub_id = second_sub.id
- response = _move(b6_client, second_sub_id, {"target": "TOP"}, login_token)
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 1, "total": 2}
- with b6_app.app_context():
- rows = db.session.scalars(
- db.select(Document).where(
- Document.document_type == "SUB_PLAN",
- Document.parent_document_id == ids["main_custom"],
- Document.is_deleted.is_(False),
- )
- ).all()
- by_sort = sorted(rows, key=lambda item: (item.sort_no, item.id))
- assert [item.id for item in by_sort] == [second_sub_id, ids["sub"]]
- # 子案移动不影响主案排序
- assert [item.sort_no for item in _main_order(b6_app)] == [0] * 6
- audit = db.session.scalar(
- db.select(AuditLog).where(AuditLog.action_type == "MOVE_DOCUMENT")
- )
- assert audit.operation_detail["scope"] == str(ids["main_custom"])
- def _binding_id(app, main_id: int, attachment_id: int) -> int:
- with app.app_context():
- binding = db.session.scalar(
- db.select(AttachmentBinding).where(
- AttachmentBinding.main_document_id == main_id,
- AttachmentBinding.attachment_document_id == attachment_id,
- AttachmentBinding.is_deleted.is_(False),
- )
- )
- assert binding is not None
- return binding.id
- def _binding_moves(app, main_id: int) -> list[tuple[int, int]]:
- """主案下有效挂载按 (sort_no, id) 升序的 (binding_id, sort_no)。"""
- with app.app_context():
- rows = db.session.scalars(
- db.select(AttachmentBinding).where(
- AttachmentBinding.main_document_id == main_id,
- AttachmentBinding.is_deleted.is_(False),
- )
- ).all()
- return [
- (item.id, item.sort_no)
- for item in sorted(rows, key=lambda row: (row.sort_no, row.id))
- ]
- def test_move_attachment_binding_to_top_bumps_row_version(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- # main_all 挂载:表格附件(10) 在前、规范附件(20) 在后
- binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
- json={"target": "TOP", "mainPlanRowVersion": 0},
- headers=_auth(login_token),
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 1, "total": 2, "mainPlanRowVersion": 1}
- moves = {binding: sort for binding, sort in _binding_moves(b6_app, ids["main_all"])}
- binding_two = _binding_id(b6_app, ids["main_all"], ids["attachment_two"])
- assert moves[binding_one] < moves[binding_two]
- with b6_app.app_context():
- main = db.session.get(Document, ids["main_all"])
- assert main.row_version == 1
- audit = db.session.scalar(
- db.select(AuditLog).where(AuditLog.action_type == "MOVE_ATTACHMENT")
- )
- assert audit is not None
- assert audit.target_id == binding_one
- detail = audit.operation_detail
- assert detail["fromPosition"] == 2
- assert detail["toPosition"] == 1
- assert detail["scopeTotal"] == 2
- def test_move_attachment_within_type_scope_keeps_other_type_order(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- with b6_app.app_context():
- admin = db.session.scalar(db.select(User).where(User.username == "admin"))
- main = db.session.get(Document, ids["main_all"])
- third = _attachment_document("MOVE_TEST_训考乙", "TRAINING_MATERIAL")
- db.session.add(third)
- db.session.flush()
- db.session.add(
- AttachmentBinding(
- main_document_id=main.id,
- attachment_document_id=third.id,
- sort_no=30,
- created_by=admin.id,
- updated_by=admin.id,
- )
- )
- main.attachment_count = 3
- db.session.commit()
- third_id = third.id
- binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
- # 训考资料范围内:规范附件(20) 第1、训考乙(30) 第2,移动训考乙到第1
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/"
- f"{_binding_id(b6_app, ids['main_all'], third_id)}/move",
- json={
- "targetPosition": 1,
- "scopeAttachmentType": "TRAINING_MATERIAL",
- "mainPlanRowVersion": 0,
- },
- headers=_auth(login_token),
- )
- assert response.status_code == 200
- assert response.get_json()["data"] == {"position": 1, "total": 2, "mainPlanRowVersion": 1}
- moves = _binding_moves(b6_app, ids["main_all"])
- binding_two = _binding_id(b6_app, ids["main_all"], ids["attachment_two"])
- binding_third = _binding_id(b6_app, ids["main_all"], third_id)
- # 支撑成果(类型外)保持最前,训考乙越过规范附件
- assert [binding for binding, _ in moves] == [
- binding_two, binding_third, binding_one,
- ]
- def test_move_attachment_rejects_type_scope_mismatch(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- # 规范附件是训考资料,按支撑成果口径移动应拒绝
- binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
- json={
- "target": "TOP",
- "scopeAttachmentType": "SUPPORT_ACHIEVEMENT",
- "mainPlanRowVersion": 0,
- },
- headers=_auth(login_token),
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "DOCUMENT_NOT_IN_MOVE_SCOPE"
- def test_move_attachment_rejects_stale_row_version(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
- json={"target": "TOP", "mainPlanRowVersion": 9},
- headers=_auth(login_token),
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
- @pytest.mark.parametrize(
- "payload",
- [
- {"target": "TOP"},
- {"mainPlanRowVersion": 0},
- {"targetPosition": 1, "target": "TOP", "mainPlanRowVersion": 0},
- {"target": "SIDE", "mainPlanRowVersion": 0},
- {"targetPosition": 1, "mainPlanRowVersion": -1},
- {"targetPosition": 1, "mainPlanRowVersion": 0, "extra": True},
- ],
- )
- def test_move_attachment_rejects_invalid_payload(
- b6_app, b6_client, login_token, payload
- ):
- ids = _ids(b6_app)
- binding_one = _binding_id(b6_app, ids["main_all"], ids["attachment_one"])
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/{binding_one}/move",
- json=payload,
- headers=_auth(login_token),
- )
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_move_attachment_missing_binding_returns_404(
- b6_app, b6_client, login_token
- ):
- ids = _ids(b6_app)
- response = b6_client.put(
- f"/api/v1/main-plans/{ids['main_all']}/attachments/999999/move",
- json={"target": "TOP", "mainPlanRowVersion": 0},
- headers=_auth(login_token),
- )
- assert response.status_code == 404
- assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
|