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