aps-agent/tests/golden/test_mes_http.py

345 lines
14 KiB
Python
Raw Permalink Normal View History

# ============================================================
# round-45 黄金测试:HTTP MES 适配器框架(矩阵 73/75)
# 覆盖:正常下发+报工回执+状态查询+撤单 / 幂等(重复 idemKey
# 不重复创建)/ 超时显式报错 / 5xx 重试恢复 / 重试耗尽 /
# 鉴权失败显式报错 / fail-closed(未配置 base_url)/
# mes.py 域切换(stub 默认,HTTP 显式)/ MCP 总线注册
# 本地 stub:随机端口 uvicorn(不占 8003/5173)
# ============================================================
from __future__ import annotations
import asyncio
import socket
import threading
import time
from typing import Any
import httpx
import pytest
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from server.agent_core.mcp_bus import McpBus
from server.integrations.mes_http import (
HttpMesClient,
MesHttpError,
reset_http_mes_client,
)
_HEADER_IDEM = "X-Idem-Key"
class StubState:
"""本地 MES HTTP stub 状态与故障注入旋钮。"""
def __init__(self) -> None:
self.work_orders: dict[str, dict[str, Any]] = {}
self.idempotency: dict[str, str] = {}
self.reports: list[dict[str, Any]] = []
self.seq = 0
self.require_token: str | None = None
self.delay_sec = 0.0
self.fail_500_left = 0
self.create_attempts = 0
def create_mes_http_stub_app(state: StubState) -> FastAPI:
app = FastAPI()
def _auth(request: Request) -> None:
if state.require_token:
auth = request.headers.get("Authorization", "")
if auth != "Bearer " + state.require_token:
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health")
async def health() -> dict:
return {
"ok": True, "system": "MES-HTTP-STUB", "plant": "CNWH",
"woCount": len(state.work_orders),
"openCount": sum(1 for w in state.work_orders.values()
if w.get("status") != "COMPLETED"),
"reportCount": len(state.reports),
"updatedAt": "2026-08-02 00:00",
}
@app.post("/work-orders")
async def create_work_order(request: Request) -> dict:
_auth(request)
state.create_attempts += 1
if state.delay_sec:
await asyncio.sleep(state.delay_sec)
if state.fail_500_left > 0:
state.fail_500_left -= 1
raise HTTPException(status_code=500, detail="upstream boom")
body = await request.json()
idem_key = request.headers.get(_HEADER_IDEM) or body.get("idemKey")
if not idem_key:
raise HTTPException(status_code=400, detail="missing idemKey")
if idem_key in state.idempotency:
wo = state.work_orders[state.idempotency[idem_key]]
return {"duplicate": True, "externalWo": wo}
state.seq += 1
wo = {
"id": f"MES-HTTP-WO-{state.seq:04d}", "idemKey": idem_key,
"plant": "CNWH", "at": "2026-08-02 00:00:00",
"status": "RELEASED", "qtyDone": 0, "progressPct": 0, **body,
}
state.work_orders[wo["id"]] = wo
state.idempotency[idem_key] = wo["id"]
return {"duplicate": False, "externalWo": wo}
@app.get("/work-orders/{wo_id}")
async def fetch_work_order(wo_id: str, request: Request) -> dict:
_auth(request)
if wo_id not in state.work_orders:
raise HTTPException(status_code=404, detail="not found")
return state.work_orders[wo_id]
@app.post("/work-orders/{wo_id}/reports")
async def post_report(wo_id: str, request: Request) -> dict:
_auth(request)
if wo_id not in state.work_orders:
raise HTTPException(status_code=404, detail="not found")
payload = await request.json()
state.seq += 1
report = {"id": f"MES-HTTP-RPT-{state.seq:04d}",
"externalWoId": wo_id, **payload}
state.reports.append(report)
wo = state.work_orders[wo_id]
if "qtyDone" in payload:
wo["qtyDone"] = payload["qtyDone"]
if "progressPct" in payload:
wo["progressPct"] = payload["progressPct"]
if payload.get("status"):
wo["status"] = payload["status"]
return {"report": report, "externalWo": wo}
@app.post("/work-orders/{wo_id}/cancel")
async def cancel_work_order(wo_id: str, request: Request) -> dict:
_auth(request)
if wo_id not in state.work_orders:
raise HTTPException(status_code=404, detail="not found")
wo = state.work_orders[wo_id]
if wo.get("status") == "CANCELLED":
return {"duplicate": True, "externalWo": wo}
wo["status"] = "CANCELLED"
wo["cancelReason"] = "saga-compensation"
return {"duplicate": False, "externalWo": wo}
return app
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture
def mes_http_stub():
state = StubState()
app = create_mes_http_stub_app(state)
port = _free_port()
server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=port, log_level="warning"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
try:
deadline = 100
while not server.started and deadline > 0:
time.sleep(0.05)
deadline -= 1
with httpx.Client(timeout=5.0) as client:
resp = client.get(f"http://127.0.0.1:{port}/health")
assert resp.status_code == 200 and resp.json().get("ok") is True
yield {"state": state, "base_url": f"http://127.0.0.1:{port}"}
finally:
server.should_exit = True
thread.join(timeout=5)
def _client(**kwargs: Any) -> HttpMesClient:
return HttpMesClient(timeout_sec=2.0, max_retries=1, **kwargs)
# ---------------- 正常下发 + 报工回执 + 状态查询 + 撤单 ----------------
def test_http_dispatch_report_receipt_status_and_cancel(mes_http_stub):
base = mes_http_stub["base_url"]
client = _client(base_url=base, token="")
r = client.create_work_order({"orderNo": "SO-1", "operation": "OP10", "track": "flex"}, "k-1")
assert r["duplicate"] is False
wo_id = r["externalWo"]["id"]
assert wo_id.startswith("MES-HTTP-WO-")
receipt = client.post_report(wo_id, {"progressPct": 100, "qtyDone": 5, "status": "COMPLETED"})
assert receipt["report"]["externalWoId"] == wo_id
assert receipt["externalWo"]["status"] == "COMPLETED"
status = client.fetch_work_order(wo_id)
assert status["id"] == wo_id
assert status["progressPct"] == 100
cancel = client.cancel_work_order(wo_id)
assert cancel["externalWo"]["status"] == "CANCELLED"
again = client.cancel_work_order(wo_id)
assert again["duplicate"] is True
# ---------------- 幂等:重复 idemKey 不重复创建 ----------------
def test_http_dispatch_idempotent_by_idem_key(mes_http_stub):
base = mes_http_stub["base_url"]
client = _client(base_url=base, token="")
r1 = client.create_work_order({"orderNo": "SO-1"}, "dup-key")
r2 = client.create_work_order({"orderNo": "SO-1-again"}, "dup-key")
assert r1["duplicate"] is False
assert r2["duplicate"] is True
assert r2["externalWo"]["id"] == r1["externalWo"]["id"]
assert len(mes_http_stub["state"].work_orders) == 1
# ---------------- 超时:显式报错 ----------------
def test_http_timeout_explicit_error(mes_http_stub):
mes_http_stub["state"].delay_sec = 3.0
client = HttpMesClient(
base_url=mes_http_stub["base_url"], timeout_sec=0.2, max_retries=1, token="")
with pytest.raises(MesHttpError) as ei:
client.create_work_order({"orderNo": "SO-1"}, "slow-key")
assert ei.value.code == "MES_HTTP_TIMEOUT"
assert ei.value.status_code == 504
# ---------------- 重试:瞬时 5xx 后恢复 ----------------
def test_http_retry_recovers_after_transient_5xx(mes_http_stub):
state = mes_http_stub["state"]
state.fail_500_left = 2
client = HttpMesClient(
base_url=mes_http_stub["base_url"], timeout_sec=2.0, max_retries=3, token="")
r = client.create_work_order({"orderNo": "SO-1"}, "retry-key")
assert r["duplicate"] is False
assert state.create_attempts == 3 # 2 次 500 + 1 次成功
# ---------------- 重试耗尽:显式报错(供 saga 补偿识别) ----------------
def test_http_retry_exhausted_raises_explicit(mes_http_stub):
state = mes_http_stub["state"]
state.fail_500_left = 10
client = HttpMesClient(
base_url=mes_http_stub["base_url"], timeout_sec=2.0, max_retries=1, token="")
with pytest.raises(MesHttpError) as ei:
client.create_work_order({"orderNo": "SO-1"}, "boom-key")
assert ei.value.code == "MES_HTTP_UPSTREAM_ERROR"
assert state.create_attempts == 2 # 初始 + 1 次重试
# ---------------- 鉴权失败:显式报错,正确 token 可恢复 ----------------
def test_http_auth_failure_explicit_and_success_with_token(mes_http_stub):
mes_http_stub["state"].require_token = "secret-token"
no_token = _client(base_url=mes_http_stub["base_url"], token="")
with pytest.raises(MesHttpError) as ei:
no_token.create_work_order({"orderNo": "SO-1"}, "auth-key")
assert ei.value.code == "MES_HTTP_AUTH_FAILED"
assert ei.value.status_code == 401
ok = _client(base_url=mes_http_stub["base_url"], token="secret-token")
r = ok.create_work_order({"orderNo": "SO-1"}, "auth-key")
assert r["duplicate"] is False
# ---------------- fail-closed:未配置 base_url ----------------
def test_http_not_configured_fail_closed():
client = HttpMesClient(base_url="", token="")
assert client.configured is False
assert client.status()["connected"] is False
assert client.status()["mode"] == "http"
with pytest.raises(MesHttpError) as ei:
client.create_work_order({"orderNo": "SO-1"}, "k")
assert ei.value.code == "MES_HTTP_NOT_CONFIGURED"
# ---------------- mes.py 域切换:stub 默认,HTTP 显式启用 ----------------
def test_mes_domain_switches_to_http_client_when_configured(mes_http_stub, monkeypatch):
from server.aps_domain.mes import cancel_dispatch, mes_connection_status
class _MemStore:
def __init__(self) -> None:
self.data = {"mesLinks": [], "mesCancellations": {}, "_seq": 0}
def next_id(self, kind: str) -> int:
self.data["_seq"] = self.data.get("_seq", 0) + 1
return self.data["_seq"]
def save(self) -> None:
pass
monkeypatch.delenv("MES_HTTP_BASE_URL", raising=False)
reset_http_mes_client(base_url="", token="")
assert mes_connection_status()["mode"] == "stub" # 默认 stub
monkeypatch.setenv("MES_HTTP_BASE_URL", mes_http_stub["base_url"])
reset_http_mes_client()
assert mes_connection_status()["mode"] == "http" # 显式配置 → HTTP
assert mes_connection_status()["connected"] is True
client = HttpMesClient(base_url=mes_http_stub["base_url"], token="")
r = client.create_work_order({"orderNo": "SO-1"}, "domain-key")
wo_id = r["externalWo"]["id"]
store = _MemStore()
store.data["mesLinks"] = [{"kind": "dispatch", "idemKey": "domain-key",
"externalWoId": wo_id}]
res = cancel_dispatch(store, idem_key="domain-key", actor="saga")
assert res["cancelled"] == [wo_id]
assert res["failed"] == []
res2 = cancel_dispatch(store, idem_key="domain-key", actor="saga")
assert res2["duplicate"] is True
res3 = cancel_dispatch(store, external_wo_ids=["MES-HTTP-WO-9999"], actor="saga")
assert res3["failed"] == ["MES_HTTP_NOT_FOUND:MES-HTTP-WO-9999"]
# ---------------- MCP 总线注册:power 门禁 + fail-closed + 端到端 ----------------
def test_mcp_bus_mes_http_adapter_dispatch_and_fail_closed(tmp_path, mes_http_stub, monkeypatch):
monkeypatch.delenv("MES_HTTP_BASE_URL", raising=False)
reset_http_mes_client(base_url="", token="")
bus = McpBus(path=str(tmp_path / "plugins.json"))
plugin = bus.get("mes.http")
assert plugin is not None
tools = {t["name"]: t for t in plugin["tools"]}
assert {"mes.http_dispatch", "mes.http_status", "mes.http_report", "mes.http_cancel"} <= set(tools)
assert tools["mes.http_dispatch"]["power"] == "P3"
assert tools["mes.http_status"]["power"] == "P0"
assert tools["mes.http_dispatch"]["idempotent"] is True
# P3/P2 默认 deny:需显式放行(power 门禁)
with pytest.raises(PermissionError):
bus.call_tool("mes.http", "mes.http_dispatch", {"idemKey": "mcp-key"}, actor="planner")
for tool in ("mes.http_dispatch", "mes.http_report", "mes.http_cancel"):
bus.set_permission("mes.http", tool, True, actor="planner", reason="golden")
# fail-closed:未配置 base_url 时工具调用明确报错
with pytest.raises(MesHttpError) as ei:
bus.call_tool("mes.http", "mes.http_dispatch",
{"idemKey": "mcp-key", "orderNo": "SO-1"}, actor="planner")
assert ei.value.code == "MES_HTTP_NOT_CONFIGURED"
# 配置 stub 后:下发 + 状态 + 报工 + 撤单 全链路通过总线
reset_http_mes_client(base_url=mes_http_stub["base_url"], token="")
r = bus.call_tool("mes.http", "mes.http_dispatch",
{"idemKey": "mcp-key", "orderNo": "SO-1", "track": "flex"},
actor="planner")
assert r["duplicate"] is False
wo_id = r["externalWo"]["id"]
status = bus.call_tool("mes.http", "mes.http_status", {"externalWoId": wo_id},
actor="planner")
assert status["id"] == wo_id
report = bus.call_tool("mes.http", "mes.http_report",
{"externalWoId": wo_id, "progressPct": 100, "status": "COMPLETED"},
actor="planner")
assert report["externalWo"]["status"] == "COMPLETED"
cancel = bus.call_tool("mes.http", "mes.http_cancel", {"externalWoId": wo_id},
actor="planner")
assert cancel["externalWo"]["status"] == "CANCELLED"