Newer
Older
gnexus-tasks / backend / tests / test_agent_mandate.py
"""Тесты мандата ИИ-агента: доступность задачи, гейт агента, аренда (ТЗ 3.20)."""

from datetime import timedelta
from types import SimpleNamespace
from typing import Any

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import update

from app import mcp_server
from app.actor import VIA_API, agent_actor
from app.dependencies import actor_of, require_actor, require_user
from app.models import Task, utcnow
from tests.conftest import _test_session_factory, agent_ctx, mcp_ctx  # type: ignore[attr-defined]


def _create(client: TestClient, **payload: Any) -> dict[str, Any]:
    resp = client.post("/api/tasks", json={"title": "задача", **payload})
    assert resp.status_code == 200, resp.text
    got = client.get(f"/api/tasks/{resp.json()['id']}")
    assert got.status_code == 200, got.text
    return got.json()


def _as_agent(client: TestClient, name: str = "Нави", token_id: int = 7) -> Any:
    """Действие REST — агентское: подменяем актора, авторизация остаётся.

    Заголовок `X-Actor` разбирает actor_of() по пути; здесь подменён именно актор,
    поэтому правило заголовка проверяется отдельным тестом. token_id разводит
    агентов: аренда — на токен, а не на имя (label не уникален).
    """
    overrides = client.app.dependency_overrides  # type: ignore[attr-defined]
    saved = overrides.get(require_actor)
    overrides[require_actor] = lambda: agent_actor(name, token_id, "Владелец", VIA_API)
    return saved


def _restore_agent(client: TestClient, saved: Any) -> None:
    overrides = client.app.dependency_overrides  # type: ignore[attr-defined]
    if saved is None:
        overrides.pop(require_actor, None)
    else:
        overrides[require_actor] = saved


def _shift_lease(task_id: int, minutes: int) -> None:
    """Сдвинуть срок аренды от «сейчас»: −1 мин — протухла, +1 мин — вот-вот.

    Ждать два часа в тесте нечем, а протухание в проекте ленивое — по времени
    (см. Task.claim_is_alive), так что ход времени подделываем здесь.
    """
    with _test_session_factory() as session:
        session.execute(
            update(Task)
            .where(Task.id == task_id)
            .values(claim_expires_at=utcnow() + timedelta(minutes=minutes))
        )
        session.commit()


def test_flag_defaults_off(client: TestClient) -> None:
    task = _create(client)
    assert task["ai_eligible"] is False
    assert task["accept_state"] is None
    assert task["claimed_by"] is None


def test_owner_sets_flag_by_patch(client: TestClient) -> None:
    task = _create(client)
    resp = client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True})
    assert resp.status_code == 200, resp.text
    assert resp.json()["ai_eligible"] is True


def test_agent_needs_flag(client: TestClient) -> None:
    task = _create(client)
    flagged = _create(client, ai_eligible=True)
    saved = _as_agent(client)
    try:
        denied = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})
        assert denied.status_code == 403, denied.text
        allowed = client.patch(f"/api/tasks/{flagged['id']}", json={"priority": 5})
        assert allowed.status_code == 200, allowed.text
    finally:
        _restore_agent(client, saved)


def test_owner_is_not_limited(client: TestClient) -> None:
    """Владелец (сессия SSO) правит любую задачу — флаг для него не преграда."""
    task = _create(client)
    assert client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}).status_code == 200


def test_agent_cannot_grant_itself_the_flag(client: TestClient) -> None:
    flagged = _create(client, ai_eligible=True)
    saved = _as_agent(client)
    try:
        # Ни на помеченной задаче, ни с объявлением «действую как владелец»
        denied = client.patch(f"/api/tasks/{flagged['id']}", json={"ai_eligible": False})
        assert denied.status_code == 403, denied.text
        denied2 = client.patch(
            f"/api/tasks/{flagged['id']}",
            json={"ai_eligible": False},
            headers={"X-Actor": "user"},
        )
        assert denied2.status_code == 403, denied2.text
    finally:
        _restore_agent(client, saved)


