Skip to content

Commit 8ad6a22

Browse files
committed
feat: achieve 100% test coverage with story detection and timeout handling
- Add has_story() function to detect forwarded stories in anti-spam checks - Integrate story detection into handle_new_user_spam violation logic - Add comprehensive tests for story detection (TestHasStory) - Add urlparse exception handling test in TestUrlWhitelist - Add test case for messages with shared stories (test_deletes_message_with_story) - Add check_command_complete_profile_whitelisted test with unverify button - Add timeout handling tests for check, check_forwarded, and warn_callback - Import TimedOut exception from telegram.error for error handling - Update README test coverage metrics: 99% → 100% (1,181 statements) - Update test count: 328 → 336 tests (all passing) - Update handler coverage: anti_spam.py 98% → 100%, check.py 95% → 100% All modules now achieve 100% code coverage with complete error handling and edge case testing for the Telegram bot's profile verification, captcha, and anti-spam protection systems.
1 parent 1984c32 commit 8ad6a22

4 files changed

Lines changed: 157 additions & 8 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,12 @@ uv run pytest -v
150150
### Test Coverage
151151

152152
The project maintains comprehensive test coverage:
153-
- **Coverage**: 99% (1,179 statements, 9 missed)
154-
- **Tests**: 328 total
155-
- **Pass Rate**: 100% (328/328 passed)
156-
- **All modules**: 99% coverage including JobQueue scheduler integration, captcha verification, and anti-spam enforcement
153+
- **Coverage**: 100% (1,181 statements)
154+
- **Tests**: 336 total
155+
- **Pass Rate**: 100% (336/336 passed)
156+
- **All modules**: 100% coverage including JobQueue scheduler integration, captcha verification, and anti-spam enforcement
157157
- Services: `bot_info.py` (100%), `scheduler.py` (100%), `user_checker.py` (100%), `telegram_utils.py` (100%), `captcha_recovery.py` (100%)
158-
- Handlers: `anti_spam.py` (98%), `captcha.py` (100%), `check.py` (95%), `dm.py` (100%), `message.py` (100%), `topic_guard.py` (100%), `verify.py` (100%)
158+
- Handlers: `anti_spam.py` (100%), `captcha.py` (100%), `check.py` (100%), `dm.py` (100%), `message.py` (100%), `topic_guard.py` (100%), `verify.py` (100%)
159159
- Database: `service.py` (100%), `models.py` (100%)
160160
- Config: `config.py` (100%)
161161
- Constants: `constants.py` (100%)

src/bot/handlers/anti_spam.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ def has_external_reply(message: Message) -> bool:
7676
return message.external_reply is not None
7777

7878

