"""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 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 --- @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") 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") 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") 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") 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") 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") 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") 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") 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")