157 lines
6.2 KiB
Python
157 lines
6.2 KiB
Python
"""MCP transport contracts: no real device actions, files and HTTP are isolated."""
|
|
|
|
from unittest.mock import AsyncMock
|
|
import httpx
|
|
import pytest
|
|
|
|
from mcp_server.tools import automation as tools
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_favorites_share_backend_preserve_request_id_and_refuse_secrets(monkeypatch):
|
|
request = AsyncMock(return_value={"id": "favorite"})
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
await tools.mobile_favorites("create", source_kind="batch", source_id="source")
|
|
request.assert_awaited_once_with(
|
|
"POST",
|
|
"/api/automation/favorites",
|
|
json={"source_kind": "batch", "source_id": "source", "version": None},
|
|
)
|
|
request.reset_mock()
|
|
with pytest.raises(ValueError):
|
|
await tools.mobile_favorites("run", favorite_id="favorite")
|
|
with pytest.raises(ValueError, match="密钥"):
|
|
await tools.mobile_favorites(
|
|
"update",
|
|
favorite_id="favorite",
|
|
changes={"case_configs": {"0": {"api_key": "never-send"}}},
|
|
)
|
|
request.assert_not_awaited()
|
|
await tools.mobile_favorites(
|
|
"run",
|
|
favorite_id="favorite",
|
|
request_id="stable",
|
|
changes={"task_config": {"model": "original"}},
|
|
)
|
|
request.assert_awaited_once_with(
|
|
"POST",
|
|
"/api/automation/favorites/favorite/run",
|
|
json={"task_config": {"model": "original"}, "request_id": "stable"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch):
|
|
request = AsyncMock(
|
|
return_value={
|
|
"model": "old",
|
|
"base_url": "https://example.test/v1",
|
|
"api_key_configured": True,
|
|
"env_path": "/private/.env",
|
|
"reasoning_effort": "high",
|
|
}
|
|
)
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
with pytest.raises(ValueError, match="persist_defaults"):
|
|
await tools.mobile_config(changes={"model": "new"})
|
|
with pytest.raises(ValueError, match="密钥"):
|
|
await tools.mobile_config(changes={"api_key": "never-sent"}, persist_defaults=True)
|
|
request.assert_not_awaited()
|
|
await tools.mobile_config(changes={"model": "new"}, persist_defaults=True)
|
|
payload = request.call_args.kwargs["json"]
|
|
assert payload == {
|
|
"model": "new",
|
|
"base_url": "https://example.test/v1",
|
|
"reasoning_effort": "high",
|
|
}
|
|
|
|
await tools.mobile_config(
|
|
changes={"rate_limit_retry_seconds": 90, "tool_fallback_model": "gpt-6-luna"},
|
|
persist_defaults=True,
|
|
)
|
|
payload = request.call_args.kwargs["json"]
|
|
assert payload["rate_limit_retry_seconds"] == 90
|
|
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
|
source = tmp_path / "cases.xlsx"
|
|
source.write_bytes(b"test workbook")
|
|
request = AsyncMock(return_value={"valid": True, "tasks": [], "errors": []})
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
await tools.mobile_task_file("preview", str(source))
|
|
assert request.call_args.args == ("POST", "/api/automation/import/preview")
|
|
request.reset_mock()
|
|
with pytest.raises(ValueError):
|
|
await tools.mobile_batch("create", batch={"name": "x"})
|
|
request.assert_not_awaited()
|
|
selected = [{"name": "选中用例", "goal": "观察设置"}]
|
|
await tools.mobile_batch(
|
|
"create", request_id="stable-id", batch={"name": "x", "cases": selected, "repeat_count": 3}
|
|
)
|
|
assert request.await_count == 1
|
|
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
|
|
assert request.call_args.kwargs["json"]["cases"] == selected
|
|
assert request.call_args.kwargs["json"]["repeat_count"] == 3
|
|
assert request.call_args.args[1] == "/api/automation/batches"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_running_batch_start_is_idempotent(monkeypatch):
|
|
request = AsyncMock(return_value={"id": "batch", "status": "running"})
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
await tools.mobile_batch("start", batch_id="batch")
|
|
assert request.await_count == 1
|
|
assert request.call_args.args[0] == "GET"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_batch_uses_the_same_backend_as_the_web_ui(monkeypatch):
|
|
request = AsyncMock(return_value={"batch_id": "batch", "status": "deleted"})
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
|
|
assert (await tools.mobile_batch("delete", batch_id="batch"))["status"] == "deleted"
|
|
request.assert_awaited_once_with("DELETE", "/api/automation/batches/batch")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
|
|
monkeypatch.setattr(tools, "_base_url", AsyncMock(return_value="http://test"))
|
|
client_type = httpx.AsyncClient
|
|
transport = httpx.MockTransport(
|
|
lambda request: httpx.Response(200, content=b"PK\x03\x04report-bytes")
|
|
)
|
|
monkeypatch.setattr(
|
|
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
|
)
|
|
result = await tools._download(
|
|
"/api/automation/reports/test/docx",
|
|
"test.docx",
|
|
owner_kind="sessions",
|
|
owner_id="test",
|
|
)
|
|
from pathlib import Path
|
|
|
|
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
|
assert Path(result["path"]).is_absolute()
|
|
assert Path(result["path"]).parent == tmp_path / "sessions" / "test"
|
|
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
|
assert not list(tmp_path.rglob("*.part"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_uses_saved_formats_without_mutating_execution(monkeypatch):
|
|
request = AsyncMock(
|
|
side_effect=[{"formats": ["xlsx"]}, {"verdict": "未验证", "status": "completed"}]
|
|
)
|
|
download = AsyncMock(return_value={"path": "/exports/report.xlsx"})
|
|
monkeypatch.setattr(tools, "_request", request)
|
|
monkeypatch.setattr(tools, "_download", download)
|
|
result = await tools.mobile_export_report(session_id="session")
|
|
assert result["report"]["verdict"] == "未验证"
|
|
assert len(result["files"]) == 1
|
|
assert all(call.args[0] == "GET" for call in request.call_args_list)
|
|
assert download.call_args.args[0].endswith("/xlsx")
|