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], project_names: list[str]
    ) -> 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 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": "ремонт", "description": "ремонт дачи летом"}]
    prompt = build_prompt("задача", "", ["быт", "дом"], projects)
    assert "быт" in prompt and "ремонт" in prompt
    assert "задача" in prompt
    assert "new_project" in prompt
    # описание проекта в контексте (LLM назначает задачи по смыслу)
    assert "ремонт дачи летом" in prompt


def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None:
    proposal = {"tags": [], "project": None, "new_project": False, "priority": 4}
    _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_priority_out_of_scale(client: TestClient, monkeypatch: Any) -> None:
    service = services.detailing.DetailingService(base_url="http://mock", model="m")
    monkeypatch.setattr(
        services.detailing.DetailingService,
        "generate",
        lambda self, prompt: '{"tags": ["быт", "новый тег"], "project": "Дом", '
        '"new_project": false, "priority": 99}',
    )
    proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}])
    assert proposal is not None
    assert proposal["priority"] is None  # 99 вне шкалы 0–10
    # неизвестный тег больше не отбрасывается: LLM может предлагать новые
    assert proposal["tags"] == ["быт", "новый тег"]
    assert proposal["new_project"] is False  # проект есть в списке


def test_propose_rejects_garbage_strings(client: TestClient, monkeypatch: Any) -> None:
    """Мусор LLM («NULL», пробел, заглушки) не превращается в проект/тег."""
    service = services.detailing.DetailingService(base_url="http://mock", model="m")
    monkeypatch.setattr(
        services.detailing.DetailingService,
        "generate",
        lambda self, prompt: '{"tags": ["  ", "null", "быт"], "project": "NULL", '
        '"new_project": true, "priority": null}',
    )
    proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}])
    assert proposal is not None
    assert proposal["tags"] == ["быт"]
    assert proposal["project"] is None
    assert proposal["new_project"] is False


def test_propose_title_description_and_same_as_original(
    client: TestClient, monkeypatch: Any
) -> None:
    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 json.dumps(
            {
                "title": "Помыть посуду",  # совпадает с исходным — отбросить
                "description": "1. Включить воду\n2. Помыть тарелки",
                "tags": ["быт"],
                "project": None,
                "priority": 4,
            },
            ensure_ascii=False,
        )

    monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)
    proposal = service.propose("Помыть посуду", "", ["быт"], [])
    assert proposal is not None
    assert proposal["title"] is None  # повтор исходного заголовка
    assert proposal["description"] == "1. Включить воду\n2. Помыть тарелки"
    # переносы описания не схлопнуты (маркдаун-список)
    assert "\n" in proposal["description"]
    # промпт объясняет правила заголовка и шагов
    assert "короткий" in calls[0]
    assert "шагов" in calls[0]


def test_propose_new_project_flag(client: TestClient, monkeypatch: Any) -> None:
    service = services.detailing.DetailingService(base_url="http://mock", model="m")
    monkeypatch.setattr(
        services.detailing.DetailingService,
        "generate",
        lambda self, prompt: '{"title": null, "description": null, "tags": [], '
        '"project": "Новый проект", "new_project": false, "priority": null}',
    )
    proposal = service.propose("задача", "", [], [{"name": "Дом", "description": ""}])
    assert proposal is not None
    assert proposal["new_project"] is True  # проекта нет в списке


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

    proposal = {"tags": ["БЫТ"], "project": "Дом", "new_project": False, "priority": 7}
    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"]["name"] == "Дом"
    assert approved["priority"] == 7


def test_apply_proposal_creates_new_project(client: TestClient) -> None:
    resp = client.post("/api/tasks", json={"title": "новое направление"})
    task_id = resp.json()["id"]
    proposal = {"tags": [], "project": "Ремонт дачи", "new_project": True, "priority": None}
    set_proposal(task_id, proposal)

    approved = client.post(
        f"/api/tasks/{task_id}/approve", json={"apply_proposal": True}
    ).json()
    assert approved["project"]["name"] == "Ремонт дачи"
    # Проект создан в справочнике
    names = [p["name"] for p in client.get("/api/projects").json()]
    assert "Ремонт дачи" in names


def test_apply_proposal_function_directly(client: TestClient) -> None:
    """apply_proposal создаёт несуществующие теги (LLM может предлагать новые)."""
    proposal = {"tags": ["нет такого"], "project": None, "new_project": False, "priority": 3}
    task = create_task(client)
    from app.models import 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
    apply_proposal(session, db_task, proposal)
    assert db_task.priority == 3
    assert [t.name for t in db_task.tags] == ["нет такого"]
    session.close()


def test_apply_proposal_rewrites_title_and_description(client: TestClient) -> None:
    task = create_task(client, title="очень длинный заголовок который надо сократить")
    proposal = {
        "title": "Сократить заголовок",
        "description": "1. Шаг раз",
        "tags": [],
        "project": None,
        "new_project": False,
        "priority": None,
    }
    set_proposal(task["id"], proposal)
    approved = client.post(
        f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}
    ).json()
    assert approved["title"] == "Сократить заголовок"
    assert approved["description"] == "1. Шаг раз"


def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None:
    _mock_proposal(monkeypatch, {"tags": [], "project": None, "new_project": False, "priority": 1})
    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_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"