Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
280
tests/test_text_inputs.py
Normal file
280
tests/test_text_inputs.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import Chat, Message, PhotoSize, Update, User as TelegramUser
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.models import Account, Lottery, User
|
||||
from src.core.services import LotteryService, UserService
|
||||
from src.handlers.registration_handlers import RegistrationStates
|
||||
from src.utils.account_input import parse_account_records
|
||||
from src.utils.telegram_messages import utf16_length
|
||||
from test_dispatcher import TelegramStub, dispatch, event
|
||||
|
||||
|
||||
def context(actor):
|
||||
import main
|
||||
return main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=actor, user_id=actor)
|
||||
|
||||
|
||||
async def start_registration(actor=950):
|
||||
await dispatch(event(actor, text="/register"))
|
||||
await dispatch(event(actor, text="Tester"))
|
||||
return context(actor)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("card", ["123", "1234", "0007", " 0123 ", "1" * 50])
|
||||
async def test_card_length_and_leading_zeroes_survive_complete_registration(card):
|
||||
state = await start_registration()
|
||||
await dispatch(event(950, text=card))
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_phone.state
|
||||
await dispatch(event(950, text="-"))
|
||||
assert await state.get_state() is None
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, 950)
|
||||
assert user.is_registered and user.club_card_number == card.strip()
|
||||
assert user.nickname == "Tester" and user.verification_code
|
||||
|
||||
|
||||
async def test_duplicate_and_invalid_cards_can_be_corrected_without_restarting():
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_or_create_user(session, 951)
|
||||
user.club_card_number = "1234"
|
||||
await session.commit()
|
||||
state = await start_registration()
|
||||
for bad in ["1234", "12x4", "1" * 51, " ", "1234"]:
|
||||
calls = await dispatch(event(950, text=bad))
|
||||
assert any(getattr(call, "text", None) for call in calls)
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
|
||||
assert (await state.get_data())["nickname"] == "Tester"
|
||||
await dispatch(event(950, text="0008"))
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_phone.state
|
||||
|
||||
|
||||
async def test_client_can_finish_registration_with_their_own_preassigned_four_digit_card():
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_or_create_user(session, 950)
|
||||
user.club_card_number = "1234"
|
||||
user.is_registered = False
|
||||
await session.commit()
|
||||
await start_registration()
|
||||
await dispatch(event(950, text="1234"))
|
||||
assert await context(950).get_state() == RegistrationStates.waiting_for_phone.state
|
||||
await dispatch(event(950, text="-"))
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, 950)
|
||||
assert user.club_card_number == "1234" and user.is_registered
|
||||
|
||||
|
||||
async def test_invalid_phone_keeps_card_and_nickname_until_corrected():
|
||||
state = await start_registration()
|
||||
await dispatch(event(950, text="1234"))
|
||||
for bad in [" ", "1" * 21, "<invalid>"]:
|
||||
await dispatch(event(950, text=bad))
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_phone.state
|
||||
assert (await state.get_data())["club_card_number"] == "1234"
|
||||
await dispatch(event(950, text="+82 10-1234-5678"))
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, 950)
|
||||
assert user.is_registered and user.phone == "+82 10-1234-5678"
|
||||
|
||||
|
||||
async def test_failed_success_reply_does_not_repeat_registration_or_lose_saved_nickname():
|
||||
import main
|
||||
state = await start_registration()
|
||||
await dispatch(event(950, text="1234"))
|
||||
|
||||
class FailingReply(TelegramStub):
|
||||
async def make_request(self, bot, method, timeout=None):
|
||||
if (getattr(method, "text", "") or "").startswith("✅ Регистрация завершена"):
|
||||
raise TelegramNetworkError(method=method, message="synthetic timeout")
|
||||
return await super().make_request(bot, method, timeout)
|
||||
|
||||
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=FailingReply())
|
||||
await main.dp.feed_update(bot, event(950, text="-"))
|
||||
assert await state.get_state() is None
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, 950)
|
||||
assert user.is_registered and user.nickname == "Tester"
|
||||
|
||||
|
||||
async def test_registration_keyword_and_delete_reply_do_not_steal_form_input():
|
||||
state = await start_registration()
|
||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||
await dispatch(event(950, text="регистрация"))
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
|
||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||
update = event(950, text="Adele")
|
||||
update = update.model_copy(update={"message": update.message.model_copy(update={"reply_to_message": event(950).message})})
|
||||
await dispatch(update)
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
|
||||
assert (await state.get_data())["nickname"] == "Adele"
|
||||
|
||||
|
||||
async def test_all_text_forms_route_commands_and_reject_non_text_without_losing_data(caplog):
|
||||
from src.handlers.admin_panel import AdminStates
|
||||
from src.handlers.admin_emoji_handlers import EmojiStates
|
||||
from src.handlers.admin_account_handlers import AddAccountStates
|
||||
from src.handlers.cashier_handlers import CashierStates
|
||||
from src.handlers.staff_handlers import StaffStates
|
||||
forms = [*RegistrationStates.__all_states__, *EmojiStates.__all_states__, StaffStates.target,
|
||||
StaffStates.confirm, AddAccountStates.waiting_for_data, CashierStates.accounts]
|
||||
fields = ["lottery_title", "lottery_description", "lottery_prizes", "add_participant_user",
|
||||
"remove_participant_user", "add_participant_bulk", "remove_participant_bulk",
|
||||
"add_participant_bulk_accounts", "remove_participant_bulk_accounts", "add_to_lottery_user",
|
||||
"remove_from_lottery_user", "set_winner_place", "set_winner_user", "participant_search",
|
||||
"broadcast_add_channel_id", "broadcast_add_channel_title", "user_management_search"]
|
||||
forms += [getattr(AdminStates, name) for name in fields]
|
||||
await dispatch(event(900001, text="/cancel"))
|
||||
state = context(900001)
|
||||
for form in forms:
|
||||
await state.set_state(form)
|
||||
await state.set_data({"sentinel": "keep me"})
|
||||
for command in ("/MY_ACCOUNTS", "/unknown_test_command"):
|
||||
calls = await dispatch(event(900001, text=command))
|
||||
assert any(getattr(call, "text", None) for call in calls), (form, command)
|
||||
assert await state.get_state() == form.state, (form, command)
|
||||
assert await state.get_data() == {"sentinel": "keep me"}, (form, command)
|
||||
photo = event(900001, text="placeholder")
|
||||
photo = photo.model_copy(update={"message": photo.message.model_copy(update={"text": None, "photo": [PhotoSize(
|
||||
file_id="test", file_unique_id="test", width=1, height=1)]})})
|
||||
calls = await dispatch(photo)
|
||||
assert any(getattr(call, "text", None) for call in calls), form
|
||||
assert await state.get_state() == form.state, form
|
||||
await state.clear()
|
||||
assert not any(record.levelname in {"ERROR", "CRITICAL"} for record in caplog.records)
|
||||
|
||||
|
||||
async def test_idle_card_input_gets_recovery_hint_instead_of_silence():
|
||||
await dispatch(event(950, text="/cancel"))
|
||||
calls = await dispatch(event(950, text="1234"))
|
||||
assert any("не ожидает" in (getattr(call, "text", "") or "") for call in calls)
|
||||
|
||||
|
||||
async def create_draw():
|
||||
async with async_session_maker() as session:
|
||||
admin = await UserService.get_or_create_user(session, 900001)
|
||||
return await LotteryService.create_lottery(session, "Original", None, ["Prize"], admin.id)
|
||||
|
||||
|
||||
async def test_edit_without_description_and_cancel_then_create_keep_draws_separate(caplog):
|
||||
from src.handlers.admin_panel import AdminStates
|
||||
lottery = await create_draw()
|
||||
await dispatch(event(900001, callback_data=f"admin_edit_field_{lottery.id}_title"))
|
||||
calls = await dispatch(event(900001, text="Edited"))
|
||||
assert not any(call.__api_method__ == "answerCallbackQuery" for call in calls)
|
||||
assert not any(record.levelname == "ERROR" for record in caplog.records)
|
||||
await dispatch(event(900001, callback_data=f"admin_edit_field_{lottery.id}_title"))
|
||||
await dispatch(event(900001, callback_data="admin_lotteries"))
|
||||
assert await context(900001).get_state() is None
|
||||
await dispatch(event(900001, callback_data="admin_create_lottery"))
|
||||
await dispatch(event(900001, text="регистрация"))
|
||||
assert await context(900001).get_state() == AdminStates.lottery_description.state
|
||||
async with async_session_maker() as session:
|
||||
assert (await session.get(Lottery, lottery.id)).title == "Edited"
|
||||
assert "edit_lottery_id" not in await context(900001).get_data()
|
||||
|
||||
|
||||
async def test_lottery_text_validation_and_long_preview_keep_confirmation_usable():
|
||||
from src.handlers.admin_panel import AdminStates
|
||||
await dispatch(event(900001, callback_data="admin_create_lottery"))
|
||||
for invalid in (" ", "x" * 501):
|
||||
await dispatch(event(900001, text=invalid))
|
||||
assert await context(900001).get_state() == AdminStates.lottery_title.state
|
||||
await dispatch(event(900001, text="Title"))
|
||||
await dispatch(event(900001, text="d" * 4000))
|
||||
await dispatch(event(900001, text=" "))
|
||||
assert await context(900001).get_state() == AdminStates.lottery_prizes.state
|
||||
calls = await dispatch(event(900001, text="\n".join(["🎁" * 250] * 4)))
|
||||
assert all(utf16_length(call.text) <= 4096 for call in calls if getattr(call, "text", None))
|
||||
assert await context(900001).get_state() == AdminStates.lottery_confirm.state
|
||||
assert any(getattr(call, "reply_markup", None) for call in calls)
|
||||
|
||||
|
||||
def test_account_parser_preserves_every_row_and_three_or_four_digit_cards():
|
||||
entries, errors = parse_account_records("123 11-22-33-44-55-66-77\n0007\t88-99-00-11-22-33-44")
|
||||
assert entries == ["123 11-22-33-44-55-66-77", "0007 88-99-00-11-22-33-44"] and not errors
|
||||
assert parse_account_records("11-22-33-44-55-66-77\n88-99-00-11-22-33-44")[0] == [
|
||||
"11-22-33-44-55-66-77", "88-99-00-11-22-33-44"]
|
||||
assert parse_account_records("11-22-33-44-55-66-77\n0007")[0] == ["0007 11-22-33-44-55-66-77"]
|
||||
text = "Viposnova 16-11-2025 22:19:36\n11-22-33-44-55-66-77\n0.00 123\nViposnova\n88-99-00-11-22-33-44\n0.00 0007"
|
||||
assert parse_account_records(text)[0] == entries
|
||||
assert parse_account_records("1234")[0] == []
|
||||
assert parse_account_records("11-22-33-44-55-66-77-88")[0] == []
|
||||
|
||||
|
||||
async def test_bulk_account_form_accepts_tabs_and_stores_only_json_in_fsm():
|
||||
await create_draw()
|
||||
async with async_session_maker() as session:
|
||||
for actor, card in [(950, "123"), (951, "0007")]:
|
||||
user = await UserService.get_or_create_user(session, actor)
|
||||
user.club_card_number = card
|
||||
await session.commit()
|
||||
await dispatch(event(900002, text="/ADD_ACCOUNT"))
|
||||
await dispatch(event(900002, text="123\t11-22-33-44-55-66-77\n0007\t88-99-00-11-22-33-44"))
|
||||
data = await context(900002).get_data()
|
||||
assert len(data["accounts"]) == 2
|
||||
json.dumps(data) # RedisStorage serializes the same structure.
|
||||
async with async_session_maker() as session:
|
||||
assert len((await session.scalars(select(Account))).all()) == 2
|
||||
|
||||
|
||||
async def test_two_clients_cannot_claim_the_same_card_and_loser_can_retry():
|
||||
await start_registration(950)
|
||||
await start_registration(951)
|
||||
await dispatch(event(950, text="1234"))
|
||||
await dispatch(event(951, text="1234"))
|
||||
await asyncio.gather(dispatch(event(950, text="-")), dispatch(event(951, text="-")))
|
||||
async with async_session_maker() as session:
|
||||
registered = (await session.scalars(select(User).where(User.is_registered.is_(True)))).all()
|
||||
assert len(registered) == 1
|
||||
loser = 951 if registered[0].telegram_id == 950 else 950
|
||||
assert await context(loser).get_state() == RegistrationStates.waiting_for_club_card.state
|
||||
assert (await context(loser).get_data())["nickname"] == "Tester"
|
||||
await dispatch(event(loser, text="0007"))
|
||||
await dispatch(event(loser, text="-"))
|
||||
assert await context(loser).get_state() is None
|
||||
|
||||
|
||||
async def test_multiline_participant_ids_and_invalid_numbers_have_explicit_results(caplog):
|
||||
from src.handlers.admin_panel import AdminStates
|
||||
from src.core.models import Participation
|
||||
lottery = await create_draw()
|
||||
async with async_session_maker() as session:
|
||||
await UserService.get_or_create_user(session, 950)
|
||||
await UserService.get_or_create_user(session, 951)
|
||||
await dispatch(event(900001, text="/cancel"))
|
||||
state = context(900001)
|
||||
await state.set_state(AdminStates.add_participant_bulk)
|
||||
await state.set_data({"bulk_add_lottery_id": lottery.id})
|
||||
await dispatch(event(900001, text="9" * 80))
|
||||
assert await state.get_state() == AdminStates.add_participant_bulk.state
|
||||
await dispatch(event(900001, text="950\n951"))
|
||||
async with async_session_maker() as session:
|
||||
assert len((await session.scalars(select(Participation))).all()) == 2
|
||||
for command in ("/redraw", "/check_unclaimed", "/winner_status", "/ban", "/unban", "/set_forward"):
|
||||
calls = await dispatch(event(900001, text=command + " " + "9" * 80))
|
||||
assert any(getattr(call, "text", None) for call in calls)
|
||||
assert not any(record.levelname == "ERROR" for record in caplog.records)
|
||||
|
||||
|
||||
async def test_ban_reply_resolves_copied_message_and_escapes_reason():
|
||||
from src.core.models import ChatMessage, BannedUser
|
||||
async with async_session_maker() as session:
|
||||
target = await UserService.get_or_create_user(session, 950, first_name="<name>")
|
||||
await UserService.get_or_create_user(session, 900001)
|
||||
session.add(ChatMessage(user_id=target.id, telegram_message_id=9, message_type="text",
|
||||
forwarded_message_ids={"900001": 44}))
|
||||
await session.commit()
|
||||
update = event(900001, text="/ban <reason>")
|
||||
reply = event(900001).message.model_copy(update={"message_id": 44})
|
||||
update = update.model_copy(update={"message": update.message.model_copy(update={"reply_to_message": reply})})
|
||||
calls = await dispatch(update)
|
||||
assert any("<reason>" in (getattr(call, "text", "") or "") for call in calls)
|
||||
async with async_session_maker() as session:
|
||||
assert await session.scalar(select(BannedUser.telegram_id).where(BannedUser.is_active.is_(True))) == 950
|
||||
Reference in New Issue
Block a user