- subscription_time_left: replace floor-div '23 ч.'/'мин.' branches with the concrete expiry timestamp when less than a full day remains - add MSK_TIMEZONE (UTC+3) as display default; label time as '(МСК)' - add shared subscription_status_text helper, use it in status menu, post-payment reply and dev confirmation instead of floor-rounded days - tests: cover under-a-day clock format; fix stale to_thread mock
449 lines
22 KiB
Python
449 lines
22 KiB
Python
import unittest
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from aiogram.types import Chat, InlineKeyboardMarkup, Message
|
|
|
|
import bot
|
|
import keyboards
|
|
|
|
|
|
class _StubState:
|
|
def __init__(self, mode="lobby"):
|
|
self.value = bot.MODE_STATES[mode].state
|
|
self.data = {}
|
|
|
|
async def get_state(self):
|
|
return self.value
|
|
|
|
async def set_state(self, state):
|
|
self.value = state.state
|
|
|
|
async def clear(self):
|
|
self.value = None
|
|
self.data = {}
|
|
|
|
async def get_data(self):
|
|
return dict(self.data)
|
|
|
|
async def update_data(self, **kwargs):
|
|
self.data.update(kwargs)
|
|
return dict(self.data)
|
|
|
|
|
|
class ButtonTextTests(unittest.TestCase):
|
|
def test_mode_buttons_map_to_switch_commands(self):
|
|
self.assertEqual(keyboards.resolve_button_text("🎓 Обучение"), "/training")
|
|
self.assertEqual(keyboards.resolve_button_text("✍️ Генерация"), "/generate")
|
|
self.assertEqual(keyboards.resolve_button_text("💳 Подписка"), "/subscription")
|
|
self.assertEqual(keyboards.resolve_button_text("📖 Помощь"), "/help")
|
|
|
|
def test_main_menu_button_maps_to_internal_transition(self):
|
|
self.assertEqual(
|
|
keyboards.resolve_button_text(keyboards.MAIN_MENU_BUTTON),
|
|
keyboards.MAIN_MENU_VALUE,
|
|
)
|
|
|
|
def test_generate_post_button_maps_to_post_command(self):
|
|
self.assertEqual(keyboards.resolve_button_text(keyboards.POST_GENERATE_BUTTON), "/post")
|
|
|
|
def test_subscription_actions_map_to_internal_action(self):
|
|
for label in (keyboards.BUY_SUBSCRIPTION_BUTTON, keyboards.RENEW_SUBSCRIPTION_BUTTON):
|
|
self.assertEqual(
|
|
keyboards.resolve_button_text(label),
|
|
keyboards.SUBSCRIPTION_ACTION_VALUE,
|
|
)
|
|
|
|
def test_regular_text_is_not_treated_as_button(self):
|
|
self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск"))
|
|
self.assertIsNone(keyboards.resolve_button_text("привет"))
|
|
self.assertIsNone(keyboards.resolve_button_text(None))
|
|
|
|
def test_button_lookup_tolerates_whitespace(self):
|
|
self.assertEqual(keyboards.resolve_button_text(" 📖 Помощь "), "/help")
|
|
|
|
|
|
class KeyboardLayoutTests(unittest.TestCase):
|
|
def test_main_menu_only_shows_user_facing_modes(self):
|
|
labels = {b.text for row in keyboards.main_menu_keyboard().keyboard for b in row}
|
|
self.assertEqual(labels, set(keyboards.MODE_BUTTONS))
|
|
self.assertNotIn("🏠 Лобби", labels)
|
|
|
|
def test_each_mode_keyboard_shows_expected_actions(self):
|
|
for mode in bot.MODE_STATES:
|
|
labels = [b.text for row in keyboards.mode_keyboard(mode).keyboard for b in row]
|
|
expected = [keyboards.MAIN_MENU_BUTTON]
|
|
if mode == "training":
|
|
expected.insert(0, keyboards.TRAINING_INFO_BUTTON)
|
|
expected.append(keyboards.TRAINING_RESET_BUTTON)
|
|
elif mode == "generation":
|
|
expected.insert(0, keyboards.POST_GENERATE_BUTTON)
|
|
elif mode == "help":
|
|
expected.insert(0, keyboards.SUPPORT_BUTTON)
|
|
elif mode == "subscription":
|
|
expected.insert(0, keyboards.BUY_SUBSCRIPTION_BUTTON)
|
|
self.assertEqual(labels, expected, mode)
|
|
|
|
def test_subscription_keyboard_uses_renew_label_after_paid_subscription(self):
|
|
labels = [
|
|
button.text
|
|
for row in keyboards.mode_keyboard("subscription", subscription_purchased=True).keyboard
|
|
for button in row
|
|
]
|
|
self.assertEqual(labels, [keyboards.RENEW_SUBSCRIPTION_BUTTON, keyboards.MAIN_MENU_BUTTON])
|
|
|
|
def test_support_keyboard_keeps_only_main_menu(self):
|
|
for mode in bot.MODE_STATES:
|
|
labels = [
|
|
b.text
|
|
for row in keyboards.mode_keyboard(mode, support=True).keyboard
|
|
for b in row
|
|
]
|
|
self.assertEqual(labels, [keyboards.MAIN_MENU_BUTTON], mode)
|
|
|
|
def test_help_keyboard_offers_support(self):
|
|
labels = [b.text for row in keyboards.mode_keyboard("help").keyboard for b in row]
|
|
self.assertIn(keyboards.SUPPORT_BUTTON, labels)
|
|
|
|
def test_support_button_maps_to_internal_value(self):
|
|
self.assertEqual(
|
|
keyboards.resolve_button_text(keyboards.SUPPORT_BUTTON),
|
|
keyboards.SUPPORT_VALUE,
|
|
)
|
|
|
|
def test_mode_entry_copy_uses_short_paragraphs(self):
|
|
self.assertIn("\n", bot.MODE_COPY["training"][1])
|
|
self.assertIn("\n", bot.MODE_COPY["generation"][1])
|
|
self.assertIn("Подписка", bot.MODE_COPY["subscription"][0])
|
|
|
|
def test_training_reset_button_routes_to_confirmation_action(self):
|
|
self.assertEqual(
|
|
keyboards.resolve_button_text(keyboards.TRAINING_RESET_BUTTON),
|
|
keyboards.TRAINING_RESET_VALUE,
|
|
)
|
|
|
|
def test_every_button_is_resolvable(self):
|
|
markups = [keyboards.main_menu_keyboard()]
|
|
markups.extend(keyboards.mode_keyboard(mode) for mode in bot.MODE_STATES)
|
|
for markup in markups:
|
|
for row in markup.keyboard:
|
|
for button in row:
|
|
self.assertIsNotNone(keyboards.resolve_button_text(button.text), button.text)
|
|
|
|
def test_keyboards_are_resize_keyboard(self):
|
|
self.assertTrue(keyboards.main_menu_keyboard().resize_keyboard)
|
|
for mode in bot.MODE_STATES:
|
|
self.assertTrue(keyboards.mode_keyboard(mode).resize_keyboard)
|
|
|
|
|
|
class _StubMessage:
|
|
def __init__(self, text=None, user_id=42, chat_id=555):
|
|
self.text = text
|
|
self.from_user = SimpleNamespace(id=user_id)
|
|
self.chat = SimpleNamespace(id=chat_id)
|
|
self.message_thread_id = None
|
|
self.bot = AsyncMock()
|
|
self.answers: list[tuple[str, dict]] = []
|
|
|
|
async def answer(self, text, **kwargs):
|
|
self.answers.append((text, kwargs))
|
|
return SimpleNamespace(message_id=1)
|
|
|
|
|
|
class StyleCommandTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_style_without_tone_explains_how_to_set_it(self):
|
|
message = _StubMessage()
|
|
with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["", [], []])):
|
|
await bot.style_command(message, _StubState("training"))
|
|
self.assertIn("скриншот", message.answers[0][0])
|
|
|
|
async def test_style_shows_saved_tone(self):
|
|
message = _StubMessage()
|
|
with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["Короткие фразы", ["a", "b"], []])):
|
|
await bot.style_command(message, _StubState("training"))
|
|
self.assertIn("Короткие фразы", message.answers[0][0])
|
|
|
|
|
|
async def test_style_shows_saved_account_profile(self):
|
|
message = _StubMessage("/style")
|
|
state = _StubState("training")
|
|
with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["Профиль аккаунта", ["Пост"], ["Заметка"]])):
|
|
await bot.style_command(message, state)
|
|
self.assertIn("Профиль аккаунта", message.answers[0][0])
|
|
|
|
|
|
async def test_style_is_rejected_outside_training(self):
|
|
message = _StubMessage()
|
|
with patch.object(bot, "get_tone_of_voice") as get_tone:
|
|
await bot.style_command(message, _StubState("generation"))
|
|
get_tone.assert_not_called()
|
|
self.assertIn("доступна в разделе «Обучение»", message.answers[0][0])
|
|
|
|
|
|
class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_mode_state_is_stored_by_aiogram_memory_storage(self):
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.fsm.storage.base import StorageKey
|
|
from aiogram.fsm.storage.memory import MemoryStorage
|
|
|
|
storage = MemoryStorage()
|
|
state = FSMContext(storage, StorageKey(bot_id=1, chat_id=2, user_id=3))
|
|
await bot.enter_mode(_StubMessage(), state, "generation")
|
|
self.assertEqual(await bot.current_mode(state), "generation")
|
|
await storage.close()
|
|
|
|
async def test_mode_button_switches_from_any_mode(self):
|
|
message = _StubMessage("🎓 Обучение")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
|
|
await bot.handle_text(message, state)
|
|
self.assertEqual(await state.get_state(), bot.BotMode.training.state)
|
|
|
|
async def test_main_menu_button_returns_to_mode_selection(self):
|
|
message = _StubMessage(keyboards.MAIN_MENU_BUTTON)
|
|
state = _StubState("training")
|
|
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
|
|
await bot.handle_text(message, state)
|
|
self.assertEqual(await state.get_state(), bot.BotMode.lobby.state)
|
|
labels = [
|
|
button.text
|
|
for row in message.answers[0][1]["reply_markup"].keyboard
|
|
for button in row
|
|
]
|
|
self.assertEqual(set(labels), set(keyboards.MODE_BUTTONS))
|
|
|
|
async def test_slash_switch_command_works_from_another_mode(self):
|
|
message = _StubMessage("/training")
|
|
state = _StubState("subscription")
|
|
await bot.handle_command_text(message, state)
|
|
self.assertEqual(await state.get_state(), bot.BotMode.training.state)
|
|
|
|
async def test_help_button_switches_to_help_mode(self):
|
|
message = _StubMessage("📖 Помощь")
|
|
state = _StubState("lobby")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot, "send_rich_message", new=AsyncMock()),
|
|
):
|
|
await bot.handle_text(message, state)
|
|
self.assertEqual(await state.get_state(), bot.BotMode.help.state)
|
|
|
|
async def test_subscription_entry_shows_buy_reply_button_without_purchase(self):
|
|
message = _StubMessage("💳 Подписка")
|
|
state = _StubState("lobby")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value={"telegram_user_id":42, "trial_started_at":"2026-10-19T00:00:00", "last_paid_at":None, "is_active":1})),
|
|
):
|
|
await bot.handle_text(message, state)
|
|
self.assertEqual(await state.get_state(), bot.BotMode.subscription.state)
|
|
self.assertIn("Подписка закончилась", message.answers[0][0])
|
|
|
|
async def test_subscription_status_uses_renew_button_after_previous_payment(self):
|
|
message = _StubMessage()
|
|
sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1}
|
|
with patch.object(bot.user_store, "subscription_time_left", return_value=(True, 2, "дн.")):
|
|
await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
|
|
markup = message.answers[0][1]["reply_markup"]
|
|
self.assertIn("Подписка активна", message.answers[0][0])
|
|
self.assertIsNotNone(markup)
|
|
|
|
async def test_subscription_status_shows_clock_time_when_under_a_day(self):
|
|
message = _StubMessage()
|
|
sub = {"last_paid_at": "2026-01-01T00:00:00", "is_active": 1}
|
|
with patch.object(
|
|
bot.user_store, "subscription_time_left", return_value=(True, "31.10.2026 12:51 (МСК)", "")
|
|
):
|
|
await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
|
|
text = message.answers[0][0]
|
|
self.assertIn("Подписка активна", text)
|
|
self.assertIn("Закончится в 31.10.2026 12:51 (МСК)", text)
|
|
self.assertNotIn("ч.", text)
|
|
|
|
async def test_subscription_action_button_shows_inline_plan_choices(self):
|
|
message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON)
|
|
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
|
|
await bot.handle_text(message, _StubState("subscription"))
|
|
self.assertEqual(len(message.answers), 1)
|
|
self.assertEqual(message.answers[0][0], "Выберите срок подписки")
|
|
labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row]
|
|
self.assertEqual(labels, ["0 дней — 1 ⭐", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
|
|
|
|
async def test_subscription_action_button_is_rejected_outside_subscription_mode(self):
|
|
message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON)
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice,
|
|
):
|
|
await bot.handle_text(message, _StubState("generation"))
|
|
send_invoice.assert_not_awaited()
|
|
self.assertIn("доступна в разделе «Подписка»", message.answers[0][0])
|
|
|
|
async def test_renew_callback_shows_plan_choices(self):
|
|
message_bot = AsyncMock()
|
|
message = Message(message_id=17, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot)
|
|
callback = SimpleNamespace(message=message, from_user=SimpleNamespace(id=42), answer=AsyncMock())
|
|
with patch.object(bot, "current_mode", new=AsyncMock(return_value="subscription")):
|
|
await bot.subscription_renew(callback, _StubState("subscription"))
|
|
callback.answer.assert_awaited_once_with()
|
|
self.assertEqual(len(message_bot.mock_calls), 2)
|
|
sent = message_bot.mock_calls[-1].args[0]
|
|
self.assertEqual(sent.text, "Выберите срок подписки")
|
|
self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["0 дней — 1 ⭐", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
|
|
|
|
async def test_idea_command_generates_only_in_generation_mode(self):
|
|
message = _StubMessage("/idea")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify:
|
|
await bot.handle_command_text(message, state)
|
|
classify.assert_awaited_once_with(message, "предложи идею для нового поста", state=state)
|
|
|
|
async def test_post_command_generates_only_in_generation_mode(self):
|
|
message = _StubMessage("/post")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "_generate_post_from_training", new=AsyncMock()) as generate:
|
|
await bot.handle_command_text(message, state)
|
|
generate.assert_awaited_once_with(message)
|
|
|
|
async def test_generate_post_button_runs_post_command(self):
|
|
message = _StubMessage(keyboards.POST_GENERATE_BUTTON)
|
|
state = _StubState("generation")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot, "_generate_post_from_training", new=AsyncMock()) as generate,
|
|
):
|
|
await bot.handle_text(message, state)
|
|
generate.assert_awaited_once_with(message)
|
|
|
|
async def test_training_text_is_saved_as_note_without_entering_generation(self):
|
|
message = _StubMessage("Кофейня в Калининграде для семей с детьми")
|
|
with patch.object(bot, "_rebuild_account_profile", new=AsyncMock()) as rebuild:
|
|
await bot.handle_text(message, _StubState("training"))
|
|
call = cast(Any, rebuild.await_args)
|
|
self.assertEqual(call.args, (42, message))
|
|
self.assertEqual(call.kwargs["new_note"], "Кофейня в Калининграде для семей с детьми")
|
|
self.assertIsInstance(call.kwargs["status"], bot.OperationStatus)
|
|
|
|
async def test_training_profile_rebuild_does_not_send_profile_details_when_status_exists(self):
|
|
status = AsyncMock()
|
|
message = _StubMessage()
|
|
with (
|
|
patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["old", ["note"], ["post"], None])),
|
|
patch.object(bot, "create_tone_profile", new=AsyncMock(return_value="new profile")),
|
|
patch.object(bot, "send_rich_message", new=AsyncMock()) as rich_send,
|
|
):
|
|
await bot._rebuild_account_profile(42, cast(Any, message), new_note="note", status=status)
|
|
|
|
status.finish.assert_awaited_once_with("✅ Обучение завершено")
|
|
rich_send.assert_not_awaited()
|
|
self.assertEqual(message.answers, [])
|
|
|
|
async def test_plain_text_is_only_sent_to_generator_in_generation_mode(self):
|
|
message = _StubMessage("Напиши пост про запуск продукта")
|
|
state = _StubState("generation")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
|
):
|
|
await bot.handle_text(message, state)
|
|
classify.assert_awaited_once_with(message, "Напиши пост про запуск продукта", state=state)
|
|
|
|
async def test_plain_text_in_lobby_does_not_trigger_generation(self):
|
|
message = _StubMessage("Напиши пост")
|
|
state = _StubState("lobby")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
|
):
|
|
await bot.handle_text(message, state)
|
|
classify.assert_not_awaited()
|
|
self.assertIn("Выбери действие", message.answers[0][0])
|
|
|
|
async def test_style_command_is_rejected_outside_training(self):
|
|
message = _StubMessage("/style")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "style_command", new=AsyncMock()) as style:
|
|
await bot.handle_command_text(message, state)
|
|
style.assert_awaited_once_with(message, state)
|
|
|
|
async def test_clear_command_is_rejected_outside_training(self):
|
|
message = _StubMessage("/clear")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "clear_user_data") as clear_data:
|
|
await bot.handle_command_text(message, state)
|
|
clear_data.assert_not_called()
|
|
self.assertIn("доступна в разделе «Обучение»", message.answers[0][0])
|
|
|
|
async def test_subscription_command_is_rejected_outside_subscription_mode(self):
|
|
message = _StubMessage("/balance")
|
|
state = _StubState("generation")
|
|
with patch.object(bot, "subscription_row") as get_subscription:
|
|
await bot.handle_command_text(message, state)
|
|
get_subscription.assert_not_called()
|
|
self.assertIn("доступна в разделе «Подписка»", message.answers[0][0])
|
|
|
|
async def test_image_is_rejected_outside_training_and_generation(self):
|
|
message = _StubMessage()
|
|
with patch.object(bot, "_handle_image_message", new=AsyncMock()) as process_image:
|
|
await bot.update_tone_from_image(message, AsyncMock(), _StubState("subscription"))
|
|
process_image.assert_not_awaited()
|
|
self.assertIn("настройки стиля и подготовки поста", message.answers[0][0])
|
|
|
|
async def test_voice_is_rejected_outside_learning_and_generation(self):
|
|
message = _StubMessage()
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()) as notify,
|
|
patch.object(bot, "_process_voice", new=AsyncMock()) as process_voice,
|
|
):
|
|
await bot.transcribe_voice(message, AsyncMock(), _StubState("subscription"))
|
|
notify.assert_not_awaited()
|
|
process_voice.assert_not_awaited()
|
|
self.assertIn("настройки стиля и подготовки поста", message.answers[0][0])
|
|
|
|
async def test_voice_in_training_transcribes_then_updates_profile_not_generation(self):
|
|
message = SimpleNamespace(
|
|
from_user=SimpleNamespace(id=42),
|
|
voice=SimpleNamespace(file_size=4, mime_type="audio/ogg"),
|
|
audio=None,
|
|
answer=AsyncMock(),
|
|
chat=SimpleNamespace(id=555),
|
|
)
|
|
bot_client = SimpleNamespace(download=AsyncMock(side_effect=lambda file, destination: destination.write(b"ogg")))
|
|
state = _StubState("training")
|
|
with (
|
|
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
|
patch.dict("os.environ", {"APINEX_API_KEY": "not-logged"}, clear=False),
|
|
patch.object(bot, "transcribe_audio", new=AsyncMock(return_value="Мы — кофейня в Калининграде")) as stt,
|
|
patch.object(bot, "_rebuild_account_profile", new=AsyncMock()) as rebuild,
|
|
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
|
patch.object(bot, "typing_status", return_value=AsyncMock()),
|
|
):
|
|
await bot._process_voice(message, bot_client, state)
|
|
stt.assert_awaited_once()
|
|
call = cast(Any, rebuild.await_args)
|
|
self.assertEqual(call.args, (42, message))
|
|
self.assertEqual(call.kwargs["new_note"], "Мы — кофейня в Калининграде")
|
|
self.assertIsNone(call.kwargs["status"])
|
|
classify.assert_not_awaited()
|
|
|
|
|
|
|
|
class MenuPublishTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_commands_are_published_to_telegram(self):
|
|
fake_bot = AsyncMock()
|
|
await bot.set_main_menu(fake_bot)
|
|
names = [c.command for c in fake_bot.set_my_commands.await_args.kwargs["commands"]]
|
|
for expected in ("start", "training", "generate", "subscription", "help", "clear", "post"):
|
|
self.assertIn(expected, names)
|
|
|
|
async def test_publish_failure_does_not_crash_startup(self):
|
|
fake_bot = AsyncMock()
|
|
fake_bot.set_my_commands.side_effect = RuntimeError("boom")
|
|
await bot.set_main_menu(fake_bot)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|