Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

27
tests/conftest.py Normal file
View File

@@ -0,0 +1,27 @@
"""All tests use disposable databases and a synthetic Telegram token."""
import os
import tempfile
from pathlib import Path
import pytest_asyncio
_temporary = tempfile.TemporaryDirectory(prefix="lottery-tests-")
os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL") or (
"sqlite+aiosqlite:///" + (Path(_temporary.name) / "test.db").as_posix()
)
os.environ["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
os.environ["ADMIN_IDS"] = "900001"
os.environ["CASHIER_IDS"] = "900002"
os.environ["REDIS_URL"] = ""
from src.core.database import Base, engine, async_session_maker
from src.core import models
@pytest_asyncio.fixture(autouse=True)
async def database():
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
await engine.dispose()

View File

@@ -0,0 +1,120 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from aiogram import Bot, Dispatcher, Router, F
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, Chat, User as TelegramUser, CallbackQuery, Update
from sqlalchemy import select
from src.core.access import is_admin, is_staff
from src.core.database import async_session_maker
from src.core.models import User, Winner
from src.core.services import UserService
from src.middlewares.access import AccessMiddleware
from src.controllers.bot_controller import BotController
async def test_assigned_admin_and_cashier_permissions_update_immediately():
async with async_session_maker() as session:
admin = await UserService.get_or_create_user(session, 301)
admin.is_admin = True
cashier = await UserService.get_or_create_user(session, 302)
cashier.is_cashier = True
await session.commit()
assert await is_admin(301)
assert await is_staff(302) and not await is_admin(302)
admin.is_admin = False
await session.commit()
assert not await is_admin(301)
@pytest.mark.parametrize("role,allow_cashier,permitted", [("user", False, False), ("user", True, False),
("cashier", False, False), ("cashier", True, True), ("admin", False, True)])
async def test_every_staff_fsm_step_checks_permissions(role, allow_cashier, permitted):
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, 303)
user.is_admin = role == "admin"
user.is_cashier = role == "cashier"
await session.commit()
handler = AsyncMock()
event = SimpleNamespace(from_user=SimpleNamespace(id=303), answer=AsyncMock())
await AccessMiddleware(allow_cashier=allow_cashier)(handler, event, {})
assert handler.await_count == int(permitted)
async def test_callback_main_menu_uses_clicker_identity():
from main import back_to_main_handler
import main
from contextlib import asynccontextmanager
controller = SimpleNamespace(handle_start=AsyncMock())
@asynccontextmanager
async def get_controller():
yield controller
old = main.get_controller
main.get_controller = get_controller
try:
actor = TelegramUser(id=304, is_bot=False, first_name="Human")
event = SimpleNamespace(from_user=actor, message=object(), answer=AsyncMock())
state = SimpleNamespace(clear=AsyncMock())
await back_to_main_handler(event, state)
controller.handle_start.assert_awaited_once_with(event.message, actor=actor)
state.clear.assert_awaited_once()
finally:
main.get_controller = old
async def test_slow_user_does_not_block_other_user_and_same_user_is_serialized():
storage = MemoryStorage()
dispatcher = Dispatcher(storage=storage, events_isolation=SimpleEventIsolation())
router = Router()
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS")
first_started, release_first, other_finished = asyncio.Event(), asyncio.Event(), asyncio.Event()
seen = []
@router.message(F.text)
async def receive(message: Message, state: FSMContext):
if message.text == "slow":
first_started.set()
await release_first.wait()
data = await state.get_data()
seen.append((message.from_user.id, data.get("count", 0)))
await state.update_data(count=data.get("count", 0) + 1)
if message.from_user.id == 402:
other_finished.set()
dispatcher.include_router(router)
def update(number, user_id, text):
return Update(update_id=number, message=Message(message_id=number, date=datetime.now(timezone.utc),
chat=Chat(id=user_id, type="private"), from_user=TelegramUser(id=user_id, is_bot=False, first_name="Actor"), text=text))
slow = asyncio.create_task(dispatcher.feed_update(bot, update(1, 401, "slow")))
await asyncio.wait_for(first_started.wait(), 1)
same = asyncio.create_task(dispatcher.feed_update(bot, update(2, 401, "next")))
other = asyncio.create_task(dispatcher.feed_update(bot, update(3, 402, "fast")))
try:
await asyncio.wait_for(other_finished.wait(), 1)
assert not same.done()
finally:
release_first.set()
await asyncio.gather(slow, same, other)
await bot.session.close()
await dispatcher.fsm.close()
assert seen == [(402, 0), (401, 0), (401, 1)]
async def test_non_owner_cannot_confirm_user_only_prize():
from src.handlers.redraw_handlers import confirm_winner_callback
from src.core.services import LotteryService
async with async_session_maker() as session:
owner = await UserService.get_or_create_user(session, 601)
draw = await LotteryService.create_lottery(session, "Draw", "", ["Prize"], owner.id)
winner = Winner(lottery_id=draw.id, user_id=owner.id, place=1)
session.add(winner)
await session.commit()
winner_id = winner.id
callback = SimpleNamespace(data=f"confirm_win_{winner_id}", from_user=SimpleNamespace(id=602), answer=AsyncMock())
await confirm_winner_callback(callback)
async with async_session_maker() as session:
assert not (await session.get(Winner, winner_id)).is_claimed
callback.answer.assert_awaited_once()

