Implement GuardDen Discord moderation bot
Features: - Core moderation: warn, kick, ban, timeout, strike system - Automod: banned words filter, scam detection, anti-spam, link filtering - AI moderation: Claude/OpenAI integration, NSFW detection, phishing analysis - Verification system: button, captcha, math, emoji challenges - Rate limiting system with configurable scopes - Event logging: joins, leaves, message edits/deletes, voice activity - Per-guild configuration with caching - Docker deployment support Bug fixes applied: - Fixed await on session.delete() in guild_config.py - Fixed memory leak in AI moderation message tracking (use deque) - Added error handling to bot shutdown - Added error handling to timeout command - Removed unused Literal import - Added prefix validation - Added image analysis limit (3 per message) - Fixed test mock for SQLAlchemy model
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for GuardDen."""
|
||||
15
tests/conftest.py
Normal file
15
tests/conftest.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Pytest fixtures for GuardDen tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_guild_id() -> int:
|
||||
"""Return a sample Discord guild ID."""
|
||||
return 123456789012345678
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_id() -> int:
|
||||
"""Return a sample Discord user ID."""
|
||||
return 987654321098765432
|
||||
119
tests/test_ai.py
Normal file
119
tests/test_ai.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for AI services."""
|
||||
|
||||
import pytest
|
||||
|
||||
from guardden.services.ai.base import ContentCategory, ModerationResult
|
||||
from guardden.services.ai.factory import NullProvider, create_ai_provider
|
||||
|
||||
|
||||
class TestModerationResult:
|
||||
"""Tests for ModerationResult dataclass."""
|
||||
|
||||
def test_severity_not_flagged(self) -> None:
|
||||
"""Test severity is 0 when not flagged."""
|
||||
result = ModerationResult(is_flagged=False, confidence=0.9)
|
||||
assert result.severity == 0
|
||||
|
||||
def test_severity_with_confidence(self) -> None:
|
||||
"""Test severity includes confidence."""
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=0.8,
|
||||
categories=[],
|
||||
)
|
||||
# 0.8 * 50 = 40
|
||||
assert result.severity == 40
|
||||
|
||||
def test_severity_high_category(self) -> None:
|
||||
"""Test severity with high-severity category."""
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=0.5,
|
||||
categories=[ContentCategory.HATE_SPEECH],
|
||||
)
|
||||
# 0.5 * 50 + 30 = 55
|
||||
assert result.severity == 55
|
||||
|
||||
def test_severity_medium_category(self) -> None:
|
||||
"""Test severity with medium-severity category."""
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=0.5,
|
||||
categories=[ContentCategory.HARASSMENT],
|
||||
)
|
||||
# 0.5 * 50 + 20 = 45
|
||||
assert result.severity == 45
|
||||
|
||||
def test_severity_multiple_categories(self) -> None:
|
||||
"""Test severity with multiple categories."""
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=0.5,
|
||||
categories=[ContentCategory.HATE_SPEECH, ContentCategory.VIOLENCE],
|
||||
)
|
||||
# 0.5 * 50 + 30 + 20 = 75
|
||||
assert result.severity == 75
|
||||
|
||||
def test_severity_capped_at_100(self) -> None:
|
||||
"""Test severity is capped at 100."""
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=1.0,
|
||||
categories=[
|
||||
ContentCategory.HATE_SPEECH,
|
||||
ContentCategory.SELF_HARM,
|
||||
ContentCategory.SCAM,
|
||||
],
|
||||
)
|
||||
# Would be 50 + 30 + 30 + 30 = 140, capped to 100
|
||||
assert result.severity == 100
|
||||
|
||||
|
||||
class TestNullProvider:
|
||||
"""Tests for NullProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self) -> NullProvider:
|
||||
return NullProvider()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_moderate_text_returns_empty(self, provider: NullProvider) -> None:
|
||||
"""Test moderate_text returns unflagged result."""
|
||||
result = await provider.moderate_text("test content")
|
||||
assert result.is_flagged is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_image_returns_empty(self, provider: NullProvider) -> None:
|
||||
"""Test analyze_image returns empty result."""
|
||||
result = await provider.analyze_image("http://example.com/image.jpg")
|
||||
assert result.is_nsfw is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_phishing_returns_empty(self, provider: NullProvider) -> None:
|
||||
"""Test analyze_phishing returns empty result."""
|
||||
result = await provider.analyze_phishing("http://example.com")
|
||||
assert result.is_phishing is False
|
||||
|
||||
|
||||
class TestFactory:
|
||||
"""Tests for AI provider factory."""
|
||||
|
||||
def test_create_null_provider(self) -> None:
|
||||
"""Test creating null provider."""
|
||||
provider = create_ai_provider("none")
|
||||
assert isinstance(provider, NullProvider)
|
||||
|
||||
def test_create_anthropic_without_key(self) -> None:
|
||||
"""Test creating anthropic provider without key raises error."""
|
||||
with pytest.raises(ValueError, match="API key required"):
|
||||
create_ai_provider("anthropic", None)
|
||||
|
||||
def test_create_openai_without_key(self) -> None:
|
||||
"""Test creating openai provider without key raises error."""
|
||||
with pytest.raises(ValueError, match="API key required"):
|
||||
create_ai_provider("openai", None)
|
||||
|
||||
def test_create_unknown_provider(self) -> None:
|
||||
"""Test creating unknown provider raises error."""
|
||||
with pytest.raises(ValueError, match="Unknown AI provider"):
|
||||
create_ai_provider("unknown", "key") # type: ignore
|
||||
153
tests/test_automod.py
Normal file
153
tests/test_automod.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for the automod service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from guardden.models import BannedWord
|
||||
from guardden.services.automod import AutomodService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def automod() -> AutomodService:
|
||||
"""Create an automod service instance."""
|
||||
return AutomodService()
|
||||
|
||||
|
||||
class TestBannedWords:
|
||||
"""Tests for banned word filtering."""
|
||||
|
||||
def test_simple_match(self, automod: AutomodService) -> None:
|
||||
"""Test simple text matching."""
|
||||
banned = [_make_banned_word("badword")]
|
||||
result = automod.check_banned_words("This contains badword in it", banned)
|
||||
assert result is not None
|
||||
assert result.should_delete
|
||||
|
||||
def test_case_insensitive(self, automod: AutomodService) -> None:
|
||||
"""Test case insensitive matching."""
|
||||
banned = [_make_banned_word("BadWord")]
|
||||
result = automod.check_banned_words("this contains BADWORD here", banned)
|
||||
assert result is not None
|
||||
|
||||
def test_no_match(self, automod: AutomodService) -> None:
|
||||
"""Test no match returns None."""
|
||||
banned = [_make_banned_word("badword")]
|
||||
result = automod.check_banned_words("This is a clean message", banned)
|
||||
assert result is None
|
||||
|
||||
def test_regex_pattern(self, automod: AutomodService) -> None:
|
||||
"""Test regex pattern matching."""
|
||||
banned = [_make_banned_word(r"bad\w+", is_regex=True)]
|
||||
result = automod.check_banned_words("This is badword and badstuff", banned)
|
||||
assert result is not None
|
||||
|
||||
def test_action_warn(self, automod: AutomodService) -> None:
|
||||
"""Test warn action is set."""
|
||||
banned = [_make_banned_word("badword", action="warn")]
|
||||
result = automod.check_banned_words("badword", banned)
|
||||
assert result is not None
|
||||
assert result.should_warn
|
||||
|
||||
def test_action_strike(self, automod: AutomodService) -> None:
|
||||
"""Test strike action is set."""
|
||||
banned = [_make_banned_word("badword", action="strike")]
|
||||
result = automod.check_banned_words("badword", banned)
|
||||
assert result is not None
|
||||
assert result.should_strike
|
||||
|
||||
|
||||
class TestScamDetection:
|
||||
"""Tests for scam/phishing detection."""
|
||||
|
||||
def test_discord_nitro_scam(self, automod: AutomodService) -> None:
|
||||
"""Test detection of fake Discord Nitro links."""
|
||||
result = automod.check_scam_links("Free nitro at discord-nitro.gift")
|
||||
assert result is not None
|
||||
assert result.should_delete
|
||||
|
||||
def test_steam_scam(self, automod: AutomodService) -> None:
|
||||
"""Test detection of Steam scam patterns."""
|
||||
result = automod.check_scam_links("Check out this steam-community-giveaway.xyz")
|
||||
assert result is not None
|
||||
|
||||
def test_legitimate_discord_link(self, automod: AutomodService) -> None:
|
||||
"""Test that legitimate Discord links pass."""
|
||||
result = automod.check_scam_links("Join us at discord.gg/example")
|
||||
assert result is None
|
||||
|
||||
def test_suspicious_tld_with_keyword(self, automod: AutomodService) -> None:
|
||||
"""Test suspicious TLD with impersonation keyword."""
|
||||
result = automod.check_scam_links("Visit discord-verify.xyz to claim")
|
||||
assert result is not None
|
||||
|
||||
def test_normal_url(self, automod: AutomodService) -> None:
|
||||
"""Test normal URLs pass."""
|
||||
result = automod.check_scam_links("Check out https://github.com/example")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestInviteLinks:
|
||||
"""Tests for Discord invite link detection."""
|
||||
|
||||
def test_discord_gg_invite(self, automod: AutomodService) -> None:
|
||||
"""Test discord.gg invite detection."""
|
||||
result = automod.check_invite_links("Join discord.gg/example", allow_invites=False)
|
||||
assert result is not None
|
||||
assert result.should_delete
|
||||
|
||||
def test_discordapp_invite(self, automod: AutomodService) -> None:
|
||||
"""Test discordapp.com invite detection."""
|
||||
result = automod.check_invite_links(
|
||||
"Join https://discordapp.com/invite/abc123", allow_invites=False
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
def test_allowed_invites(self, automod: AutomodService) -> None:
|
||||
"""Test invites pass when allowed."""
|
||||
result = automod.check_invite_links("Join discord.gg/example", allow_invites=True)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCapsDetection:
|
||||
"""Tests for excessive caps detection."""
|
||||
|
||||
def test_excessive_caps(self, automod: AutomodService) -> None:
|
||||
"""Test detection of all caps message."""
|
||||
result = automod.check_all_caps("THIS IS ALL CAPS MESSAGE HERE")
|
||||
assert result is not None
|
||||
|
||||
def test_normal_caps(self, automod: AutomodService) -> None:
|
||||
"""Test normal message passes."""
|
||||
result = automod.check_all_caps("This is a Normal Message with Some Caps")
|
||||
assert result is None
|
||||
|
||||
def test_short_message_ignored(self, automod: AutomodService) -> None:
|
||||
"""Test short messages are ignored."""
|
||||
result = automod.check_all_caps("HI THERE")
|
||||
assert result is None
|
||||
|
||||
|
||||
class MockBannedWord:
|
||||
"""Mock BannedWord for testing without database."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
action: str = "delete",
|
||||
) -> None:
|
||||
self.id = 1
|
||||
self.guild_id = 123
|
||||
self.pattern = pattern
|
||||
self.is_regex = is_regex
|
||||
self.action = action
|
||||
self.reason = None
|
||||
self.added_by = 456
|
||||
|
||||
|
||||
def _make_banned_word(
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
action: str = "delete",
|
||||
) -> MockBannedWord:
|
||||
"""Create a mock BannedWord object for testing."""
|
||||
return MockBannedWord(pattern, is_regex, action)
|
||||
130
tests/test_ratelimit.py
Normal file
130
tests/test_ratelimit.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Tests for rate limiting service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from guardden.services.ratelimit import (
|
||||
RateLimitBucket,
|
||||
RateLimitConfig,
|
||||
RateLimiter,
|
||||
RateLimitScope,
|
||||
)
|
||||
|
||||
|
||||
class TestRateLimitBucket:
|
||||
"""Tests for RateLimitBucket."""
|
||||
|
||||
def test_not_limited_initially(self) -> None:
|
||||
"""Test bucket is not limited when empty."""
|
||||
bucket = RateLimitBucket(max_requests=3, window_seconds=60)
|
||||
assert bucket.is_limited() is False
|
||||
assert bucket.remaining() == 3
|
||||
|
||||
def test_limited_after_max_requests(self) -> None:
|
||||
"""Test bucket is limited after max requests."""
|
||||
bucket = RateLimitBucket(max_requests=3, window_seconds=60)
|
||||
|
||||
for _ in range(3):
|
||||
bucket.record()
|
||||
|
||||
assert bucket.is_limited() is True
|
||||
assert bucket.remaining() == 0
|
||||
|
||||
def test_remaining_decreases(self) -> None:
|
||||
"""Test remaining count decreases."""
|
||||
bucket = RateLimitBucket(max_requests=5, window_seconds=60)
|
||||
|
||||
bucket.record()
|
||||
assert bucket.remaining() == 4
|
||||
|
||||
bucket.record()
|
||||
assert bucket.remaining() == 3
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Tests for RateLimiter."""
|
||||
|
||||
@pytest.fixture
|
||||
def limiter(self) -> RateLimiter:
|
||||
return RateLimiter()
|
||||
|
||||
def test_check_not_limited(self, limiter: RateLimiter) -> None:
|
||||
"""Test check returns not limited for new bucket."""
|
||||
result = limiter.check("command", user_id=123, guild_id=456)
|
||||
assert result.is_limited is False
|
||||
|
||||
def test_acquire_records_request(self, limiter: RateLimiter) -> None:
|
||||
"""Test acquire records the request."""
|
||||
# Configure a simple limit
|
||||
limiter.configure(
|
||||
"test",
|
||||
RateLimitConfig(max_requests=2, window_seconds=60, scope=RateLimitScope.USER),
|
||||
)
|
||||
|
||||
result1 = limiter.acquire("test", user_id=123)
|
||||
assert result1.is_limited is False
|
||||
assert result1.remaining == 1
|
||||
|
||||
result2 = limiter.acquire("test", user_id=123)
|
||||
assert result2.is_limited is False
|
||||
assert result2.remaining == 0
|
||||
|
||||
result3 = limiter.acquire("test", user_id=123)
|
||||
assert result3.is_limited is True
|
||||
|
||||
def test_different_scopes(self, limiter: RateLimiter) -> None:
|
||||
"""Test different scopes create different buckets."""
|
||||
# User scope - shared across guilds
|
||||
limiter.configure(
|
||||
"user_action",
|
||||
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.USER),
|
||||
)
|
||||
|
||||
limiter.acquire("user_action", user_id=123, guild_id=1)
|
||||
result = limiter.acquire("user_action", user_id=123, guild_id=2)
|
||||
assert result.is_limited is True # Same user, different guild
|
||||
|
||||
# Member scope - per guild
|
||||
limiter.configure(
|
||||
"member_action",
|
||||
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.MEMBER),
|
||||
)
|
||||
|
||||
limiter.acquire("member_action", user_id=456, guild_id=1)
|
||||
result = limiter.acquire("member_action", user_id=456, guild_id=2)
|
||||
assert result.is_limited is False # Same user, different guild = different bucket
|
||||
|
||||
def test_reset(self, limiter: RateLimiter) -> None:
|
||||
"""Test resetting a bucket."""
|
||||
limiter.configure(
|
||||
"test",
|
||||
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.USER),
|
||||
)
|
||||
|
||||
limiter.acquire("test", user_id=123)
|
||||
assert limiter.acquire("test", user_id=123).is_limited is True
|
||||
|
||||
limiter.reset("test", user_id=123)
|
||||
assert limiter.acquire("test", user_id=123).is_limited is False
|
||||
|
||||
def test_unknown_action(self, limiter: RateLimiter) -> None:
|
||||
"""Test unknown action returns not limited."""
|
||||
result = limiter.acquire("unknown_action", user_id=123)
|
||||
assert result.is_limited is False
|
||||
assert result.remaining == 999
|
||||
|
||||
def test_guild_scope(self, limiter: RateLimiter) -> None:
|
||||
"""Test guild-scoped rate limiting."""
|
||||
limiter.configure(
|
||||
"guild_action",
|
||||
RateLimitConfig(max_requests=2, window_seconds=60, scope=RateLimitScope.GUILD),
|
||||
)
|
||||
|
||||
# Different users in same guild share the limit
|
||||
limiter.acquire("guild_action", user_id=1, guild_id=100)
|
||||
limiter.acquire("guild_action", user_id=2, guild_id=100)
|
||||
result = limiter.acquire("guild_action", user_id=3, guild_id=100)
|
||||
assert result.is_limited is True
|
||||
|
||||
# Different guild is not limited
|
||||
result = limiter.acquire("guild_action", user_id=1, guild_id=200)
|
||||
assert result.is_limited is False
|
||||
48
tests/test_utils.py
Normal file
48
tests/test_utils.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for utility functions."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from guardden.cogs.moderation import parse_duration
|
||||
|
||||
|
||||
class TestParseDuration:
|
||||
"""Tests for the parse_duration function."""
|
||||
|
||||
def test_parse_seconds(self) -> None:
|
||||
"""Test parsing seconds."""
|
||||
assert parse_duration("30s") == timedelta(seconds=30)
|
||||
assert parse_duration("1s") == timedelta(seconds=1)
|
||||
|
||||
def test_parse_minutes(self) -> None:
|
||||
"""Test parsing minutes."""
|
||||
assert parse_duration("5m") == timedelta(minutes=5)
|
||||
assert parse_duration("30m") == timedelta(minutes=30)
|
||||
|
||||
def test_parse_hours(self) -> None:
|
||||
"""Test parsing hours."""
|
||||
assert parse_duration("1h") == timedelta(hours=1)
|
||||
assert parse_duration("24h") == timedelta(hours=24)
|
||||
|
||||
def test_parse_days(self) -> None:
|
||||
"""Test parsing days."""
|
||||
assert parse_duration("7d") == timedelta(days=7)
|
||||
assert parse_duration("1d") == timedelta(days=1)
|
||||
|
||||
def test_parse_weeks(self) -> None:
|
||||
"""Test parsing weeks."""
|
||||
assert parse_duration("2w") == timedelta(weeks=2)
|
||||
assert parse_duration("1w") == timedelta(weeks=1)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
"""Test invalid duration formats."""
|
||||
assert parse_duration("invalid") is None
|
||||
assert parse_duration("") is None
|
||||
assert parse_duration("10") is None
|
||||
assert parse_duration("abc") is None
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
"""Test that parsing is case insensitive."""
|
||||
assert parse_duration("1H") == timedelta(hours=1)
|
||||
assert parse_duration("30M") == timedelta(minutes=30)
|
||||
142
tests/test_verification.py
Normal file
142
tests/test_verification.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Tests for verification service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from guardden.services.verification import (
|
||||
ButtonChallengeGenerator,
|
||||
CaptchaChallengeGenerator,
|
||||
ChallengeType,
|
||||
EmojiChallengeGenerator,
|
||||
MathChallengeGenerator,
|
||||
VerificationService,
|
||||
)
|
||||
|
||||
|
||||
class TestChallengeGenerators:
|
||||
"""Tests for challenge generators."""
|
||||
|
||||
def test_button_challenge(self) -> None:
|
||||
"""Test button challenge generation."""
|
||||
gen = ButtonChallengeGenerator()
|
||||
challenge = gen.generate()
|
||||
|
||||
assert challenge.challenge_type == ChallengeType.BUTTON
|
||||
assert challenge.answer == "verified"
|
||||
|
||||
def test_captcha_challenge(self) -> None:
|
||||
"""Test captcha challenge generation."""
|
||||
gen = CaptchaChallengeGenerator(length=6)
|
||||
challenge = gen.generate()
|
||||
|
||||
assert challenge.challenge_type == ChallengeType.CAPTCHA
|
||||
assert len(challenge.answer) == 6
|
||||
assert challenge.answer.isalnum()
|
||||
|
||||
def test_math_challenge(self) -> None:
|
||||
"""Test math challenge generation."""
|
||||
gen = MathChallengeGenerator()
|
||||
challenge = gen.generate()
|
||||
|
||||
assert challenge.challenge_type == ChallengeType.MATH
|
||||
# Answer should be a number
|
||||
assert challenge.answer.lstrip("-").isdigit()
|
||||
|
||||
def test_emoji_challenge(self) -> None:
|
||||
"""Test emoji challenge generation."""
|
||||
gen = EmojiChallengeGenerator()
|
||||
challenge = gen.generate()
|
||||
|
||||
assert challenge.challenge_type == ChallengeType.EMOJI
|
||||
assert len(challenge.options) > 0
|
||||
assert challenge.answer in challenge.options
|
||||
|
||||
|
||||
class TestVerificationService:
|
||||
"""Tests for verification service."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self) -> VerificationService:
|
||||
return VerificationService()
|
||||
|
||||
def test_create_challenge(self, service: VerificationService) -> None:
|
||||
"""Test creating a challenge."""
|
||||
pending = service.create_challenge(
|
||||
user_id=123,
|
||||
guild_id=456,
|
||||
challenge_type=ChallengeType.BUTTON,
|
||||
)
|
||||
|
||||
assert pending.user_id == 123
|
||||
assert pending.guild_id == 456
|
||||
assert pending.challenge.challenge_type == ChallengeType.BUTTON
|
||||
|
||||
def test_get_pending(self, service: VerificationService) -> None:
|
||||
"""Test retrieving pending verification."""
|
||||
service.create_challenge(123, 456, ChallengeType.BUTTON)
|
||||
|
||||
pending = service.get_pending(456, 123)
|
||||
assert pending is not None
|
||||
assert pending.user_id == 123
|
||||
|
||||
# Non-existent should return None
|
||||
assert service.get_pending(456, 999) is None
|
||||
|
||||
def test_verify_correct(self, service: VerificationService) -> None:
|
||||
"""Test successful verification."""
|
||||
service.create_challenge(123, 456, ChallengeType.BUTTON)
|
||||
|
||||
success, message = service.verify(456, 123, "verified")
|
||||
assert success is True
|
||||
assert "successful" in message.lower()
|
||||
|
||||
# Should be removed after success
|
||||
assert service.get_pending(456, 123) is None
|
||||
|
||||
def test_verify_incorrect(self, service: VerificationService) -> None:
|
||||
"""Test failed verification."""
|
||||
service.create_challenge(123, 456, ChallengeType.BUTTON)
|
||||
|
||||
success, message = service.verify(456, 123, "wrong")
|
||||
assert success is False
|
||||
assert "incorrect" in message.lower()
|
||||
|
||||
# Should still exist
|
||||
assert service.get_pending(456, 123) is not None
|
||||
|
||||
def test_verify_max_attempts(self, service: VerificationService) -> None:
|
||||
"""Test max attempts exceeded."""
|
||||
service.create_challenge(123, 456, ChallengeType.BUTTON)
|
||||
|
||||
# Use up all attempts
|
||||
for _ in range(3):
|
||||
service.verify(456, 123, "wrong")
|
||||
|
||||
success, message = service.verify(456, 123, "verified")
|
||||
assert success is False
|
||||
assert "too many" in message.lower()
|
||||
|
||||
def test_verify_no_pending(self, service: VerificationService) -> None:
|
||||
"""Test verification with no pending challenge."""
|
||||
success, message = service.verify(456, 123, "verified")
|
||||
assert success is False
|
||||
assert "no pending" in message.lower()
|
||||
|
||||
def test_cancel(self, service: VerificationService) -> None:
|
||||
"""Test canceling verification."""
|
||||
service.create_challenge(123, 456, ChallengeType.BUTTON)
|
||||
|
||||
assert service.cancel(456, 123) is True
|
||||
assert service.get_pending(456, 123) is None
|
||||
|
||||
# Cancel non-existent returns False
|
||||
assert service.cancel(456, 123) is False
|
||||
|
||||
def test_pending_count(self, service: VerificationService) -> None:
|
||||
"""Test pending count per guild."""
|
||||
service.create_challenge(1, 456, ChallengeType.BUTTON)
|
||||
service.create_challenge(2, 456, ChallengeType.BUTTON)
|
||||
service.create_challenge(3, 789, ChallengeType.BUTTON)
|
||||
|
||||
assert service.get_pending_count(456) == 2
|
||||
assert service.get_pending_count(789) == 1
|
||||
assert service.get_pending_count(999) == 0
|
||||
Reference in New Issue
Block a user