import bot from datetime import datetime, timedelta def _state(uid): return bot.db.get_user_data(uid)["state"] def _prof(uid): return bot.db.get_profile(uid) def test_baza_group_funnel_to_payment(app): """Полный подбор → групповой абонемент → шаг оплаты (BAZA_PAY).""" u = 70001 app.handle_message(u, "Подобрать тренировку 🎯"); assert _state(u) == "BAZA_FORWHO" app.handle_message(u, "Для подростка"); assert _state(u) == "BAZA_AGE" app.handle_message(u, "14–15 лет"); assert _state(u) == "BAZA_GAME" app.handle_message(u, "CS2"); assert _state(u) == "BAZA_LEVEL" app.handle_message(u, "Играет для себя"); assert _state(u) == "BAZA_FORMAT" app.handle_message(u, "Групповые тренировки"); assert _state(u) == "BAZA_GROUP" app.handle_message(u, "Купить 4 тренировки"); assert _state(u) == "BAZA_PAY" p = _prof(u) assert p["role"].startswith("Родитель") and p["game"] == "Counter-Strike 2" assert p["format"] == "Групповые тренировки" and "4" in p["tariff"] assert p.get("started_at") # дата запуска сохранена (§13) assert {"role_parent", "game_cs2", "format_group", "clicked_payment", "lead_hot"} <= set(p["metki"]) def test_baza_individual_and_back(app): """Индивидуальная ветка + кнопка «Назад» возвращает на предыдущий шаг.""" u = 70011 app.handle_message(u, "Подобрать тренировку 🎯") app.handle_message(u, "Для себя"); assert _state(u) == "BAZA_AGE" app.handle_message(u, "16 лет и старше"); assert _state(u) == "BAZA_GAME" app.handle_message(u, "Назад ⬅️"); assert _state(u) == "BAZA_AGE" app.handle_message(u, "16 лет и старше") app.handle_message(u, "Dota 2"); assert _state(u) == "BAZA_LEVEL" app.handle_message(u, "Есть рейтинг или ранг") app.handle_message(u, "Индивидуальные тренировки"); assert _state(u) == "BAZA_INDIVIDUAL" app.handle_message(u, "Купить 8 тренировок"); assert _state(u) == "BAZA_PAY" assert "8" in _prof(u)["tariff"] def test_baza_under12_goes_to_manager(app): u = 70012 app.handle_message(u, "Подобрать тренировку 🎯") app.handle_message(u, "Для подростка") app.handle_message(u, "Младше 12 лет"); assert _state(u) == "BAZA_YOUNG" app.handle_message(u, "Обсудить с менеджером 💬") assert _state(u) == "OPERATOR_CHAT" assert _prof(u)["age"] == "Младше 12 лет" assert "manager_requested" in _prof(u)["metki"] def test_baza_other_game_goes_to_manager(app): u = 70013 app.handle_message(u, "Подобрать тренировку 🎯") app.handle_message(u, "Для себя") app.handle_message(u, "16 лет и старше") app.handle_message(u, "Другая игра"); assert _state(u) == "BAZA_GAME_OTHER" app.handle_message(u, "Valorant"); assert _state(u) == "OPERATOR_CHAT" assert "Valorant" in _prof(u)["game"] def test_baza_records_lead_once(app): """Воронка пишет заявку в leads при конверсии; оплата+проблема = 1 заявка (без дублей).""" u = 70030 for m in ["Подобрать тренировку 🎯", "Для себя", "16 лет и старше", "CS2", "Играет для себя", "Групповые тренировки", "Купить 4 тренировки"]: app.handle_message(u, m) app.handle_message(u, "Проблема с оплатой") # тот же заход → не должно создать вторую s = bot.db.get_lead_stats() assert s["total"] == 1 and s["from_bot"] == 1 assert any("Групповой" in name for name, _ in s["by_service"]) def test_empty_message_ignored(app): """Пустые сообщения (стикер/фото/голосовое) не вызывают фолбэк — бот молчит.""" u = 70060 app._sent.clear() app.handle_message(u, "") app.handle_message(u, " ") assert app._sent == [] # обычный нераспознанный текст фолбэк всё же даёт app.handle_message(u, "абвгдейка") assert any("Пока не получилось" in s["msg"] for s in app._sent) def test_baza_question_ticket(app): """§9: клиенту и в карточку оператору уходит номер заявки (тикет).""" u = 70040 app._sent.clear() app.handle_message(u, "Задать вопрос 💬") app.handle_message(u, "Есть ли рассрочка?") ticket = bot.db.get_profile(u).get("ticket") assert ticket # тикет присвоен assert any(f"№{ticket}" in s["msg"] for s in app._sent) # клиент видит номер def test_webinar_registration(app): """Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно).""" u = 70041 bot.db.set_flag("webinar", True) # вебинар по умолчанию скрыт — включаем app.handle_message(u, "записаться на вебинар") assert "webinar_registered" in bot.db.get_profile(u).get("metki", []) assert _state(u) == "FREE_CHAT" # основное меню доступно — обычная продающая ветка запускается app.handle_message(u, "Подобрать тренировку 🎯") assert _state(u) == "BAZA_FORWHO" def test_baza_smart_skip_known_fields(app): """§15: уже сохранённые ответы не переспрашиваются.""" u = 70020 bot.db.set_profile_field(u, "role", "Игрок (пишет сам)") bot.db.set_profile_field(u, "age", "16 лет и старше") # роль и возраст известны → воронка стартует сразу с игры app.handle_message(u, "Подобрать тренировку 🎯") assert _state(u) == "BAZA_GAME" # уровень известен → после игры сразу формат (пропуская уровень) bot.db.set_profile_field(u, "level", "Играет для себя") app.handle_message(u, "CS2") assert _state(u) == "BAZA_FORMAT" # «Назад» позволяет переопределить пропущенный уровень app.handle_message(u, "Назад ⬅️") assert _state(u) == "BAZA_LEVEL" def test_baza_intensive_skips_known_age(app): """§15: в интенсиве известный возраст не переспрашивается.""" u = 70021 bot.db.set_profile_field(u, "age", "14–15 лет") app.handle_message(u, "Летний интенсив CS2 🔥") app.handle_message(u, "Хочу участвовать 🔥") assert _state(u) == "BAZA_INT_LEVEL" # возраст пропущен def test_baza_intensive_by_keyword(app): """Кодовое слово «лето» запускает интенсив; ветка доходит до оплаты.""" u = 70014 app.handle_message(u, "лето"); assert _state(u) == "BAZA_INT_INFO" app.handle_message(u, "Хочу участвовать 🔥"); assert _state(u) == "BAZA_INT_AGE" app.handle_message(u, "14–15 лет"); assert _state(u) == "BAZA_INT_LEVEL" app.handle_message(u, "Играю регулярно"); assert _state(u) == "BAZA_INT_PAY_INTRO" app.handle_message(u, "Оплатить интенсив 💳"); assert _state(u) == "BAZA_PAY" p = _prof(u) assert p["keyword"] == "лето" and p.get("interest_summer") assert {"source_keyword_summer", "interest_summer_intensive", "clicked_payment"} <= set(p["metki"]) def test_baza_under12_intensive_to_manager(app): u = 70017 app.handle_message(u, "Летний интенсив CS2 🔥") app.handle_message(u, "Хочу участвовать 🔥") app.handle_message(u, "Младше 12 лет"); assert _state(u) == "BAZA_INT_YOUNG" app.handle_message(u, "Передать менеджеру 💬"); assert _state(u) == "OPERATOR_CHAT" def test_baza_question_button_handoff(app): """«Задать вопрос» (§9): сначала промпт, затем сообщение → менеджеру.""" u = 70015 app.handle_message(u, "Задать вопрос 💬"); assert _state(u) == "BAZA_QUESTION" app.handle_message(u, "Когда занятия?"); assert _state(u) == "OPERATOR_CHAT" assert _prof(u)["message"] == "Когда занятия?" assert "manager_requested" in _prof(u)["metki"] def test_baza_help_free_text_to_manager(app): """§7: «Пока не знаю» → свободный текст → менеджеру, format_help.""" u = 70018 app.handle_message(u, "Подобрать тренировку 🎯") app.handle_message(u, "Для себя") app.handle_message(u, "14–15 лет") app.handle_message(u, "CS2") app.handle_message(u, "Только начинает") app.handle_message(u, "Пока не знаю, нужен совет"); assert _state(u) == "BAZA_HELP" app.handle_message(u, "важно расписание"); assert _state(u) == "OPERATOR_CHAT" assert _prof(u)["message"] == "важно расписание" assert "format_help" in _prof(u)["metki"] def test_baza_prices_and_menu(app): u = 70016 app._sent.clear() app.handle_message(u, "Посмотреть цены 💰") assert _state(u) == "FREE_CHAT" assert any("2 790" in s["msg"] for s in app._sent) def test_flood_does_not_crash(app): u = 70002 for _ in range(25): app.handle_message(u, "спам") # бот не падает; пользователь остаётся в FREE_CHAT (флуд/бан только игнорят) assert _state(u) == "FREE_CHAT" def test_admin_panel_visible_to_super(app): admin = bot.config.SUPER_ADMIN_IDS[0] app._sent.clear() app.handle_message(admin, "Админка ⚙️") assert any("Статистика" in (s["kb"] or "") for s in app._sent) def test_non_admin_cannot_open_panel(app): app._sent.clear() app.handle_message(424242, "Админка ⚙️") assert not any("Админ-панель" in s["msg"] for s in app._sent) def _admin(): return bot.config.SUPER_ADMIN_IDS[0] def test_admin_edit_link(app): """Админ меняет ссылку кнопки → сохраняется в БД и подхватывается ботом.""" a = _admin() app.handle_message(a, "Ссылки 🔗") app.handle_message(a, "✏️ Изменить ссылку") app.handle_message(a, "group_4. Групповой абонемент — 4 занятия") app.handle_message(a, "не-ссылка") # отклоняется валидацией assert bot.db.get_link("group_4") is None app.handle_message(a, "https://infocyber.pro/pay/group4") assert bot.db.get_link("group_4") == "https://infocyber.pro/pay/group4" # бот отдаёт обновлённую ссылку (с UTM) assert app._link("group_4") == "https://infocyber.pro/pay/group4" def test_resolve_vk_ru_and_com_links(app): """Резолвер принимает ссылки vk.ru и vk.com (id-форма работает без сети).""" assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273 assert app._resolve_vk_user_id("https://vk.com/id777") == 777 assert app._resolve_vk_user_id("http://m.vk.ru/id42") == 42 assert app._resolve_vk_user_id("www.vk.com/id5") == 5 assert app._resolve_vk_user_id("id99") == 99 assert app._resolve_vk_user_id("12345") == 12345 def test_funnel_keyboards_no_empty_rows(): """VK отклоняет клавиатуру с пустым рядом — таких быть не должно ни в одной.""" import json, keyboards kbs = [ keyboards.get_baza_menu_only_keyboard(), keyboards.get_baza_forwho_keyboard(), keyboards.get_baza_age_keyboard(), keyboards.get_baza_game_keyboard(), keyboards.get_baza_level_keyboard(), keyboards.get_baza_format_keyboard(), keyboards.get_baza_group_keyboard(), keyboards.get_baza_individual_keyboard(), keyboards.get_baza_int_level_keyboard(), ] for kb in kbs: for row in json.loads(kb)["buttons"]: assert len(row) > 0 def test_resolve_vk_links(app): """Ссылки vk.ru и vk.com (и голый id) корректно приводятся к числовому id.""" assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273 assert app._resolve_vk_user_id("https://vk.com/id777") == 777 assert app._resolve_vk_user_id("vk.ru/id42") == 42 assert app._resolve_vk_user_id("id100") == 100 assert app._resolve_vk_user_id("999") == 999 def test_menu_feature_toggle(app): """Скрытие кнопки меню: раздел «интенсив» выключается и не запускается по слову.""" import json a = _admin() # выключаем интенсив app.handle_message(a, "Кнопки меню 🧩") app.handle_message(a, "Летний интенсив CS2 🔥 — вкл ✅") # нажатие -> выкл assert bot.db.get_flag("intensive", True) is False # кнопки нет в меню labels = [b["action"].get("label") for row in json.loads(app._menu_kb(a))["buttons"] for b in row] assert not any("Летний интенсив" in x for x in labels) # кодовое слово «лето» больше не запускает интенсив u = 70050 app.handle_message(u, "лето") assert _state(u) == "FREE_CHAT" def test_favi_stop_start(app): """Экстренное отключение супер-админом по паролю и обратное включение.""" import config as cfg # тестовый хэш пароля 'secret' import hashlib, os salt = os.urandom(16) cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \ hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex() a = bot.config.SUPER_ADMIN_IDS[0] app.handle_message(a, "/favi-stop") app.handle_message(a, "secret") assert bot.db.get_flag("bot_disabled", False) is True # обычный юзер не получает ответа app._sent.clear(); app.handle_message(70051, "привет") assert len(app._sent) == 0 # включаем обратно app.handle_message(a, "/favi-start"); app.handle_message(a, "secret") assert bot.db.get_flag("bot_disabled", False) is False def test_favi_wrong_password(app): import config as cfg, hashlib, os salt = os.urandom(16) cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \ hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex() a = bot.config.SUPER_ADMIN_IDS[0] app.handle_message(a, "/favi-stop") app.handle_message(a, "wrong") assert bot.db.get_flag("bot_disabled", False) is False # не отключился def test_admin_mute_notifications(app): """Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает.""" op = _admin() bot.config.OPERATOR_PEER_IDS = [op] app._sent.clear(); app._notify_operators("тест-уведомление") assert any("тест-уведомление" in s["msg"] for s in app._sent) # по умолчанию доходит app.handle_message(op, "Админка ⚙️") app.handle_message(op, "Уведомления: вкл 🔔") # переключаем в «выкл» assert bot.db.get_profile(op).get("notify_muted") is True app._sent.clear(); app._notify_operators("второе уведомление") assert not any("второе уведомление" in s["msg"] for s in app._sent) # больше не доходит def test_admin_faq_crud(app): a = _admin() app.handle_message(a, "Быстрые ответы 💬") app.handle_message(a, "➕ Добавить ответ") app.handle_message(a, "вопрос | ответ") assert any(kw == "вопрос" for _, kw, _ in bot.db.get_custom_faq()) def test_admin_add_remove_admin(app): a = _admin() app.handle_message(a, "Администраторы 👑") app.handle_message(a, "➕ Добавить админа") app.handle_message(a, "888001") assert 888001 in bot.db.get_admins() assert app._is_admin(888001) def test_broadcast_preview_and_cancel(app): a = _admin() app.handle_message(a, "рассылка Привет, акция!") assert a in app._pending_broadcasts app.handle_message(a, "Отмена ❌") assert a not in app._pending_broadcasts def test_stats_runs(app): a = _admin() app._sent.clear() app.handle_message(a, "Статистика 📊") assert any("Статистика" in s["msg"] for s in app._sent) def test_admin_reminder_wizard_creates_reminder(app, monkeypatch): admin = 80001 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не должен мешать app.handle_message(admin, "Админка ⚙️") app.handle_message(admin, "⏰ Напоминалки") app.handle_message(admin, "➕ Добавить") assert _state(admin) == "ADMIN_REMINDER_DATE" app.handle_message(admin, "не дата") # невалидный формат — остаёмся на шаге assert _state(admin) == "ADMIN_REMINDER_DATE" app.handle_message(admin, "01.01.2099 09:00") assert _state(admin) == "ADMIN_REMINDER_TEXT" app.handle_message(admin, "Продлить домен") assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS" app.handle_message(admin, "0") # все админы assert _state(admin) == "ADMIN_REMINDER_PERIOD" app.handle_message(admin, "Каждый год") assert _state(admin) == "FREE_CHAT" rows = bot.db.get_active_reminders() assert len(rows) == 1 assert rows[0]["recipients"] == "all" assert rows[0]["recurrence"] == "yearly" assert rows[0]["text"] == "Продлить домен" def test_admin_reminder_wizard_specific_recipients(app, monkeypatch): admin, other = 80003, 80004 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other]) bot.db.set_admin_tz_offset(admin, 0) app.handle_message(admin, "⏰ Напоминалки") app.handle_message(admin, "➕ Добавить") app.handle_message(admin, "01.01.2099 09:00") app.handle_message(admin, "Оплатить сервер") app.handle_message(admin, "9") # номер вне списка (только 2 админа) assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS" app.handle_message(admin, "1") # первый по сортировке id assert _state(admin) == "ADMIN_REMINDER_PERIOD" app.handle_message(admin, "Разово") rows = bot.db.get_active_reminders() assert rows[0]["recipients"] == [min(admin, other)] assert rows[0]["recurrence"] == "once" def test_admin_reminder_delete(app, monkeypatch): admin = 80002 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 0) rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin) app.handle_message(admin, f"напоминалка удалить {rid}") assert not bot.db.get_active_reminders() before = len(app._sent) app.handle_message(admin, f"напоминалка удалить {rid}") # повторно — не найдено # _handle_reminder_delete шлёт результат, а затем обновлённую панель «Напоминалки», # поэтому проверяем все сообщения этого шага, а не только последнее. new_msgs = [s["msg"] for s in app._sent[before:]] assert any("не найдено" in m for m in new_msgs) def test_admin_reminder_delete_via_picker_button(app, monkeypatch): """Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную).""" admin = 80005 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 0) rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin) app.handle_message(admin, "⏰ Напоминалки") app.handle_message(admin, "🗑 Удалить напоминание") assert _state(admin) == "ADMIN_REMINDER_DEL_PICK" app.handle_message(admin, f"{rid}. Продлить хостинг") # клик по кнопке пикера assert _state(admin) == "FREE_CHAT" assert not bot.db.get_active_reminders() def test_reminder_tz_gate_before_list(app, monkeypatch): """Без заданного часового пояса раздел не открывается — сначала гейт.""" admin = 80006 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) app.handle_message(admin, "⏰ Напоминалки") assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP" app.handle_message(admin, "не число") # невалидный формат — остаёмся на шаге assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP" app.handle_message(admin, "+3") assert _state(admin) == "FREE_CHAT" assert bot.db.get_admin_tz_offset(admin) == 3 # повторный вход — пояс уже задан, гейт больше не срабатывает app.handle_message(admin, "⏰ Напоминалки") assert _state(admin) == "FREE_CHAT" def test_reminder_creation_converts_author_local_time_to_server(app, monkeypatch): admin = 80007 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не мешает # Фиксируем "серверный" пояс отдельно от пояса автора (0 != 3), чтобы тест # не проходил случайно из-за совпадения с реальным поясом машины, где он # запускается — иначе он ничего не проверял бы, если сервер сам в UTC+3. monkeypatch.setattr(bot, "server_utc_offset_hours", lambda: 0) app.handle_message(admin, "⏰ Напоминалки") app.handle_message(admin, "➕ Добавить") app.handle_message(admin, "01.06.2099 09:00") # 09:00 по времени автора (UTC+3) app.handle_message(admin, "Тест конвертации") app.handle_message(admin, "0") app.handle_message(admin, "Разово") rows = bot.db.get_active_reminders() stored = datetime.strptime(rows[0]["remind_at"], "%Y-%m-%d %H:%M:%S") server_offset = 0 expected = datetime(2099, 6, 1, 9, 0) + timedelta(hours=(server_offset - 3)) assert stored == expected def test_reminder_pause_and_resume_via_picker(app, monkeypatch): admin = 80008 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 0) rid = bot.db.add_reminder("Продлить хостинг", "2020-01-01 09:00:00", "once", [admin], created_by=admin) app.handle_message(admin, "⏰ Напоминалки") app.handle_message(admin, "⏸/▶ Пауза") assert _state(admin) == "ADMIN_REMINDER_PAUSE_PICK" app.handle_message(admin, f"{rid}. Продлить хостинг") assert _state(admin) == "FREE_CHAT" row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid) assert row["paused"] == 1 # напоминание давно просрочено, но на паузе — планировщик не должен его отправить app._sent.clear() app._check_due_reminders() assert not app._sent assert bot.db.get_active_reminders() # всё ещё активно (не деактивировалось) # возобновляем через тот же пикер app.handle_message(admin, "⏸/▶ Пауза") app.handle_message(admin, f"{rid}. Продлить хостинг") row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid) assert row["paused"] == 0 # теперь просроченное разовое напоминание должно уйти при следующей проверке app._check_due_reminders() assert app._sent assert not bot.db.get_active_reminders() # once — деактивировалось после отправки def test_timezone_change_command(app, monkeypatch): admin = 80009 monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) bot.db.set_admin_tz_offset(admin, 3) app.handle_message(admin, "часовой пояс -5") assert bot.db.get_admin_tz_offset(admin) == -5 app.handle_message(admin, "часовой пояс много") assert bot.db.get_admin_tz_offset(admin) == -5 # невалидный ввод не поменял значение