View File

@@ -0,0 +1,97 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from aiogram import Bot
from aiogram.exceptions import TelegramRetryAfter
from aiogram.methods import CopyMessage
from aiogram.types import Message, Chat, User as TelegramUser
from sqlalchemy import select
from src.core.broadcast_services import BroadcastService
from src.core.chat_services import ChatMessageService
from src.core.database import async_session_maker
from src.core.models import ChatMessage, BlockedUser
from src.core.services import UserService
from src.handlers.chat_handlers import _is_message_processed, _processed_messages, _copy_with_sender
from test_dispatcher import TelegramStub
def test_equal_message_numbers_from_different_users_are_independent():
_processed_messages.clear()
assert not _is_message_processed(101, 7)
assert not _is_message_processed(102, 7)
assert _is_message_processed(101, 7)
async def test_moderation_lookup_includes_receiving_chat():
async with async_session_maker() as session:
first = await UserService.get_or_create_user(session, 101)
second = await UserService.get_or_create_user(session, 102)
one = ChatMessage(user_id=first.id, telegram_message_id=7, message_type="text",
forwarded_message_ids={"900001": 41})
two = ChatMessage(user_id=second.id, telegram_message_id=7, message_type="text",
forwarded_message_ids={"900002": 41})
session.add_all([one, two])
await session.commit()
assert (await ChatMessageService.get_message_by_telegram_id(session, 7, chat_id=101)).id == one.id
assert (await ChatMessageService.get_message_by_telegram_id(session, 41, chat_id=900002)).id == two.id
assert await ChatMessageService.get_message_by_telegram_id(session, 41, chat_id=123) is None
assert await ChatMessageService.get_message_by_telegram_id(session, 7) is None
async def test_chat_preserves_literal_sender_and_text_and_keeps_max_length_messages():
stub = TelegramStub()
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
msg = Message(message_id=2, date=datetime.now(timezone.utc), chat=Chat(id=101, type="private"),
from_user=TelegramUser(id=101, first_name="<name>", is_bot=False), text="<b>literal</b> & value").as_(bot)
assert await _copy_with_sender(msg, 102, "<name>") == 100
assert "<name>" in stub.calls[0].text
assert "<b>literal</b> & value" in stub.calls[0].text
assert stub.calls[0].parse_mode is None
assert len(stub.calls[0].entities) == 1 # Only the explicit sender header is formatted.
stub.calls.clear()
long_message = msg.model_copy(update={"text": "x" * 4096}).as_(bot)
await _copy_with_sender(long_message, 102, "Sender")
assert [call.__api_method__ for call in stub.calls] == ["sendMessage", "copyMessage"]
async def test_parallel_broadcast_retries_do_not_deadlock_delivery_slots():
service = BroadcastService()
async def send(index):
message = SimpleNamespace(copy_to=AsyncMock(side_effect=[
TelegramRetryAfter(method=CopyMessage(chat_id=index, from_chat_id=1, message_id=1),
message="Retry", retry_after=0), SimpleNamespace(message_id=1)]))
result = await service.send_message_to_user(None, SimpleNamespace(telegram_id=index), message)
assert message.copy_to.await_count == 2
return result
results = await asyncio.wait_for(asyncio.gather(*(send(index) for index in range(8))), timeout=8)
assert all(success for success, _ in results)
async def test_parallel_delivery_failures_create_one_blocked_user_record():
service = BroadcastService()
async def mark():
async with async_session_maker() as session:
await service.mark_user_blocked(session, 200, "blocked_bot", "test")
await asyncio.gather(*(mark() for _ in range(8)))
async with async_session_maker() as session:
record = (await session.scalars(select(BlockedUser))).one()
assert record.telegram_id == 200
assert record.attempt_count == 8
async def test_inactivity_job_preserves_existing_delivery_block_and_continues():
from datetime import timedelta
from src.core.activity_service import ActivityService
async with async_session_maker() as session:
for number in (201, 202):
user = await UserService.get_or_create_user(session, number)
user.is_registered = True
user.last_activity = datetime.now(timezone.utc) - timedelta(days=31)
await session.commit()
await BroadcastService().mark_user_blocked(session, 201, "blocked_bot", "test")
assert await ActivityService.mark_inactive_users(session) == 1
assert await ActivityService.mark_inactive_users(session) == 0
assert await session.scalar(select(BlockedUser.error_type).where(BlockedUser.telegram_id == 201)) == "blocked_bot"

