195 lines
8.4 KiB
Python
195 lines
8.4 KiB
Python
# 批量审批端点黄金测试(plan.md §3.3 / §6.10.2)
|
||
# ============================================================
|
||
# 覆盖:批量批准多条 P2、批量含 P3 首次批准(secondConfirmRequired)、
|
||
# 批量驳回、单条失败隔离、note 落库、仅当前用户作用域。
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from server.agent_core import harness
|
||
from server.auth.context import bind_identity, reset_identity
|
||
|
||
|
||
class _AuditStore:
|
||
def __init__(self, world_key: str = "personal-1001", tenant_uuid: str = "tenant-batch-test"):
|
||
self.world_key = world_key
|
||
self.tenant_uuid = tenant_uuid
|
||
self.data: dict = {"auditEvents": [], "scheduleVersions": [], "flexScheduleVersions": []}
|
||
|
||
def next_id(self, _kind: str) -> int:
|
||
return len(self.data["auditEvents"]) + 1
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
class _CheckpointStore:
|
||
def __init__(self) -> None:
|
||
self.pairs: list[dict] = []
|
||
|
||
def create(self, *_args, **_kwargs) -> dict:
|
||
pair = {"pairId": f"pair-{len(self.pairs) + 1}"}
|
||
self.pairs.append(pair)
|
||
return pair
|
||
|
||
def get(self, pair_id: str) -> dict | None:
|
||
return next((p for p in self.pairs if p["pairId"] == pair_id), None)
|
||
|
||
|
||
class _ProjectStore:
|
||
def active_world_key(self) -> str:
|
||
return "default"
|
||
|
||
def require_active_write(self) -> None:
|
||
pass
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_pending():
|
||
harness._approval_store.clear()
|
||
yield
|
||
harness._approval_store.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client_factory(monkeypatch):
|
||
"""返回 (client, store, provider);测试认证替换 JMS,登录身份用于 stage/审批。"""
|
||
import server.gateway.app as gateway_module
|
||
import server.state.projects as projects_module
|
||
from tests.auth_provider import install_test_auth
|
||
|
||
provider = install_test_auth(monkeypatch, "tenant-batch-test")
|
||
|
||
def factory():
|
||
store = _AuditStore()
|
||
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
|
||
monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: _CheckpointStore())
|
||
monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore())
|
||
client = TestClient(gateway_module.create_app())
|
||
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
|
||
assert login.status_code == 200, login.text
|
||
return client, store, provider
|
||
|
||
return factory
|
||
|
||
|
||
def _stage(provider, action: str) -> str:
|
||
"""用与登录一致的测试身份出卡。"""
|
||
identity = provider._identity("planner")
|
||
params = {"versionId": 1, "track": "flex"} if action == "mes.dispatch" else {"versionId": 1}
|
||
token = bind_identity(identity)
|
||
try:
|
||
block = harness.stage_confirmation(
|
||
"session-batch", action, params, title="批量测试", summary_lines=["批量审批"],
|
||
evidence_refs=["schedule-version:1"] if action == "mes.dispatch" else None,
|
||
)
|
||
finally:
|
||
reset_identity(token)
|
||
return str(block.props["confirmId"])
|
||
|
||
|
||
def _as(identity, callback):
|
||
token = bind_identity(identity)
|
||
try:
|
||
return callback()
|
||
finally:
|
||
reset_identity(token)
|
||
|
||
|
||
def test_confirm_batch_approves_multiple_p2(client_factory):
|
||
"""批量批准多条 P2:全部执行成功,审计携带 beforeSnapshot/evidenceRefs。"""
|
||
client, store, provider = client_factory()
|
||
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
|
||
"poCount": 0, "woCount": 0, "conflictCount": 0,
|
||
"totalTardiness": 0})
|
||
store.data["productionOrders"] = []
|
||
ids = [_stage(provider, "schedule.publish"), _stage(provider, "schedule.publish")]
|
||
resp = client.post("/api/actions/confirm-batch",
|
||
json={"sessionId": "batch", "confirmIds": ids, "approve": True,
|
||
"note": "批量同意"})
|
||
assert resp.status_code == 200, resp.text
|
||
data = resp.json()
|
||
assert len(data["results"]) == 2
|
||
assert all(r["ok"] for r in data["results"])
|
||
assert all(r["secondConfirmRequired"] is False for r in data["results"])
|
||
events = [e for e in store.data["auditEvents"] if e["action"] == "schedule.publish"]
|
||
assert len(events) == 2
|
||
assert all(e["beforeSnapshot"] is not None for e in events)
|
||
assert all("schedule-version:1" in (e.get("evidenceRefs") or []) for e in events)
|
||
identity = provider._identity("planner")
|
||
history = _as(identity, harness.list_approval_history)
|
||
assert len(history) >= 2
|
||
assert all(h["note"] == "批量同意" and h["status"] == "APPROVED" for h in history[-2:])
|
||
|
||
|
||
def test_confirm_batch_p3_first_approval_marks_second_required(client_factory):
|
||
"""批量含 P3:首次批准标记 secondConfirmRequired,不视为失败。"""
|
||
client, store, provider = client_factory()
|
||
ids = [_stage(provider, "mes.dispatch"), _stage(provider, "mes.dispatch")]
|
||
resp = client.post("/api/actions/confirm-batch",
|
||
json={"sessionId": "batch", "confirmIds": ids, "approve": True})
|
||
assert resp.status_code == 200, resp.text
|
||
results = resp.json()["results"]
|
||
assert len(results) == 2
|
||
assert all(r["secondConfirmRequired"] is True for r in results)
|
||
assert all(r["ok"] for r in results)
|
||
|
||
|
||
def test_confirm_batch_rejects_all(client_factory):
|
||
"""批量驳回:全部 DENIED 且无世界写入。"""
|
||
client, store, provider = client_factory()
|
||
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
|
||
"poCount": 0, "woCount": 0, "conflictCount": 0,
|
||
"totalTardiness": 0})
|
||
store.data["productionOrders"] = []
|
||
ids = [_stage(provider, "schedule.publish"), _stage(provider, "schedule.publish")]
|
||
resp = client.post("/api/actions/confirm-batch",
|
||
json={"sessionId": "batch", "confirmIds": ids, "approve": False,
|
||
"note": "数据不齐"})
|
||
assert resp.status_code == 200, resp.text
|
||
results = resp.json()["results"]
|
||
assert len(results) == 2 and all(r["ok"] for r in results)
|
||
assert store.data["scheduleVersions"][0]["status"] == "DRAFT" # 未发布
|
||
identity = provider._identity("planner")
|
||
history = _as(identity, harness.list_approval_history)
|
||
assert len(history) >= 2
|
||
assert all(h["note"] == "数据不齐" and h["status"] == "REJECTED" for h in history[-2:])
|
||
|
||
|
||
def test_single_confirm_api_carries_note(client_factory):
|
||
"""单条 confirm API 透传 note:审批历史记录意见。"""
|
||
client, store, provider = client_factory()
|
||
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
|
||
"poCount": 0, "woCount": 0, "conflictCount": 0,
|
||
"totalTardiness": 0})
|
||
store.data["productionOrders"] = []
|
||
cid = _stage(provider, "schedule.publish")
|
||
resp = client.post("/api/actions/confirm",
|
||
json={"sessionId": "single", "confirmId": cid, "approve": True,
|
||
"note": "单条同意"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["secondConfirmRequired"] is False
|
||
identity = provider._identity("planner")
|
||
history = _as(identity, harness.list_approval_history)
|
||
assert history[-1]["note"] == "单条同意"
|
||
assert history[-1]["status"] == "APPROVED"
|
||
|
||
|
||
def test_confirm_batch_isolates_failures(client_factory):
|
||
"""单条失败隔离:过期令牌失败不阻断其余成功。"""
|
||
client, store, provider = client_factory()
|
||
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
|
||
"poCount": 0, "woCount": 0, "conflictCount": 0,
|
||
"totalTardiness": 0})
|
||
store.data["productionOrders"] = []
|
||
good = _stage(provider, "schedule.publish")
|
||
bad = "nonexistent-confirm"
|
||
resp = client.post("/api/actions/confirm-batch",
|
||
json={"sessionId": "batch", "confirmIds": [good, bad], "approve": True})
|
||
assert resp.status_code == 200, resp.text
|
||
results = {r["confirmId"]: r for r in resp.json()["results"]}
|
||
assert results[good]["ok"] is True
|
||
assert results[bad]["ok"] is False
|
||
assert "不存在" in results[bad]["message"] or "失效" in results[bad]["message"]
|