aps-agent/tests/golden/test_mes_http.py

345 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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"