Newer
Older
gnexus-tasks / backend / tests / test_tasks_api.py
"""Тесты API M1: быстрый захват, стек, детализация, проекты, теги."""

from typing import Any

from fastapi.testclient import TestClient


def create_task(client: TestClient, title: str = "тестовая задача") -> dict[str, Any]:
    resp = client.post("/api/tasks", json={"title": title})
    assert resp.status_code == 200, resp.text
    task_id = resp.json()["id"]
    resp = client.get(f"/api/tasks/{task_id}")
    assert resp.status_code == 200, resp.text
    return resp.json()


def test_quick_capture_lands_in_stack(client: TestClient) -> None:
    task = create_task(client, "позвонить маме")
    assert task["detail_state"] == "raw"
    assert task["status"] == "to_do"
    assert task["title"] == "позвонить маме"

    stack = client.get("/api/tasks", params={"detail_state": "raw"}).json()
    assert any(t["id"] == task["id"] for t in stack)


def test_approve_moves_out_of_stack(client: TestClient) -> None:
    task = create_task(client)
    resp = client.post(f"/api/tasks/{task['id']}/approve")
    assert resp.status_code == 200
    approved = resp.json()
    assert approved["detail_state"] == "approved"
    assert approved["approved_at"] is not None

    stack = client.get("/api/tasks", params={"detail_state": "raw"}).json()
    assert all(t["id"] != task["id"] for t in stack)


def test_update_with_project_and_tags(client: TestClient) -> None:
    project_id = client.post("/api/projects", json={"name": "дом"}).json()["id"]
    tag_id = client.post("/api/tags", json={"name": "срочное"}).json()["id"]
    task = create_task(client)

    resp = client.patch(
        f"/api/tasks/{task['id']}",
        json={"project_id": project_id, "tag_ids": [tag_id], "priority": 3},
    )
    assert resp.status_code == 200, resp.text
    updated = resp.json()
    assert updated["project"]["id"] == project_id
    assert [t["id"] for t in updated["tags"]] == [tag_id]
    assert updated["priority"] == 3


def test_update_unknown_project_rejected(client: TestClient) -> None:
    task = create_task(client)
    resp = client.patch(f"/api/tasks/{task['id']}", json={"project_id": 99999})
    assert resp.status_code == 400


def test_status_validation_and_done_at(client: TestClient) -> None:
    task = create_task(client)
    bad = client.patch(f"/api/tasks/{task['id']}", json={"status": "nope"})
    assert bad.status_code == 422

    done = client.patch(f"/api/tasks/{task['id']}", json={"status": "done"})
    assert done.status_code == 200
    assert done.json()["done_at"] is not None


def test_list_sorts_done_by_closed_at(client: TestClient) -> None:
    """Завершённые задачи в списке — по дате закрытия, остальные — по добавлению."""
    first = create_task(client, "первая")
    second = create_task(client, "вторая")
    third = create_task(client, "третья")

    # закрываем в порядке, обратном добавлению: «первая» закрыта раньше «второй»
    for t in (second, first):
        client.patch(f"/api/tasks/{t['id']}", json={"status": "done"})

    ids = [t["id"] for t in client.get("/api/tasks").json()]
    # завершённые — по done_at (первая выше второй), затем незавершённые
    assert ids == [first["id"], second["id"], third["id"]]


def test_list_filter_by_priority(client: TestClient) -> None:
    """Фильтр по градациям приоритета (ТЗ 3.6): без приоритета — very_low."""
    low = create_task(client, "без приоритета")
    high = create_task(client, "срочная")
    client.post(f"/api/tasks/{high['id']}/approve")
    client.patch(f"/api/tasks/{high['id']}", json={"priority": 9})

    very_low = client.get("/api/tasks", params={"priority": "very_low"}).json()
    assert [t["id"] for t in very_low] == [low["id"]]
    urgent = client.get("/api/tasks", params={"priority": "urgent"}).json()
    assert [t["id"] for t in urgent] == [high["id"]]

    bad = client.get("/api/tasks", params={"priority": "nope"})
    assert bad.status_code == 422


