"""Тесты раздела «Последовательности» и парсера кнопок.""" from __future__ import annotations from datetime import UTC, datetime from typing import Any import pytest from fastapi.testclient import TestClient from admin.routes.sequences import parse_buttons from common.repositories import sequences_repo SEQUENCE: dict[str, Any] = { "key": "welcome", "title": "Приветствие", "description": "Сообщения при входе", "item_count": 2, "updated_at": datetime(2026, 10, 25, tzinfo=UTC), "updated_by": None, } ITEM_1: dict[str, Any] = { "id": 1, "position": 0, "text": "Привет, {username}!", "enabled": True, "image_name": "welcome_image.jpg", "buttons": [], } ITEM_2: dict[str, Any] = { "id": 2, "position": 1, "text": "Правила клана", "enabled": True, "image_name": None, "buttons": [{"text": "Канал", "url": "https://t.me/example"}], } @pytest.fixture def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: state: dict[str, Any] = {} async def list_sequences() -> list[dict[str, Any]]: return [SEQUENCE] async def get_sequence(key: str) -> dict[str, Any] | None: return {**SEQUENCE, "key": key} async def get_items(key: str) -> list[dict[str, Any]]: return [ {**ITEM_1, "buttons": [dict(button) for button in ITEM_1["buttons"]]}, {**ITEM_2, "buttons": [dict(button) for button in ITEM_2["buttons"]]}, ] async def history(key: str, *, limit: int = 10) -> list[dict[str, Any]]: return [] async def create_sequence(key: str, title: str, description: str, *, editor_id: int | None = None) -> bool: state["created"] = (key, title, description) return True async def update_sequence(key: str, title: str, description: str, *, editor_id: int | None = None) -> bool: state["meta"] = (key, title, description) return True async def add_item(key: str, *, editor_id: int | None = None) -> int: state["added"] = key return 3 async def update_item( item_id: int, text: str, enabled: bool, buttons: list[tuple[str, str]], *, image_name: str | None = None, update_image: bool = False, editor_id: int | None = None, ) -> tuple[str, str | None]: state["item"] = { "id": item_id, "text": text, "enabled": enabled, "buttons": buttons, "image_name": image_name, "update_image": update_image, } return "welcome", None async def move_item(item_id: int, direction: str, *, editor_id: int | None = None) -> str: state["move"] = (item_id, direction) return "welcome" async def delete_item(item_id: int, *, editor_id: int | None = None) -> str: state["deleted"] = item_id return "welcome" monkeypatch.setattr(sequences_repo, "list_sequences", list_sequences) monkeypatch.setattr(sequences_repo, "get_sequence", get_sequence) monkeypatch.setattr(sequences_repo, "get_items", get_items) monkeypatch.setattr(sequences_repo, "history", history) monkeypatch.setattr(sequences_repo, "create_sequence", create_sequence) monkeypatch.setattr(sequences_repo, "update_sequence", update_sequence) monkeypatch.setattr(sequences_repo, "add_item", add_item) monkeypatch.setattr(sequences_repo, "update_item", update_item) monkeypatch.setattr(sequences_repo, "move_item", move_item) monkeypatch.setattr(sequences_repo, "delete_item", delete_item) return state def _csrf(client: TestClient, csrf_token, path: str = "/sequences/welcome") -> str: return csrf_token(client.get(path).text) def test_parse_buttons() -> None: buttons, errors = parse_buttons("Канал | https://t.me/x\n\nПравила | http://example.com") assert errors == [] assert buttons == [("Канал", "https://t.me/x"), ("Правила", "http://example.com")] def test_parse_buttons_reports_bad_lines() -> None: buttons, errors = parse_buttons("без ссылки\nТекст | ftp://bad\n | https://x") assert buttons == [] assert len(errors) == 3 def test_sequences_list(fake_sequences: dict[str, Any], client_factory) -> None: response = client_factory().get("/sequences") assert response.status_code == 200 assert "welcome" in response.text assert "Приветствие" in response.text assert " None: response = client_factory().get("/sequences/welcome") assert response.status_code == 200 assert "Правила клана" in response.text assert 'data-tag="b"' in response.text assert "Канал | https://t.me/example" in response.text def test_create_sequence(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory(follow_redirects=False) token = _csrf(client, csrf_token, "/sequences") response = client.post( "/sequences", data={"key": "rules", "title": "Правила", "description": "Текст", "csrf_token": token}, ) assert response.status_code == 303 assert response.headers["location"] == "/sequences/rules" assert fake_sequences["created"] == ("rules", "Правила", "Текст") def test_update_sequence_meta(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory(follow_redirects=False) token = _csrf(client, csrf_token) response = client.post( "/sequences/welcome", data={"title": "Новое", "description": "Описание", "csrf_token": token}, ) assert response.status_code == 303 assert fake_sequences["meta"] == ("welcome", "Новое", "Описание") def test_add_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory(follow_redirects=False) token = _csrf(client, csrf_token) response = client.post("/sequences/welcome/items", data={"csrf_token": token}) assert response.status_code == 303 assert fake_sequences["added"] == "welcome" def test_update_item_text_and_buttons(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory(follow_redirects=False) token = _csrf(client, csrf_token) response = client.post( "/sequences/welcome/items/2", data={ "text": "Новые правила", "buttons": "Канал | https://t.me/new\nЧат | https://t.me/chat", "enabled": "on", "csrf_token": token, }, ) assert response.status_code == 303 saved = fake_sequences["item"] assert saved["text"] == "Новые правила" assert saved["buttons"] == [("Канал", "https://t.me/new"), ("Чат", "https://t.me/chat")] assert saved["enabled"] is True def test_update_item_rejects_bad_buttons(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory() token = _csrf(client, csrf_token) response = client.post( "/sequences/welcome/items/2", data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token}, ) assert response.status_code == 400 assert "Кнопка без разделителя" in response.text assert "item" not in fake_sequences def test_update_item_rejects_caption_overflow(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory() token = _csrf(client, csrf_token) response = client.post( "/sequences/welcome/items/1", data={"text": "а" * 1500, "enabled": "on", "csrf_token": token}, ) assert response.status_code == 400 assert "не больше 1024" in response.text assert "item" not in fake_sequences def test_update_item_uploads_image( fake_sequences: dict[str, Any], client_factory, csrf_token, settings_factory, tmp_path, ) -> None: settings = settings_factory(media_dir=str(tmp_path)) client: TestClient = client_factory(settings=settings, follow_redirects=False) token = _csrf(client, csrf_token) response = client.post( "/sequences/welcome/items/2", data={"text": "С картинкой", "enabled": "on", "csrf_token": token}, files={"image": ("pic.png", b"fake-png-bytes", "image/png")}, ) assert response.status_code == 303 assert fake_sequences["item"]["update_image"] is True files = list(tmp_path.glob("*.png")) assert len(files) == 1 def test_move_and_delete_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory(follow_redirects=False) token = _csrf(client, csrf_token) moved = client.post( "/sequences/welcome/items/1/move", data={"direction": "down", "csrf_token": token}, ) deleted = client.post("/sequences/welcome/items/2/delete", data={"csrf_token": token}) assert moved.status_code == 303 assert deleted.status_code == 303 assert fake_sequences["move"] == (1, "down") assert fake_sequences["deleted"] == 2