feat: add trusted groups functionality for duty information
- Introduced a new `trusted_groups` table to store groups authorized to receive duty information. - Implemented functions to add, remove, and check trusted groups in the database. - Enhanced command handlers to manage trusted groups, including `/trust_group` and `/untrust_group` commands for admin users. - Updated internationalization messages to support new commands and group status notifications. - Added unit tests for trusted groups repository functions to ensure correct behavior and data integrity.
This commit is contained in:
@@ -144,12 +144,13 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", 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)
|
||||
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(
|
||||
@@ -178,13 +179,14 @@ async def test_update_group_pin_delete_message_raises_bad_request_still_schedule
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", 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)
|
||||
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()
|
||||
@@ -200,10 +202,11 @@ async def test_update_group_pin_no_message_id_skips():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
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()
|
||||
|
||||
|
||||
@@ -219,11 +222,12 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -246,15 +250,16 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
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)
|
||||
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()
|
||||
@@ -278,14 +283,15 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", False):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
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)
|
||||
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(
|
||||
@@ -331,16 +337,41 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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."""
|
||||
@@ -363,20 +394,21 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep
|
||||
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_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)
|
||||
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
|
||||
@@ -402,15 +434,16 @@ async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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()
|
||||
@@ -439,18 +472,19 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not
|
||||
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_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)
|
||||
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")
|
||||
@@ -474,10 +508,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
|
||||
)
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -518,12 +553,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -541,12 +577,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -564,16 +601,40 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -622,11 +683,67 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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
|
||||
@@ -653,13 +770,14 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
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)
|
||||
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
|
||||
@@ -710,3 +828,182 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
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")
|
||||
|
||||
84
tests/test_trusted_groups_repository.py
Normal file
84
tests/test_trusted_groups_repository.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for trusted_groups repository functions."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from duty_teller.db.models import Base
|
||||
from duty_teller.db.repository import (
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
get_all_trusted_group_ids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""In-memory SQLite session with all tables (including trusted_groups)."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_is_trusted_group_empty_returns_false(session):
|
||||
"""is_trusted_group returns False when no record exists."""
|
||||
assert is_trusted_group(session, 100) is False
|
||||
assert is_trusted_group(session, 200) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_creates_record(session):
|
||||
"""add_trusted_group creates a record and returns TrustedGroup."""
|
||||
record = add_trusted_group(session, 100, added_by_user_id=12345)
|
||||
assert record.chat_id == 100
|
||||
assert record.added_by_user_id == 12345
|
||||
assert record.added_at is not None
|
||||
|
||||
|
||||
def test_is_trusted_group_after_add_returns_true(session):
|
||||
"""is_trusted_group returns True after add_trusted_group."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
assert is_trusted_group(session, 101) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_without_added_by_user_id(session):
|
||||
"""add_trusted_group accepts added_by_user_id None."""
|
||||
record = add_trusted_group(session, 200, added_by_user_id=None)
|
||||
assert record.chat_id == 200
|
||||
assert record.added_by_user_id is None
|
||||
|
||||
|
||||
def test_remove_trusted_group_removes_record(session):
|
||||
"""remove_trusted_group removes the record."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
remove_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is False
|
||||
|
||||
|
||||
def test_remove_trusted_group_idempotent(session):
|
||||
"""remove_trusted_group on non-existent chat_id does not raise."""
|
||||
remove_trusted_group(session, 999)
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_empty(session):
|
||||
"""get_all_trusted_group_ids returns empty list when no trusted groups."""
|
||||
assert get_all_trusted_group_ids(session) == []
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_returns_added_chats(session):
|
||||
"""get_all_trusted_group_ids returns all trusted chat_ids."""
|
||||
add_trusted_group(session, 10)
|
||||
add_trusted_group(session, 20)
|
||||
add_trusted_group(session, 30)
|
||||
ids = get_all_trusted_group_ids(session)
|
||||
assert set(ids) == {10, 20, 30}
|
||||
Reference in New Issue
Block a user