123
tests/test_concurrency.py Normal file
View File

@@ -0,0 +1,123 @@
import asyncio
import pytest
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from src.core.database import async_session_maker
from src.core.models import User, Lottery, Participation, Winner
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.registration_services import AccountService, WinnerNotificationService
from src.handlers.account_services import AccountParticipationService
async def seed(prizes=2):
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, 101, first_name="Client")
user.club_card_number = "1234"
user.verification_code = "AABBCCDD"
user.is_registered = True
await session.commit()
lottery = await LotteryService.create_lottery(session, "Prizes", "Draw", ["Prize"] * prizes, user.id)
return user.id, lottery.id
async def independent(call, *args):
async with async_session_maker() as session:
return await call(session, *args)
async def test_concurrent_user_upsert_preserves_one_user_and_metadata():
users = await asyncio.gather(*(independent(UserService.get_or_create_user, 102, "client", "Name") for _ in range(12)))
assert len({user.id for user in users}) == 1
user = await independent(UserService.get_or_create_user, 102)
assert user.first_name == "Name"
async def test_two_cashiers_add_same_ticket_once():
_, lottery_id = await seed()
results = await asyncio.gather(*(independent(AccountParticipationService.add_account_to_lottery,
lottery_id, "11-22-33-44-55-66-77") for _ in range(12)))
assert sum(result["success"] for result in results) == 1
assert await independent(ParticipationService.get_participants_count, lottery_id) == 1
async def test_two_admins_add_same_user_once():
user_id, lottery_id = await seed()
results = await asyncio.gather(*(independent(ParticipationService.add_participant, lottery_id, user_id) for _ in range(8)))
assert sum(results) == 1
async def test_two_admins_conduct_one_draw_and_keep_ticket_numbers():
_, lottery_id = await seed()
for account in ["11-22-33-44-55-66-77", "22-33-44-55-66-77-88"]:
await independent(AccountService.create_account, "1234", account)
await independent(AccountParticipationService.add_account_to_lottery, lottery_id, account)
results = await asyncio.gather(*(independent(LotteryService.conduct_draw, lottery_id) for _ in range(6)))
assert sum(bool(result) for result in results) == 1
winners = await independent(LotteryService.get_winners, lottery_id)
assert {w.account_number for w in winners} == {"11-22-33-44-55-66-77", "22-33-44-55-66-77-88"}
async with async_session_maker() as session:
lottery = await session.get(Lottery, lottery_id)
assert lottery.is_completed and not lottery.is_active and len(lottery.draw_results) == 2
async def test_two_cashiers_claim_one_prize_once():
user_id, lottery_id = await seed(prizes=1)
await independent(ParticipationService.add_participant, lottery_id, user_id)
await independent(LotteryService.conduct_draw, lottery_id)
results = await asyncio.gather(*(independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id) for _ in range(8)))
assert sum(result is not None for result in results) == 1
assert next(result for result in results if result is not None).claimed_at is not None
async def test_multiple_winning_accounts_can_be_claimed_separately():
_, lottery_id = await seed()
for account in ["11-22-33-44-55-66-77", "22-33-44-55-66-77-88"]:
await independent(AccountService.create_account, "1234", account)
await independent(AccountParticipationService.add_account_to_lottery, lottery_id, account)
await independent(LotteryService.conduct_draw, lottery_id)
one = await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id)
two = await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id)
assert one.id != two.id
assert await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id) is None
async def test_closed_draw_rejects_all_participant_mutations():
user_id, lottery_id = await seed(1)
await independent(ParticipationService.add_participant, lottery_id, user_id)
await independent(LotteryService.conduct_draw, lottery_id)
result = await independent(AccountParticipationService.add_account_to_lottery, lottery_id, "11-22-33-44-55-66-77")
assert not result["success"]
assert not await independent(ParticipationService.remove_participant, lottery_id, user_id)
assert not await independent(ParticipationService.add_participant, lottery_id, user_id)
async def test_duplicate_account_error_does_not_poison_bulk_session():
await seed()
async with async_session_maker() as session:
await AccountService.create_account(session, "1234", "11223344556677")
with pytest.raises(ValueError):
await AccountService.create_account(session, "1234", "11223344556677")
account = await AccountService.create_account(session, "1234", "22334455667788")
assert account.account_number == "22-33-44-55-66-77-88"
async def test_database_unique_constraints_protect_external_writers():
user_id, lottery_id = await seed()
async with async_session_maker() as session:
session.add_all([Winner(lottery_id=lottery_id, user_id=user_id, place=1),
Winner(lottery_id=lottery_id, user_id=user_id, place=1)])
with pytest.raises(IntegrityError):
await session.commit()
await session.rollback()
assert await session.scalar(select(func.count()).select_from(Winner)) == 0
async def test_manual_winner_json_is_persisted_and_participant_is_required():
user_id, lottery_id = await seed()
assert not await independent(LotteryService.set_manual_winner, lottery_id, 1, 101)
await independent(ParticipationService.add_participant, lottery_id, user_id)
assert await independent(LotteryService.set_manual_winner, lottery_id, 1, 101)
result = await independent(LotteryService.conduct_draw, lottery_id)
assert result[1]["is_manual"] and result[1]["user"].id == user_id

