- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
1120 lines
49 KiB
Python
1120 lines
49 KiB
Python
"""Tests for duty_teller.handlers.group_duty_pin (sync wrappers, update_group_pin, pin_duty_cmd)."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from telegram.error import BadRequest, Forbidden
|
|
from telegram.constants import ChatMemberStatus
|
|
|
|
import duty_teller.config as config
|
|
from duty_teller.handlers import group_duty_pin as mod
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def no_mini_app_url():
|
|
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
|
|
with patch.object(config, "BOT_USERNAME", ""):
|
|
yield
|
|
|
|
|
|
class TestSyncWrappers:
|
|
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
|
|
|
def test_get_duty_message_text_sync(self):
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
with patch.object(mod, "get_duty_message_text", return_value="Duty text"):
|
|
result = mod._get_duty_message_text_sync("en")
|
|
assert result == "Duty text"
|
|
mock_scope.assert_called_once()
|
|
|
|
def test_sync_save_pin(self):
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
with patch.object(mod, "save_pin") as mock_save:
|
|
mod._sync_save_pin(100, 42)
|
|
mock_save.assert_called_once_with(mock_session, 100, 42)
|
|
|
|
def test_sync_delete_pin(self):
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
with patch.object(mod, "delete_pin") as mock_delete:
|
|
mod._sync_delete_pin(200)
|
|
mock_delete.assert_called_once_with(mock_session, 200)
|
|
|
|
def test_sync_get_message_id(self):
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
with patch.object(mod, "get_message_id", return_value=99):
|
|
result = mod._sync_get_message_id(300)
|
|
assert result == 99
|
|
|
|
def test_get_all_pin_chat_ids_sync(self):
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
with patch.object(mod, "get_all_pin_chat_ids", return_value=[10, 20]):
|
|
result = mod._get_all_pin_chat_ids_sync()
|
|
assert result == [10, 20]
|
|
|
|
def test_get_next_shift_end_sync(self):
|
|
"""_get_next_shift_end_sync: uses session_scope and get_next_shift_end_utc."""
|
|
with patch.object(mod, "session_scope") as mock_scope:
|
|
mock_session = MagicMock()
|
|
mock_scope.return_value.__enter__.return_value = mock_session
|
|
mock_scope.return_value.__exit__.return_value = None
|
|
when = datetime(2026, 2, 22, 9, 0, tzinfo=timezone.utc)
|
|
with patch.object(mod, "get_next_shift_end_utc", return_value=when):
|
|
result = mod._get_next_shift_end_sync()
|
|
assert result == when
|
|
mock_scope.assert_called_once()
|
|
|
|
|
|
# --- _schedule_next_update ---
|
|
|
|
|
|
def test_get_contact_button_markup_empty_username_returns_none():
|
|
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
|
|
with patch.object(config, "BOT_USERNAME", ""):
|
|
assert mod._get_contact_button_markup("en") is None
|
|
|
|
|
|
def test_get_contact_button_markup_returns_markup_when_username_set():
|
|
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
|
from telegram import InlineKeyboardMarkup
|
|
|
|
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
|
config, "MINI_APP_SHORT_NAME", ""
|
|
):
|
|
with patch.object(mod, "t", return_value="View contacts"):
|
|
result = mod._get_contact_button_markup("en")
|
|
assert result is not None
|
|
assert isinstance(result, InlineKeyboardMarkup)
|
|
assert len(result.inline_keyboard) == 1
|
|
assert len(result.inline_keyboard[0]) == 1
|
|
btn = result.inline_keyboard[0][0]
|
|
assert btn.text == "View contacts"
|
|
assert btn.url.startswith("https://t.me/")
|
|
assert "startapp=duty" in btn.url
|
|
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
|
|
|
|
|
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
|
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
|
from telegram import InlineKeyboardMarkup
|
|
|
|
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
|
config, "MINI_APP_SHORT_NAME", "DutyApp"
|
|
):
|
|
with patch.object(mod, "t", return_value="View contacts"):
|
|
result = mod._get_contact_button_markup("en")
|
|
assert result is not None
|
|
assert isinstance(result, InlineKeyboardMarkup)
|
|
btn = result.inline_keyboard[0][0]
|
|
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schedule_next_update_job_queue_none_returns_early():
|
|
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
|
application = MagicMock()
|
|
application.job_queue = None
|
|
with patch.object(mod, "logger") as mock_logger:
|
|
await mod._schedule_next_update(application, 123, datetime.now(timezone.utc))
|
|
mock_logger.warning.assert_called_once()
|
|
assert "Job queue not available" in mock_logger.warning.call_args[0][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schedule_next_update_when_utc_not_none_runs_once_with_delay():
|
|
"""_schedule_next_update: when_utc set -> remove old jobs, run_once with delay."""
|
|
application = MagicMock()
|
|
job_queue = MagicMock()
|
|
job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
job_queue.run_once = MagicMock()
|
|
application.job_queue = job_queue
|
|
when_utc = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=1)
|
|
|
|
await mod._schedule_next_update(application, 456, when_utc)
|
|
|
|
job_queue.get_jobs_by_name.assert_called_once_with("duty_pin_456")
|
|
job_queue.run_once.assert_called_once()
|
|
call_kw = job_queue.run_once.call_args[1]
|
|
assert call_kw["data"] == {"chat_id": 456}
|
|
assert call_kw["name"] == "duty_pin_456"
|
|
assert call_kw["when"].total_seconds() >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
|
|
"""_schedule_next_update: when_utc None -> run_once with RETRY_WHEN_NO_DUTY_MINUTES."""
|
|
application = MagicMock()
|
|
job_queue = MagicMock()
|
|
job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
job_queue.run_once = MagicMock()
|
|
application.job_queue = job_queue
|
|
|
|
await mod._schedule_next_update(application, 789, None)
|
|
|
|
job_queue.run_once.assert_called_once()
|
|
call_kw = job_queue.run_once.call_args[1]
|
|
assert call_kw["when"] == timedelta(minutes=mod.RETRY_WHEN_NO_DUTY_MINUTES)
|
|
assert call_kw["data"] == {"chat_id": 789}
|
|
assert call_kw["name"] == "duty_pin_789"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
|
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 999
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 123}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.bot.delete_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod,
|
|
"_sync_get_pin_refresh_data",
|
|
return_value=(1, "Current duty", None),
|
|
):
|
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
await mod.update_group_pin(context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=123, text="Current duty", reply_markup=None
|
|
)
|
|
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=123, message_id=999, disable_notification=False
|
|
)
|
|
mock_save.assert_called_once_with(123, 999)
|
|
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
|
|
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 999
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 123}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.bot.delete_message = AsyncMock(
|
|
side_effect=BadRequest("Message to delete not found")
|
|
)
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod,
|
|
"_sync_get_pin_refresh_data",
|
|
return_value=(1, "Current duty", None),
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(mod, "logger") as mock_logger:
|
|
await mod.update_group_pin(context)
|
|
mock_save.assert_called_once_with(123, 999)
|
|
mock_schedule.assert_called_once_with(context.application, 123, None)
|
|
mock_logger.warning.assert_called_once()
|
|
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_no_message_id_skips():
|
|
"""update_group_pin: when no pin record (message_id None), does not send_message."""
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 456}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock()
|
|
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
|
):
|
|
await mod.update_group_pin(context)
|
|
context.bot.send_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called():
|
|
"""update_group_pin: send_message raises BadRequest -> no unpin/pin/save, _schedule_next_update still called."""
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 111}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
await mod.update_group_pin(context)
|
|
context.bot.unpin_chat_message.assert_not_called()
|
|
context.bot.pin_chat_message.assert_not_called()
|
|
mock_schedule.assert_called_once_with(context.application, 111, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_repin_raises_still_schedules_next():
|
|
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 888
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 222}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.unpin_chat_message = AsyncMock(
|
|
side_effect=Forbidden("Not enough rights")
|
|
)
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
|
|
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(mod, "logger") as mock_logger:
|
|
await mod.update_group_pin(context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=222, text="Text", reply_markup=None
|
|
)
|
|
mock_save.assert_not_called()
|
|
mock_logger.warning.assert_called_once()
|
|
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
|
|
mock_schedule.assert_called_once_with(context.application, 222, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
|
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 777
|
|
context = MagicMock()
|
|
context.job = MagicMock()
|
|
context.job.data = {"chat_id": 333}
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.bot.delete_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
|
|
with patch.object(config, "DUTY_PIN_NOTIFY", False):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
await mod.update_group_pin(context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=333, text="Text", reply_markup=None
|
|
)
|
|
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=333, message_id=777, disable_notification=True
|
|
)
|
|
mock_save.assert_called_once_with(333, 777)
|
|
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
|
|
mock_schedule.assert_called_once_with(context.application, 333, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_group_only_reply():
|
|
"""pin_duty_cmd in private chat -> reply group_only."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "private"
|
|
update.effective_chat.id = 1
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Group only"
|
|
await mod.pin_duty_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Group only")
|
|
mock_t.assert_called_with("en", "pin_duty.group_only")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
|
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Pinned"
|
|
await mod.pin_duty_cmd(update, context)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=100, message_id=5, disable_notification=True
|
|
)
|
|
update.message.reply_text.assert_called_once_with("Pinned")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_untrusted_group_rejects():
|
|
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Not authorized"
|
|
await mod.pin_duty_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Not authorized")
|
|
mock_t.assert_called_with("en", "group.not_trusted")
|
|
context.bot.send_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
|
|
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 42
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
):
|
|
with patch(
|
|
"duty_teller.handlers.group_duty_pin.t"
|
|
) as mock_t:
|
|
mock_t.return_value = "Pinned"
|
|
await mod.pin_duty_cmd(update, context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=100, text="Duty text", reply_markup=None
|
|
)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=100, message_id=42, disable_notification=True
|
|
)
|
|
mock_save.assert_called_once_with(100, 42)
|
|
update.message.reply_text.assert_called_once_with("Pinned")
|
|
mock_t.assert_called_with("en", "pin_duty.pinned")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
|
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
|
|
context.application = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
with patch(
|
|
"duty_teller.handlers.group_duty_pin.t"
|
|
) as mock_t:
|
|
mock_t.return_value = "Failed"
|
|
await mod.pin_duty_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Failed")
|
|
mock_t.assert_called_with("en", "pin_duty.failed")
|
|
mock_save.assert_not_called()
|
|
mock_schedule.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
|
|
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 43
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
):
|
|
with patch(
|
|
"duty_teller.handlers.group_duty_pin.t"
|
|
) as mock_t:
|
|
mock_t.return_value = "Make me admin to pin"
|
|
await mod.pin_duty_cmd(update, context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=100, text="Duty", reply_markup=None
|
|
)
|
|
mock_save.assert_called_once_with(100, 43)
|
|
update.message.reply_text.assert_called_once_with("Make me admin to pin")
|
|
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_duty_cmd_pin_raises_replies_failed():
|
|
"""pin_duty_cmd: pin_chat_message raises -> reply pin_duty.failed."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.pin_chat_message = AsyncMock(
|
|
side_effect=BadRequest("Not enough rights")
|
|
)
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Failed to pin"
|
|
await mod.pin_duty_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Failed to pin")
|
|
mock_t.assert_called_with("en", "pin_duty.failed")
|
|
|
|
|
|
# --- refresh_pin_cmd ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_cmd_private_replies_group_only():
|
|
"""refresh_pin_cmd in private chat -> reply refresh_pin.group_only."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "private"
|
|
update.effective_chat.id = 1
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Group only"
|
|
await mod.refresh_pin_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Group only")
|
|
mock_t.assert_called_with("en", "refresh_pin.group_only")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_cmd_group_updated_replies_updated():
|
|
"""refresh_pin_cmd in group with pin record -> _refresh_pin_for_chat returns updated -> reply refresh_pin.updated."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
|
):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Updated"
|
|
await mod.refresh_pin_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Updated")
|
|
mock_t.assert_called_with("en", "refresh_pin.updated")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_cmd_group_no_message_replies_no_message():
|
|
"""refresh_pin_cmd: no pin record -> _refresh_pin_for_chat returns no_message -> reply refresh_pin.no_message."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
|
):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "No message"
|
|
await mod.refresh_pin_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("No message")
|
|
mock_t.assert_called_with("en", "refresh_pin.no_message")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
|
|
"""refresh_pin_cmd: _refresh_pin_for_chat returns failed (e.g. BadRequest) -> reply refresh_pin.failed."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
|
):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Failed"
|
|
await mod.refresh_pin_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Failed")
|
|
mock_t.assert_called_with("en", "refresh_pin.failed")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_cmd_untrusted_group_rejects():
|
|
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
|
with patch.object(
|
|
mod, "_refresh_pin_for_chat", AsyncMock()
|
|
) as mock_refresh:
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Not authorized"
|
|
await mod.refresh_pin_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Not authorized")
|
|
mock_t.assert_called_with("en", "group.not_trusted")
|
|
mock_refresh.assert_not_called()
|
|
|
|
|
|
# --- my_chat_member_handler ---
|
|
|
|
|
|
def _make_my_chat_member_update(
|
|
old_status,
|
|
new_status,
|
|
chat_id=100,
|
|
chat_type="group",
|
|
bot_id=999,
|
|
from_user_id=1,
|
|
):
|
|
"""Build Update with my_chat_member for group add/remove."""
|
|
update = MagicMock()
|
|
update.effective_user = MagicMock()
|
|
update.effective_user.id = from_user_id
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.id = chat_id
|
|
update.effective_chat.type = chat_type
|
|
update.my_chat_member = MagicMock()
|
|
update.my_chat_member.old_chat_member = MagicMock()
|
|
update.my_chat_member.old_chat_member.status = old_status
|
|
update.my_chat_member.new_chat_member = MagicMock()
|
|
update.my_chat_member.new_chat_member.status = new_status
|
|
update.my_chat_member.new_chat_member.user = MagicMock()
|
|
update.my_chat_member.new_chat_member.user.id = bot_id
|
|
return update
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
|
|
"""my_chat_member_handler: bot added to group -> send_message, pin, save_pin, schedule."""
|
|
update = _make_my_chat_member_update(
|
|
old_status=ChatMemberStatus.LEFT,
|
|
new_status=ChatMemberStatus.ADMINISTRATOR,
|
|
chat_id=200,
|
|
bot_id=999,
|
|
)
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.id = 999
|
|
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin"):
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
|
await mod.my_chat_member_handler(update, context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=200, text="Duty text", reply_markup=None
|
|
)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=200, message_id=42, disable_notification=True
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
|
|
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
|
|
update = _make_my_chat_member_update(
|
|
old_status=ChatMemberStatus.LEFT,
|
|
new_status=ChatMemberStatus.ADMINISTRATOR,
|
|
chat_id=200,
|
|
bot_id=999,
|
|
)
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.id = 999
|
|
context.bot.send_message = AsyncMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Not authorized"
|
|
await mod.my_chat_member_handler(update, context)
|
|
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
|
|
mock_t.assert_called_with("en", "group.not_trusted")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_my_chat_member_handler_trusted_group_sends_duty():
|
|
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
|
|
update = _make_my_chat_member_update(
|
|
old_status=ChatMemberStatus.LEFT,
|
|
new_status=ChatMemberStatus.ADMINISTRATOR,
|
|
chat_id=200,
|
|
bot_id=999,
|
|
)
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.id = 999
|
|
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin"):
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
|
await mod.my_chat_member_handler(update, context)
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=200, text="Duty text", reply_markup=None
|
|
)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=200, message_id=42, disable_notification=True
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
|
|
"""my_chat_member_handler: pin_chat_message raises -> send_message could_not_pin_make_admin."""
|
|
update = _make_my_chat_member_update(
|
|
old_status=ChatMemberStatus.LEFT,
|
|
new_status=ChatMemberStatus.MEMBER,
|
|
chat_id=201,
|
|
bot_id=999,
|
|
)
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.id = 999
|
|
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=43))
|
|
context.bot.pin_chat_message = AsyncMock(side_effect=BadRequest("Cannot pin"))
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
|
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
|
with patch.object(mod, "_sync_save_pin"):
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
|
with patch(
|
|
"duty_teller.handlers.group_duty_pin.t"
|
|
) as mock_t:
|
|
mock_t.return_value = "Make me admin to pin"
|
|
await mod.my_chat_member_handler(update, context)
|
|
assert context.bot.send_message.call_count >= 2
|
|
pin_hint_calls = [
|
|
c
|
|
for c in context.bot.send_message.call_args_list
|
|
if c[1].get("text") == "Make me admin to pin"
|
|
]
|
|
assert len(pin_hint_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
|
|
"""my_chat_member_handler: bot removed -> _sync_delete_pin, remove jobs by name."""
|
|
update = _make_my_chat_member_update(
|
|
old_status=ChatMemberStatus.ADMINISTRATOR,
|
|
new_status=ChatMemberStatus.LEFT,
|
|
chat_id=202,
|
|
bot_id=999,
|
|
)
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.id = 999
|
|
context.application = MagicMock()
|
|
job_queue = MagicMock()
|
|
mock_job = MagicMock()
|
|
job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
|
context.application.job_queue = job_queue
|
|
|
|
with patch.object(mod, "_sync_delete_pin"):
|
|
await mod.my_chat_member_handler(update, context)
|
|
job_queue.get_jobs_by_name.assert_called_once_with("duty_pin_202")
|
|
mock_job.schedule_removal.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
|
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
|
|
application = MagicMock()
|
|
application.job_queue = MagicMock()
|
|
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
application.job_queue.run_once = MagicMock()
|
|
|
|
with patch.object(mod, "_get_all_pin_chat_ids_sync", return_value=[10, 20]):
|
|
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
) as mock_schedule:
|
|
await mod.restore_group_pin_jobs(application)
|
|
assert mock_schedule.call_count == 2
|
|
mock_schedule.assert_any_call(application, 10, None)
|
|
mock_schedule.assert_any_call(application, 20, None)
|
|
|
|
|
|
# --- _refresh_pin_for_chat untrusted ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_pin_for_chat_untrusted_removes_pin():
|
|
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.delete_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
mock_job = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
|
|
|
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=11):
|
|
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
|
|
result = await mod._refresh_pin_for_chat(context, 100)
|
|
assert result == "untrusted"
|
|
mock_delete_pin.assert_called_once_with(100)
|
|
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
|
"duty_pin_100"
|
|
)
|
|
mock_job.schedule_removal.assert_called_once()
|
|
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
|
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
|
|
|
|
|
|
# --- trust_group_cmd / untrust_group_cmd ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trust_group_cmd_non_admin_rejects():
|
|
"""trust_group_cmd: non-admin -> reply import.admin_only."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
update.effective_user.id = 111
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Admin only"
|
|
await mod.trust_group_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Admin only")
|
|
mock_t.assert_called_with("en", "import.admin_only")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trust_group_cmd_admin_adds_group():
|
|
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
update.effective_user.id = 111
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
new_msg = MagicMock()
|
|
new_msg.message_id = 50
|
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
|
context.bot.pin_chat_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
|
context.application.job_queue.run_once = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
|
with patch.object(mod, "_sync_trust_group", return_value=False):
|
|
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
|
with patch.object(
|
|
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
|
):
|
|
with patch.object(mod, "_sync_save_pin") as mock_save:
|
|
with patch.object(
|
|
mod, "_get_next_shift_end_sync", return_value=None
|
|
):
|
|
with patch.object(
|
|
mod, "_schedule_next_update", AsyncMock()
|
|
):
|
|
with patch(
|
|
"duty_teller.handlers.group_duty_pin.t"
|
|
) as mock_t:
|
|
mock_t.return_value = "Added"
|
|
with patch.object(
|
|
config, "DUTY_PIN_NOTIFY", False
|
|
):
|
|
await mod.trust_group_cmd(update, context)
|
|
update.message.reply_text.assert_any_call("Added")
|
|
mock_t.assert_any_call("en", "trust_group.added")
|
|
context.bot.send_message.assert_called_once_with(
|
|
chat_id=100, text="Duty text", reply_markup=None
|
|
)
|
|
context.bot.pin_chat_message.assert_called_once_with(
|
|
chat_id=100, message_id=50, disable_notification=True
|
|
)
|
|
mock_save.assert_called_once_with(100, 50)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
|
|
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
update.effective_user.id = 111
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.send_message = AsyncMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
|
with patch.object(mod, "_sync_trust_group", return_value=True):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Already trusted"
|
|
await mod.trust_group_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Already trusted")
|
|
mock_t.assert_called_with("en", "trust_group.already_trusted")
|
|
context.bot.send_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_untrust_group_cmd_removes_group():
|
|
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
update.effective_user.id = 111
|
|
context = MagicMock()
|
|
context.bot = MagicMock()
|
|
context.bot.unpin_chat_message = AsyncMock()
|
|
context.bot.delete_message = AsyncMock()
|
|
context.application = MagicMock()
|
|
mock_job = MagicMock()
|
|
context.application.job_queue = MagicMock()
|
|
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
|
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Removed"
|
|
await mod.untrust_group_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Removed")
|
|
mock_t.assert_called_with("en", "untrust_group.removed")
|
|
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
|
"duty_pin_100"
|
|
)
|
|
mock_job.schedule_removal.assert_called_once()
|
|
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
|
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
|
|
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
|
|
update = MagicMock()
|
|
update.message = MagicMock()
|
|
update.message.reply_text = AsyncMock()
|
|
update.effective_chat = MagicMock()
|
|
update.effective_chat.type = "group"
|
|
update.effective_chat.id = 100
|
|
update.effective_user = MagicMock()
|
|
context = MagicMock()
|
|
|
|
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
|
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
|
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
|
|
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
|
mock_t.return_value = "Not trusted"
|
|
await mod.untrust_group_cmd(update, context)
|
|
update.message.reply_text.assert_called_once_with("Not trusted")
|
|
mock_t.assert_called_with("en", "untrust_group.not_trusted")
|