Skip to content

Commit adad8f6

Browse files
authored
Merge pull request #79 from zestones/15-m28-script-de-test-isolation
feat: unit tests for MCPClient, schema adapter, and work order schemas
2 parents 86720cb + 0db505b commit adad8f6

20 files changed

Lines changed: 319 additions & 2 deletions

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ e2e: ## Run backend E2E smoke test (requires stack to be up)
175175
backend.smoke.mcp: ## Run MCP server E2E smoke (requires stack + canonical KB; see issue #69)
176176
cd $(BACKEND_DIR) && PYTHONPATH=. $(VENV_BIN)/python tests/e2e/aria_mcp_smoke.py
177177

178+
backend.smoke.tools: ## Run per-tool MCPClient isolation smoke on P-02 (issue #15; requires stack + canonical KB)
179+
cd $(BACKEND_DIR) && PYTHONPATH=. $(VENV_BIN)/python tests/integration/aria_mcp/tools_p02_isolation.py
180+
178181
clean: ## Remove caches and build artifacts
179182
@find . -type d \( -name __pycache__ -o -name .pytest_cache -o -name .mypy_cache -o -name .ruff_cache \) -prune -exec rm -rf {} +
180183
@rm -rf $(BACKEND_DIR)/coverage.xml $(BACKEND_DIR)/htmlcov $(FRONTEND_DIR)/dist

backend/tests/e2e/aria_mcp_smoke.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from datetime import datetime, timedelta, timezone
2626

2727
from mcp import ClientSession
28-
from mcp.client.streamable_http import streamablehttp_client
28+
from mcp.client.streamable_http import streamable_http_client
2929

3030
URL = os.environ.get("ARIA_MCP_URL", "http://localhost:8000/mcp/")
3131

@@ -59,7 +59,7 @@ async def main() -> int:
5959
start = end - timedelta(hours=24)
6060
cell_id = 1 # P-02
6161

62-
async with streamablehttp_client(URL) as (read, write, _):
62+
async with streamable_http_client(URL) as (read, write, _):
6363
async with ClientSession(read, write) as session:
6464
await session.initialize()
6565

backend/tests/integration/__init__.py

Whitespace-only changes.

backend/tests/integration/aria_mcp/__init__.py