def test_list_filter_by_priority_min(client: TestClient) -> None:
    """Отсечка priority_min (0.74): ею работает переключатель «Приоритетные»."""
    none = create_task(client, "без приоритета")
    medium = create_task(client, "средняя")
    client.patch(f"/api/tasks/{medium['id']}", json={"priority": 6})
    high = create_task(client, "высокая")
    client.patch(f"/api/tasks/{high['id']}", json={"priority": 7})
    urgent = create_task(client, "срочная")
    client.patch(f"/api/tasks/{urgent['id']}", json={"priority": 9})

    # 7 — нижняя граница градации «high» (ТЗ 3.13): средняя и задачи без
    # приоритета (very_low) в отсечку «выше среднего» не попадают
    top = client.get("/api/tasks", params={"priority_min": 7}).json()
    assert sorted(t["id"] for t in top) == sorted([high["id"], urgent["id"]])

    # без параметра отсечки нет
    all_ids = {t["id"] for t in client.get("/api/tasks").json()}
    assert {none["id"], medium["id"], high["id"], urgent["id"]} <= all_ids

    # вне шкалы 0-10 отсекает валидация
    assert client.get("/api/tasks", params={"priority_min": 11}).status_code == 422


def test_list_filter_by_task_type(client: TestClient) -> None:
    """Фильтр «Регулярные» (0.91, ТЗ 3.6): список показывает только регулярные."""
    once = create_task(client, "разовая")
    daily = create_task(client, "зарядка")
    client.patch(
        f"/api/tasks/{daily['id']}",
        json={"task_type": "recurring", "recur_kind": "interval", "recur_interval_days": 1},
    )
    weekly = create_task(client, "отчёт")
    client.patch(
        f"/api/tasks/{weekly['id']}",
        json={"task_type": "recurring", "recur_kind": "weekdays", "recur_weekdays": "5"},
    )

    recurring = client.get("/api/tasks", params={"task_type": "recurring"}).json()
    assert sorted(t["id"] for t in recurring) == sorted([daily["id"], weekly["id"]])

    one_time = client.get("/api/tasks", params={"task_type": "one_time"}).json()
    assert [t["id"] for t in one_time] == [once["id"]]

    # без параметра фильтра нет — в списке всё
    all_ids = {t["id"] for t in client.get("/api/tasks").json()}
    assert {once["id"], daily["id"], weekly["id"]} <= all_ids

    # третьего типа не бывает: значение вне перечня отсекает валидация
    assert client.get("/api/tasks", params={"task_type": "sometimes"}).status_code == 422


def test_list_filter_by_tag_substring(client: TestClient) -> None:
    """Фильтр tag (0.70) — поиск по подстроке имени тега, без учёта регистра."""
    work = client.post("/api/tags", json={"name": "Work"}).json()["id"]
    home = client.post("/api/tags", json={"name": "Home"}).json()["id"]
    call = create_task(client, "созвон")
    clean = create_task(client, "уборка")
    client.patch(f"/api/tasks/{call['id']}", json={"tag_ids": [work]})
    client.patch(f"/api/tasks/{clean['id']}", json={"tag_ids": [home]})

    # подстрока «wor» и другой регистр «WORK» находят задачу с тегом «Work».
    # Регистр проверяем на латинице: в SQLite (тестовая БД) lower() без учёта
    # регистра работает только для ASCII, в проде Postgres — для любого текста
    for needle in ("wor", "WORK"):
        found = client.get("/api/tasks", params={"tag": needle}).json()
        assert [t["id"] for t in found] == [call["id"]], needle

    # кириллица: подстрока ищется, регистр берём как в теге
    client.post("/api/tags", json={"name": "домашние"}).json()
    tag_id = next(t["id"] for t in client.get("/api/tags").json() if t["name"] == "домашние")
    client.patch(f"/api/tasks/{clean['id']}", json={"tag_ids": [home, tag_id]})
    assert len(client.get("/api/tasks", params={"tag": "машн"}).json()) == 1

    # пустой и пробельный ввод — фильтр не применяется
    assert len(client.get("/api/tasks", params={"tag": ""}).json()) == 2
    assert len(client.get("/api/tasks", params={"tag": "   "}).json()) == 2

    # служебные знаки LIKE не работают как маска
    assert client.get("/api/tasks", params={"tag": "%"}).json() == []

    # комбинируется с другими фильтрами
    combo = client.get("/api/tasks", params={"tag": "wor", "status": "to_do"}).json()
    assert [t["id"] for t in combo] == [call["id"]]
    assert client.get("/api/tasks", params={"tag": "wor", "status": "done"}).json() == []