def test_agent_may_create_flagged_task(client: TestClient) -> None:
    """Своя задача — другое дело: при создании агент вправе попросить мандат."""
    saved = _as_agent(client)
    try:
        task = _create(client, ai_eligible=True)
        assert task["ai_eligible"] is True
        assert task["created_by_kind"] == "agent"
        assert task["created_by_name"] == "Нави"
    finally:
        _restore_agent(client, saved)


def test_list_filters_by_flag_and_author(client: TestClient) -> None:
    flagged = _create(client, ai_eligible=True)
    plain = _create(client)
    assert [t["id"] for t in client.get("/api/tasks", params={"ai_eligible": True}).json()] == [
        flagged["id"]
    ]
    owners = [t["id"] for t in client.get("/api/tasks", params={"created_by_kind": "user"}).json()]
    assert set(owners) == {flagged["id"], plain["id"]}
    assert client.get("/api/tasks", params={"created_by_kind": "agent"}).json() == []


def test_bearer_token_is_agent_until_declared(client: TestClient) -> None:
    """REST с Bearer: по умолчанию агент, `X-Actor: user` объявляет действие владельца."""
    token = client.post("/api/mcp-tokens", json={"label": "Нави"}).json()["token"]
    plain = _create(client)
    saved = client.app.dependency_overrides.pop(require_user, None)  # type: ignore[attr-defined]
    auth = {"Authorization": f"Bearer {token}"}
    try:
        denied = client.patch(f"/api/tasks/{plain['id']}", json={"priority": 5}, headers=auth)
        assert denied.status_code == 403, denied.text
        allowed = client.patch(
            f"/api/tasks/{plain['id']}",
            json={"priority": 5},
            headers={**auth, "X-Actor": "user"},
        )
        assert allowed.status_code == 200, allowed.text
        # Создано агентом (расширение объявило бы себя владельцем) — виден актор
        mine = client.post("/api/tasks", json={"title": "от агента"}, headers=auth).json()
        got = client.get(f"/api/tasks/{mine['id']}", headers=auth).json()
        assert (got["created_by_kind"], got["created_by_name"]) == ("agent", "Нави")
    finally:
        if saved is not None:
            client.app.dependency_overrides[require_user] = saved  # type: ignore[attr-defined]


def test_x_actor_header_rule() -> None:
    """Правило actor_of: агент за заголовком не читается, «user» делает его владельцем."""
    state = SimpleNamespace(gntodo_actor=agent_actor("Нави", 7, "Владелец", VIA_API))
    assert actor_of(SimpleNamespace(state=state, headers={})).is_agent
    declared = actor_of(SimpleNamespace(state=state, headers={"x-actor": "user"}))
    assert (declared.kind, declared.name, declared.via) == ("user", "Владелец", "api")


def test_mcp_tool_refuses_unflagged_task(client: TestClient) -> None:
    ctx = agent_ctx(label="Нави")
    plain = mcp_server.create_task("без флага", ctx=ctx)
    with pytest.raises(ValueError, match="not available to an AI agent"):
        mcp_server.update_task(plain["id"], priority=5, ctx=ctx)
    # ... и то же самое с is_user=true проходит: так действует владелец
    assert mcp_server.update_task(plain["id"], priority=5, is_user=True, ctx=ctx)["priority"] == 5


def test_mcp_flag_is_owner_only(client: TestClient) -> None:
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx)
    with pytest.raises(ValueError, match="ai_eligible is set by the owner"):
        mcp_server.update_task(task["id"], ai_eligible=False, ctx=ctx)
    # Владелец (is_user=true) флаг снять вправе
    closed = mcp_server.update_task(task["id"], ai_eligible=False, is_user=True, ctx=ctx)
    assert closed["ai_eligible"] is False