29
tests/test_deployment.py Normal file
View File

@@ -0,0 +1,29 @@
from pathlib import Path
import tarfile
from alembic.config import Config
from alembic.script import ScriptDirectory
import yaml
from scripts.build_release import build_release
def test_migrations_have_one_head():
assert ScriptDirectory.from_config(Config("alembic.ini")).get_heads() == ["20260913_staff_concurrency"]
def test_release_contains_runtime_and_migrations_but_no_secrets(tmp_path):
path = build_release(tmp_path / "lottery.tar.gz")
with tarfile.open(path) as archive:
names = archive.getnames()
assert "main.py" in names and "Dockerfile" in names
assert "migrations/versions/20260913_staff_concurrency.py" in names
assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names)
def test_ci_requires_success_and_never_deploys_pull_requests():
config = yaml.safe_load(Path(".drone.yml").read_text(encoding="utf-8"))
deploy = config["steps"][-1]
assert deploy["when"] == {"branch": ["master"], "event": ["push"], "status": ["success"]}
assert not any("||" in command for step in config["steps"] for command in step["commands"])
assert {step["name"] for step in config["steps"]} >= {"checks", "postgres-tests", "package", "deploy"}

90
tests/test_dispatcher.py Normal file
View File

@@ -0,0 +1,90 @@
from datetime import datetime, timezone
from aiogram import Bot
from aiogram.client.session.base import BaseSession
from aiogram.types import Update, Message, User as TelegramUser, Chat, CallbackQuery
from sqlalchemy import select
from src.core.database import async_session_maker
from src.core.models import User
from src.core.services import UserService
class TelegramStub(BaseSession):
def __init__(self):
super().__init__()
self.calls = []
async def close(self):
pass
async def make_request(self, bot, method, timeout=None):
self.calls.append(method)
if method.__api_method__ in {"answerCallbackQuery", "deleteMessage"}:
return True
return Message(message_id=100, date=datetime.now(timezone.utc),
chat=Chat(id=int(getattr(method, "chat_id", 1)), type="private"),
from_user=TelegramUser(id=bot.id, is_bot=True, first_name="Bot"), text=getattr(method, "text", ""))
async def stream_content(self, url, headers=None, timeout=30, chunk_size=65536, raise_for_status=True):
yield b""
def event(user_id, text=None, callback_data=None):
actor = TelegramUser(id=user_id, is_bot=False, first_name="Human")
msg = Message(message_id=1, date=datetime.now(timezone.utc), chat=Chat(id=user_id, type="private"),
from_user=actor if text else TelegramUser(id=123456, is_bot=True, first_name="Bot"), text=text or "Menu")
if callback_data:
return Update(update_id=1, callback_query=CallbackQuery(id="test", from_user=actor, chat_instance="private",
message=msg, data=callback_data))
return Update(update_id=1, message=msg)
async def dispatch(update):
import main
if not main.dp.sub_routers:
main.configure_dispatcher()
stub = TelegramStub()
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
await main.dp.feed_update(bot, update)
return stub.calls
async def test_actual_back_button_never_registers_bot_as_user():
calls = await dispatch(event(701, callback_data="back_to_main"))
async with async_session_maker() as session:
ids = (await session.scalars(select(User.telegram_id))).all()
assert ids == [701]
assert any((getattr(call, "text", "") or "").startswith("👋") for call in calls)
async def test_actual_cashier_menu_available_to_cashier_but_admin_menu_denied():
calls = await dispatch(event(900002, text="/cashier"))
assert any("💼 Касса" in (getattr(call, "text", "") or "") for call in calls)
calls = await dispatch(event(900002, callback_data="admin_participants"))
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
async def test_actual_forged_account_callback_is_denied_before_mutation():
calls = await dispatch(event(702, callback_data="add_to_lottery_1"))
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
async def test_assigned_admin_can_open_participant_management():
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, 703)
user.is_admin = True
await session.commit()
calls = await dispatch(event(703, callback_data="admin_participants"))
assert any(getattr(call, "reply_markup", None) for call in calls)
assert not any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
async def test_registration_cancel_clears_state():
calls = await dispatch(event(704, text="/register"))
assert any("Никнейм" in (getattr(call, "text", "") or "") or "никнейм" in (getattr(call, "text", "") or "") for call in calls)
import main
state = main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=704, user_id=704)
assert (await state.get_state()).endswith("waiting_for_nickname")
await dispatch(event(704, text="/cancel"))
assert await state.get_state() is None