def test_delete_task(client: TestClient) -> None:
    task = create_task(client)
    assert client.delete(f"/api/tasks/{task['id']}").json() == {"ok": True}
    assert client.get(f"/api/tasks/{task['id']}").status_code == 404


def test_project_crud_and_duplicate_name(client: TestClient) -> None:
    resp = client.post("/api/projects", json={"name": "gntodo-разработка"})
    assert resp.status_code == 200
    pid = resp.json()["id"]

    dup = client.post("/api/projects", json={"name": "gntodo-разработка"})
    assert dup.status_code == 409

    resp = client.patch(f"/api/projects/{pid}", json={"relevance_status": "paused"})
    assert resp.status_code == 200
    assert resp.json()["relevance_status"] == "paused"

    bad = client.patch(f"/api/projects/{pid}", json={"relevance_status": "nope"})
    assert bad.status_code == 422


def test_project_archive_lifecycle(client: TestClient) -> None:
    """Архив (ТЗ 3.11): проект уходит в архив с задачами и возвращается."""
    pid = client.post("/api/projects", json={"name": "ремонт"}).json()["id"]
    task_id = create_task(client, "поклеить обои")["id"]
    client.patch(f"/api/tasks/{task_id}", json={"project_id": pid})
    standalone = create_task(client, "без проекта")

    # в архиве пусто, в активных проект и задача видны
    assert client.get("/api/projects", params={"archived": "true"}).json() == []
    assert any(p["id"] == pid for p in client.get("/api/projects").json())
    assert any(t["id"] == task_id for t in client.get("/api/tasks").json())

    # архивируем: проект скрыт, задача с ним уходит из рабочих видов
    archived = client.post(f"/api/projects/{pid}/archive")
    assert archived.status_code == 200
    assert archived.json()["is_archived"] is True
    assert client.get("/api/projects").json() == []
    archived_list = client.get("/api/projects", params={"archived": "true"}).json()
    assert [p["id"] for p in archived_list] == [pid]

    tasks = client.get("/api/tasks").json()
    assert all(t["id"] != task_id for t in tasks)
    assert any(t["id"] == standalone["id"] for t in tasks)
    # но на странице проекта (фильтр по project_id) история видна
    assert any(
        t["id"] == task_id for t in client.get("/api/tasks", params={"project_id": pid}).json()
    )
    # и в выдаче задачи архивные проекты не участвуют
    options = client.post("/api/tasks/suggest", json={"available_minutes": 600}).json()
    assert all(t["id"] != task_id for t in options)

    # восстановление возвращает всё как было
    restored = client.post(f"/api/projects/{pid}/restore")
    assert restored.status_code == 200
    assert restored.json()["is_archived"] is False
    assert any(t["id"] == task_id for t in client.get("/api/tasks").json())


def test_archive_invalid_relevance_rejected(client: TestClient) -> None:
    pid = client.post("/api/projects", json={"name": "устаревший статус"}).json()["id"]
    resp = client.patch(f"/api/projects/{pid}", json={"relevance_status": "archived"})
    assert resp.status_code == 422


def test_tag_duplicate(client: TestClient) -> None:
    assert client.post("/api/tags", json={"name": "быт"}).status_code == 200
    assert client.post("/api/tags", json={"name": "быт"}).status_code == 409