Newer
Older
gnexus-tasks / backend / tests / test_acceptance.py
"""Тесты приёмки агентской работы: pending/accepted/rejected (ТЗ 3.20)."""

from typing import Any

import pytest
from fastapi.testclient import TestClient

from app import mcp_server
from app.actor import VIA_API, agent_actor
from app.dependencies import require_actor
from tests.conftest import agent_ctx  # type: ignore[attr-defined]


def _as_agent(client: TestClient, name: str = "Нави", token_id: int = 7) -> Any:
    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 _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 _closed_by_agent(client: TestClient, comment: str = "сделано") -> dict[str, Any]:
    """Задача, закрытая агентом: ждёт приёмки."""
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("агентская работа", ai_eligible=True, ctx=ctx)
    closed = mcp_server.complete_task(task["id"], comment=comment, ctx=ctx)
    assert closed["accept_state"] == "pending"
    return closed


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


def test_agent_close_requires_comment(client: TestClient) -> None:
    """Агент закрывает задачу с отчётом — иначе владельцу нечего принимать (3.20)."""
    ctx = agent_ctx(label="Нави")
    task = mcp_server.create_task("молчаливая", ai_eligible=True, ctx=ctx)
    with pytest.raises(ValueError, match="comment is required"):
        mcp_server.complete_task(task["id"], ctx=ctx)
    # is_user=true — закрытие владельца: комментарий необязателен
    mine = mcp_server.update_task(task["id"], status="done", is_user=True, ctx=ctx)
    assert mine["status"] == "done"

    other = _create(client, ai_eligible=True)
    saved = _as_agent(client)
    try:
        denied = client.patch(f"/api/tasks/{other['id']}", json={"status": "done"})
        assert denied.status_code == 422, denied.text
    finally:
        _restore_agent(client, saved)


def test_agent_close_marks_pending_and_owner_close_does_not(client: TestClient) -> None:
    closed = _closed_by_agent(client)
    assert closed["status"] == "done"
    assert closed["accept_state"] == "pending"
    # Кто закрыл последним — видно владельцу в задаче (агент в MCP-ответе его не получает)
    assert client.get(f"/api/tasks/{closed['id']}").json()["done_by_kind"] == "agent"
    # «Ждёт приёмки» — это фильтр списка задач, а не статус: задача остаётся done
    waiting = client.get("/api/tasks", params={"accept_state": "pending"}).json()
    assert [t["id"] for t in waiting] == [closed["id"]]

    mine = _create(client)
    assert client.patch(f"/api/tasks/{mine['id']}", json={"status": "done"}).status_code == 200
    done = client.get(f"/api/tasks/{mine['id']}").json()
    assert (done["accept_state"], done["done_by_kind"]) == (None, "user")


def test_accept_keeps_task_done(client: TestClient) -> None:
    closed = _closed_by_agent(client, "позвонил в банк")
    resp = client.post(f"/api/tasks/{closed['id']}/accept", json={"comment": "спасибо"})
    assert resp.status_code == 200, resp.text
    accepted = resp.json()
    assert (accepted["accept_state"], accepted["status"]) == ("accepted", "done")
    rows = _events(client, kind="accepted")
    assert rows[0]["comment"] == "спасибо"
    # Принимать дважды нечего: задачи в pending уже нет
    assert client.post(f"/api/tasks/{closed['id']}/accept").status_code == 409


def test_reject_returns_task_to_work(client: TestClient) -> None:
    closed = _closed_by_agent(client, "набросал черновик")
    resp = client.post(f"/api/tasks/{closed['id']}/reject", json={"comment": "не то"})
    assert resp.status_code == 200, resp.text
    back = resp.json()
    assert back["status"] == "to_do"
    assert back["accept_state"] == "rejected"
    assert back["done_at"] is None
    assert back["claimed_by"] is None  # аренда снята: задача снова свободна
    assert _events(client, kind="rejected")[0]["comment"] == "не то"
    # Агент видит возврат и берёт задачу заново — с чистого листа
    ctx = agent_ctx(label="Нави")
    assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [closed["id"]]


def test_reclose_after_reject_does_not_pay_twice(client: TestClient) -> None:
    """Возврат в работу не отзывает награду и не даёт второй (3.13, 8.7)."""
    tid = _create(client, ai_eligible=True)["id"]
    client.patch(
        f"/api/tasks/{tid}",
        json={"task_type": "recurring", "recur_kind": "interval", "recur_interval_days": 2},
    )
    client.post(f"/api/tasks/{tid}/approve")
    ctx = agent_ctx(label="Нави")
    first = mcp_server.complete_task(tid, comment="первый раз", ctx=ctx)
    assert first["spawned_next_id"]
    xp_after_first = client.get("/api/xp").json()

    client.post(f"/api/tasks/{tid}/reject", json={"comment": "переделай"})
    again = mcp_server.complete_task(tid, comment="второй раз", ctx=ctx)
    assert "spawned_next_id" not in again  # spawned_at держит спавн
    assert again["accept_state"] == "pending"  # работа агента снова ждёт приёмки
    summary = client.get("/api/xp").json()
    assert (summary["total_xp"], summary["coins"]) == (
        xp_after_first["total_xp"],
        xp_after_first["coins"],
    )
    # Второго экземпляра регулярной тоже нет
    open_tasks = client.get("/api/tasks", params={"status": "to_do"}).json()
    spawned = [t for t in open_tasks if t["id"] != tid]
    assert len(spawned) == 1


def test_owner_close_cancels_pending(client: TestClient) -> None:
    """Владелец, закрывший задачу сам, приёмкой не занимается — пометка снимается."""
    closed = _closed_by_agent(client)
    resp = client.patch(f"/api/tasks/{closed['id']}", json={"status": "done"})
    assert resp.status_code == 200, resp.text
    assert resp.json()["accept_state"] is None
    assert client.post(f"/api/tasks/{closed['id']}/accept").status_code == 409


def test_agent_cannot_accept_own_work(client: TestClient) -> None:
    """Принимает работу владелец: агент себе приёмку не подписывает (3.20)."""
    closed = _closed_by_agent(client)
    saved = _as_agent(client)
    try:
        denied = client.post(f"/api/tasks/{closed['id']}/accept", json={"comment": "молодец"})
        assert denied.status_code == 403, denied.text
        denied2 = client.post(f"/api/tasks/{closed['id']}/reject")
        assert denied2.status_code == 403, denied2.text
    finally:
        _restore_agent(client, saved)
    assert client.get(f"/api/tasks/{closed['id']}").json()["accept_state"] == "pending"