View File

@@ -0,0 +1,33 @@
from io import BytesIO
from openpyxl import load_workbook
from sqlalchemy import select
from src.core.database import async_session_maker
from src.core.models import User
from src.core.import_services import import_users
from src.core.services import UserService
from src.utils.spreadsheets import export_users, read_users_xlsx
async def test_excel_formulas_remain_literal_text():
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, 501, first_name='=HYPERLINK("https://invalid.example")')
data = export_users([user])
workbook = load_workbook(BytesIO(data), data_only=False)
assert workbook.active.cell(2, 3).data_type == "s"
assert read_users_xlsx(data)[0]["first_name"] == user.first_name
workbook.close()
async def test_import_duplicate_card_does_not_break_following_rows_or_elevate_roles():
added, updated, errors = await import_users([
dict(telegram_id=502, club_card_number="5678", is_registered=True, is_admin=True, is_cashier=True),
dict(telegram_id=503, club_card_number="5678"),
dict(telegram_id=504, club_card_number="6789"),
])
assert (added, updated, errors) == (2, 0, 1)
async with async_session_maker() as session:
user = await session.scalar(select(User).where(User.telegram_id == 502))
assert not user.is_admin and not user.is_cashier and user.verification_code
assert await session.scalar(select(User.id).where(User.telegram_id == 504))

