117 lines
6.0 KiB
Python
117 lines
6.0 KiB
Python
import asyncio
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.mcp import query_service
|
|
from app.mcp.catalog import TOOLS
|
|
from app.mcp.models import BoundContext, TaskContextInput
|
|
from app.mcp.work_order_skills import read_work_order_skill
|
|
|
|
|
|
@pytest.mark.parametrize("workflow,name", [
|
|
("burst_report", "burst-event-handling"),
|
|
("water_quality_complaint", "water-quality-complaint"),
|
|
("general", "appointment-follow-up"),
|
|
("scada_anomaly", "scada-anomaly"),
|
|
])
|
|
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
|
descriptor = read_work_order_skill(workflow)
|
|
body = read_work_order_skill(workflow, include_content=True)
|
|
assert descriptor == {key: value for key, value in body.items() if key != "instructions"}
|
|
assert body["name"] == name and body["version"]
|
|
assert len(body["content_sha256"]) == 64 and body["instructions"]
|
|
assert "instructions" not in descriptor
|
|
# MCP task context has no evidence resource fallback: the complete instructions
|
|
# and result envelope must fit its inline response budget.
|
|
assert len(json.dumps(body, ensure_ascii=False).encode()) < 10000
|
|
|
|
|
|
@pytest.mark.parametrize("workflow", [None, "unknown", "../../secrets", "burst-report"])
|
|
def test_unknown_workflow_never_falls_back_or_reads_an_arbitrary_path(workflow):
|
|
assert read_work_order_skill(workflow)["available"] is False
|
|
|
|
|
|
def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills():
|
|
handling = read_work_order_skill("burst_report", include_content=True)
|
|
replay = read_work_order_skill("burst_report", replay_context={"replay_id": "r"}, include_content=True)
|
|
assert handling["name"] == "burst-event-handling"
|
|
assert replay["name"] == "scada-anomaly"
|
|
assert replay["workflow"] == "burst_report"
|
|
assert "影响说明" in handling["instructions"]
|
|
assert "supply_affected_nodes" in handling["instructions"]
|
|
assert "run_burst_location" not in handling["instructions"]
|
|
assert "run_burst_location" in replay["instructions"]
|
|
|
|
|
|
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
|
|
task_id, project_id = uuid4(), uuid4()
|
|
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
|
|
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
|
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
|
scope = SimpleNamespace(connection=object(), project=object())
|
|
binding = BoundContext(
|
|
context_handle="a" * 43, project_id=project_id, subject="u", client_id="host",
|
|
native_session_id="s", purpose="task_execution", task_id=task_id,
|
|
tool_cap=["get_task_context"], permission_cap=["operational_task.view"],
|
|
expires_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
def query(section):
|
|
return asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
|
TaskContextInput(context_handle=binding.context_handle, section=section)))
|
|
|
|
summary = query("summary")
|
|
body = query("skill")
|
|
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
|
assert summary.data["display_time_zone"] == "Asia/Shanghai"
|
|
assert body.data["skill"]["workflow"] == "burst_report"
|
|
assert body.data["skill"]["instructions"] and not body.truncated
|
|
assert body.evidence_id is None and body.semantics == "task"
|
|
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
|
binding.permission_cap = []
|
|
with pytest.raises(HTTPException) as caught:
|
|
query("skill")
|
|
assert caught.value.status_code == 403 and reader.await_count == 2
|
|
|
|
|
|
@pytest.mark.parametrize("extra", [{"workflow": "general"}, {"data_path": ["instructions"]}, {"offset": 1}])
|
|
def test_skill_input_cannot_select_another_workflow_or_partial_instructions(extra):
|
|
with pytest.raises(ValueError):
|
|
TaskContextInput(context_handle="a" * 43, section="skill", **extra)
|
|
|
|
|
|
def test_replay_context_advertises_demo_location_without_revealing_truth(monkeypatch):
|
|
from app.services import burst_location_demo as demo
|
|
|
|
monkeypatch.setattr(query_service.reads, "task_context_summary", AsyncMock(
|
|
return_value={"task": {"workflow": "scada_anomaly"}}))
|
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"operational_task.view"})
|
|
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2"))
|
|
binding = BoundContext(
|
|
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
|
|
native_session_id="s", purpose="task_execution", task_id=uuid4(),
|
|
tool_cap=["get_task_context", "run_burst_location"], permission_cap=["operational_task.view"],
|
|
expires_at=datetime.now(timezone.utc),
|
|
replay_context={"replay_id": "r", "knowledge_cutoff": demo.EVENT_START.isoformat(), "batch_sequence": 3},
|
|
)
|
|
for section in ("summary", "skill"):
|
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
|
TaskContextInput(context_handle=binding.context_handle, section=section)))
|
|
capability = result.data["location_demo"]
|
|
assert capability["available"] is True
|
|
assert capability["tool"] == "run_burst_location"
|
|
assert capability["burst_leakage_required"] is False
|
|
assert datetime.fromisoformat(capability["scada_burst_end"]) <= demo.EVENT_START
|
|
assert all(key not in json.dumps(capability) for key in (demo.REFERENCE_PIPE, demo.REFERENCE_NODE, demo.CANDIDATE_PIPE))
|
|
|
|
binding.replay_context["knowledge_cutoff"] = "2026-07-15T23:45:00Z"
|
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
|
TaskContextInput(context_handle=binding.context_handle)))
|
|
assert result.data["location_demo"]["available"] is False
|