Newer
Older
gnexus-tasks / backend / tests / test_detailing.py
"""Тесты M2: автодетализация (LLM-предложение) и вложения."""

import json
from typing import Any

from fastapi.testclient import TestClient

from app import services
from app.services.detailing import apply_proposal, build_prompt


def _mock_proposal(monkeypatch: Any, proposal: dict[str, Any]) -> None:
    """Подменить LLM-вызов фиксированным ответом."""

    def fake_propose(
        self: Any,
        title: str,
        description: str,
        tag_names: list[str],
        assigned_tags: list[str],
        projects: list[dict[str, str]],
        *,
        include_priority: bool = True,
        include_estimate: bool = True,
    ) -> dict[str, Any]:
        return proposal

    monkeypatch.setattr(services.detailing.DetailingService, "propose", fake_propose)


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
    return client.get(f"/api/tasks/{resp.json()['id']}").json()


def _propose_with(monkeypatch: Any, payload: dict[str, Any]) -> dict[str, Any] | None:
    """Прогнать propose на фиксированном JSON-ответе модели."""
    monkeypatch.setattr(
        services.detailing.DetailingService,
        "generate",
        lambda self, prompt: json.dumps(payload, ensure_ascii=False),
    )
    service = services.detailing.DetailingService(base_url="http://mock", model="m")
    return service.propose("задача", "", [], [], [])


def set_proposal(task_id: int, proposal: dict[str, Any]) -> None:
    """Записать LLM-предложение в задачу напрямую (как сделал бы background worker)."""
    from app.models import Task
    from tests.conftest import _test_session_factory

    session = _test_session_factory()
    task = session.get(Task, task_id)
    assert task is not None
    task.ai_proposal = proposal
    session.commit()
    session.close()


def test_build_prompt_lists_catalog() -> None:
    projects = [{"name": "ремонт", "summary": "ремонт дачи летом"}]
    prompt = build_prompt("задача", "", ["быт", "дом"], ["быт"], projects)
    assert "быт" in prompt and "ремонт" in prompt
    assert "задача" in prompt
    # язык ответа = язык исходных данных; новые теги — только английские
    assert "языке" in prompt and "английском" in prompt
    # проект LLM не выбирает: контекст только для темы
    assert "new_project" not in prompt
    assert "ремонт дачи летом" in prompt
    # назначенные теги уходят в промпт отдельной строкой, повторять их нельзя:
    # теги ДОПОЛНЯЮТ задачу, а не заменяют назначенное (0.93)
    assert "Уже назначены" in prompt and "не повторяй" in prompt
    # заголовок и описание модель не переписывает — их нет и в схеме ответа
    assert '"title"' not in prompt and '"description"' not in prompt


def test_build_prompt_en_asks_same_fields() -> None:
    prompt = build_prompt("Fix the login form", "", ["auth"], ["bug"], [])
    assert "Already assigned" in prompt and "Do not repeat" in prompt
    assert '"tags"' in prompt and '"priority"' in prompt and '"estimated_minutes"' in prompt


def test_build_prompt_truncates_long_description() -> None:
    """Описание уходит только контекстом: после импорта из BugTrail оно бывает
    на десятки тысяч знаков (ТЗ 3.22) — хвост в промпт не тащим."""
    prompt = build_prompt("задача", "х" * 5000, [], [], [])
    # считаем блоками, а не буквами: «х» встречается и в русских словах промпта
    assert prompt.count("х" * 50) == 40  # ровно MAX_CONTEXT_DESC_LEN = 2000 знаков


def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None:
    proposal = {"tags": [], "priority": None, "estimated_minutes": None}
    _mock_proposal(monkeypatch, proposal)

    # BackgroundTasks выполняются TestClient синхронно после ответа
    resp = client.post("/api/tasks", json={"title": "помыть посуду"})
    assert resp.status_code == 200
    task = client.get(f"/api/tasks/{resp.json()['id']}").json()
    assert task["ai_proposal"] == proposal