33
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,33 @@
import asyncio
import os
import subprocess
import sys
import uuid
import asyncpg
import pytest
from sqlalchemy.engine import make_url
@pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL", "").startswith("postgresql"), reason="requires disposable PostgreSQL")
@pytest.mark.parametrize("baseline", [None, "20260701_perf_indexes"])
async def test_fresh_migrations_and_schema_check(baseline):
url = make_url(os.environ["TEST_DATABASE_URL"])
temporary_database = "lottery_migration_test_" + uuid.uuid4().hex
admin_url = url.set(drivername="postgresql").render_as_string(hide_password=False)
connection = await asyncpg.connect(admin_url)
# Database name is generated here; the configured test database is never dropped.
await connection.execute(f'CREATE DATABASE "{temporary_database}"')
try:
environment = dict(os.environ, DATABASE_URL=url.set(database=temporary_database).render_as_string(hide_password=False))
def check(*command):
result = subprocess.run([sys.executable, *command], env=environment, capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr
if baseline:
await asyncio.to_thread(check, "-m", "alembic", "upgrade", baseline)
await asyncio.to_thread(check, "-m", "alembic", "upgrade", "head")
await asyncio.to_thread(check, "-m", "alembic", "upgrade", "head")
await asyncio.to_thread(check, "scripts/check_schema.py")
finally:
await connection.execute(f'DROP DATABASE "{temporary_database}" WITH (FORCE)')
await connection.close()

View File

@@ -123,13 +123,14 @@ async def test_display_types():
print("=" * 50)
async with async_session_maker() as session:
creator = await UserService.get_or_create_user(session, 990001, first_name="Test creator")
# Создаём розыгрыш
lottery = await LotteryService.create_lottery(
session,
title="Тест отображения победителей",
description="Розыгрыш для тестирования различных типов отображения",
prizes=["Первый приз", "Второй приз"],
creator_id=1
creator_id=creator.id
)
# Получаем пользователя со счётом

165
tests/test_premium_emoji.py Normal file
View File

@@ -0,0 +1,165 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import CopyMessage
from aiogram.types import Chat, Message, MessageEntity, Update, User as TelegramUser
from sqlalchemy import select
from src.core.broadcast_services import BroadcastService
from src.core.database import async_session_maker
from src.core.emoji_mapping_service import EmojiMappingService
from src.core.models import BlockedUser, EmojiMapping, P2PMessage
from src.core.services import UserService
from src.handlers.p2p_chat import handle_p2p_message
from src.utils.telegram_messages import copy_preserving_entities
from test_dispatcher import TelegramStub, dispatch, event
BODY = "😀 🎲 🎲 <b>"
FIRST_ID = "5368324170671202286"
SECOND_ID = "5368324170671202287"
def emoji_entities():
return [MessageEntity(type="bold", offset=3, length=5),
MessageEntity(type="custom_emoji", offset=3, length=2, custom_emoji_id=FIRST_ID),
MessageEntity(type="custom_emoji", offset=6, length=2, custom_emoji_id=SECOND_ID),
MessageEntity(type="text_link", offset=9, length=3, url="https://example.com")]
def source_message(bot, **values):
return Message(message_id=7, date=datetime.now(timezone.utc), chat=Chat(id=101, type="private"),
from_user=TelegramUser(id=101, is_bot=False, first_name="Sender"), **values).as_(bot)
def make_bot():
stub = TelegramStub()
return Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub,
default=DefaultBotProperties(parse_mode="Markdown")), stub
def assert_custom_emoji(text, entities):
custom = [entity for entity in entities if entity.type == "custom_emoji"]
assert [entity.custom_emoji_id for entity in custom] == [FIRST_ID, SECOND_ID]
assert [entity.extract_from(text) for entity in custom] == ["🎲", "🎲"]
link = next(entity for entity in entities if entity.type == "text_link")
assert link.extract_from(text) == "<b>"
async def test_custom_emoji_and_nested_formatting_survive_utf16_header():
bot, stub = make_bot()
message = source_message(bot, text=BODY, entities=emoji_entities())
await copy_preserving_entities(message, 102, "Кассир 🧑‍💻 <name>")
sent = stub.calls[0]
assert sent.parse_mode is None
assert sent.text.endswith(BODY)
assert_custom_emoji(sent.text, sent.entities)
assert [entity.offset for entity in message.entities] == [3, 3, 6, 9]
@pytest.mark.parametrize("media", ["photo", "video", "document", "animation", "audio", "voice"])
async def test_custom_emoji_survive_media_caption(media):
bot, stub = make_bot()
value = dict(file_id="file", file_unique_id="unique", width=1, height=1, duration=1)
message = source_message(bot, caption=BODY, caption_entities=emoji_entities(),
**{media: [value] if media == "photo" else value})
await copy_preserving_entities(message, 102, "Отправитель 😀")
sent = stub.calls[0]
assert sent.__api_method__ == "copyMessage"
assert sent.parse_mode is None
assert_custom_emoji(sent.caption, sent.caption_entities)
@pytest.mark.parametrize("caption", [False, True])
async def test_max_length_message_is_copied_without_trimming_emoji(caption):
bot, stub = make_bot()
limit = 1024 if caption else 4096
body = "x" * (limit - 2) + "🎲"
entities = [MessageEntity(type="custom_emoji", offset=limit - 2, length=2, custom_emoji_id=FIRST_ID)]
values = dict(caption=body, caption_entities=entities, document={"file_id": "file", "file_unique_id": "unique"}) if caption else dict(text=body, entities=entities)
message = source_message(bot, **values)
await copy_preserving_entities(message, 102, "Sender")
assert [method.__api_method__ for method in stub.calls] == ["sendMessage", "copyMessage"]
assert stub.calls[1].from_chat_id == 101 and stub.calls[1].message_id == 7
assert stub.calls[1].caption is None # Telegram copies the original caption and all its entities.
async def test_p2p_delivers_custom_emoji_and_stores_literal_text():
bot, stub = make_bot()
async with async_session_maker() as session:
sender = await UserService.get_or_create_user(session, 101, first_name="Sender")
recipient = await UserService.get_or_create_user(session, 102, first_name="Recipient")
recipient_id = recipient.id
state = SimpleNamespace(get_data=AsyncMock(return_value={"recipient_id": recipient_id, "recipient_telegram_id": 102}))
message = source_message(bot, text=BODY, entities=emoji_entities())
await handle_p2p_message(message, state)
delivery = next(call for call in stub.calls if getattr(call, "chat_id", None) == 102)
assert_custom_emoji(delivery.text, delivery.entities)
async with async_session_maker() as session:
saved = (await session.scalars(select(P2PMessage))).one()
assert saved.text == BODY
assert saved.recipient_message_id == 100
async def test_direct_and_channel_broadcast_copy_original_custom_emoji_message():
bot, stub = make_bot()
message = source_message(bot, text=BODY, entities=emoji_entities())
async with async_session_maker() as session:
admin = await UserService.get_or_create_user(session, 101)
recipient = await UserService.get_or_create_user(session, 102)
admin_id = admin.id
service = BroadcastService()
assert (await service.send_message_to_user(bot, recipient, message))[0]
assert await service.broadcast_to_channel(bot, message, -100123, admin_id)
assert [call.chat_id for call in stub.calls] == [102, -100123]
assert all(call.__api_method__ == "copyMessage" and call.from_chat_id == 101
and call.message_id == 7 and call.caption is None for call in stub.calls)
async def test_rejected_custom_emoji_does_not_block_future_broadcasts():
message = SimpleNamespace(copy_to=AsyncMock(side_effect=[
TelegramBadRequest(method=CopyMessage(chat_id=102, from_chat_id=101, message_id=7),
message="CUSTOM_EMOJI_INVALID"), SimpleNamespace(message_id=100)]))
service = BroadcastService()
user = SimpleNamespace(telegram_id=102)
assert await service.send_message_to_user(None, user, message) == (False, "bad_request")
assert await service.send_message_to_user(None, user, message) == (True, None)
async with async_session_maker() as session:
assert (await session.scalars(select(BlockedUser))).all() == []
async def test_real_admin_dialog_rejects_plain_emoji_and_registers_actual_id():
await dispatch(event(900001, text="/cancel"))
await dispatch(event(900001, text="/add_emoji"))
calls = await dispatch(event(900001, text="🎲"))
assert any("Обычный символ" in (getattr(call, "text", "") or "") for call in calls)
update = event(900001, text="🎲")
update = update.model_copy(update={"message": update.message.model_copy(update={
"entities": [MessageEntity(type="custom_emoji", offset=0, length=2, custom_emoji_id=FIRST_ID)]})})
await dispatch(update)
await dispatch(event(900001, text="Розыгрыш"))
async with async_session_maker() as session:
mapping = (await session.scalars(select(EmojiMapping))).one()
admin = await UserService.get_user_by_telegram_id(session, 900001)
assert mapping.admin_id == admin.id
assert mapping.emoji_id == FIRST_ID
assert (await EmojiMappingService(session).get_all_emoji_by_admin(900001))[0].id == mapping.id
async def test_catalog_renders_valid_safe_html_and_rejects_fake_id():
async with async_session_maker() as session:
service = EmojiMappingService(session)
with pytest.raises(ValueError, match="custom_emoji_id"):
await service.register_emoji("🎲", "🎲", 900001)
await service.register_emoji("🎲", FIRST_ID, 900001)
rendered = await service.replace_emojis_in_text("<b> & 🎲 🎲")
assert rendered == f'&lt;b&gt; &amp; <tg-emoji emoji-id="{FIRST_ID}">🎲</tg-emoji> <tg-emoji emoji-id="{FIRST_ID}">🎲</tg-emoji>'
assert await service.restore_emojis_in_text(rendered) == "<b> & 🎲 🎲"
with pytest.raises(ValueError, match="уже зарегистрирован"):
await service.register_emoji("🎲", FIRST_ID, 900002)
assert len(await service.get_all_emojis()) == 1

