Newer
Older
gnexus-tasks / backend / tests / test_bugtrail.py
"""Импорт задачи из ссылки на багрепорт 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"![скриншот]({shot_url})" 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() == []