"""Тесты M2: автодетализация (LLM-предложение) и вложения."""
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:
prompt = build_prompt("задача", "", ["быт", "дом"], ["ремонт"])
assert "быт" in prompt and "ремонт" in prompt
assert "задача" in prompt
assert "new_project" 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_filters_unknown_tags_and_priority(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("задача", "", ["быт"], ["Дом"])
assert proposal is not None
assert proposal["tags"] == ["быт"] # неизвестный тег отфильтрован
assert proposal["priority"] is None # 99 вне шкалы 0–10
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 игнорирует неизвестные теги."""
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 db_task.tags == []
session.close()
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/tasks/{task['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/tasks/{task['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/tasks/{task['id']}/attachments",
files={"files": ("doc.pdf", b"%PDF-1.4", "application/pdf")},
)
assert up.status_code == 415