Stabilize staff concurrency and premium emoji; enable verified Drone deployment
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:
27
tests/conftest.py
Normal file
27
tests/conftest.py
Normal 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()
|
||||
120
tests/test_access_runtime.py
Normal file
120
tests/test_access_runtime.py
Normal 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()
|
||||
97
tests/test_chat_delivery.py
Normal file
97
tests/test_chat_delivery.py
Normal 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
123
tests/test_concurrency.py
Normal 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
29
tests/test_deployment.py
Normal 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
90
tests/test_dispatcher.py
Normal 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
|
||||
33
tests/test_imports_exports.py
Normal file
33
tests/test_imports_exports.py
Normal 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
33
tests/test_migrations.py
Normal 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()
|
||||
@@ -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
165
tests/test_premium_emoji.py
Normal 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'<b> & <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
73
tests/test_redraw_chat.py
Normal 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())
|
||||
Reference in New Issue
Block a user