Files
new_lottery_bot/tests/test_operator_scenarios.py
Trevor1985 a9af9cb010
Some checks failed
continuous-integration/drone/push Build is failing
Fix account enrollment and operator callback flows with Redis scenarios
2026-09-14 21:01:56 +09:00

322 lines
14 KiB
Python

"""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 <September> & 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