Whitespace-only changes.
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""Per-tool isolation smoke for the ARIA MCP server (issue #15 — M2.8).
2+
3+
Standalone script (not pytest-collected) that exercises every registered MCP
4+
tool **directly through the ``MCPClient`` singleton** on the canonical P-02
5+
seed (cell_id=1). Failures here block M3 (KB Builder) and M4 (Sentinel +
6+
Investigator agent loops) — debugging an agent loop with a broken tool is
7+
hell.
8+
9+
Differs from ``tests/e2e/aria_mcp_smoke.py``:
10+
* That script uses raw ``mcp.client.streamable_http_client`` to validate the
11+
transport contract.
12+
* **This** script uses the ``aria_mcp.client.MCPClient`` wrapper that the
13+
Investigator/Sentinel agents will actually consume — so it catches bugs
14+
in the wrapper layer (schema cache, ``ToolCallResult`` shape, error
15+
semantics) on top of tool correctness.
16+
17+
Per-tool assertion rules (audit §3 of issue #15):
18+
* Reads (``get_*``, ``list_*``): ``is_error=False`` + at least one expected
19+
business key in the payload.
20+
* Writes (``update_equipment_kb``): round-trip — patch a leaf, read back,
21+
confirm the leaf is the new value, then **restore** the original value in
22+
``finally`` so the script is idempotent and the demo seed stays clean.
23+
24+
Pre-requisites:
25+
docker compose up -d
26+
make db.seed.p02 # canonical KB
27+
28+
Run:
29+
make backend.smoke.tools
30+
or:
31+
cd backend && PYTHONPATH=. python tests/integration/aria_mcp/tools_p02_isolation.py
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import asyncio
37+
import json
38+
import os
39+
import sys
40+
from datetime import datetime, timedelta, timezone
41+
from typing import Any
42+
43+
# Allow ``python tests/integration/aria_mcp/tools_p02_isolation.py`` from the
44+
# backend root by injecting it onto sys.path when invoked directly.
45+
if __package__ is None or __package__ == "":
46+
_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
47+
if _BACKEND_ROOT not in sys.path:
48+
sys.path.insert(0, _BACKEND_ROOT)
49+
50+
from aria_mcp.client import MCPClient, ToolCallResult, mcp_client # noqa: E402
51+
52+
CELL_ID = 1 # P-02 in the canonical seed (migration 006)
53+
EXPECTED_TOOLS: set[str] = {
54+
# M2.2 KPI
55+
"get_oee",
56+
"get_mtbf",
57+
"get_mttr",
58+
"get_downtime_events",
59+
# M2.3 signals
60+
"get_signal_trends",
61+
"get_signal_anomalies",
62+
"get_current_signals",
63+
# M2.4 human context + hierarchy
64+
"get_logbook_entries",
65+
"get_shift_assignments",
66+
"get_work_orders",
67+
"list_cells",
68+
# M2.5 KB
69+
"get_equipment_kb",
70+
"get_failure_history",
71+
"update_equipment_kb",
72+
# M2.6 production
73+
"get_quality_metrics",
74+
"get_production_stats",
75+
}
76+
77+
78+
def _iso(dt: datetime) -> str:
79+
return dt.isoformat()
80+
81+
82+
def _payload(result: ToolCallResult) -> Any:
83+
"""Decode a tool result; flatten the FastMCP ``{"result": [...]}`` wrapper."""
84+
if result.is_error:
85+
raise AssertionError(f"tool returned is_error=True: {result.content}")
86+
if not result.content:
87+
return None
88+
data = json.loads(result.content)
89+
if (
90+
isinstance(data, dict)
91+
and set(data.keys()) == {"result"}
92+
and isinstance(data["result"], list)
93+
):
94+
return data["result"]
95+
return data
96+
97+
98+
async def _check(
99+
client: MCPClient,
100+
name: str,
101+
args: dict[str, Any],
102+
expected_keys: set[str] | None = None,
103+
list_non_empty: bool = False,
104+
) -> Any:
105+
"""Call a read tool, assert success + expected business keys."""
106+
res = await client.call_tool(name, args)
107+
payload = _payload(res)
108+
if list_non_empty:
109+
assert isinstance(payload, list) and len(payload) > 0, (
110+
f"{name}: expected non-empty list, got {type(payload).__name__} len="
111+
f"{len(payload) if hasattr(payload, '__len__') else 'n/a'}"
112+
)
113+
if expected_keys is not None:
114+
sample = payload[0] if isinstance(payload, list) else payload
115+
assert isinstance(sample, dict) and expected_keys <= set(
116+
sample
117+
), f"{name}: missing keys {expected_keys - set(sample)} in payload sample {sample!r}"
118+
print(f"[OK] {name}")
119+
return payload
120+
121+
122+
async def main() -> int:
123+
end = datetime.now(timezone.utc)
124+
start = end - timedelta(hours=24)
125+
today = end.date()
126+
window = {"window_start": _iso(start), "window_end": _iso(end)}
127+
cells = {"cell_ids": [CELL_ID], **window}
128+
129+
# Use a dedicated client so we never clobber the module singleton's cache
130+
# when invoked alongside other consumers in the same process.
131+
client = MCPClient(os.environ.get("ARIA_MCP_URL", mcp_client.url))
132+
133+
# ---- discovery ----
134+
schemas = await client.get_tools_schema()
135+
names = {s["name"] for s in schemas}
136+
missing = EXPECTED_TOOLS - names
137+
assert not missing, f"missing tools on server: {missing}"
138+
print(f"[OK] tools discovered: {len(names)} (expected {len(EXPECTED_TOOLS)} present)")
139+
140+
# ---- M2.2 KPI ----
141+
await _check(client, "get_oee", cells, expected_keys={"oee", "availability"})
142+
await _check(client, "get_mtbf", cells, expected_keys={"mtbf_seconds"})
143+
await _check(client, "get_mttr", cells, expected_keys={"mttr_seconds"})
144+
dt_ev = await _check(client, "get_downtime_events", cells, list_non_empty=False)
145+
assert isinstance(
146+
dt_ev, list
147+
), f"get_downtime_events: expected list, got {type(dt_ev).__name__}"
148+
149+
# ---- M2.3 signals ----
150+
cur = await _check(
151+
client,
152+
"get_current_signals",
153+
{"cell_id": CELL_ID},
154+
expected_keys={"signal_def_id", "raw_value", "display_name"},
155+
list_non_empty=True,
156+
)
157+
sig_ids = [r["signal_def_id"] for r in cur]
158+
await _check(
159+
client,
160+
"get_signal_trends",
161+
{"signal_def_ids": sig_ids, **window, "aggregation": "1h"},
162+
list_non_empty=True,
163+
)
164+
anom = await _check(
165+
client,
166+
"get_signal_anomalies",
167+
{"cell_id": CELL_ID, **window},
168+
list_non_empty=True, # issue #69 regression guard
169+
)
170+
assert len(anom) > 0, (
171+
"REGRESSION: get_signal_anomalies returned 0 breaches on canonical P-02 KB. "
172+
"Likely KB drift — run `make db.seed.p02` and retry."
173+
)
174+
175+
# ---- M2.4 context + hierarchy ----
176+
cells_all = await _check(
177+
client, "list_cells", {}, expected_keys={"id", "name"}, list_non_empty=True
178+
)
179+
assert any(c["name"] == "P-02" for c in cells_all), "P-02 missing from list_cells"
180+
181+
await _check(
182+
client,
183+
"get_logbook_entries",
184+
{"cell_id": CELL_ID, **window},
185+
list_non_empty=True,
186+
)
187+
await _check(
188+
client,
189+
"get_shift_assignments",
190+
{
191+
"cell_id": CELL_ID,
192+
"date_start": (today - timedelta(days=7)).isoformat(),
193+
"date_end": today.isoformat(),
194+
},
195+
list_non_empty=True,
196+
)
197+
await _check(
198+
client,
199+
"get_work_orders",
200+
{"cell_id": CELL_ID},
201+
list_non_empty=True,
202+
)
203+
await _check(
204+
client,
205+
"get_work_orders",
206+
{"cell_id": CELL_ID, "priority": "critical", "generated_by_agent": True},
207+
list_non_empty=True, # audit §1.2 filters wired
208+
)
209+
210+
# ---- M2.5 KB ----
211+
kb_before = await _check(
212+
client,
213+
"get_equipment_kb",
214+
{"cell_id": CELL_ID},
215+
expected_keys={"structured_data", "confidence_score"},
216+
)
217+
assert isinstance(
218+
kb_before["structured_data"], dict
219+
), "get_equipment_kb: structured_data must be a parsed dict (not raw asyncpg string)"
220+
await _check(
221+
client,
222+
"get_failure_history",
223+
{"cell_id": CELL_ID, "limit": 10},
224+
list_non_empty=False,
225+
)
226+
227+
# Write tool — round-trip with restore in finally (audit §1 Option B-lite).
228+
# Tracker: post-M3 we should add a TEST-00 sentinel cell so writes never
229+
# touch the demo seed at all (audit §1 Option A).
230+
vib_before = (
231+
kb_before["structured_data"].get("thresholds", {}).get("vibration_mm_s", {}).get("alert")
232+
)
233+
assert vib_before is not None, "P-02 KB seed must have thresholds.vibration_mm_s.alert"
234+
new_alert = float(vib_before) + 0.1
235+
try:
236+
patched = await _check(
237+
client,
238+
"update_equipment_kb",
239+
{
240+
"cell_id": CELL_ID,
241+
"structured_data_patch": {"thresholds": {"vibration_mm_s": {"alert": new_alert}}},
242+
"source": "tools_p02_isolation",
243+
"calibrated_by": "isolation_smoke",
244+
},
245+
expected_keys={"structured_data"},
246+
)
247+
sd = patched["structured_data"]
248+
assert (
249+
sd["thresholds"]["vibration_mm_s"]["alert"] == new_alert
250+
), "update_equipment_kb: leaf-level patch did not round-trip"
251+
log = sd.get("calibration_log") or []
252+
assert (
253+
log and log[-1]["calibrated_by"] == "isolation_smoke"
254+
), "update_equipment_kb: calibration_log not appended"
255+
finally:
256+
# Restore — keeps the demo seed clean even if assertions above fail.
257+
restore = await client.call_tool(
258+
"update_equipment_kb",
259+
{
260+
"cell_id": CELL_ID,
261+
"structured_data_patch": {
262+
"thresholds": {"vibration_mm_s": {"alert": float(vib_before)}}
263+
},
264+
"source": "tools_p02_isolation",
265+
"calibrated_by": "isolation_smoke",
266+
},
267+
)
268+
assert not restore.is_error, f"FAILED to restore P-02 KB: {restore.content}"
269+
270+
# ---- M2.6 production ----
271+
quality = await _check(
272+
client,
273+
"get_quality_metrics",
274+
cells,
275+
expected_keys={
276+
"cell_id",
277+
"total_pieces",
278+
"good_pieces",
279+
"bad_pieces",
280+
"quality_rate",
281+
},
282+
list_non_empty=True,
283+
)
284+
assert quality[0]["total_pieces"] >= 0
285+
await _check(
286+
client,
287+
"get_production_stats",
288+
cells,
289+
expected_keys={
290+
"productive_seconds",
291+
"unplanned_stop_seconds",
292+
"planned_stop_seconds",
293+
"total_pieces",
294+
"good_pieces",
295+
"bad_pieces",
296+
},
297+
)
298+
299+
# ---- error-path contract — bogus arg must surface as is_error=True, not raise ----
300+
bogus = await client.call_tool(
301+
"get_oee",
302+
{"cell_ids": [CELL_ID], "window_start": "bogus", "window_end": _iso(end)},
303+
)
304+
assert (
305+
bogus.is_error is True
306+
), "MCPClient contract: tool-side validation errors must return is_error=True, not raise"
307+
print("[OK] error path: invalid args surface as is_error=True (no exception)")
308+
309+
print(f"\nALL {len(EXPECTED_TOOLS)} TOOLS PASS ISOLATION SMOKE ON P-02 (cell_id={CELL_ID})")
310+
return 0
311+
312+
313+
if __name__ == "__main__":
314+
sys.exit(asyncio.run(main()))

backend/tests/unit/__init__.py

Whitespace-only changes.

backend/tests/unit/aria_mcp/__init__.py

Whitespace-only changes.
File renamed without changes.
File renamed without changes.

backend/tests/test_mcp_tools.py renamed to backend/tests/unit/aria_mcp/test_tools_registration.py

File renamed without changes.

0 commit comments

Comments
 (0)