Newer
Older
gnexus-tasks / backend / tests / test_mcp.py
"""Тесты 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 — один раз на задачу


# --- эргономика для агентов: проектный контекст (ТЗ 3.10) ---


def _make_project(name: str, note: str = "") -> int:
    # через mcp_server.get_session_factory — monkeypatch в conftest подменяет
    # её на тестовую SQLite; прямой импорт из app.db ушёл бы в dev-Postgres
    session = mcp_server.get_session_factory()()
    from app.models import Project

    try:
        project = Project(name=name, note=note)
        session.add(project)
        session.commit()
        return project.id
    finally:
        session.close()


def test_mcp_list_and_get_projects(client: TestClient) -> None:
    pid = _make_project("gntodo-разработка", note="Личный менеджер задач")
    mcp_server.create_task("Починить спавн", project_id=pid)
    mcp_server.create_task("Свободная задача")

    projects = mcp_server.list_projects()
    project = next(p for p in projects if p["id"] == pid)
    assert project["name"] == "gntodo-разработка"
    assert project["open_tasks"] == 1
    assert "менеджер задач" in project["note"]
    # задача без проекта не приписана этому проекту
    assert all(p["open_tasks"] == 1 for p in projects if p["id"] == pid)

    full = mcp_server.get_project(name="gntodo-разработка")
    assert full["id"] == pid
    assert full["note"] == "Личный менеджер задач"
    assert [t["title"] for t in full["tasks"]] == ["Починить спавн"]
    assert full["tasks"][0]["project"] == "gntodo-разработка"


def test_mcp_get_project_requires_id_or_name(client: TestClient) -> None:
    with pytest.raises(ValueError, match="list_projects"):
        mcp_server.get_project()


def test_mcp_task_by_project_name_and_hint(client: TestClient) -> None:
    _make_project("Дом и быт")
    created = mcp_server.create_task("Повесить полку", project_name=" дом и быт ")
    assert created["project"] == "Дом и быт"
    assert "hint" not in created

    found = mcp_server.list_tasks(project_name="Дом и быт")
    assert [t["id"] for t in found] == [created["id"]]

    # перенос задачи в другой проект по имени
    moved = mcp_server.update_task(created["id"], project_name="дом и БЫТ")
    assert moved["project"] == "Дом и быт"

    free = mcp_server.create_task("Без проекта")
    assert free["project"] is None
    assert "list_projects" in free["hint"]


def test_mcp_unknown_project_hints_next_step(client: TestClient) -> None:
    with pytest.raises(ValueError, match="list_projects"):
        mcp_server.create_task("x", project_name="несуществующий")
    tid = mcp_server.create_task("x")["id"]
    with pytest.raises(ValueError, match="list_projects"):
        mcp_server.update_task(tid, project_id=999)


def test_mcp_unknown_task_hints_search(client: TestClient) -> None:
    with pytest.raises(ValueError, match="list_tasks"):
        mcp_server.get_task(999)
    with pytest.raises(ValueError, match="list_tasks"):
        mcp_server.update_task(999, priority=5)


def test_mcp_list_tags(client: TestClient) -> None:
    tags = mcp_server.list_tags()
    assert tags == []  # теги создаёт UI/детализация; для агента важна пустая выдача