"""Drive real routers with disposable data; never call the Telegram network.""" import asyncio import logging from html.parser import HTMLParser from aiogram import Bot from sqlalchemy import func, select import pytest from src.core.database import async_session_maker from src.core.models import Account, Lottery, Participation, User, Winner from src.core.services import LotteryService, UserService from src.handlers.account_services import AccountParticipationService from test_dispatcher import TelegramStub, event from test_text_inputs import context ADMIN = 900001 CLIENT = 960001 ACCOUNT = "11-22-33-44-55-66-77" SECOND = "88-99-00-11-22-33-44" class TelegramHTML(HTMLParser): def __init__(self): super().__init__() self.stack = [] def handle_starttag(self, tag, attrs): assert tag in {"b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "span", "tg-spoiler", "a", "code", "pre", "blockquote", "tg-emoji"}, tag self.stack.append(tag) def handle_endtag(self, tag): assert self.stack and self.stack.pop() == tag, tag class CheckedTelegram(TelegramStub): async def make_request(self, bot, method, timeout=None): text = getattr(method, "text", None) if text and method.__api_method__ in {"sendMessage", "editMessageText"}: assert len(text.encode("utf-16-le")) // 2 <= 4096, "Telegram text exceeds 4096" if getattr(method, "parse_mode", None) == "HTML": parser = TelegramHTML() parser.feed(text) assert not parser.stack markup = getattr(method, "reply_markup", None) if markup and hasattr(markup, "inline_keyboard"): for row in markup.inline_keyboard: for button in row: if button.callback_data: assert 1 <= len(button.callback_data.encode()) <= 64 return await super().make_request(bot, method, timeout) async def send(actor=ADMIN, *, text=None, callback=None, stub=None): import main if not main.dp.sub_routers: main.configure_dispatcher() transport = stub or CheckedTelegram() bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=transport) await main.dp.feed_update(bot, event(actor, text=text, callback_data=callback)) return transport.calls def output(calls): return "\n".join(getattr(call, "text", "") or "" for call in calls) def assert_clean(caplog): failures = [r for r in caplog.records if r.levelno >= logging.ERROR] assert not failures, [(r.message, str(r.exc_info[1]) if r.exc_info else "") for r in failures] async def seed(*, title="Test draw", participants=True): await context(ADMIN).clear() await context(CLIENT).clear() await context(900002).clear() async with async_session_maker() as session: admin = await UserService.get_or_create_user(session, ADMIN, first_name="Admin") client = await UserService.get_or_create_user(session, CLIENT, first_name="Client") client.club_card_number = "0007" client.is_registered = True client.verification_code = "TEST1234" await session.commit() lottery = await LotteryService.create_lottery(session, title, "Description", ["Prize A", "Prize B"], admin.id) lottery_id, client_id = lottery.id, client.id session.add_all([Account(account_number=ACCOUNT, owner_id=client_id), Account(account_number=SECOND, owner_id=client_id)]) await session.commit() if participants: await AccountParticipationService.add_accounts_bulk(session, lottery_id, [ACCOUNT, SECOND]) return lottery_id, client_id @pytest.mark.parametrize("route", ["detected", "command", "cashier", "bulk"]) async def test_duplicate_account_reports_and_finishes_dialog(route, caplog): lottery_id, _ = await seed(title="Draw & friends") actor = 900002 if route == "cashier" else ADMIN if route == "detected": await send(text=f"0007 {ACCOUNT}") await send(callback="account_action:add_to_lottery") calls = await send(callback=f"add_accounts_to:{lottery_id}") elif route == "command": await context(actor).update_data(accounts=[{"account_number": ACCOUNT}]) calls = await send(callback=f"add_to_lottery_{lottery_id}") elif route == "cashier": await send(actor, text="/cashier") await send(actor, callback=f"cash_add:{lottery_id}") calls = await send(actor, text=ACCOUNT) else: await send(callback=f"admin_bulk_add_accounts_to_{lottery_id}") calls = await send(text=ACCOUNT) assert_clean(caplog) assert "уже участвует" in output(calls) assert await context(actor).get_state() is None assert not await context(actor).get_data() async with async_session_maker() as session: assert await session.scalar(select(func.count(Participation.id))) == 2 @pytest.mark.parametrize("callback", [ "admin_participants_by_lottery", "admin_participants_report", "admin_edit_winner", "admin_edit_winner_lottery_{id}", "admin_edit_lottery_select_{id}", "admin_lottery_detail_{id}", "admin_participants_{id}", "admin_stats", "admin_list_all_participants", "admin_users_list:1", "admin_winners", "admin_system_info", "admin_broadcast", "admin_settings", ]) async def test_admin_menu_routes(callback, caplog): lottery_id, _ = await seed() calls = await send(callback=callback.format(id=lottery_id)) assert_clean(caplog) assert calls, callback assert "Кнопка устарела" not in output(calls) async def test_delete_confirmation_executes_instead_of_reopening(caplog): lottery_id, _ = await seed() await send(callback=f"admin_del_lottery_{lottery_id}") await send(callback=f"admin_del_lottery_yes_{lottery_id}") assert_clean(caplog) async with async_session_maker() as session: assert await session.get(Lottery, lottery_id) is None async def test_redraw_confirmation_reaches_execution(caplog): lottery_id, _ = await seed() async with async_session_maker() as session: await LotteryService.conduct_draw(session, lottery_id) calls = await send(callback=f"admin_redraw_confirm_{lottery_id}") assert_clean(caplog) assert "Нет просроченных" in output(calls) @pytest.mark.parametrize("callback", ["winner_lottery:1", "winner_place:1", "add_accounts_to:1"]) async def test_expired_account_buttons_recover_without_error(callback, caplog): await seed() calls = await send(callback=callback) assert_clean(caplog) assert calls async def test_client_account_registration_to_cashier_prize_claim(caplog): lottery_id, _ = await seed(participants=False) await send(CLIENT, text="/start") calls = await send(CLIENT, text="/my_accounts") assert ACCOUNT in output(calls) await send(900002, callback=f"cash_add:{lottery_id}") await send(900002, text=f"0007 {ACCOUNT}\n0007 {SECOND}") await send(callback=f"admin_conduct_confirmed_{lottery_id}") calls = await send(900002, text=f"/verify_winner TEST1234 {lottery_id}") assert "подтвержден" in output(calls) assert_clean(caplog) async with async_session_maker() as session: assert await session.scalar(select(func.count(Winner.id)).where(Winner.is_claimed.is_(True))) == 1 async def test_two_cashiers_share_draw_without_duplicate_tickets(caplog): lottery_id, _ = await seed(participants=False) async with async_session_maker() as session: cashier = await UserService.get_or_create_user(session, 960002) cashier.is_cashier = True await session.commit() for actor in (900002, 960002): await send(actor, callback=f"cash_add:{lottery_id}") calls = await asyncio.gather(send(900002, text=ACCOUNT), send(960002, text=ACCOUNT), send(CLIENT, text="/start")) assert_clean(caplog) assert sum("Добавлено: 1" in output(result) for result in calls) == 1 async with async_session_maker() as session: assert await session.scalar(select(func.count(Participation.id))) == 1 async def test_new_card_ticket_is_created_and_visible_to_client(caplog): lottery_id, client_id = await seed(participants=False) new_account = "01-02-03-04-05-06-07" await send(text=f"0007 {new_account}") await send(callback="account_action:add_to_lottery") calls = await send(callback=f"add_accounts_to:{lottery_id}") assert "Добавлено: 1" in output(calls) assert new_account in output(await send(CLIENT, text="/my_accounts")) async with async_session_maker() as session: account = await session.scalar(select(Account).where(Account.account_number == new_account)) participation = await session.scalar(select(Participation).where(Participation.account_number == new_account)) assert account.owner_id == participation.user_id == client_id assert participation.account_id == account.id assert_clean(caplog) @pytest.mark.parametrize("route", ["admin_add_part_to_{id}", "admin_add_to_{id}", "admin_bulk_add_to_{id}"]) async def test_existing_user_addition_can_be_repeated(route, caplog): lottery_id, _ = await seed() for _ in range(2): await send(callback=route.format(id=lottery_id)) await send(text=str(CLIENT)) assert_clean(caplog) @pytest.mark.parametrize("action", ["add", "remove"]) async def test_detail_account_operations_cannot_change_closed_draw(action, caplog): lottery_id, _ = await seed() async with async_session_maker() as session: await LotteryService.conduct_draw(session, lottery_id) await send(callback=f"admin_{'add_to' if action == 'add' else 'remove_from'}_{lottery_id}") await send(text="01-02-03-04-05-06-07" if action == "add" else ACCOUNT) assert_clean(caplog) async with async_session_maker() as session: assert await session.scalar(select(func.count(Participation.id))) == 2 @pytest.mark.parametrize("callback", [ "admin_add_part_to_99999", "admin_bulk_add_to_99999", "admin_bulk_remove_from_99999", "admin_bulk_add_accounts_to_99999", "admin_bulk_remove_accounts_from_99999", "admin_confirm_finish_99999", "admin_confirm_delete_99999", "admin_set_display_99999", ]) async def test_deleted_lottery_buttons_recover(callback, caplog): await seed() assert output(await send(callback=callback)) assert_clean(caplog) async def test_anonymous_winner_admin_pages(caplog): lottery_id, _ = await seed() async with async_session_maker() as session: winner = Winner(lottery_id=lottery_id, account_number="01-02-03-04-05-06-07", place=1, prize="Prize") session.add(winner) await session.commit() winner_id = winner.id for callback in (f"admin_edit_winner_lottery_{lottery_id}", f"admin_edit_winner_id_{winner_id}", f"admin_remove_winner_lottery_{lottery_id}", f"admin_confirm_remove_winner_{winner_id}", f"admin_do_remove_winner_{winner_id}"): await send(callback=callback) assert_clean(caplog) async def test_manual_winner_preserves_exact_account_and_rejects_second_place(caplog): lottery_id, _ = await seed() await send(text=f"0007 {SECOND}") await send(callback="account_action:set_as_winner") await send(callback=f"winner_lottery:{lottery_id}") await send(callback="winner_place:1") await send(text=f"0007 {SECOND}") await send(callback="account_action:set_as_winner") await send(callback=f"winner_lottery:{lottery_id}") calls = await send(callback="winner_place:2") assert "другое призовое место" in output(calls) await send(callback=f"admin_conduct_confirmed_{lottery_id}") assert_clean(caplog) async with async_session_maker() as session: winner = await session.scalar(select(Winner).where(Winner.place == 1)) assert winner.account_number == SECOND async def test_registered_card_cannot_take_another_owner_account(caplog): lottery_id, _ = await seed(participants=False) async with async_session_maker() as session: user = await UserService.get_or_create_user(session, 960003) user.club_card_number = "0008" await session.commit() await send(text=f"0008 {ACCOUNT}") await send(callback="account_action:add_to_lottery") calls = await send(callback=f"add_accounts_to:{lottery_id}") assert "не соответствует" in output(calls) assert_clean(caplog) async with async_session_maker() as session: assert await session.scalar(select(func.count(Participation.id))) == 0 async def test_malformed_callback_ids_do_not_reach_database(caplog): await seed() for prefix in ("admin_participants_", "admin_edit_", "admin_del_lottery_", "admin_redraw_", "add_accounts_to:", "winner_lottery:", "add_to_lottery_", "cash_add:", "admin_users_list:", "admin_user_view:", "admin_message_delete_"): for bad_id in ("abc", "-1", "0", "9" * 30): calls = await send(callback=prefix + bad_id) assert "Кнопка устарела" in output(calls) assert_clean(caplog) async def test_long_detected_account_list_keeps_action_buttons(caplog): await seed(participants=False) records = ["0007 " + "-".join(f"{i:014d}"[j:j+2] for j in range(0, 14, 2)) for i in range(120)] calls = await send(text="\n".join(records)) pages = [call for call in calls if call.__api_method__ == "sendMessage"] assert len(pages) > 1 and pages[-1].reply_markup assert_clean(caplog) async def test_new_account_race_between_different_lotteries(): lottery_id, _ = await seed(participants=False) async with async_session_maker() as session: admin_id = await session.scalar(select(User.id).where(User.telegram_id == ADMIN)) other = await LotteryService.create_lottery(session, "Second draw", "", ["Prize"], admin_id) other_id = other.id account = "01-02-03-04-05-06-07" async def add(draw_id): async with async_session_maker() as session: return await AccountParticipationService.add_account_to_lottery(session, draw_id, "0007 " + account) results = await asyncio.gather(add(lottery_id), add(other_id)) assert all(result["success"] for result in results) async with async_session_maker() as session: assert await session.scalar(select(func.count(Account.id)).where(Account.account_number == account)) == 1 assert await session.scalar(select(func.count(Participation.id))) == 2