def test_propose_ignores_title_and_description(
    client: TestClient, monkeypatch: Any
) -> None:
    """Заголовок и описание модель не переписывает (0.93): даже вернув их в ответе,
    она не увидит их в предложении. Приоритет и оценка, наоборот, читаются."""
    proposal = _propose_with(
        monkeypatch,
        {
            "tags": ["быт", "новый тег"],
            "title": "Помыть посуду",
            "description": "1. Включить воду",
            "priority": "high",
            "estimated_minutes": 42,
        },
    )
    assert proposal == {"tags": ["быт", "новый тег"], "priority": 7, "estimated_minutes": 40}


def test_propose_rejects_garbage_strings(client: TestClient, monkeypatch: Any) -> None:
    """Мусор LLM («NULL», пробел, заглушки) не превращается в тег."""
    proposal = _propose_with(
        monkeypatch,
        {"tags": ["  ", "null", "быт"], "priority": "null", "estimated_minutes": "много"},
    )
    assert proposal == {"tags": ["быт"], "priority": None, "estimated_minutes": None}


def test_propose_dedups_and_caps_tags(client: TestClient, monkeypatch: Any) -> None:
    """Дубли (без учёта регистра) схлопываются, тегов не больше MAX_TAGS."""
    proposal = _propose_with(
        monkeypatch, {"tags": ["Быт", "быт", "a", "b", "c", "d"], "priority": None}
    )
    assert proposal is not None
    assert proposal["tags"] == ["Быт", "a", "b"]


def test_propose_accepts_tags_as_string(client: TestClient, monkeypatch: Any) -> None:
    """Модель вернула строку вместо списка — разбираем по запятым, а не по буквам."""
    proposal = _propose_with(monkeypatch, {"tags": "bug, auth", "priority": "low"})
    assert proposal is not None
    assert proposal["tags"] == ["bug", "auth"]


def test_propose_empty_answer(client: TestClient, monkeypatch: Any) -> None:
    """Модель ответила пустым JSON: предложение непустое как объект, но пустое
    по содержанию — фронт по такому ответу молчит, а не рисует карточку."""
    proposal = _propose_with(monkeypatch, {})
    assert proposal == {"tags": [], "priority": None, "estimated_minutes": None}


def test_propose_priority_grades(client: TestClient, monkeypatch: Any) -> None:
    """Градация словами переводится в число шкалы задачи; регистр и дефисы нормализуем."""
    cases: list[tuple[Any, int | None]] = [
        ("very_low", 1),
        ("low", 3),
        ("medium", 5),
        ("high", 7),
        ("urgent", 9),
        ("Very High", 9),  # своя градация модели — притягиваем к ближайшей
        ("very-low", 1),
        ("  URGENT  ", 9),
        # готовое число 0-10 принимаем (снисходительность), остальное — нет
        (0, 0),
        (10, 10),
        ("4", 4),
        (11, None),
        (-1, None),
        ("срочно", None),
        (None, None),
    ]
    for raw, expected in cases:
        proposal = _propose_with(monkeypatch, {"priority": raw})
        assert proposal is not None, raw
        assert proposal["priority"] == expected, raw


def test_propose_estimate_bounds_and_rounding(client: TestClient, monkeypatch: Any) -> None:
    """Минуты: число или строка с числом, границы 5…1440, округление до 5 минут."""
    cases: list[tuple[Any, int | None]] = [
        (30, 30),
        (47, 45),
        ("30 мин", 30),
        (4, None),  # ниже границы
        (1441, None),  # верхняя граница — как estimated_minutes le=24*60
        ("скоро", None),
        (True, None),  # bool — не минуты
    ]
    for raw, expected in cases:
        proposal = _propose_with(monkeypatch, {"estimated_minutes": raw})
        assert proposal is not None, raw
        assert proposal["estimated_minutes"] == expected, raw