73
tests/test_redraw_chat.py Normal file
View File

@@ -0,0 +1,73 @@
import asyncio
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from src.core.database import async_session_maker
from src.core.models import Winner, WinnerVerification, User
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.redraw_services import redraw_unclaimed
from src.core.chat_services import ChatSettingsService, BanService, ChatPermissionService
from src.core.user_management import UserManagementService
async def run(call, *args):
async with async_session_maker() as session:
return await call(session, *args)
async def test_parallel_redraw_replaces_only_expired_unclaimed_prizes_once():
async with async_session_maker() as session:
users = [await UserService.get_or_create_user(session, 800 + i) for i in range(4)]
lottery = await LotteryService.create_lottery(session, "Draw", "", ["First", "Second"], users[0].id)
lottery_id = lottery.id
for user in users:
await ParticipationService.add_participant(session, lottery_id, user.id)
await LotteryService.conduct_draw(session, lottery_id)
winners = await LotteryService.get_winners(session, lottery_id)
claimed_id = winners[0].id
winners[0].is_claimed = True
winners[1].is_notified = True
winners[1].created_at = datetime.now(timezone.utc) - timedelta(days=2)
await session.commit()
results = await asyncio.gather(*(run(redraw_unclaimed, lottery_id) for _ in range(5)))
assert sum(len(result) for result in results) == 1
async with async_session_maker() as session:
remaining = await LotteryService.get_winners(session, lottery_id)
assert len(remaining) == 2
assert (await session.get(Winner, claimed_id)).is_claimed
assert {winner.prize for winner in remaining} == {"First", "Second"}
async def test_chat_settings_created_once_under_concurrency():
settings = await asyncio.gather(*(run(ChatSettingsService.get_or_create_settings) for _ in range(8)))
assert {item.id for item in settings} == {1}
async def test_both_admin_interfaces_clear_chat_bans():
async with async_session_maker() as session:
admin = await UserService.get_or_create_user(session, 850)
user = await UserService.get_or_create_user(session, 851)
user.is_registered = True
await session.commit()
await BanService.ban_user(session, user.id, user.telegram_id, admin.id)
assert not (await ChatPermissionService.can_send_message(session, 851))[0]
await UserManagementService.unban_user_in_chat(session, user.id)
assert (await ChatPermissionService.can_send_message(session, 851))[0]
await BanService.ban_user(session, user.id, user.telegram_id, admin.id)
assert await BanService.unban_user(session, 851)
assert (await ChatPermissionService.can_send_message(session, 851))[0]
async def test_lottery_delete_includes_verification_children():
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, 870)
lottery = await LotteryService.create_lottery(session, "Draw", "", ["Prize"], user.id)
await ParticipationService.add_participant(session, lottery.id, user.id)
await LotteryService.conduct_draw(session, lottery.id)
winner = (await LotteryService.get_winners(session, lottery.id))[0]
session.add(WinnerVerification(winner_id=winner.id, verification_token="test-token",
expires_at=datetime.now(timezone.utc) + timedelta(days=1)))
await session.commit()
assert await LotteryService.delete_lottery(session, lottery.id)
assert not list((await session.scalars(select(WinnerVerification))).all())