79+
def has_story(message: Message) -> bool:
80+
"""
81+
Check if a message contains a forwarded story.
82+
83+
Stories can be shared/forwarded into chats and may be used as a spam vector.
84+
85+
Args:
86+
message: Telegram message to check.
87+
88+
Returns:
89+
bool: True if message contains a story.
90+
"""
91+
return message.story is not None
92+
93+
7994
def extract_urls(message: Message) -> list[str]:
8095
"""
8196
Extract all URLs from a message.
@@ -218,13 +233,13 @@ async def handle_new_user_spam(
218233
user_mention = get_user_mention(user)
219234

220235
# Check for violations (forwarded message or non-whitelisted link or external reply)
221-
if not (is_forwarded(msg) or has_non_whitelisted_link(msg) or has_external_reply(msg)):
236+
if not (is_forwarded(msg) or has_non_whitelisted_link(msg) or has_external_reply(msg) or has_story(msg)):
222237
return # Not a violation
223238

224239
logger.info(
225240
f"Probation violation detected: user_id={user.id}, "
226241
f"forwarded={is_forwarded(msg)}, has_non_whitelisted_link={has_non_whitelisted_link(msg)}, "
227-
f"external_reply={has_external_reply(msg)}"
242+
f"external_reply={has_external_reply(msg)}, has_story={has_story(msg)}"
228243
)
229244

230245
# 1. Delete the violating message

tests/test_anti_spam.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
has_external_reply,
1313
has_link,
1414
has_non_whitelisted_link,
15+
has_story,
1516
is_forwarded,
1617
is_url_whitelisted,
1718
)
@@ -109,6 +110,24 @@ def test_no_external_reply_returns_false(self):
109110
assert has_external_reply(msg) is False
110111

111112

113+
class TestHasStory:
114+
"""Tests for the has_story helper function."""
115+
116+
def test_story_detected(self):
117+
"""Test that message with story is detected."""
118+
msg = MagicMock(spec=Message)
119+
msg.story = MagicMock() # Any non-None value indicates a shared story
120+
121+
assert has_story(msg) is True
122+
123+
def test_no_story_returns_false(self):
124+
"""Test that message without story returns False."""
125+
msg = MagicMock(spec=Message)
126+
msg.story = None
127+
128+
assert has_story(msg) is False
129+
130+
112131
class TestUrlWhitelist:
113132
"""Tests for URL whitelist functionality."""
114133

@@ -171,6 +190,11 @@ def test_malformed_url_exception_handled(self):
171190
# This URL has an invalid character that may cause parsing issues
172191
assert is_url_whitelisted("\x00invalid") is False
173192

193+
def test_urlparse_exception_returns_false(self):
194+
"""Test that exceptions during URL parsing return False."""
195+
with patch("bot.handlers.anti_spam.urlparse", side_effect=ValueError("parse error")):
196+
assert is_url_whitelisted("https://github.com/user/repo") is False
197+
174198

175199
class TestExtractUrls:
176200
"""Tests for URL extraction."""
@@ -274,9 +298,10 @@ def mock_update(self):
274298
update.effective_chat = MagicMock(spec=Chat)
275299
update.effective_chat.id = -100123456 # group_id from settings
276300

277-
# Default: not forwarded, no links, no external reply
301+
# Default: not forwarded, no links, no external reply, no story
278302
update.message.forward_origin = None
279303
update.message.external_reply = None
304+
update.message.story = None
280305
update.message.entities = None
281306
update.message.caption_entities = None
282307
update.message.text = None
@@ -621,6 +646,31 @@ async def test_deletes_message_with_external_reply(
621646

622647
mock_update.message.delete.assert_called_once()
623648

649+
@pytest.mark.asyncio
650+
async def test_deletes_message_with_story(
651+
self, mock_update, mock_context, mock_settings
652+
):
653+
"""Test that messages with shared stories are deleted."""
654+
mock_update.message.story = MagicMock() # Any non-None value
655+
656+
mock_record = MagicMock()
657+
mock_record.joined_at = datetime.now(UTC)
658+
659+
updated_record = MagicMock()
660+
updated_record.violation_count = 1
661+
662+
mock_db = MagicMock()
663+
mock_db.get_new_user_probation.return_value = mock_record
664+
mock_db.increment_new_user_violation.return_value = updated_record
665+
666+
with (
667+
patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings),
668+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
669+
):
670+
await handle_new_user_spam(mock_update, mock_context)
671+
672+
mock_update.message.delete.assert_called_once()
673+
624674
@pytest.mark.asyncio
625675
async def test_ignores_update_without_message(self, mock_context, mock_settings):
626676
"""Test that update without message is ignored."""

tests/test_check.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import pytest
6+
from telegram.error import TimedOut
67

78
from bot.handlers.check import (
89
handle_check_command,
@@ -116,6 +117,39 @@ async def test_check_command_complete_profile(
116117
assert "✅" in call_args.args[0]
117118
assert call_args.kwargs.get("reply_markup") is None
118119

120+
async def test_check_command_complete_profile_whitelisted(
121+
self, mock_update, mock_context, mock_settings
122+
):
123+
"""Shows complete profile with unverify button when user is whitelisted."""
124+
mock_context.args = ["555666"]
125+
126+
complete_result = ProfileCheckResult(
127+
has_profile_photo=True, has_username=True
128+
)
129+
130+
mock_db = MagicMock()
131+
mock_db.is_user_photo_whitelisted.return_value = True
132+
133+
with (
134+
patch("bot.handlers.check.get_settings", return_value=mock_settings),
135+
patch(
136+
"bot.handlers.check.check_user_profile",
137+
return_value=complete_result,
138+
),
139+
patch("bot.handlers.check.get_database", return_value=mock_db),
140+
):
141+
await handle_check_command(mock_update, mock_context)
142+
143+
mock_update.message.reply_text.assert_called_once()
144+
call_args = mock_update.message.reply_text.call_args
145+
assert "555666" in call_args.args[0]
146+
assert "✅" in call_args.args[0]
147+
keyboard = call_args.kwargs.get("reply_markup")
148+
assert keyboard is not None
149+
buttons = keyboard.inline_keyboard[0]
150+
assert any("unverify:555666" in btn.callback_data for btn in buttons)
151+
assert any("Unverify User" in btn.text for btn in buttons)
152+
119153
async def test_check_command_incomplete_profile(
120154
self, mock_update, mock_context, mock_settings
121155
):
@@ -187,6 +221,17 @@ async def test_check_command_get_chat_error(self, mock_update, mock_context):
187221
call_args = mock_update.message.reply_text.call_args
188222
assert "Gagal memeriksa" in call_args.args[0]
189223

224+
async def test_check_command_timeout(self, mock_update, mock_context):
225+
"""Handles TimedOut error gracefully."""
226+
mock_context.args = ["555666"]
227+
mock_context.bot.get_chat.side_effect = TimedOut()
228+
229+
await handle_check_command(mock_update, mock_context)
230+
231+
mock_update.message.reply_text.assert_called_once()
232+
call_args = mock_update.message.reply_text.call_args
233+
assert "timeout" in call_args.args[0].lower()
234+
190235

191236
class TestHandleCheckForwardedMessage:
192237
async def test_check_forwarded_non_admin(self, mock_update, mock_context):
@@ -308,6 +353,21 @@ async def test_check_forwarded_error(self, mock_update, mock_context, mock_setti
308353
call_args = mock_update.message.reply_text.call_args
309354
assert "Gagal memeriksa" in call_args.args[0]
310355

356+
async def test_check_forwarded_timeout(self, mock_update, mock_context):
357+
"""Handles TimedOut error gracefully."""
358+
forwarded_user = MagicMock()
359+
forwarded_user.id = 555666
360+
forwarded_user.full_name = "Forwarded User"
361+
mock_update.message.forward_from = forwarded_user
362+
363+
mock_context.bot.get_chat.side_effect = TimedOut()
364+
365+
await handle_check_forwarded_message(mock_update, mock_context)
366+
367+
mock_update.message.reply_text.assert_called_once()
368+
call_args = mock_update.message.reply_text.call_args
369+
assert "timeout" in call_args.args[0].lower()
370+
311371

312372
class TestHandleWarnCallback:
313373
async def test_warn_callback_non_admin(self, mock_context):
@@ -453,3 +513,27 @@ async def test_warn_callback_send_message_error(self, mock_context, mock_setting
453513
query.edit_message_text.assert_called_once()
454514
call_args = query.edit_message_text.call_args
455515
assert "Gagal mengirim" in call_args.args[0]
516+
517+
async def test_warn_callback_timeout(self, mock_context, mock_settings):
518+
"""Handles TimedOut error gracefully."""
519+
update = MagicMock()
520+
query = MagicMock()
521+
query.from_user = MagicMock()
522+
query.from_user.id = 12345
523+
query.from_user.full_name = "Admin User"
524+
query.data = "warn:555666:pu"
525+
query.answer = AsyncMock()
526+
query.edit_message_text = AsyncMock()
527+
update.callback_query = query
528+
529+
mock_chat = MagicMock()
530+
mock_chat.full_name = "Test User"
531+
mock_context.bot.get_chat.return_value = mock_chat
532+
mock_context.bot.send_message.side_effect = TimedOut()
533+
534+
with patch("bot.handlers.check.get_settings", return_value=mock_settings):
535+
await handle_warn_callback(update, mock_context)
536+
537+
query.edit_message_text.assert_called_once()
538+
call_args = query.edit_message_text.call_args
539+
assert "timeout" in call_args.args[0].lower()

0 commit comments

Comments
 (0)