"""Импорт задачи из ссылки на багрепорт BugTrail (ТЗ 3.22, 0.92)."""
import asyncio
from typing import Any
import httpx
import pytest
from app.api import bugtrail as bugtrail_api
from app.services import bugtrail
API_BASE = "http://tracker.local:8001"
PANEL_BASE = "http://panel.local:5173"
TOKEN = "9f3aQwErTyuIoP-1"
class _Settings:
"""Настройки трекера, не зависящие от .env машины, где гоняются тесты."""
bugtrail_url = API_BASE
bugtrail_panel_url = PANEL_BASE
@property
def bugtrail_enabled(self) -> bool:
return bool(self.bugtrail_url)
@property
def bugtrail_panel(self) -> str:
return (self.bugtrail_panel_url or self.bugtrail_url).rstrip("/")
@pytest.fixture(autouse=True)
def fake_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bugtrail, "get_settings", lambda: _Settings())
monkeypatch.setattr(bugtrail_api, "get_settings", lambda: _Settings())
def _report(**kwargs: Any) -> bugtrail.Report:
return bugtrail.Report.model_validate(kwargs)
def _step(**kwargs: Any) -> dict[str, Any]:
return {"type": "click", "offset_ms": 0, "data": {}, **kwargs}
# --- разбор ссылки -----------------------------------------------------------
def test_parse_share_token_accepts_both_forms() -> None:
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/{TOKEN}") == TOKEN
assert bugtrail.parse_share_token(f"{API_BASE}/api/reports/{TOKEN}") == TOKEN
# хвостовой слэш и query не мешают
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/{TOKEN}/?from=mail") == TOKEN
def test_parse_share_token_rejects_foreign_and_junk() -> None:
assert bugtrail.parse_share_token(f"https://example.com/r/{TOKEN}") is None
# похожий, но чужой хост — именно на этом стоит защита от SSRF
assert bugtrail.parse_share_token(f"https://tracker.local.evil.tld/r/{TOKEN}") is None
assert bugtrail.parse_share_token(f"http://127.0.0.1:8001/r/{TOKEN}") is None
assert bugtrail.parse_share_token(f"javascript:alert(1)//r/{TOKEN}") is None
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/") is None
assert bugtrail.parse_share_token(f"{PANEL_BASE}/p/{TOKEN}") is None
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/{TOKEN}/extra") is None
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/токен") is None
assert bugtrail.parse_share_token("просто текст") is None
def test_parse_share_token_disabled_without_config(monkeypatch: pytest.MonkeyPatch) -> None:
class _Off(_Settings):
bugtrail_url = ""
monkeypatch.setattr(bugtrail, "get_settings", lambda: _Off())
assert bugtrail.parse_share_token(f"{PANEL_BASE}/r/{TOKEN}") is None
# --- сборка черновика -------------------------------------------------------
def test_build_draft_renders_steps_and_screenshots() -> None:
report = _report(
title="Логин не проходит",
description="Нажимаю «Войти» — 400.",
page_url="http://localhost:5173/login",
page_title="Вход",
share_token=TOKEN,
steps=[
_step(
offset_ms=1200,
data={
"element": {"tag": "button", "text_snippet": "Войти", "selector": "#submit"},
},
screenshot_attachment_id="shot-1",
),
_step(type="input", offset_ms=4000, data={"value": "a+b", "value_length": 3}),
_step(
type="navigation",
offset_ms=5100,
data={"from_url": "/login", "to_url": "/dashboard"},
),
],
attachments=[
{"file_id": "shot-1", "kind": "screenshot", "mime": "image/png", "filename": "1.png"},
{"file_id": "shot-2", "kind": "screenshot", "mime": "image/png", "filename": "2.png"},
{"file_id": "vid-1", "kind": "video", "mime": "video/webm", "filename": "r.webm"},
],
)
draft = bugtrail.build_draft(report, api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN)
assert draft.title == "Логин не проходит"
assert draft.description.startswith("Нажимаю «Войти» — 400.")
assert "**Записанные шаги**" in draft.description
assert "1. 1.2 с — Клик: <button> «Войти» #submit" in draft.description
assert "2. 4.0 с — Ввод: «a+b» (3 симв.)" in draft.description
assert "3. 5.1 с — Переход: /login → /dashboard" in draft.description
# скриншот шага — сразу под своим шагом (как в панели BugTrail)
shot_url = f"{API_BASE}/api/reports/by-token/{TOKEN}/files/shot-1"
assert f"" in draft.description
# вложение, не привязанное к шагу, — в «Вложениях»; видео — ссылкой
assert "**Вложения**" in draft.description
assert f"{API_BASE}/api/reports/by-token/{TOKEN}/files/shot-2" in draft.description
assert f"- Запись экрана: {API_BASE}/api/reports/by-token/{TOKEN}/files/vid-1" in (
draft.description
)
assert "**Страница:** Вход — http://localhost:5173/login" in draft.description
# ссылка-источник — на панель, картинки — на API: в dev это разные хосты
assert f"**Источник:** BugTrail — {PANEL_BASE}/r/{TOKEN}" in draft.description
def test_build_draft_element_note_without_steps() -> None:
"""Отчёт-заметка об элементе: шагов нет, суть в элементе."""
report = _report(
title="Кнопка съехала",
share_token=TOKEN,
element={"tag": "button", "text_snippet": "Ок", "unique_selector": ".modal .ok"},
)
draft = bugtrail.build_draft(report, api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN)
assert "**Элемент:** <button> «Ок» .modal .ok" in draft.description
assert "Записанные шаги" not in draft.description
def test_build_draft_minimal_report() -> None:
"""Пустой отчёт: остаётся только источник, заголовок — фолбэк на заголовок страницы."""
draft = bugtrail.build_draft(
_report(page_title="Вход"), api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN
)
assert draft.title == "Вход"
assert draft.description == (
f"**Страница:** Вход\n\n**Источник:** BugTrail — {PANEL_BASE}/r/{TOKEN}"
)
empty = bugtrail.build_draft(
_report(title=" "), api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN
)
assert empty.title == "Багрепорт BugTrail"
assert empty.description == f"**Источник:** BugTrail — {PANEL_BASE}/r/{TOKEN}"
def test_build_draft_null_fields_do_not_break() -> None:
"""BugTrail отдаёт null в необязательных полях — не падаем и не пишем «None»."""
report = _report(
title="x", description=None, page_url=None, page_title=None, steps=None, attachments=None
)
draft = bugtrail.build_draft(report, api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN)
assert "None" not in draft.description
def test_build_draft_caps_steps_and_length() -> None:
many = bugtrail.Report.model_validate(
{
"title": "Долгий сценарий",
"description": "строка\n" * 5000,
"steps": [_step(offset_ms=i * 100) for i in range(bugtrail.MAX_STEPS + 7)],
}
)
draft = bugtrail.build_draft(many, api_base=API_BASE, panel_base=PANEL_BASE, token=TOKEN)
assert len(draft.description) <= bugtrail.MAX_DESCRIPTION + 40 # + хвостовая пометка
assert "… отчёт большой, показано начало" in draft.description
assert "… ещё 7" not in draft.description # шаги ушли за обрезкой вместе с блоком
short = bugtrail.build_draft(
bugtrail.Report.model_validate(
{"title": "x", "steps": [_step(offset_ms=i) for i in range(bugtrail.MAX_STEPS + 7)]}
),
api_base=API_BASE,
panel_base=PANEL_BASE,
token=TOKEN,
)
assert "… ещё 7" in short.description
# --- запрос к трекеру --------------------------------------------------------
def _client(handler: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0)
def _get(handler: Any) -> tuple[Any, str]:
async def run() -> tuple[Any, str]:
async with _client(handler) as client:
return await bugtrail._get_json(client, f"{API_BASE}/api/reports/{TOKEN}")
return asyncio.run(run())
def test_get_json_ok() -> None:
payload, status = _get(lambda request: httpx.Response(200, json={"title": "x"}))
assert status == "ok"
assert payload == {"title": "x"}
def test_get_json_not_found() -> None:
payload, status = _get(lambda request: httpx.Response(404, json={"detail": "Report not found"}))
assert (payload, status) == (None, "not_found")
def test_get_json_server_error() -> None:
assert _get(lambda request: httpx.Response(500))[1] == "unavailable"
def test_get_json_broken_body() -> None:
handler = lambda request: httpx.Response(200, content=b"<html>not json</html>") # noqa: E731
assert _get(handler)[1] == "unavailable"
def test_get_json_rejects_oversize() -> None:
big = b"{" + b"0" * bugtrail.MAX_BYTES + b"}"
assert _get(lambda request: httpx.Response(200, content=big))[1] == "unavailable"
def test_get_json_network_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("нет связи")
assert _get(handler)[1] == "unavailable"
# --- эндпоинт ----------------------------------------------------------------
def _fake_fetch(report: bugtrail.Report | None, status: str = "ok") -> Any:
async def fetch(token: str) -> tuple[bugtrail.Report | None, str]:
assert token == TOKEN
return report, status
return fetch
def test_draft_endpoint_ok(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
report = _report(title="Логин", share_token=TOKEN)
monkeypatch.setattr(bugtrail, "fetch_report", _fake_fetch(report))
resp = client.get("/api/bugtrail/draft", params={"url": f"{PANEL_BASE}/r/{TOKEN}"})
assert resp.status_code == 200
body = resp.json()
assert body["title"] == "Логин"
assert f"**Источник:** BugTrail — {PANEL_BASE}/r/{TOKEN}" in body["description"]
def test_draft_endpoint_foreign_link(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bugtrail, "fetch_report", _fake_fetch(None, "unavailable"))
resp = client.get("/api/bugtrail/draft", params={"url": "https://example.com/r/tok"})
assert resp.status_code == 400
def test_draft_endpoint_disabled(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
class _Off(_Settings):
bugtrail_url = ""
monkeypatch.setattr(bugtrail_api, "get_settings", lambda: _Off())
resp = client.get("/api/bugtrail/draft", params={"url": f"{PANEL_BASE}/r/{TOKEN}"})
assert resp.status_code == 503
def test_draft_endpoint_not_found(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bugtrail, "fetch_report", _fake_fetch(None, "not_found"))
resp = client.get("/api/bugtrail/draft", params={"url": f"{PANEL_BASE}/r/{TOKEN}"})
assert resp.status_code == 404
def test_draft_endpoint_unavailable(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bugtrail, "fetch_report", _fake_fetch(None, "unavailable"))
resp = client.get("/api/bugtrail/draft", params={"url": f"{PANEL_BASE}/r/{TOKEN}"})
assert resp.status_code == 502
def test_draft_endpoint_creates_nothing(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
"""Импорт ничего не создаёт: задачи в БД появляются только по кнопке."""
monkeypatch.setattr(bugtrail, "fetch_report", _fake_fetch(_report(title="x")))
client.get("/api/bugtrail/draft", params={"url": f"{PANEL_BASE}/r/{TOKEN}"})
assert client.get("/api/tasks").json() == []