"""Тесты приёмки агентской работы: 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"