325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""Тесты раздела «Последовательности» и парсера кнопок."""
|
||
|
||
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"
|
||
|
||
async def save_items(key: str, items: list[dict[str, Any]], *, editor_id: int | None = None) -> list[str]:
|
||
state["saved_all"] = items
|
||
return []
|
||
|
||
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)
|
||
monkeypatch.setattr(sequences_repo, "save_items", save_items)
|
||
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 "<built-in method" not in response.text
|
||
assert "Шаблоны сообщений" in response.text
|
||
|
||
|
||
def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> 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
|
||
|
||
|
||
def test_save_all_items(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/save",
|
||
data={
|
||
"title": "Приветствие",
|
||
"description": "Описание",
|
||
"item_order": "1,2",
|
||
"text_1": "Первый",
|
||
"buttons_1": "",
|
||
"enabled_1": "on",
|
||
"text_2": "Второй",
|
||
"buttons_2": "Канал | https://t.me/x",
|
||
"enabled_2": "on",
|
||
"csrf_token": token,
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 303
|
||
assert response.headers["location"] == "/sequences/welcome?saved=all"
|
||
saved = fake_sequences["saved_all"]
|
||
assert [item["text"] for item in saved] == ["Первый", "Второй"]
|
||
assert saved[1]["buttons"] == [("Канал", "https://t.me/x")]
|
||
|
||
|
||
def test_save_all_rejects_bad_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
|
||
client: TestClient = client_factory()
|
||
token = _csrf(client, csrf_token)
|
||
|
||
response = client.post(
|
||
"/sequences/welcome/save",
|
||
data={
|
||
"title": "Приветствие",
|
||
"item_order": "1",
|
||
"text_1": "Текст",
|
||
"buttons_1": "без ссылки",
|
||
"enabled_1": "on",
|
||
"csrf_token": token,
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
assert "Кнопка без разделителя" in response.text
|
||
assert "saved_all" not in fake_sequences
|