def test_propose_include_flags(client: TestClient, monkeypatch: Any) -> None:
    """include_priority/include_estimate выключают свои поля: заполненное
    пользователем не угадываем (первичный анализ). Теги предлагаются всегда —
    они дополняют назначенные, а не заменяют их."""
    service = services.detailing.DetailingService(base_url="http://mock", model="m")
    calls: list[str] = []

    def fake_generate(self: Any, prompt: str) -> str:
        calls.append(prompt)
        return '{"tags": ["x"], "priority": "high", "estimated_minutes": 30}'

    monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)
    proposal = service.propose(
        "задача",
        "уже есть описание",
        ["быт"],
        [],
        [],
        include_priority=False,
        include_estimate=False,
    )
    assert proposal is not None
    assert proposal["tags"] == ["x"]
    assert proposal["priority"] is None
    assert proposal["estimated_minutes"] is None
    # в промпте нет правил для отключённых полей, но правила тегов есть
    assert "важна" not in calls[0] and "минут займёт" not in calls[0]
    assert "тегов" in calls[0]


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

    proposal = {"tags": ["БЫТ"], "priority": "urgent", "estimated_minutes": 45}
    resp = client.post("/api/tasks", json={"title": "задача с предложением"})
    task_id = resp.json()["id"]
    set_proposal(task_id, proposal)

    approved = client.post(f"/api/tasks/{task_id}/approve", json={"apply_proposal": True}).json()
    assert approved["detail_state"] == "approved"
    assert [t["name"] for t in approved["tags"]] == ["быт"]  # case-insensitive матч
    assert approved["project"] is None  # проект LLM не предлагает
    # заголовок и описание не переписываются, приоритет и оценка — заполняются
    assert approved["title"] == "задача с предложением"
    assert approved["priority"] == 9
    assert approved["estimated_minutes"] == 45


def test_apply_proposal_without_apply_flag_keeps_fields(client: TestClient) -> None:
    """«Утвердить» без галочки применения — метаданные предложения не трогают."""
    proposal = {"tags": ["быт"], "priority": "urgent", "estimated_minutes": 45}
    resp = client.post("/api/tasks", json={"title": "задача"})
    task_id = resp.json()["id"]
    set_proposal(task_id, proposal)

    approved = client.post(f"/api/tasks/{task_id}/approve").json()
    assert approved["detail_state"] == "approved"
    assert approved["tags"] == []
    assert approved["priority"] is None
    assert approved["estimated_minutes"] is None


def test_apply_proposal_merges_tags(client: TestClient) -> None:
    """Теги предложения добавляются к назначенным (пользовательские не отбираются)."""
    task = create_task(client)
    from app.models import Tag, Task
    from tests.conftest import _test_session_factory

    session = _test_session_factory()
    db_task = session.get(Task, task["id"])
    assert db_task is not None
    client.post("/api/tags", json={"name": "своё"})
    own = client.get("/api/tags").json()[0]
    own_tag = session.get(Tag, own["id"])
    assert own_tag is not None
    db_task.tags = [own_tag]
    session.commit()
    apply_proposal(session, db_task, {"tags": ["новый", "своё"]}, "1")
    names = sorted(t.name for t in db_task.tags)
    assert names == ["новый", "своё"]
    # тег предложения создан в каталоге
    assert "новый" in [t["name"] for t in client.get("/api/tags").json()]
    session.close()


def test_apply_proposal_keeps_user_priority_and_estimate(client: TestClient) -> None:
    """Правило владельца (0.93): заполненные вручную приоритет и оценка не
    перетираются даже при согласии с предложением."""
    task = create_task(client)
    client.patch(f"/api/tasks/{task['id']}", json={"priority": 3, "estimated_minutes": 120})
    set_proposal(
        task["id"], {"tags": [], "priority": "urgent", "estimated_minutes": 15}
    )

    approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json()
    assert approved["priority"] == 3
    assert approved["estimated_minutes"] == 120


def test_apply_proposal_skips_junk_values(client: TestClient) -> None:
    """Мусор в предложении не доходит до задачи (валидаторы работают и здесь)."""
    task = create_task(client)
    set_proposal(task["id"], {"tags": [], "priority": 99, "estimated_minutes": 0})

    approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json()
    assert approved["priority"] is None
    assert approved["estimated_minutes"] is None


