"""Тесты 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],
projects: list[dict[str, str]],
*,
include_description: bool = True,
include_tags: 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 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 не выбирает (решение 2026-09-22): контекст только для темы
assert "new_project" not in prompt and "project" not in prompt.split("Контекст")[0].replace(
"проект выбирать не нужно", ""
)
assert "ремонт дачи летом" in prompt
def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None:
proposal = {"title": None, "description": None, "tags": []}
_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_planning_fields(client: TestClient, monkeypatch: Any) -> None:
"""Приоритет, проект и время LLM не предлагает (решение 2026-09-22): даже
вернув их в ответе, модель не увидит их в предложении."""
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, "estimated_minutes": 42}'
),
)
proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "summary": ""}])
assert proposal is not None
assert set(proposal) == {"title", "description", "tags"}
# неизвестный тег больше не отбрасывается: LLM может предлагать новые
assert proposal["tags"] == ["быт", "новый тег"]
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", "быт"], "title": " "}',
)
proposal = service.propose("задача", "", ["быт"], [])
assert proposal is not None
assert proposal["tags"] == ["быт"]
assert proposal["title"] is None
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_include_flags(client: TestClient, monkeypatch: Any) -> None:
"""include_description/include_tags выключают соответствующие поля: заполненное
пользователем не угадываем (первичный анализ)."""
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 '{"title": "Коротко", "description": "1. Шаг", "tags": ["x"]}'
monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)
proposal = service.propose(
"задача", "уже есть описание", ["быт"], [], include_description=False, include_tags=False
)
assert proposal is not None
assert proposal["title"] == "Коротко"
assert proposal["description"] is None
assert proposal["tags"] == []
# в промпте нет правил для отключённых полей
assert "шагов" not in calls[0]
assert "тегов" not in calls[0]
def test_apply_proposal_maps_and_creates(client: TestClient) -> None:
client.post("/api/tags", json={"name": "быт"})
proposal = {"title": None, "description": None, "tags": ["БЫТ"]}
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["priority"] 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_rewrites_title_and_description(client: TestClient) -> None:
task = create_task(client, title="очень длинный заголовок который надо сократить")
proposal = {"title": "Сократить заголовок", "description": "1. Шаг раз", "tags": []}
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, {"title": None, "description": None, "tags": []})
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"], {"title": None, "description": None, "tags": []})
resp = client.post(f"/api/tasks/{task['id']}/redetail")
assert resp.json()["ai_proposal"] is not None
def test_initial_detail_skips_filled_fields(client: TestClient, monkeypatch: Any) -> None:
"""Первичный анализ: у задачи есть описание и теги — LLM не угадывает их,
предлагается только сжатие заголовка. Переспрос (force) — полный анализ."""
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 '{"title": "Коротко"}'
monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate)
detail_task(task_id)
assert "шагов" not in calls[0] and "тегов" not in calls[0]
task = client.get(f"/api/tasks/{task_id}").json()
assert task["ai_proposal"]["description"] is None
assert task["ai_proposal"]["tags"] == []
detail_task(task_id, force=True)
assert "шагов" in calls[1] and "тегов" in calls[1]
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"