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"]
|