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