Newer
Older
gnexus-tasks / backend / tests / test_task_events.py
"""Тесты журнала действий по задачам (ТЗ 3.20)."""

from typing import Any

from fastapi.testclient import TestClient

from app import mcp_server
from tests.conftest import agent_ctx, mcp_ctx  # type: ignore[attr-defined]


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


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


def test_create_logs_event_from_ui(client: TestClient) -> None:
    task = _create(client, "позвонить маме")
    page = _events(client, kind="created")
    assert page["total"] == 1
    row = page["items"][0]
    assert row["task_id"] == task["id"]
    assert row["task_title"] == "позвонить маме"
    assert row["actor_kind"] == "user"
    assert row["via"] == "ui"


def test_status_and_eligibility_changes_logged(client: TestClient) -> None:
    task = _create(client)
    client.patch(f"/api/tasks/{task['id']}", json={"status": "in_progress"})
    client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True})
    # PATCH, ничего не изменивший, журнал не засоряет
    client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True})
    client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})

    kinds = [row["kind"] for row in _events(client)["items"]]
    assert kinds == ["eligible_changed", "status_changed", "created"]
    status_row = _events(client, kind="status_changed")["items"][0]
    assert status_row["comment"] == "to_do→in_progress"
    assert _events(client, kind="eligible_changed")["items"][0]["comment"] == "on"


def test_close_logs_completed_with_comment(client: TestClient) -> None:
    task = _create(client)
    resp = client.patch(f"/api/tasks/{task['id']}", json={"status": "done", "close_comment": "всё"})
    assert resp.status_code == 200, resp.text
    row = _events(client, kind="completed")["items"][0]
    assert row["comment"] == "всё"


def test_delete_keeps_event_with_title_snapshot(client: TestClient) -> None:
    task = _create(client, "удаляемая")
    assert client.delete(f"/api/tasks/{task['id']}").status_code == 200
    row = _events(client, kind="deleted")["items"][0]
    # Ссылки на задачу нет — она удалена; запись живёт снапшотом заголовка
    assert row["task_id"] is None
    assert row["task_title"] == "удаляемая"


def test_events_are_scoped_to_owner(client: TestClient) -> None:
    """Журнал чужого пользователя не виден (изоляция по user_id, ТЗ 1.2)."""
    from app.dependencies import require_user

    _create(client)
    overrides = client.app.dependency_overrides  # type: ignore[attr-defined]
    saved = overrides[require_user]
    overrides[require_user] = lambda: {"user_id": "2", "email": "other@example.com"}
    try:
        assert _events(client)["total"] == 0
    finally:
        overrides[require_user] = saved
    assert _events(client)["total"] == 1


def test_pagination_envelope_and_filters(client: TestClient) -> None:
    project_id = client.post("/api/projects", json={"name": "дом"}).json()["id"]
    for i in range(5):
        task = _create(client, f"задача {i}")
        client.patch(
            f"/api/tasks/{task['id']}", json={"project_id": project_id, "status": "in_progress"}
        )
    # 5 созданий + 5 смен статуса; смена проекта — не событие журнала
    page = _events(client, page=2, per_page=3)
    assert (page["total"], page["page"], page["per_page"]) == (10, 2, 3)
    assert len(page["items"]) == 3
    assert _events(client, project_id=project_id)["total"] == 5
    # Потолок per_page: журнал растёт быстро, отдавать его целиком нельзя
    assert client.get("/api/task-events", params={"per_page": 101}).status_code == 422
    assert client.get("/api/task-events", params={"page": 0}).status_code == 422


def test_mcp_agent_actions_are_marked(client: TestClient) -> None:
    """Действия агента отличимы от действий владельца: актор и канал."""
    ctx = agent_ctx(label="Нави")
    created = mcp_server.create_task("от агента", ai_eligible=True, ctx=ctx)
    mcp_server.update_task(created["id"], status="in_progress", ctx=ctx)

    rows = _events(client)["items"]
    assert [row["kind"] for row in rows] == ["status_changed", "created"]
    assert {(row["actor_kind"], row["actor_name"], row["via"]) for row in rows} == {
        ("agent", "Нави", "mcp")
    }


def test_mcp_is_user_marks_owner(client: TestClient) -> None:
    """is_user=True — действие от имени владельца, но канал остаётся mcp."""
    ctx = agent_ctx(label="Нави", owner="Хозяин")
    created = mcp_server.create_task("как я", is_user=True, ctx=ctx)
    row = _events(client, kind="created")["items"][0]
    assert (row["actor_kind"], row["actor_name"], row["via"]) == ("user", "Хозяин", "mcp")
    assert created["id"]


def test_mcp_close_comment_lands_in_journal(client: TestClient) -> None:
    ctx = mcp_ctx()
    created = mcp_server.create_task("задача", ctx=ctx)
    mcp_server.complete_task(created["id"], comment="сделано", ctx=ctx)
    assert _events(client, kind="completed")["items"][0]["comment"] == "сделано"