def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None:
    _mock_proposal(monkeypatch, {"tags": [], "priority": None, "estimated_minutes": None})
    task = create_task(client)
    assert task["ai_proposal"] is not None

    redetailed = client.post(f"/api/tasks/{task['id']}/redetail").json()
    # фон выполнится после ответа; в ответе предложение уже сброшено
    assert redetailed["ai_proposal"] is None
    after = client.get(f"/api/tasks/{task['id']}").json()
    assert after["ai_proposal"] is not None


def test_redetail_noop_for_approved(client: TestClient) -> None:
    """Утверждённая задача уже детализирована: повтор — no-op, предложение
    не сбрасывается (раньше redetail молча стирал ai_proposal)."""
    task = create_task(client)
    client.post(f"/api/tasks/{task['id']}/approve")
    set_proposal(task["id"], {"tags": [], "priority": None, "estimated_minutes": None})

    resp = client.post(f"/api/tasks/{task['id']}/redetail")
    assert resp.json()["ai_proposal"] is not None


def test_initial_detail_asks_tags_and_empty_fields_only(
    client: TestClient, monkeypatch: Any
) -> None:
    """Первичный анализ: теги предлагаются всегда (они дополняют назначенные),
    приоритет и оценка — только пока поля пусты."""
    from app.services.detailing import detail_task

    resp = client.post(
        "/api/tasks", json={"title": "длинный заголовок задачи", "description": "уже описано"}
    )
    task_id = resp.json()["id"]
    client.post("/api/tags", json={"name": "готовый"})
    tag = client.get("/api/tags").json()[0]
    client.patch(f"/api/tasks/{task_id}", json={"tag_ids": [tag["id"]]})

    calls: list[str] = []

    def fake_generate(self: Any, prompt: str) -> str:
        calls.append(prompt)
        return '{"tags": ["новый"], "priority": "high", "estimated_minutes": 30}'

    monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)

    detail_task(task_id)
    # приоритет и оценка у задачи пусты — их спрашиваем; назначенный тег уходит
    # в контекст отдельной строкой, чтобы модель не тратила слоты на дубли
    assert "важна" in calls[0] and "минут займёт" in calls[0]
    assert "Уже назначены" in calls[0]
    task = client.get(f"/api/tasks/{task_id}").json()
    assert task["ai_proposal"]["tags"] == ["новый"]

    # поля заполнены — переспрос предлагает только теги
    client.patch(f"/api/tasks/{task_id}", json={"priority": 5, "estimated_minutes": 30})
    detail_task(task_id)
    assert "важна" not in calls[1] and "минут займёт" not in calls[1]
    assert "Уже назначены" in calls[1]  # назначенные теги уходят в контекст всегда


def test_detail_keeps_user_values_in_proposal(client: TestClient, monkeypatch: Any) -> None:
    """Заполненные вручную приоритет и оценка не предлагаются даже при переспросе."""
    from app.services.detailing import detail_task

    resp = client.post("/api/tasks", json={"title": "задача"})
    task_id = resp.json()["id"]
    client.patch(f"/api/tasks/{task_id}", json={"priority": 7, "estimated_minutes": 60})

    monkeypatch.setattr(
        services.detailing.DetailingService,
        "generate",
        lambda self, prompt: '{"tags": [], "priority": "urgent", "estimated_minutes": 15}',
    )
    detail_task(task_id)
    task = client.get(f"/api/tasks/{task_id}").json()
    assert task["ai_proposal"]["priority"] is None
    assert task["ai_proposal"]["estimated_minutes"] is None


def test_detail_uses_project_summary(client: TestClient, monkeypatch: Any) -> None:
    """В контекст LLM идёт суммаризация проекта, а не полная заметка."""
    from sqlalchemy import select

    from app.models import Project
    from app.services.detailing import _project_context
    from tests.conftest import _test_session_factory

    client.post("/api/projects", json={"name": "Дача", "note": "краткое резюме проекта"})
    session = _test_session_factory()
    project = session.scalars(select(Project).where(Project.name == "Дача")).one()
    project.summary = "Дача: ремонт и благоустройство"
    session.commit()

    ctx = _project_context(session, "1")
    assert ctx == [{"name": "Дача", "summary": "Дача: ремонт и благоустройство"}]
    session.close()


