Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:39:40 +09:00
parent 3c1a6a02a5
commit f3f8d0f737
28 changed files with 788 additions and 334 deletions

280
tests/test_text_inputs.py Normal file
View 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, " ", ""]:
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("&lt;reason&gt;" 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