import re import pytest from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage, SendPhoto from aiogram.types import ReplyKeyboardMarkup from app.access.service import AccessService from app.bot.routers.members import MembersAction, MembersCb from app.db.session import SessionMaker from app.main import build_dispatcher from app.texts import Texts from tests.conftest import ADMIN from tests.fakes import TgClient NEWBIE = 3003 @pytest.fixture def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient: return TgClient(build_dispatcher(texts, sessions, access)) async def invite_token(tg: TgClient) -> str: await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack()) [photo] = tg.session.sent(SendPhoto) match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "") assert match, photo.caption tg.session.requests.clear() return match.group(1) async def test_admin_sees_members_list(tg: TgClient, texts: Texts) -> None: await tg.send(ADMIN, texts.menu.members) [reply] = tg.session.sent(SendMessage) assert reply.text.startswith(texts.members.title.format(count=1)) async def test_full_invite_flow(tg: TgClient, texts: Texts) -> None: token = await invite_token(tg) await tg.send(NEWBIE, f"/start {token}") [welcome] = tg.session.sent(SendMessage) assert welcome.text == texts.start.welcome assert isinstance(welcome.reply_markup, ReplyKeyboardMarkup) labels = [b.text for row in welcome.reply_markup.keyboard for b in row] assert texts.menu.members not in labels # a member, not an admin # the same QR again, from someone else tg.session.requests.clear() await tg.send(NEWBIE + 1, f"/start {token}") assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected] async def test_bad_token_rejected_and_bare_start_refused(tg: TgClient, texts: Texts) -> None: await tg.send(NEWBIE, "/start nope") await tg.send(NEWBIE + 1, "/start") assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected, texts.start.no_access] async def test_revoke_with_confirmation_cuts_access(tg: TgClient, texts: Texts) -> None: await tg.send(NEWBIE, f"/start {await invite_token(tg)}") tg.session.requests.clear() await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE, tg_id=NEWBIE).pack()) await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE, tg_id=NEWBIE).pack()) confirm, done = tg.session.sent(EditMessageText) assert confirm.text == texts.members.confirm.format(name=f"u{NEWBIE}") assert done.text.startswith(texts.members.revoked.format(name=f"u{NEWBIE}")) tg.session.requests.clear() await tg.send(NEWBIE, texts.menu.commit) assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access] async def test_member_cannot_use_admin_actions(tg: TgClient, texts: Texts) -> None: await tg.send(NEWBIE, f"/start {await invite_token(tg)}") tg.session.requests.clear() await tg.send(NEWBIE, texts.menu.members) await tg.press(NEWBIE, MembersCb(action=MembersAction.INVITE).pack()) assert tg.session.sent(SendMessage) == [] and tg.session.sent(SendPhoto) == [] assert len(tg.session.sent(AnswerCallbackQuery)) == 1 async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None: await tg.press(ADMIN, MembersCb(action=MembersAction.LIST).pack()) [edit] = tg.session.sent(EditMessageText) assert edit.text.startswith(texts.members.title.format(count=1))