diff --git a/tests/unit/domain/test_verification_code.py b/tests/unit/domain/test_verification_code.py new file mode 100755 index 000000000..d55e0f9d1 --- /dev/null +++ b/tests/unit/domain/test_verification_code.py @@ -0,0 +1,280 @@ +"""VerificationCode 单元测试.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from domain.verification_code import VerificationCode + + +class TestVerificationCodeCreate: + """create() 工厂方法测试.""" + + def test_create_basic(self): + vc = VerificationCode.create("test@example.com", "email_login") + assert vc.id is not None + assert len(vc.id) == 32 + assert vc.recipient == "test@example.com" + assert vc.code_type == "email_login" + assert len(vc.code) == 6 + assert vc.code.isdigit() + assert vc.used_at is None + assert vc.attempts == 0 + assert vc.created_at is not None + assert vc.expires_at > vc.created_at + + def test_create_recipient_stripped(self): + vc = VerificationCode.create(" test@example.com ", "email_login") + assert vc.recipient == "test@example.com" + + def test_create_custom_code(self): + vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456") + assert vc.code == "123456" + + def test_create_custom_ttl(self): + fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + with patch("domain.verification_code.datetime") as mock_dt: + mock_dt.now.return_value = fixed_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60) + assert vc.expires_at == fixed_now + timedelta(seconds=60) + + def test_create_default_ttl_300(self): + fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + with patch("domain.verification_code.datetime") as mock_dt: + mock_dt.now.return_value = fixed_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + vc = VerificationCode.create("test@example.com", "email_login") + assert vc.expires_at == fixed_now + timedelta(seconds=300) + + def test_create_unique_ids(self): + vc1 = VerificationCode.create("a@b.com", "email_login") + vc2 = VerificationCode.create("a@b.com", "email_login") + assert vc1.id != vc2.id + + def test_create_unique_codes(self): + codes = set() + for _ in range(20): + vc = VerificationCode.create("a@b.com", "email_login") + codes.add(vc.code) + # 20个随机6位码几乎肯定不都一样 + assert len(codes) > 1 + + def test_create_phone_recipient(self): + vc = VerificationCode.create("13800138000", "phone_login") + assert vc.recipient == "13800138000" + assert vc.code_type == "phone_login" + + def test_create_all_code_types(self): + for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]: + vc = VerificationCode.create("test@example.com", ct) + assert vc.code_type == ct + + +class TestVerificationCodeIsExpired: + """is_expired 属性测试.""" + + def test_not_expired_future(self): + future = datetime.now(timezone.utc) + timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=future, + ) + assert vc.is_expired is False + + def test_expired_past(self): + past = datetime.now(timezone.utc) - timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=past, + ) + assert vc.is_expired is True + + def test_expired_boundary_exact(self): + # 用mock固定时间,expires_at等于当前时间不算过期 + fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + with patch("domain.verification_code.datetime") as mock_dt: + mock_dt.now.return_value = fixed_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=fixed_now, + ) + assert vc.is_expired is False + + +class TestVerificationCodeIsUsed: + """is_used 属性测试.""" + + def test_not_used_default(self): + vc = VerificationCode.create("a@b.com", "email_login") + assert vc.is_used is False + + def test_is_used_after_mark(self): + vc = VerificationCode.create("a@b.com", "email_login") + vc.mark_used() + assert vc.is_used is True + + +class TestVerificationCodeIsValid: + """is_valid 属性测试.""" + + def test_valid_fresh(self): + future = datetime.now(timezone.utc) + timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=future, + ) + assert vc.is_valid is True + + def test_invalid_expired(self): + past = datetime.now(timezone.utc) - timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=past, + ) + assert vc.is_valid is False + + def test_invalid_used(self): + future = datetime.now(timezone.utc) + timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=future, + ) + vc.mark_used() + assert vc.is_valid is False + + def test_invalid_expired_and_used(self): + past = datetime.now(timezone.utc) - timedelta(hours=1) + vc = VerificationCode( + id="1", + recipient="a@b.com", + code="123456", + code_type="email_login", + expires_at=past, + ) + vc.mark_used() + assert vc.is_valid is False + + +class TestVerificationCodeMarkUsed: + """mark_used 方法测试.""" + + def test_mark_used_sets_timestamp(self): + vc = VerificationCode.create("a@b.com", "email_login") + assert vc.used_at is None + before = datetime.now(timezone.utc) + vc.mark_used() + after = datetime.now(timezone.utc) + assert vc.used_at is not None + assert before <= vc.used_at <= after + + def test_mark_used_twice_overwrites(self): + vc = VerificationCode.create("a@b.com", "email_login") + vc.mark_used() + first = vc.used_at + # 时间足够短,一般不会不同,但确保可以重复调用 + vc.mark_used() + assert vc.used_at is not None + + +class TestVerificationCodeIncrementAttempts: + """increment_attempts 方法测试.""" + + def test_default_zero(self): + vc = VerificationCode.create("a@b.com", "email_login") + assert vc.attempts == 0 + + def test_increment_once(self): + vc = VerificationCode.create("a@b.com", "email_login") + vc.increment_attempts() + assert vc.attempts == 1 + + def test_increment_multiple(self): + vc = VerificationCode.create("a@b.com", "email_login") + for _i in range(5): + vc.increment_attempts() + assert vc.attempts == 5 + + +class TestVerificationCodeBasics: + """基础构造和 slots 测试.""" + + def test_direct_construction(self): + now = datetime.now(timezone.utc) + vc = VerificationCode( + id="abc123", + recipient="test@test.com", + code="000000", + code_type="email_bind", + expires_at=now + timedelta(minutes=5), + used_at=None, + attempts=0, + created_at=now, + ) + assert vc.id == "abc123" + assert vc.recipient == "test@test.com" + assert vc.code == "000000" + + def test_slots_no_extra_attrs(self): + vc = VerificationCode.create("a@b.com", "email_login") + with pytest.raises((AttributeError, TypeError)): + vc.new_field = "value" # type: ignore[attr-defined] + + def test_equality_same_id(self): + now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + vc1 = VerificationCode( + id="same", + recipient="a@b.com", + code="111", + code_type="email_login", + expires_at=now, + created_at=now, + ) + vc2 = VerificationCode( + id="same", + recipient="a@b.com", + code="111", + code_type="email_login", + expires_at=now, + created_at=now, + ) + assert vc1 == vc2 + + def test_equality_different_id(self): + now = datetime.now(timezone.utc) + vc1 = VerificationCode( + id="id1", + recipient="a@b.com", + code="111", + code_type="email_login", + expires_at=now, + ) + vc2 = VerificationCode( + id="id2", + recipient="a@b.com", + code="111", + code_type="email_login", + expires_at=now, + ) + assert vc1 != vc2