e41db35f13
При гейте часового пояса теперь показывается клавиатура с частыми российскими городами-поясами (Калининград..Камчатка). Клик по кнопке шлёт 'Город, UTC+N' — _parse_tz_offset распознаёт и это, и обычный ручной ввод вида '+3'/'-5', если своего города в списке нет. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
540 lines
26 KiB
Python
540 lines
26 KiB
Python
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_tz_setup_via_city_button(app, monkeypatch):
|
||
"""Часовой пояс можно выбрать кнопкой города, а не только вводом числа."""
|
||
admin = 80010
|
||
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||
|
||
app.handle_message(admin, "⏰ Напоминалки")
|
||
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
|
||
|
||
app.handle_message(admin, "Екатеринбург, UTC+5") # клик по кнопке клавиатуры
|
||
assert _state(admin) == "FREE_CHAT"
|
||
assert bot.db.get_admin_tz_offset(admin) == 5
|
||
|
||
|
||
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 # невалидный ввод не поменял значение
|