Fix account enrollment and operator callback flows with Redis scenarios
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""All tests use disposable databases and a synthetic Telegram token."""
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest_asyncio
|
||||
@@ -12,7 +13,7 @@ os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL") or (
|
||||
os.environ["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||
os.environ["ADMIN_IDS"] = "900001"
|
||||
os.environ["CASHIER_IDS"] = "900002"
|
||||
os.environ["REDIS_URL"] = ""
|
||||
os.environ["REDIS_URL"] = os.getenv("TEST_REDIS_URL", "")
|
||||
|
||||
from src.core.database import Base, engine, async_session_maker
|
||||
from src.core import models
|
||||
@@ -20,8 +21,18 @@ from src.core import models
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def database():
|
||||
if os.getenv("TEST_REDIS_URL"):
|
||||
from redis.asyncio import Redis
|
||||
redis = Redis.from_url(os.environ["TEST_REDIS_URL"])
|
||||
# Only the synthetic bot's FSM namespace in the disposable test service.
|
||||
keys = [key async for key in redis.scan_iter(match="fsm:123456:*")]
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
await redis.aclose()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
yield async_session_maker
|
||||
if os.getenv("TEST_REDIS_URL") and "main" in sys.modules:
|
||||
await sys.modules["main"].dp.storage.close()
|
||||
await engine.dispose()
|
||||
|
||||
321
tests/test_operator_scenarios.py
Normal file
321
tests/test_operator_scenarios.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""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
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import select
|
||||
from src.core.access import get_role
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.models import User
|
||||
from src.core.services import UserService
|
||||
from src.core.services import LotteryService, UserService
|
||||
from src.core.staff_service import StaffError, change_role, get_member
|
||||
from test_dispatcher import dispatch, event
|
||||
|
||||
@@ -104,8 +104,11 @@ async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(act
|
||||
|
||||
|
||||
async def test_revoked_cashier_cannot_continue_open_participant_dialog():
|
||||
await create_user(819, is_cashier=True)
|
||||
await dispatch(event(819, callback_data="cash_add:1"))
|
||||
cashier = await create_user(819, is_cashier=True)
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.create_lottery(session, "Open draw", "", ["Prize"], cashier.id)
|
||||
lottery_id = lottery.id
|
||||
await dispatch(event(819, callback_data=f"cash_add:{lottery_id}"))
|
||||
await change_role(900001, 819, "user", (False, True))
|
||||
calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
|
||||
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
|
||||
|
||||
Reference in New Issue
Block a user