345 lines
14 KiB
Python
345 lines
14 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 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"
|