def test_mcp_compacts_expose_mandate(client: TestClient) -> None:
    ctx = mcp_ctx()
    created = mcp_server.create_task("задача", ai_eligible=True, ctx=ctx)
    full = mcp_server.get_task(created["id"], ctx=ctx)
    assert full["ai_eligible"] is True
    assert full["created_by_kind"] == "user"
    assert full["accept_state"] is None
    assert full["claimed_by"] is None


def _events(client: TestClient, **params: Any) -> list[dict[str, Any]]:
    resp = client.get("/api/task-events", params=params)
    assert resp.status_code == 200, resp.text
    return list(resp.json()["items"])


def test_lease_flow_over_mcp(client: TestClient) -> None:
    """Полный круг агента: список свободных → взял → вернул (ТЗ 3.20)."""
    ctx = agent_ctx(label="Нави", token_id=7)
    task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx)
    assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]]

    taken = mcp_server.claim_task(task["id"], ctx=ctx)
    assert taken["claimed_by"] == "Нави"
    assert taken["claimed_by_me"] is True
    assert taken["claim_expires_at"] is not None
    assert taken["renewed"] is False
    # Своя живая аренда не прячет задачу из списка: агенту виден срок, до которого он занят
    assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]]

    back = mcp_server.release_task(task["id"], reason="нужен доступ к банку", ctx=ctx)
    assert back["released"] is True
    assert back["claimed_by"] is None
    assert back["claim_expires_at"] is None
    rows = _events(client, task_id=task["id"])
    assert [row["kind"] for row in rows] == ["released", "claimed", "created"]
    assert rows[0]["comment"] == "нужен доступ к банку"
    # Повторный возврат — не ошибка и не событие: аренды уже нет
    assert mcp_server.release_task(task["id"], ctx=ctx)["released"] is False
    assert [r["kind"] for r in _events(client, kind="released")] == ["released"]


def test_claim_refuses_unflagged_and_started(client: TestClient) -> None:
    ctx = agent_ctx(label="Нави")
    plain = mcp_server.create_task("без флага", ctx=ctx)
    with pytest.raises(ValueError, match="not available to an AI agent"):
        mcp_server.claim_task(plain["id"], ctx=ctx)

    started = mcp_server.create_task("начатая", ai_eligible=True, ctx=ctx)
    mcp_server.update_task(started["id"], status="in_progress", is_user=True, ctx=ctx)
    with pytest.raises(ValueError, match="is not in to_do"):
        mcp_server.claim_task(started["id"], ctx=ctx)
    assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == []


def test_claim_is_for_agents_only(client: TestClient) -> None:
    """Владелец не «берёт» задачу: аренда — способ агента заявить, что он занят."""
    ctx = mcp_ctx()
    task = mcp_server.create_task("моя", ai_eligible=True, ctx=ctx)
    with pytest.raises(ValueError, match="Task claim is for AI agents"):
        mcp_server.claim_task(task["id"], ctx=ctx)


def test_heartbeat_renews_lease_without_second_event(client: TestClient) -> None:
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("долгая", ai_eligible=True, ctx=ctx)
    taken = mcp_server.claim_task(task["id"], ctx=ctx)
    _shift_lease(task["id"], 1)

    renewed = mcp_server.claim_task(task["id"], ctx=ctx)
    assert renewed["renewed"] is True
    assert renewed["claim_expires_at"] > taken["claim_expires_at"]
    # Продление — не событие: журнал не забивается пингами
    assert [r["kind"] for r in _events(client, kind="claimed")] == ["claimed"]


def test_foreign_agent_cannot_claim_or_touch(client: TestClient) -> None:
    """Живая аренда чужая — и взять нельзя, и изменить нельзя (ТЗ 3.20)."""
    first = agent_ctx(label="Нави", token_id=7)
    second = agent_ctx(label="Другой", token_id=9)
    task = mcp_server.create_task("общая", ai_eligible=True, ctx=first)
    mcp_server.claim_task(task["id"], ctx=first)

    with pytest.raises(ValueError, match="claimed by Нави"):
        mcp_server.claim_task(task["id"], ctx=second)
    with pytest.raises(ValueError, match="another agent is working on it"):
        mcp_server.update_task(task["id"], priority=5, ctx=second)


