diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1e499d..5e9526e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,23 +28,29 @@ jobs: - name: Ruff format check run: ruff format --check "$GITHUB_WORKSPACE/app" "$GITHUB_WORKSPACE/tests" + # Job id is the status-check context required by the "main protection" + # ruleset ("test"). A matrix would report it as "test (3.12)" and never + # satisfy that requirement, so keep it a single un-matrixed job. test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install Poetry run: python -m pip install --upgrade pip poetry - name: Install bot dependencies (system env, mirrors Dockerfile) working-directory: app/bot + # --only main,testing: the dev group (a non-optional group, so installed + # by default) pulls poetry-plugin-up -> an old poetry -> dulwich<0.22, + # which with virtualenvs.create false clobbers Poetry's own dulwich + # mid-install and breaks fetching the dialog-engine git dependency + # ("No module named dulwich.worktree"). Tests need pytest-cov, which now + # lives in the testing group. run: | poetry config virtualenvs.create false - poetry install --with testing,dev --no-root + poetry install --only main,testing --no-root - name: Run tests # Publisher microservice tests need each service's own deps (asyncssh, # aioprometheus, ...) which aren't in the bot env — they run in the diff --git a/app/bot/filters/dialog_filters.py b/app/bot/filters/dialog_filters.py new file mode 100644 index 0000000..600a231 --- /dev/null +++ b/app/bot/filters/dialog_filters.py @@ -0,0 +1,43 @@ +"""aiogram filters that route messages by the active dialog step. + +These replace the aiogram ``StateFilter`` / ``StatesGroup`` membership checks. +Routing decisions are delegated to :mod:`dialog_engine`: a message reaches a +handler only when the user's :class:`~dialog_engine.DialogSession` is active and +sitting on the expected step. +""" + +from __future__ import annotations + +from aiogram.filters import BaseFilter +from aiogram.fsm.context import FSMContext +from aiogram.types import TelegramObject +from dialog_engine import DialogEngine + +from forms.upload_file import upload_file_engine +from utils.dialog import load_session + + +class InDialog(BaseFilter): + """Pass when the user has an active dialog session (on any step).""" + + def __init__(self, engine: DialogEngine = upload_file_engine) -> None: + self.engine = engine + + async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool: + session = await load_session(state, self.engine) + return session is not None and session.is_active + + +class OnStep(BaseFilter): + """Pass when the active dialog session is currently on ``step_id``.""" + + def __init__(self, step_id: str, engine: DialogEngine = upload_file_engine) -> None: + self.step_id = step_id + self.engine = engine + + async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool: + session = await load_session(state, self.engine) + if session is None or not session.is_active: + return False + current = self.engine.current_step(session) + return current is not None and current.id == self.step_id diff --git a/app/bot/forms/upload_file.py b/app/bot/forms/upload_file.py index d639a31..830bb56 100644 --- a/app/bot/forms/upload_file.py +++ b/app/bot/forms/upload_file.py @@ -1,7 +1,46 @@ -from aiogram.fsm.state import State, StatesGroup +"""Upload-episode dialog schema, driven by :mod:`dialog_engine`. +This replaces the former aiogram ``UploadFile`` ``StatesGroup``. The flow has +three steps — pick the episode type, send the MP3, send the metadata template — +and the engine owns step order, validation and answer storage. -class UploadFile(StatesGroup): - type_episode = State() - mp3 = State() - template = State() +The aiogram handlers in :mod:`handlers.podcast_handler` still render the +per-language prompts and perform the Telegram-side side effects (download, +tagging, upload); they consult this engine only for navigation and to read the +collected answers. +""" + +from dialog_engine import DialogEngine + +# Step IDs are referenced by the handlers and the ``OnStep`` filter, so keep +# them as named constants to avoid stringly-typed drift. +TYPE_EPISODE = "type_episode" +MP3 = "mp3" +TEMPLATE = "template" + +DIALOG_ID = "upload_file" + +# Canonical episode kinds stored in ``session.answers[TYPE_EPISODE]``. The +# display text is rendered per-language by the handlers/keyboards, so the +# ``choices`` values here are just i18n attribute names for reference. +upload_file_engine = DialogEngine.from_list( + [ + { + "id": TYPE_EPISODE, + "type": "choice", + "text": "ask_typeEpisode", + "choices": {"main": "main_episode", "aftershow": "episode_aftershow"}, + }, + { + "id": MP3, + "type": "file", + "text": "ask_mp3", + }, + { + "id": TEMPLATE, + "type": "text", + "text": "ask_template", + }, + ], + dialog_id=DIALOG_ID, +) diff --git a/app/bot/handlers/podcast_handler.py b/app/bot/handlers/podcast_handler.py index 40aab3a..30a03ab 100644 --- a/app/bot/handlers/podcast_handler.py +++ b/app/bot/handlers/podcast_handler.py @@ -5,7 +5,7 @@ from pathlib import Path from aiogram import Bot, F, Router -from aiogram.filters import CommandStart, StateFilter +from aiogram.filters import CommandStart from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile, Message, ReplyKeyboardRemove from loguru import logger @@ -21,9 +21,11 @@ LOCAL, PODCAST_PATH, ) +from filters.dialog_filters import InDialog, OnStep from filters.dispatcher_filters import ContextButton, IsAdmin, IsPrivate -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine from services import context, keyboards +from utils.dialog import load_session, save_session, start_dialog from utils.FTP_methods import get_last_post_ID from utils.MP3_methods import audio_tag from utils.podcast_methods import generate_file_name @@ -66,11 +68,11 @@ async def start(msg: Message, state: FSMContext, language: str): context[language].ask_typeEpisode, reply_markup=keyboards["podcast_handler"][language].type_episode, ) - await state.set_state(UploadFile.type_episode) + await start_dialog(state, upload_file_engine) @logger.catch -@router.message(F.text, ContextButton("cancel"), StateFilter(UploadFile)) +@router.message(F.text, ContextButton("cancel"), InDialog()) async def cancel(msg: Message, state: FSMContext, language: str, username: str): """Отмена загрузки MP3.""" logger.debug(f"[{username}]: Отмена загрузки MP3") @@ -85,12 +87,14 @@ async def cancel(msg: Message, state: FSMContext, language: str, username: str): @router.message( F.text, ContextButton(["main_episode", "episode_aftershow"]), - UploadFile.type_episode, + OnStep(TYPE_EPISODE), ) async def get_type(msg: Message, state: FSMContext, language: str, username: str): """Выбор типа эпизода.""" type_episode = "main" if msg.text == context[language].main_episode else "aftershow" - await state.update_data(type_episode=type_episode) + session = await load_session(state, upload_file_engine) + await upload_file_engine.async_submit(session, type_episode) + await save_session(state, session) logger.debug(f"[{username}]: Выбран тип эпизода: {type_episode}") type_episode_text = ( # noqa: F841 — used by context format_map @@ -100,13 +104,13 @@ async def get_type(msg: Message, state: FSMContext, language: str, username: str context[language].ask_mp3, reply_markup=keyboards["podcast_handler"][language].cancel, ) - await state.set_state(UploadFile.mp3) @logger.catch -@router.message(UploadFile.mp3, F.audio) +@router.message(OnStep(MP3), F.audio) async def get_MP3(msg: Message, state: FSMContext, bot: Bot, language: str, username: str): """Обработка загрузки MP3.""" + session = await load_session(state, upload_file_engine) await clear_old_mp3_files() logger.debug(f"[{username}]: Загружает MP3...") @@ -153,24 +157,26 @@ async def progress_callback(bytes_uploaded: int): await state.clear() return - episode_data = await state.get_data() - type_episode = episode_data["type_episode"] + type_episode = session.answers[TYPE_EPISODE] numberLastEpisode = str(int(await get_last_post_ID(type_episode, FTP_SERVER, FTP_LOGIN, FTP_PASSWORD)) + 1) + await upload_file_engine.async_submit(session, msg.audio.file_id) + await save_session(state, session) + await download_msg.edit_text(context[language].downloaded) await msg.answer( context[language].ask_template[type_episode].replace("600", numberLastEpisode), reply_markup=keyboards["podcast_handler"][language].cancel, ) - await state.set_state(UploadFile.template) @logger.catch -@router.message(F.text, UploadFile.template, flags={"long_operation": "upload_audio"}) +@router.message(F.text, OnStep(TEMPLATE), flags={"long_operation": "upload_audio"}) async def set_template(msg: Message, state: FSMContext, language: str, username: str): """Обработка шаблона для MP3-тегов.""" - type_episode = (await state.get_data())["type_episode"] + session = await load_session(state, upload_file_engine) + type_episode = session.answers[TYPE_EPISODE] logger.debug(f"[{username}]: Выбранный тип эпизода: {type_episode}") info = validate_template(msg.text) @@ -220,4 +226,7 @@ async def progress_callback(bytes_uploaded: int): await tmp.delete() logger.debug(f"[{username}]: MP3 загружен и отправлен в чат") + + # Final step — mark the dialog complete, then drop the session from storage. + await upload_file_engine.async_submit(session, msg.text) await state.clear() diff --git a/app/bot/poetry.lock b/app/bot/poetry.lock index 46d84c2..b810814 100644 --- a/app/bot/poetry.lock +++ b/app/bot/poetry.lock @@ -851,7 +851,7 @@ version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["testing"] files = [ {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, @@ -1120,6 +1120,27 @@ files = [ [package.dependencies] packaging = "*" +[[package]] +name = "dialog-engine" +version = "0.1.0" +description = "Universal multi-step dialog engine for building form-driven flows and Telegram bots." +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.extras] +aiogram = ["aiogram (>=3.0,<4.0)"] +validation = ["pydantic (>=2.0,<3.0)"] +yaml = ["PyYAML (>=6.0)"] + +[package.source] +type = "git" +url = "https://github.com/k0te1ch/DialogEngine.git" +reference = "HEAD" +resolved_reference = "be5bf7c9fbd252156a220dc3bfd0e6e8e4b9c521" + [[package]] name = "distlib" version = "0.4.0" @@ -2925,7 +2946,7 @@ version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["testing"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, @@ -3846,4 +3867,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.15" -content-hash = "705c8f8b44f4283dd69f826443c5178f1b8cea7e3c42ddf0ef4234542804ff96" +content-hash = "a91f41bb744b9c3eb03c33a0bcf147cc9b850e5cb1f857f75a3bed5c32011651" diff --git a/app/bot/pyproject.toml b/app/bot/pyproject.toml index eda42a3..e08b785 100644 --- a/app/bot/pyproject.toml +++ b/app/bot/pyproject.toml @@ -33,6 +33,9 @@ pydantic = "^2.12.3" pydantic-settings = "^2.9.1" pre-commit = "^4.5.1" pytz = "^2026.1.post1" +# Multi-step dialog engine. Pulled from GitHub until it lands on PyPI, +# then this switches to a plain version constraint. +dialog-engine = { git = "https://github.com/k0te1ch/DialogEngine.git" } [tool.poetry.group.testing.dependencies] @@ -40,6 +43,7 @@ pytest = "^8.3.2" pytest-asyncio = "^0.24.0" aiogram-testing = "^1.1.0" responses = "^0.23.1" +pytest-cov = "^5.0.0" [tool.poetry.group.e2e] @@ -52,7 +56,6 @@ tgtest = {git = "https://github.com/k0te1ch/tgtest"} [tool.poetry.group.dev.dependencies] ruff = "^0.11" poetry-plugin-up = "^0.7.1" -pytest-cov = "^5.0.0" pytest = "^8.3.2" diff --git a/app/bot/utils/dialog.py b/app/bot/utils/dialog.py new file mode 100644 index 0000000..c9895e2 --- /dev/null +++ b/app/bot/utils/dialog.py @@ -0,0 +1,37 @@ +"""Glue between :mod:`dialog_engine` and aiogram's FSM storage. + +``dialog_engine`` is framework-agnostic: the schema lives in a stateless +:class:`~dialog_engine.DialogEngine` and all run-time state lives in a +serialisable :class:`~dialog_engine.DialogSession`. These helpers persist that +session inside aiogram's :class:`~aiogram.fsm.context.FSMContext` (backed by +Redis in production), so a running dialog survives restarts just like the old +FSM states did. +""" + +from __future__ import annotations + +from aiogram.fsm.context import FSMContext +from dialog_engine import DialogEngine, DialogSession + +# Key under which the serialised session lives in the FSM data dict. +SESSION_KEY = "dialog_session" + + +async def load_session(state: FSMContext, engine: DialogEngine) -> DialogSession | None: + """Return the dialog session stored in *state*, or ``None`` if absent.""" + raw = (await state.get_data()).get(SESSION_KEY) + if not raw: + return None + return engine.restore_session(raw) + + +async def save_session(state: FSMContext, session: DialogSession) -> None: + """Serialise *session* back into the FSM data.""" + await state.update_data({SESSION_KEY: session.to_dict()}) + + +async def start_dialog(state: FSMContext, engine: DialogEngine) -> DialogSession: + """Begin a fresh dialog, replacing any session already in *state*.""" + session = engine.create_session() + await save_session(state, session) + return session diff --git a/tests/unit/filters/test_dialog_filters.py b/tests/unit/filters/test_dialog_filters.py new file mode 100644 index 0000000..e72fcfd --- /dev/null +++ b/tests/unit/filters/test_dialog_filters.py @@ -0,0 +1,57 @@ +"""Tests for the dialog-step routing filters (``filters.dialog_filters``).""" + +import pytest +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.base import StorageKey +from aiogram.fsm.storage.memory import MemoryStorage + +from filters.dialog_filters import InDialog, OnStep +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine +from utils.dialog import save_session, start_dialog + + +@pytest.fixture +def state() -> FSMContext: + storage = MemoryStorage() + key = StorageKey(bot_id=1, chat_id=1, user_id=1) + return FSMContext(storage=storage, key=key) + + +@pytest.mark.asyncio +async def test_in_dialog_false_without_session(state): + assert await InDialog()(None, state) is False + + +@pytest.mark.asyncio +async def test_in_dialog_true_with_active_session(state): + await start_dialog(state, upload_file_engine) + assert await InDialog()(None, state) is True + + +@pytest.mark.asyncio +async def test_on_step_matches_current_step_only(state): + await start_dialog(state, upload_file_engine) # on type_episode + assert await OnStep(TYPE_EPISODE)(None, state) is True + assert await OnStep(MP3)(None, state) is False + + +@pytest.mark.asyncio +async def test_on_step_follows_session_progress(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") # advance to mp3 + await save_session(state, session) + + assert await OnStep(TYPE_EPISODE)(None, state) is False + assert await OnStep(MP3)(None, state) is True + + +@pytest.mark.asyncio +async def test_filters_false_after_dialog_completes(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") + upload_file_engine.submit(session, "file_id") + upload_file_engine.submit(session, "Number: 1\nTitle: x\nComment: y") # completes + await save_session(state, session) + + assert await InDialog()(None, state) is False + assert await OnStep(TEMPLATE)(None, state) is False diff --git a/tests/unit/forms/test_upload_file.py b/tests/unit/forms/test_upload_file.py new file mode 100644 index 0000000..5839fb3 --- /dev/null +++ b/tests/unit/forms/test_upload_file.py @@ -0,0 +1,61 @@ +"""Tests for the upload-episode dialog schema (``forms.upload_file``). + +These exercise the ``dialog_engine`` schema directly — step order, the choice +constraint, media collection, completion and round-trip serialisation — so the +contract the handlers rely on is pinned independently of aiogram. +""" + +import pytest +from dialog_engine import DialogSession, SessionStatus, ValidationError + +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine + + +def test_schema_step_order_and_types(): + steps = upload_file_engine.steps + assert [s.id for s in steps] == [TYPE_EPISODE, MP3, TEMPLATE] + by_id = {s.id: s for s in steps} + assert by_id[TYPE_EPISODE].type == "choice" + assert by_id[TYPE_EPISODE].choices == {"main": "main_episode", "aftershow": "episode_aftershow"} + assert by_id[MP3].type == "file" + assert by_id[TEMPLATE].type == "text" + + +def test_first_step_is_type_episode(): + session = upload_file_engine.create_session() + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + assert session.is_active + + +@pytest.mark.parametrize("type_episode", ["main", "aftershow"]) +def test_full_happy_path(type_episode): + session = upload_file_engine.create_session() + + nxt = upload_file_engine.submit(session, type_episode) + assert nxt.id == MP3 + assert session.answers[TYPE_EPISODE] == type_episode + + nxt = upload_file_engine.submit(session, "audio_file_id") + assert nxt.id == TEMPLATE + assert session.answers[MP3] == ["audio_file_id"] # file step normalises to a list + + nxt = upload_file_engine.submit(session, "Number: 1\nTitle: x\nComment: y") + assert nxt is None # dialog complete + assert session.status == SessionStatus.COMPLETED + + +def test_invalid_episode_type_is_rejected(): + session = upload_file_engine.create_session() + with pytest.raises(ValidationError): + upload_file_engine.submit(session, "not-a-choice") + # Session stays on the first step so the user can retry. + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + + +def test_session_survives_serialisation_round_trip(): + session = upload_file_engine.create_session() + upload_file_engine.submit(session, "main") + + restored = DialogSession.from_dict(session.to_dict()) + assert restored.answers == session.answers + assert upload_file_engine.current_step(restored).id == MP3 diff --git a/tests/unit/handlers/podcast_handler/conftest.py b/tests/unit/handlers/podcast_handler/conftest.py new file mode 100644 index 0000000..d8f6438 --- /dev/null +++ b/tests/unit/handlers/podcast_handler/conftest.py @@ -0,0 +1,47 @@ +"""Shared fixtures for the podcast_handler dialog tests. + +The handlers were migrated from an aiogram ``StatesGroup`` to the +``dialog_engine`` library: the per-user progress now lives in a serialised +:class:`~dialog_engine.DialogSession` stored in the FSM data under +``utils.dialog.SESSION_KEY`` instead of in aiogram's ``state``. + +``aiogram_tests`` only injects ``state_data`` into storage when a non-empty +``state`` is also supplied (see ``TelegramEventObserverHandler.__call__``), so +tests pass the :func:`dialog_state` sentinel purely to trigger that injection — +the handlers themselves no longer read the aiogram state. +""" + +from collections.abc import Callable + +import pytest + +from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine +from utils.dialog import SESSION_KEY + +# Sentinel aiogram state used only so aiogram_tests writes our state_data. +_DIALOG_STATE = "uploading" + + +@pytest.fixture +def dialog_state() -> str: + """Sentinel FSM state that makes aiogram_tests persist ``state_data``.""" + return _DIALOG_STATE + + +@pytest.fixture +def session_state_data() -> Callable[..., dict]: + """Factory building FSM ``state_data`` with a session on a given step. + + Steps are reached by replaying the real engine transitions, so the session's + history/answers are exactly what production would have produced. + """ + + def _make(*, step: str = TYPE_EPISODE, type_episode: str = "main") -> dict: + session = upload_file_engine.create_session() + if step in (MP3, TEMPLATE): + upload_file_engine.submit(session, type_episode) # type_episode -> mp3 + if step == TEMPLATE: + upload_file_engine.submit(session, "stub_file_id") # mp3 -> template + return {SESSION_KEY: session.to_dict()} + + return _make diff --git a/tests/unit/handlers/podcast_handler/test_cancel.py b/tests/unit/handlers/podcast_handler/test_cancel.py index b003a37..01d50a7 100644 --- a/tests/unit/handlers/podcast_handler/test_cancel.py +++ b/tests/unit/handlers/podcast_handler/test_cancel.py @@ -3,35 +3,32 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from filters.dispatcher_filters import ContextButton -from forms.upload_file import UploadFile +from forms.upload_file import MP3 from handlers.podcast_handler import cancel from services import context +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_user", "test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) -async def test_cancel_command(language, username, handler_factory, bot_factory, state_context_factory): - # Настройка обработчика и команды для cancel - handler_func = cancel - command = ContextButton("cancel") - state = UploadFile.type_episode - - # Создание обработчика, бота и контекста с использованием фабрик - handler = handler_factory(handler_func, command=command, state=state) +async def test_cancel_command( + language, username, handler_factory, bot_factory, state_context_factory, dialog_state, session_state_data +): + # An upload is in progress (session on some step) when the user cancels. + handler = handler_factory(cancel, state=dialog_state, state_data=session_state_data(step=MP3)) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Отмена", from_user=user) - # Выполняем обработчик cancel calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - # Проверка отправленного сообщения и сброса состояния FSM assert len(calls.send_message) == 1 sent_message = calls.send_message.fetchone() - expected_text = context[language].canceled - assert sent_message.text == expected_text + assert sent_message.text == context[language].canceled assert sent_message.reply_markup == ReplyKeyboardRemove(remove_keyboard=True) + + # Cancelling wipes the dialog session and the FSM state. + assert SESSION_KEY not in (await state_context.get_data()), "Dialog session was not cleared on cancel" assert (await state_context.get_state()) is None diff --git a/tests/unit/handlers/podcast_handler/test_get_MP3.py b/tests/unit/handlers/podcast_handler/test_get_MP3.py index 1d0e4dd..3d17fba 100644 --- a/tests/unit/handlers/podcast_handler/test_get_MP3.py +++ b/tests/unit/handlers/podcast_handler/test_get_MP3.py @@ -7,9 +7,10 @@ from aiogram_tests.types.dataset import AUDIO, MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TEMPLATE, upload_file_engine from handlers.podcast_handler import get_MP3 from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.fixture @@ -52,18 +53,18 @@ async def test_get_MP3_handler( state_context_factory, configure_paths, mock_get_last_post_id, + dialog_state, + session_state_data, ): files_path, podcast_path = configure_paths - handler_func = get_MP3 - state = UploadFile.mp3 mock_file_id = "test_file_id" - state_data = {"type_episode": "main"} + # Session sits on the mp3 step with the episode type already chosen. + state_data = session_state_data(step=MP3, type_episode="main") test_mp3_file = files_path / "test_delete.mp3" test_mp3_file.touch() - # Общие настройки для бота, пользователя, сообщения и состояния - handler = handler_factory(handler_func, state=state, state_data=state_data) + handler = handler_factory(get_MP3, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) audio = AUDIO.as_object(file_id=mock_file_id) @@ -102,10 +103,10 @@ async def test_get_MP3_handler( assert not test_mp3_file.exists(), "Temporary MP3 file was not deleted" assert all(item.suffix != ".mp3" for item in files_path.iterdir()), "Previous MP3 files were not deleted" - # Проверка изменения состояния FSM - assert await state_context.get_state() == UploadFile.template, ( - "FSM state did not update to UploadFile.template as expected" - ) + # The engine recorded the uploaded file and advanced to the template step. + session = upload_file_engine.restore_session((await state_context.get_data())[SESSION_KEY]) + assert session.answers[MP3] == [mock_file_id] + assert upload_file_engine.current_step(session).id == TEMPLATE # Проверка текста сообщения и клавиатуры number_last_episode = "43" # так как `get_last_post_ID` вернул 42 diff --git a/tests/unit/handlers/podcast_handler/test_get_type.py b/tests/unit/handlers/podcast_handler/test_get_type.py index 0ba1acd..e2a06eb 100644 --- a/tests/unit/handlers/podcast_handler/test_get_type.py +++ b/tests/unit/handlers/podcast_handler/test_get_type.py @@ -2,15 +2,16 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import MP3, TYPE_EPISODE, upload_file_engine from handlers.podcast_handler import get_type from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) -@pytest.mark.parametrize("type_episode_key", ["main_episode", "aftershow_episode"]) +@pytest.mark.parametrize("type_episode_key", ["main_episode", "episode_aftershow"]) async def test_get_type_handler( username, language, @@ -18,41 +19,35 @@ async def test_get_type_handler( handler_factory, bot_factory, state_context_factory, + dialog_state, + session_state_data, ): - # Set up handler for get_type - handler_func = get_type - state = UploadFile.type_episode - typeEpisode = context[language][type_episode_key] - - # Create handler, bot, and context - handler = handler_factory(handler_func, state=state) - + # Session is freshly created, sitting on the type_episode step. + handler = handler_factory( + get_type, + state=dialog_state, + state_data=session_state_data(step=TYPE_EPISODE), + ) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) + msg = MESSAGE.as_object(text=context[language][type_episode_key], from_user=user) - # Simulate a message from the user - msg = MESSAGE.as_object(text=typeEpisode, from_user=user) calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - typeEpisode = typeEpisode.lower() - # Verify FSM state update and sent message - state_data = await state_context.get_data() - expected_type = type_episode_key.replace("_episode", "") - assert state_data.get("type_episode") == expected_type, "Expected typeEpisode to be set in state data" - assert (await state_context.get_state()) == UploadFile.mp3, ( - "FSM state did not update to UploadFile.mp3 as expected" - ) + expected_type = "main" if type_episode_key == "main_episode" else "aftershow" + + # The engine stored the choice and advanced the session to the mp3 step. + session = upload_file_engine.restore_session((await state_context.get_data())[SESSION_KEY]) + assert session.answers[TYPE_EPISODE] == expected_type + assert upload_file_engine.current_step(session).id == MP3 - assert len(calls.send_message) == 1, "Expected one message to be sent" + assert len(calls.send_message) == 1 sent_message = calls.send_message.fetchone() # ``ask_mp3`` interpolates ``type_episode`` / ``type_episode_text`` from the # caller's locals (see services.context). Mirror what the handler computes # so the expected text matches across locales. type_episode = expected_type # noqa: F841 type_episode_text = "основной эпизод" if expected_type == "main" else "эпизод послешоу" # noqa: F841 - expected_text = context[language].ask_mp3 - assert sent_message.text == expected_text, "Sent message text does not match expected text" - assert sent_message.reply_markup == keyboards["podcast_handler"][language].cancel, ( - "Keyboard does not match expected 'cancel' keyboard" - ) + assert sent_message.text == context[language].ask_mp3 + assert sent_message.reply_markup == keyboards["podcast_handler"][language].cancel diff --git a/tests/unit/handlers/podcast_handler/test_set_template.py b/tests/unit/handlers/podcast_handler/test_set_template.py index 732c87d..c360975 100644 --- a/tests/unit/handlers/podcast_handler/test_set_template.py +++ b/tests/unit/handlers/podcast_handler/test_set_template.py @@ -8,9 +8,10 @@ from aiogram_tests.requester import Calls from aiogram_tests.types.dataset import MESSAGE, USER -from forms.upload_file import UploadFile +from forms.upload_file import TEMPLATE from handlers.podcast_handler import set_template from services import context, keyboards +from utils.dialog import SESSION_KEY @pytest.fixture @@ -54,15 +55,17 @@ async def test_set_template( type_episode, username, language, + dialog_state, + session_state_data, ): - state_data = {"type_episode": type_episode} + # Session sits on the template step; the episode type comes from its answers. + state_data = session_state_data(step=TEMPLATE, type_episode=type_episode) # Настроим mock-ответ от validate_template для успешного кейса valid_info = {"number": "42", "title": "Podcast Episode Title"} mock_validate_template.return_value = valid_info - # Создаём моки для бота, пользователя, сообщения и состояния - handler = handler_factory(set_template, state=UploadFile.template, state_data=state_data) + handler = handler_factory(set_template, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Some valid template text", from_user=user) @@ -111,8 +114,11 @@ async def test_set_template( else keyboards["podcast_handler"][language].audio_menu_post ) - # Проверяем, что состояние было очищено + # On success the dialog is finished and its session is + # dropped from FSM storage. state_context = await state_context_factory(handler, message=msg) + data = await state_context.get_data() + assert SESSION_KEY not in data, "Dialog session was not cleared" assert await state_context.get_state() is None, "State was not cleared" @@ -127,14 +133,15 @@ async def test_set_template_invalid_input( mock_validate_template, username, language, + dialog_state, + session_state_data, ): - state_data = {"type_episode": "main"} + state_data = session_state_data(step=TEMPLATE, type_episode="main") # Устанавливаем mock-ответ validate_template как None для проверки обработки невалидного ввода mock_validate_template.return_value = None - # Создаём моки для бота, пользователя, сообщения и состояния - handler = handler_factory(set_template, state=UploadFile.template, state_data=state_data) + handler = handler_factory(set_template, state=dialog_state, state_data=state_data) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="Invalid template", from_user=user) @@ -152,3 +159,7 @@ async def test_set_template_invalid_input( # Проверяем, что никакой файл не был отправлен attrs = calls._get_attributes() assert "send_audio" not in attrs, "Audio should not be sent on invalid input" + + # The dialog stays on the template step so the user can retry. + state_context = await state_context_factory(handler, message=msg) + assert SESSION_KEY in (await state_context.get_data()), "Session should survive an invalid template" diff --git a/tests/unit/handlers/podcast_handler/test_start.py b/tests/unit/handlers/podcast_handler/test_start.py index d698cc8..5b41db0 100644 --- a/tests/unit/handlers/podcast_handler/test_start.py +++ b/tests/unit/handlers/podcast_handler/test_start.py @@ -3,34 +3,32 @@ from aiogram_tests.types.dataset import MESSAGE, USER from config import LANGUAGES -from forms.upload_file import UploadFile +from forms.upload_file import TYPE_EPISODE, upload_file_engine from handlers.podcast_handler import start from services import context +from utils.dialog import SESSION_KEY @pytest.mark.asyncio @pytest.mark.parametrize("username", ["test_of_test"]) @pytest.mark.parametrize("language", LANGUAGES) async def test_start_command(language, username, handler_factory, bot_factory, state_context_factory): - # Настройка обработчика и команды start - handler_func = start - command = Command(commands=["start"]) - - # Создание бота и контекста с использованием фабрик - handler = handler_factory(handler_func, command=command) + handler = handler_factory(start, command=Command(commands=["start"])) bot = await bot_factory(handler) user = USER.as_object(username=username, language_code=language) msg = MESSAGE.as_object(text="/start", from_user=user) - # Выполняем команду /start calls = await bot.query(message=msg) state_context = await state_context_factory(handler, message=msg) - # Проверка отправленного сообщения + # The prompt is unchanged. assert len(calls.send_message) == 1 - sent_message = calls.send_message.fetchone() - expected_text = context[language].ask_typeEpisode - assert sent_message.text == expected_text + assert calls.send_message.fetchone().text == context[language].ask_typeEpisode - # Проверка установки состояния FSM - assert (await state_context.get_state()) == UploadFile.type_episode + # /start now opens a dialog_engine session sitting on the first step, + # instead of setting an aiogram FSM state. + data = await state_context.get_data() + assert SESSION_KEY in data, "start() should create a dialog session in FSM data" + session = upload_file_engine.restore_session(data[SESSION_KEY]) + assert session.is_active + assert upload_file_engine.current_step(session).id == TYPE_EPISODE diff --git a/tests/unit/utils/dialog/test_dialog.py b/tests/unit/utils/dialog/test_dialog.py new file mode 100644 index 0000000..b76a3c5 --- /dev/null +++ b/tests/unit/utils/dialog/test_dialog.py @@ -0,0 +1,55 @@ +"""Tests for the aiogram <-> dialog_engine glue in ``utils.dialog``.""" + +import pytest +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.base import StorageKey +from aiogram.fsm.storage.memory import MemoryStorage + +from forms.upload_file import MP3, TYPE_EPISODE, upload_file_engine +from utils.dialog import SESSION_KEY, load_session, save_session, start_dialog + + +@pytest.fixture +def state() -> FSMContext: + storage = MemoryStorage() + key = StorageKey(bot_id=1, chat_id=1, user_id=1) + return FSMContext(storage=storage, key=key) + + +@pytest.mark.asyncio +async def test_load_session_returns_none_when_empty(state): + assert await load_session(state, upload_file_engine) is None + + +@pytest.mark.asyncio +async def test_start_dialog_creates_and_persists_session(state): + session = await start_dialog(state, upload_file_engine) + + assert upload_file_engine.current_step(session).id == TYPE_EPISODE + assert SESSION_KEY in (await state.get_data()) + + restored = await load_session(state, upload_file_engine) + assert restored is not None + assert upload_file_engine.current_step(restored).id == TYPE_EPISODE + + +@pytest.mark.asyncio +async def test_save_session_round_trips_progress(state): + session = await start_dialog(state, upload_file_engine) + upload_file_engine.submit(session, "main") # advance to mp3 + await save_session(state, session) + + restored = await load_session(state, upload_file_engine) + assert restored.answers[TYPE_EPISODE] == "main" + assert upload_file_engine.current_step(restored).id == MP3 + + +@pytest.mark.asyncio +async def test_save_session_preserves_other_fsm_data(state): + await state.update_data(unrelated="keep-me") + session = await start_dialog(state, upload_file_engine) + await save_session(state, session) + + data = await state.get_data() + assert data["unrelated"] == "keep-me" + assert SESSION_KEY in data