def test_summarize_project_stores_summary(client: TestClient, monkeypatch: Any) -> None:
    """Суммаризация пишется в projects.summary при правке заметки (фоном)."""
    from app.models import Project
    from tests.conftest import _test_session_factory

    def fake_generate(self: Any, prompt: str) -> str:
        return "Дача: ремонт и благоустройство летом"

    monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)

    resp = client.post("/api/projects", json={"name": "Дача", "note": "Очень длинная заметка"})
    project_id = resp.json()["id"]
    # BackgroundTasks TestClient выполняет синхронно — суммаризация уже готова
    session = _test_session_factory()
    project = session.get(Project, project_id)
    assert project is not None
    assert project.summary == "Дача: ремонт и благоустройство летом"

    # правка заметки — перегенерация
    monkeypatch.setattr(
        services.detailing.DetailingService, "generate", lambda self, prompt: "Новое резюме"
    )
    client.patch(f"/api/projects/{project_id}", json={"note": "Совсем другая заметка"})
    session.expire_all()
    project = session.get(Project, project_id)
    assert project is not None
    assert project.summary == "Новое резюме"
    session.close()


def test_json_serializer_no_unicode_escapes() -> None:
    r"""Кириллица в JSON-колонках не превращается в \u-эскейпы (виден агентам в MCP)."""
    from app.db import json_serializer

    raw = json_serializer({"tags": ["быт"]})
    assert "быт" in raw
    assert "\\u" not in raw


def test_attachments_upload_list_fetch_delete(client: TestClient) -> None:
    task = create_task(client)
    png = b"\x89PNG\r\n\x1a\nfake-image-bytes"

    up = client.post(
        f"/api/documents/{task['document_id']}/attachments",
        files={"files": ("скрин.png", png, "image/png")},
    )
    assert up.status_code == 200, up.text
    att = up.json()[0]
    assert att["original_name"] == "скрин.png"
    assert att["mime"] == "image/png"

    lst = client.get(f"/api/documents/{task['document_id']}/attachments").json()
    assert len(lst) == 1

    got = client.get(f"/api/attachments/{att['id']}/file")
    assert got.status_code == 200
    assert got.content == png

    assert client.delete(f"/api/attachments/{att['id']}").json() == {"ok": True}
    assert client.get(f"/api/attachments/{att['id']}/file").status_code == 404


def test_attachments_reject_non_image(client: TestClient) -> None:
    task = create_task(client)
    up = client.post(
        f"/api/documents/{task['document_id']}/attachments",
        files={"files": ("doc.pdf", b"%PDF-1.4", "application/pdf")},
    )
    assert up.status_code == 415


def test_attachments_reject_svg(client: TestClient) -> None:
    """SVG — скриптуемый формат: inline-отдача в origin приложения = stored XSS."""
    task = create_task(client)
    up = client.post(
        f"/api/documents/{task['document_id']}/attachments",
        files={
            "files": (
                "evil.svg",
                b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
                "image/svg+xml",
            )
        },
    )
    assert up.status_code == 415


def test_attachments_reject_oversize(client: TestClient) -> None:
    from app.api.attachments import MAX_FILE_BYTES

    task = create_task(client)
    big = b"\x89PNG" + b"\x00" * (MAX_FILE_BYTES + 1)
    up = client.post(
        f"/api/documents/{task['document_id']}/attachments",
        files={"files": ("big.png", big, "image/png")},
    )
    assert up.status_code == 413


def test_attachment_file_has_nosniff(client: TestClient) -> None:
    task = create_task(client)
    up = client.post(
        f"/api/documents/{task['document_id']}/attachments",
        files={"files": ("pic.png", b"\x89PNG\r\n\x1a\nx", "image/png")},
    )
    att = up.json()[0]
    got = client.get(f"/api/attachments/{att['id']}/file")
    assert got.headers["x-content-type-options"] == "nosniff"