"""Тесты M5: MCP-инструменты и авторизация /mcp."""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app import mcp_server
from app.main import app
from tests.conftest import MCP_TOKEN # type: ignore[attr-defined]
def test_mcp_create_and_get_task(client: TestClient) -> None:
created = mcp_server.create_task("Задача от агента", description="описание")
assert created["detail_state"] == "raw"
full = mcp_server.get_task(created["id"])
assert full["title"] == "Задача от агента"
assert full["description"] == "описание"
assert full["attachments"] == []
def test_mcp_update_and_complete(client: TestClient) -> None:
created = mcp_server.create_task("x")
updated = mcp_server.update_task(created["id"], priority=8)
assert updated["priority"] == 8
done = mcp_server.complete_task(created["id"], actual_minutes=25)
assert done["status"] == "done"
full = mcp_server.get_task(created["id"])
assert full["actual_minutes"] == 25
assert full["done_at"] is not None
def test_mcp_unknown_refs_rejected(client: TestClient) -> None:
with pytest.raises(ValueError, match="not found"):
mcp_server.create_task("x", project_id=999)
with pytest.raises(ValueError, match="not found"):
mcp_server.create_task("x", parent_task_id=999)
tid = mcp_server.create_task("x")["id"]
with pytest.raises(ValueError, match="Unknown status"):
mcp_server.update_task(tid, status="unknown")
def test_mcp_list_and_search(client: TestClient) -> None:
a = mcp_server.create_task("Помыть окна")
b = mcp_server.create_task("Купить шторы")
all_tasks = mcp_server.list_tasks(detail_state="raw")
assert [t["id"] for t in all_tasks] == [b["id"], a["id"]] # свежие первыми
found = mcp_server.list_tasks(query="окн")
assert [t["id"] for t in found] == [a["id"]]
by_status = mcp_server.list_tasks(status="done")
assert by_status == []
def test_mcp_requires_bearer_token() -> None:
# Этот тест — единственный, где запускается lifespan (MCP session manager)
with TestClient(app) as client:
# без токена
assert client.post("/mcp", json={}).status_code == 401
# неверный токен
resp = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong-token"})
assert resp.status_code == 401
# верный токен — проходит аутентификацию (ошибки протокола — не 401)
resp = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
headers={"Authorization": f"Bearer {MCP_TOKEN}"},
)
assert resp.status_code != 401
# --- награды и повторения: общий путь закрытия с HTTP-API ---
def _db_events(task_id: int) -> tuple[list[Any], list[Any]]:
from sqlalchemy import select
from app.models import CoinEvent, XpEvent
from tests.conftest import _test_session_factory
session = _test_session_factory()
try:
xp = list(session.scalars(select(XpEvent).where(XpEvent.task_id == task_id)).all())
coins = list(session.scalars(select(CoinEvent).where(CoinEvent.task_id == task_id)).all())
return xp, coins
finally:
session.close()
def test_mcp_complete_grants_rewards(client: TestClient) -> None:
"""Закрытие агентом даёт те же награды, что и закрытие из UI (XP + монеты)."""
from app.services.xp import XP_BASE
tid = mcp_server.create_task("Награда от агента", priority=8)["id"]
mcp_server.complete_task(tid)
xp, coins = _db_events(tid)
assert len(xp) == 1 and xp[0].amount >= XP_BASE
assert len(coins) == 1 and coins[0].amount > 0
def test_mcp_create_grants_micro_reward(client: TestClient) -> None:
from sqlalchemy import select
from app.models import CoinEvent, XpEvent
from tests.conftest import _test_session_factory
mcp_server.create_task("Микронаграда")
session = _test_session_factory()
try:
kinds = session.scalars(select(XpEvent.kind).where(XpEvent.kind == "create_task")).all()
assert kinds == ["create_task"]
coins = session.scalars(select(CoinEvent).where(CoinEvent.source == "create_task")).all()
assert len(coins) == 1
finally:
session.close()
def test_mcp_recurring_spawns_once(client: TestClient) -> None:
"""Регулярная от агента: спавн в момент закрытия, повторные обновления
закрытой задачи дубликат не создают (раньше спавнили каждый раз)."""
tid = mcp_server.create_task("Полить от агента")["id"]
mcp_server.update_task(tid, task_type="recurring", recur_kind="interval", recur_interval_days=3)
done = mcp_server.update_task(tid, status="done")
assert done.get("spawned_next_id") is not None
# любое обновление уже закрытой — без нового экземпляра
for _ in range(2):
again = mcp_server.update_task(tid, priority=1)
assert "spawned_next_id" not in again
tasks = mcp_server.list_tasks(query="Полить от агента")
assert len(tasks) == 2 # закрытая + единственный следующий экземпляр
def test_mcp_undone_and_reclose_no_duplicates(client: TestClient) -> None:
"""un-done сбрасывает done_at; повторное закрытие не даёт XP и не спавнит."""
tid = mcp_server.create_task("Туда-обратно")["id"]
mcp_server.complete_task(tid)
assert mcp_server.get_task(tid)["done_at"] is not None
mcp_server.update_task(tid, status="to_do")
assert mcp_server.get_task(tid)["done_at"] is None
again = mcp_server.update_task(tid, status="done")
assert "spawned_next_id" not in again
xp, _coins = _db_events(tid)
assert len(xp) == 1 # XP — один раз на задачу