def test_expired_lease_is_free(client: TestClient) -> None:
    """Протухшая аренда считается свободной — задачу не «заклинивает» (ТЗ 3.20)."""
    first = agent_ctx(label="Нави", token_id=7)
    second = agent_ctx(label="Другой", token_id=9)
    task = mcp_server.create_task("брошенная", ai_eligible=True, ctx=first)
    mcp_server.claim_task(task["id"], ctx=first)
    _shift_lease(task["id"], -1)

    assert mcp_server.get_task(task["id"], ctx=second)["claimed_by"] is None
    taken = mcp_server.claim_task(task["id"], ctx=second)
    assert taken["claimed_by"] == "Другой"
    assert taken["renewed"] is False


def test_agent_keeps_lease_while_working_and_loses_it_on_close(client: TestClient) -> None:
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("в работе", ai_eligible=True, ctx=ctx)
    mcp_server.claim_task(task["id"], ctx=ctx)

    working = mcp_server.update_task(task["id"], status="in_progress", ctx=ctx)
    assert working["claimed_by"] == "Нави"
    # Закрытая задача аренду не держит: иначе heartbeat «оживил» бы закрытую
    closed = mcp_server.update_task(task["id"], status="done", comment="готово", ctx=ctx)
    assert closed["claimed_by"] is None


def test_owner_action_releases_foreign_lease(client: TestClient) -> None:
    """Вмешательство владельца снимает аренду: задача снова свободна для агентов."""
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("чужая работа", ai_eligible=True, ctx=ctx)
    mcp_server.claim_task(task["id"], ctx=ctx)

    assert mcp_server.update_task(task["id"], priority=7, is_user=True, ctx=ctx)["priority"] == 7
    second = agent_ctx(label="Другой", token_id=9)
    assert mcp_server.claim_task(task["id"], ctx=second)["claimed_by"] == "Другой"


def test_rest_claim_and_release(client: TestClient) -> None:
    """REST тех же правил: агент берёт и возвращает задачу, аренда видна в TaskOut."""
    task = _create(client, ai_eligible=True)
    saved = _as_agent(client)
    try:
        taken = client.post(f"/api/tasks/{task['id']}/claim")
        assert taken.status_code == 200, taken.text
        assert taken.json()["claimed_by"] == "Нави"
        assert taken.json()["claim_expires_at"] is not None

        released = client.post(f"/api/tasks/{task['id']}/release", params={"reason": "не по силам"})
        assert released.status_code == 200, released.text
        assert released.json()["claimed_by"] is None
    finally:
        _restore_agent(client, saved)
    # Владелец (сессия) взять задачу не может — это не его механика
    assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 403


def test_rest_foreign_lease_conflicts(client: TestClient) -> None:
    """Занятая чужим агентом задача — 409 (конфликт), а не «нельзя» (403)."""
    task = _create(client, ai_eligible=True)
    mine = _as_agent(client, "Нави")
    try:
        assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 200
    finally:
        _restore_agent(client, mine)
    other = _as_agent(client, "Другой", token_id=9)
    try:
        taken = client.post(f"/api/tasks/{task['id']}/claim")
        assert taken.status_code == 409, taken.text
        touched = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})
        assert touched.status_code == 409, touched.text
        assert client.post(f"/api/tasks/{task['id']}/release").status_code == 409
    finally:
        _restore_agent(client, other)


def test_rest_agent_claim_requires_flag(client: TestClient) -> None:
    task = _create(client)
    saved = _as_agent(client)
    try:
        denied = client.post(f"/api/tasks/{task['id']}/claim")
        assert denied.status_code == 403, denied.text
    finally:
        _restore_agent(client, saved)