From 3dd10c62c086e0fc0e5ac388d5e1ac42f6ab46c1 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 20 Mar 2026 02:16:09 +0700 Subject: [PATCH 1/2] feat: combine captcha verification with profile photo/username check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update captcha welcome message to inform users about profile requirements - Add profile completeness check (photo + username) in captcha callback handler - Reject verification with alert if profile is incomplete, keeping timeout active - Fix double query.answer() bug — each code path now answers exactly once - Add CAPTCHA_INCOMPLETE_PROFILE_MESSAGE constant for rejection alert - Update and add tests for new captcha+profile flow (553 tests, 99% coverage) --- src/bot/constants.py | 8 +- src/bot/handlers/captcha.py | 27 ++++- tests/test_captcha.py | 222 +++++++++++++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 14 deletions(-) diff --git a/src/bot/constants.py b/src/bot/constants.py index 2940f93..87833c6 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -103,8 +103,8 @@ def format_hours_display(hours: int) -> str: # Captcha verification message templates CAPTCHA_WELCOME_MESSAGE = ( "👋 Selamat datang {user_mention}!\n\n" - "Untuk memastikan kamu bukan robot, silakan klik tombol di bawah ini " - "dalam waktu {timeout} detik." + "Sebelum bergabung, pastikan kamu sudah memiliki *foto profil publik* dan *username*.\n" + "Setelah melengkapi profil, tekan tombol di bawah ini dalam waktu {timeout} detik." ) CAPTCHA_VERIFIED_MESSAGE = "✅ Terima kasih {user_mention}, verifikasi berhasil! Selamat bergabung." @@ -121,6 +121,10 @@ def format_hours_display(hours: int) -> str: "Silakan cek grup dan tekan tombol verifikasi." ) +CAPTCHA_INCOMPLETE_PROFILE_MESSAGE = ( + "❌ Lengkapi {missing_text} terlebih dahulu, lalu tekan tombol ini lagi." +) + CAPTCHA_FAILED_VERIFICATION_MESSAGE = "Gagal memverifikasi. Silakan coba lagi." # DM handler message templates diff --git a/src/bot/handlers/captcha.py b/src/bot/handlers/captcha.py index 75a22cf..236e7e4 100644 --- a/src/bot/handlers/captcha.py +++ b/src/bot/handlers/captcha.py @@ -21,14 +21,17 @@ from bot.constants import ( CAPTCHA_FAILED_VERIFICATION_MESSAGE, + CAPTCHA_INCOMPLETE_PROFILE_MESSAGE, CAPTCHA_VERIFIED_MESSAGE, CAPTCHA_WELCOME_MESSAGE, CAPTCHA_WRONG_USER_MESSAGE, + MISSING_ITEMS_SEPARATOR, RESTRICTED_PERMISSIONS, ) from bot.database.service import get_database from bot.group_config import GroupConfig, get_group_config_for_update, get_group_registry from bot.services.telegram_utils import get_user_mention, unrestrict_user +from bot.services.user_checker import check_user_profile logger = logging.getLogger(__name__) @@ -262,8 +265,6 @@ async def captcha_callback_handler( if not query or not query.data: return - await query.answer() - callback_user_id = query.from_user.id parts = query.data.split("_") target_user_id = int(parts[-1]) @@ -273,7 +274,6 @@ async def captcha_callback_handler( await query.answer(CAPTCHA_WRONG_USER_MESSAGE, show_alert=True) return - # Look up group config directly using group_id from callback data db = get_database() registry = get_group_registry() @@ -284,6 +284,21 @@ async def captcha_callback_handler( await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) return + try: + result = await check_user_profile(context.bot, query.from_user) + except Exception: + logger.error(f"Profile check failed during captcha for user {target_user_id}", exc_info=True) + await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) + return + + if not result.is_complete: + missing_text = MISSING_ITEMS_SEPARATOR.join(result.get_missing_items()) + await query.answer( + CAPTCHA_INCOMPLETE_PROFILE_MESSAGE.format(missing_text=missing_text), + show_alert=True, + ) + return + job_name = get_captcha_job_name(group_config.group_id, target_user_id) current_jobs = context.job_queue.get_jobs_by_name(job_name) for job in current_jobs: @@ -296,15 +311,15 @@ async def captcha_callback_handler( except Exception as e: logger.error(f"Failed to unrestrict user {target_user_id}: {e}") await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) - return # Stop execution here so user can retry + return db.remove_pending_captcha(target_user_id, group_config.group_id) - - # Start anti-spam probation for verified user db.start_new_user_probation(target_user_id, group_config.group_id) user_mention = get_user_mention(query.from_user) + await query.answer() + try: await query.edit_message_text( text=CAPTCHA_VERIFIED_MESSAGE.format(user_mention=user_mention), diff --git a/tests/test_captcha.py b/tests/test_captcha.py index f537b7a..814afe4 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -6,6 +6,7 @@ from bot.database.service import init_database, reset_database from bot.group_config import GroupConfig, GroupRegistry +from bot.services.user_checker import ProfileCheckResult from bot.handlers.captcha import ( captcha_callback_handler, captcha_timeout_callback, @@ -250,7 +251,10 @@ async def test_captcha_callback_verifies_correct_user( update = MagicMock() update.callback_query = query - with patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry): + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + ): await captcha_callback_handler(update, mock_context) query.answer.assert_called_once() @@ -279,6 +283,7 @@ async def test_captcha_callback_unrestricts_user( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), ): mock_unrestrict.return_value = AsyncMock() await captcha_callback_handler(update, mock_context) @@ -310,6 +315,7 @@ async def test_captcha_callback_deletes_message( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), ): await captcha_callback_handler(update, mock_context) @@ -338,10 +344,9 @@ async def test_wrong_user_rejected(self, mock_context, mock_registry, temp_db): ): await captcha_callback_handler(update, mock_context) - assert query.answer.call_count == 2 - second_call = query.answer.call_args_list[1] - assert "bukan untukmu" in second_call.args[0] - assert second_call.kwargs["show_alert"] is True + query.answer.assert_called_once() + assert "bukan untukmu" in query.answer.call_args.args[0] + assert query.answer.call_args.kwargs["show_alert"] is True mock_unrestrict.assert_not_called() assert db.get_pending_captcha(12345, -1001234567890) is not None @@ -390,6 +395,7 @@ async def test_cancels_timeout_job(self, mock_context, mock_registry, temp_db): with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), ): await captcha_callback_handler(update, mock_context) @@ -422,6 +428,7 @@ async def test_unrestrict_failure_stops_execution( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), ): mock_unrestrict.side_effect = Exception("Unrestrict failed") await captcha_callback_handler(update, mock_context) @@ -457,11 +464,81 @@ async def test_edit_message_failure_in_callback_continues_gracefully( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), ): await captcha_callback_handler(update, mock_context) assert db.get_pending_captcha(12345, -1001234567890) is None + async def test_incomplete_profile_blocks_verification( + self, mock_context, mock_registry, temp_db + ): + """Test that incomplete profile shows alert and does not verify.""" + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = None + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + incomplete_profile = ProfileCheckResult(has_profile_photo=True, has_username=False) + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", return_value=incomplete_profile), + ): + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once() + call_args = query.answer.call_args + assert "Lengkapi" in call_args.args[0] + assert "username" in call_args.args[0] + assert call_args.kwargs["show_alert"] is True + query.edit_message_text.assert_not_called() + assert db.get_pending_captcha(12345, -1001234567890) is not None + + async def test_profile_check_exception_shows_error( + self, mock_context, mock_registry, temp_db + ): + """Test that profile check exception shows failed verification alert.""" + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = "testuser" + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", side_effect=Exception("API error")), + ): + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once_with( + "Gagal memverifikasi. Silakan coba lagi.", show_alert=True + ) + query.edit_message_text.assert_not_called() + assert db.get_pending_captcha(12345, -1001234567890) is not None + async def test_unknown_group_in_callback_rejects( self, mock_context, mock_registry, temp_db ): @@ -478,10 +555,143 @@ async def test_unknown_group_in_callback_rejects( with patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry): await captcha_callback_handler(update, mock_context) - query.answer.assert_called_with( + query.answer.assert_called_once_with( "Gagal memverifikasi. Silakan coba lagi.", show_alert=True ) + async def test_captcha_callback_incomplete_profile_rejected( + self, mock_context, mock_registry, temp_db + ): + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = "testuser" + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik"]))), + patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, + ): + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once() + call_args = query.answer.call_args + assert call_args.kwargs["show_alert"] is True + assert "foto profil publik" in call_args.args[0] + mock_unrestrict.assert_not_called() + assert db.get_pending_captcha(12345, -1001234567890) is not None + mock_context.job_queue.get_jobs_by_name.assert_not_called() + + async def test_captcha_callback_incomplete_profile_both_missing( + self, mock_context, mock_registry, temp_db + ): + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = None + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik", "username"]))), + ): + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once() + call_args = query.answer.call_args + assert call_args.kwargs["show_alert"] is True + assert "foto profil publik" in call_args.args[0] + assert "username" in call_args.args[0] + + async def test_captcha_callback_profile_check_exception( + self, mock_context, mock_registry, temp_db + ): + from bot.constants import CAPTCHA_FAILED_VERIFICATION_MESSAGE + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = "testuser" + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", side_effect=Exception("API error")), + patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, + ): + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once_with(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) + mock_unrestrict.assert_not_called() + assert db.get_pending_captcha(12345, -1001234567890) is not None + mock_context.job_queue.get_jobs_by_name.assert_not_called() + + async def test_captcha_callback_incomplete_profile_timeout_not_cancelled( + self, mock_context, mock_registry, temp_db + ): + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + mock_job = MagicMock() + mock_job.schedule_removal = MagicMock() + mock_context.job_queue.get_jobs_by_name.return_value = [mock_job] + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = "testuser" + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik"]))), + ): + await captcha_callback_handler(update, mock_context) + + mock_context.job_queue.get_jobs_by_name.assert_not_called() + mock_job.schedule_removal.assert_not_called() + class TestGetHandlers: def test_get_handlers_returns_list(self): From 37a3c8c1fd40b96c9d9fd6fa9bf9ba4ca5e589d8 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 20 Mar 2026 02:29:10 +0700 Subject: [PATCH 2/2] fix: harden captcha callback handler and improve test quality - Add parse guard for malformed callback data (ValueError/IndexError) - Move timeout job cancellation after successful unrestrict + DB cleanup - Wrap DB finalization in try/except to prevent unanswered callbacks - Replace AsyncMock with ProfileCheckResult dataclass in all test mocks - Add tests for malformed callback data and unrestrict failure preserving timeout --- src/bot/handlers/captcha.py | 29 ++++++++++------ tests/test_captcha.py | 68 ++++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/bot/handlers/captcha.py b/src/bot/handlers/captcha.py index 236e7e4..a1e54a5 100644 --- a/src/bot/handlers/captcha.py +++ b/src/bot/handlers/captcha.py @@ -267,8 +267,13 @@ async def captcha_callback_handler( callback_user_id = query.from_user.id parts = query.data.split("_") - target_user_id = int(parts[-1]) - group_id = int(parts[-2]) + try: + target_user_id = int(parts[-1]) + group_id = int(parts[-2]) + except (ValueError, IndexError): + logger.warning(f"Malformed captcha callback data: {query.data}") + await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) + return if callback_user_id != target_user_id: await query.answer(CAPTCHA_WRONG_USER_MESSAGE, show_alert=True) @@ -299,12 +304,6 @@ async def captcha_callback_handler( ) return - job_name = get_captcha_job_name(group_config.group_id, target_user_id) - current_jobs = context.job_queue.get_jobs_by_name(job_name) - for job in current_jobs: - job.schedule_removal() - logger.info(f"Cancelled timeout job for user {target_user_id}") - try: await unrestrict_user(context.bot, group_config.group_id, target_user_id) logger.info(f"Unrestricted verified user {target_user_id}") @@ -313,8 +312,18 @@ async def captcha_callback_handler( await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) return - db.remove_pending_captcha(target_user_id, group_config.group_id) - db.start_new_user_probation(target_user_id, group_config.group_id) + try: + db.remove_pending_captcha(target_user_id, group_config.group_id) + db.start_new_user_probation(target_user_id, group_config.group_id) + except Exception: + logger.error(f"DB finalization failed for user {target_user_id}", exc_info=True) + await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) + return + + job_name = get_captcha_job_name(group_config.group_id, target_user_id) + for job in context.job_queue.get_jobs_by_name(job_name): + job.schedule_removal() + logger.info(f"Cancelled timeout job for user {target_user_id}") user_mention = get_user_mention(query.from_user) diff --git a/tests/test_captcha.py b/tests/test_captcha.py index 814afe4..1f05915 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -253,7 +253,7 @@ async def test_captcha_callback_verifies_correct_user( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): await captcha_callback_handler(update, mock_context) @@ -283,7 +283,7 @@ async def test_captcha_callback_unrestricts_user( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): mock_unrestrict.return_value = AsyncMock() await captcha_callback_handler(update, mock_context) @@ -315,7 +315,7 @@ async def test_captcha_callback_deletes_message( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): await captcha_callback_handler(update, mock_context) @@ -395,7 +395,7 @@ async def test_cancels_timeout_job(self, mock_context, mock_registry, temp_db): with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): await captcha_callback_handler(update, mock_context) @@ -428,7 +428,7 @@ async def test_unrestrict_failure_stops_execution( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): mock_unrestrict.side_effect = Exception("Unrestrict failed") await captcha_callback_handler(update, mock_context) @@ -464,7 +464,7 @@ async def test_edit_message_failure_in_callback_continues_gracefully( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=True, get_missing_items=MagicMock(return_value=[]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), ): await captcha_callback_handler(update, mock_context) @@ -581,7 +581,7 @@ async def test_captcha_callback_incomplete_profile_rejected( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik"]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=False, has_username=True)), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, ): await captcha_callback_handler(update, mock_context) @@ -616,7 +616,7 @@ async def test_captcha_callback_incomplete_profile_both_missing( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik", "username"]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=False, has_username=False)), ): await captcha_callback_handler(update, mock_context) @@ -685,13 +685,63 @@ async def test_captcha_callback_incomplete_profile_timeout_not_cancelled( with ( patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), - patch("bot.handlers.captcha.check_user_profile", return_value=AsyncMock(is_complete=False, get_missing_items=MagicMock(return_value=["foto profil publik"]))), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=False, has_username=True)), ): await captcha_callback_handler(update, mock_context) mock_context.job_queue.get_jobs_by_name.assert_not_called() mock_job.schedule_removal.assert_not_called() + async def test_malformed_callback_data_rejected(self, mock_context): + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.data = "captcha_verify_baddata" + + update = MagicMock() + update.callback_query = query + + await captcha_callback_handler(update, mock_context) + + query.answer.assert_called_once_with( + "Gagal memverifikasi. Silakan coba lagi.", show_alert=True + ) + + async def test_unrestrict_failure_preserves_timeout_job( + self, mock_context, mock_registry, temp_db + ): + from bot.database.service import get_database + + db = get_database() + db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") + + mock_job = MagicMock() + mock_job.schedule_removal = MagicMock() + mock_context.job_queue.get_jobs_by_name.return_value = [mock_job] + + query = MagicMock() + query.answer = AsyncMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.username = "testuser" + query.from_user.full_name = "Test User" + query.data = "captcha_verify_-1001234567890_12345" + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + + with ( + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), + patch("bot.handlers.captcha.unrestrict_user", side_effect=Exception("API error")), + patch("bot.handlers.captcha.check_user_profile", return_value=ProfileCheckResult(has_profile_photo=True, has_username=True)), + ): + await captcha_callback_handler(update, mock_context) + + mock_job.schedule_removal.assert_not_called() + assert db.get_pending_captcha(12345, -1001234567890) is not None + class TestGetHandlers: def test_get_handlers_returns_list(self):