"""验证码服务单元测试.""" import re from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock import pytest from packages.application.auth.verification_code_service import ( CODE_TYPE_EMAIL_BIND, CODE_TYPE_EMAIL_LOGIN, CODE_TYPE_PHONE_BIND, DAILY_LIMIT, MAX_ATTEMPTS, RESEND_COOLDOWN_SECONDS, VerificationCodeService, normalize_phone, validate_email, validate_phone, ) from packages.domain.verification_code import VerificationCode # ── Test Fixtures ──────────────────────────────────────────────────────────── @pytest.fixture def mock_repo(): """mock 验证码仓储.""" repo = MagicMock() repo.find_latest.return_value = None repo.count_today.return_value = 0 return repo @pytest.fixture def service(mock_repo): """验证码服务实例.""" return VerificationCodeService(repo=mock_repo) def _make_code( recipient="test@example.com", code_type=CODE_TYPE_EMAIL_BIND, code="123456", ttl=300, used=False, attempts=0, created_at=None, ): """创建一个测试用验证码实体.""" now = created_at or datetime.now(timezone.utc) vc = VerificationCode( id="test-code-id", recipient=recipient, code=code, code_type=code_type, expires_at=now + timedelta(seconds=ttl), used_at=now if used else None, attempts=attempts, created_at=now, ) return vc # ── generate 方法测试 ─────────────────────────────────────────────────────── class TestGenerate: """generate 方法测试""" def test_generate_success(self, service, mock_repo): """成功生成验证码.""" code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND) assert error is None assert code is not None assert code.recipient == "user@example.com" assert code.code_type == CODE_TYPE_EMAIL_BIND assert len(code.code) == 6 assert code.code.isdigit() assert not code.is_used mock_repo.save.assert_called_once() def test_generate_with_custom_code(self, service, mock_repo): """使用自定义验证码.""" code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999") assert error is None assert code.code == "999999" def test_generate_custom_ttl(self, service, mock_repo): """自定义 TTL.""" code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600) delta = code.expires_at - code.created_at assert delta.total_seconds() == 600 def test_generate_default_ttl(self, service, mock_repo): """默认 TTL.""" code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND) delta = code.expires_at - code.created_at assert delta.total_seconds() == 300 # 默认5分钟 def test_generate_empty_recipient(self, service): """空接收方.""" code, error = service.generate("", CODE_TYPE_EMAIL_BIND) assert code is None assert "不能为空" in error def test_generate_whitespace_recipient(self, service): """全空白接收方.""" code, error = service.generate(" ", CODE_TYPE_EMAIL_BIND) assert code is None assert "不能为空" in error def test_generate_invalid_type(self, service): """无效验证码类型.""" code, error = service.generate("u@e.com", "invalid_type") assert code is None assert "无效的验证码类型" in error def test_generate_recipient_stripped(self, service, mock_repo): """接收方前后空格会被清理.""" code, _ = service.generate(" user@e.com ", CODE_TYPE_EMAIL_BIND) assert code.recipient == "user@e.com" def test_generate_phone_code(self, service, mock_repo): """手机验证码生成.""" code, error = service.generate("13800138000", CODE_TYPE_PHONE_BIND) assert error is None assert code.code_type == CODE_TYPE_PHONE_BIND assert len(code.code) == 6 # ── generate 频控测试 ─────────────────────────────────────────────────────── class TestGenerateRateLimit: """generate 频控测试""" def test_cooldown_active_rejects(self, service, mock_repo): """冷却期内拒绝重发.""" recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10)) mock_repo.find_latest.return_value = recent code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert code is None assert "发送太频繁" in error # 等待时间应该接近 50 秒 (60-10) match = re.search(r"(\d+)\s*秒", error) assert match wait = int(match.group(1)) assert 45 <= wait <= 55 def test_cooldown_expired_allows(self, service, mock_repo): """冷却期过后允许重发.""" old = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120)) mock_repo.find_latest.return_value = old mock_repo.count_today.return_value = 1 code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert error is None assert code is not None def test_daily_limit_reached(self, service, mock_repo): """达到每日上限.""" mock_repo.find_latest.return_value = None mock_repo.count_today.return_value = DAILY_LIMIT code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert code is None assert "今日发送次数已达上限" in error def test_daily_limit_one_below_allows(self, service, mock_repo): """未达到上限时允许.""" mock_repo.find_latest.return_value = None mock_repo.count_today.return_value = DAILY_LIMIT - 1 code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert error is None assert code is not None def test_custom_daily_limit(self, mock_repo): """自定义每日上限.""" svc = VerificationCodeService(repo=mock_repo, daily_limit=3) mock_repo.count_today.return_value = 3 code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert code is None assert "已达上限" in error def test_custom_cooldown(self, mock_repo): """自定义冷却时间.""" svc = VerificationCodeService(repo=mock_repo, resend_cooldown=30) recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10)) mock_repo.find_latest.return_value = recent code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND) assert code is None match = re.search(r"(\d+)\s*秒", error) assert match wait = int(match.group(1)) assert 15 <= wait <= 25 def test_cooldown_different_types_independent(self, service, mock_repo): """不同类型的验证码冷却独立.""" # email_bind 类型有一个近期验证码 recent = _make_code(code_type=CODE_TYPE_EMAIL_BIND) mock_repo.find_latest.side_effect = lambda r, t: recent if t == CODE_TYPE_EMAIL_BIND else None mock_repo.count_today.return_value = 0 # email_login 类型应该可以正常发送 code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN) assert error is None assert code is not None # ── verify 方法测试 ───────────────────────────────────────────────────────── class TestVerify: """verify 方法测试""" def test_verify_success(self, service, mock_repo): """验证码正确.""" code = _make_code(code="654321") mock_repo.find_latest.return_value = code ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321") assert ok is True assert error is None assert code.is_used # 标记为已使用 assert mock_repo.save.call_count >= 2 # increment + mark_used def test_verify_wrong_code(self, service, mock_repo): """验证码错误.""" code = _make_code(code="123456") mock_repo.find_latest.return_value = code ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000") assert ok is False assert "验证码错误" in error assert not code.is_used # 不标记为已使用 assert code.attempts == 1 # 尝试次数+1 def test_verify_no_code_found(self, service, mock_repo): """找不到验证码.""" mock_repo.find_latest.return_value = None ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456") assert ok is False assert "不存在或已过期" in error def test_verify_empty_params(self, service): """参数为空.""" ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456") assert ok is False assert "参数不完整" in error ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "") assert ok2 is False assert "参数不完整" in error2 def test_verify_whitespace_params(self, service, mock_repo): """参数前后空格会被清理.""" code = _make_code(recipient="u@e.com", code="111111") mock_repo.find_latest.return_value = code ok, error = service.verify(" u@e.com ", CODE_TYPE_EMAIL_BIND, " 111111 ") assert ok is True assert error is None def test_verify_already_used(self, service, mock_repo): """验证码已使用.""" code = _make_code(used=True) mock_repo.find_latest.return_value = code ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code) assert ok is False assert "已使用" in error def test_verify_expired(self, service, mock_repo): """验证码已过期.""" code = _make_code(ttl=-60) # 已过期 mock_repo.find_latest.return_value = code ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code) assert ok is False assert "已过期" in error def test_verify_too_many_attempts(self, service, mock_repo): """尝试次数过多.""" code = _make_code(attempts=MAX_ATTEMPTS + 1) mock_repo.find_latest.return_value = code ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code) assert ok is False assert "验证次数过多" in error def test_verify_attempts_increment_each_time(self, service, mock_repo): """每次错误尝试都增加尝试次数.""" code = _make_code(code="123456", attempts=0) mock_repo.find_latest.return_value = code for _ in range(3): service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong") assert code.attempts == 3 def test_verify_without_consume(self, service, mock_repo): """验证成功但不标记为已使用(consume=False).""" code = _make_code(code="999999") mock_repo.find_latest.return_value = code ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False) assert ok is True assert error is None assert not code.is_used # 不标记为已使用 def test_verify_consume_default_true(self, service, mock_repo): """默认 consume=True.""" code = _make_code(code="123456") mock_repo.find_latest.return_value = code service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456") assert code.is_used def test_verify_used_checked_before_attempts(self, service, mock_repo): """已使用优先于其他检查.""" code = _make_code(used=True, attempts=0) mock_repo.find_latest.return_value = code ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code) assert ok is False assert "已使用" in error # attempts 会被 increment,但错误原因是已使用 assert code.attempts == 1 def test_custom_max_attempts(self, mock_repo): """自定义最大尝试次数.""" svc = VerificationCodeService(repo=mock_repo, max_attempts=2) code = _make_code(attempts=2) mock_repo.find_latest.return_value = code ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code) assert ok is False assert "验证次数过多" in error # ── validate_phone 测试 ───────────────────────────────────────────────────── class TestValidatePhone: """手机号格式校验测试""" def test_valid_11_digit(self): """标准11位手机号.""" ok, msg = validate_phone("13800138000") assert ok is True assert msg == "" def test_valid_with_plus_86(self): """带+86前缀.""" ok, msg = validate_phone("+8613800138000") assert ok is True def test_invalid_too_short(self): """位数不足.""" ok, msg = validate_phone("1380013800") assert ok is False assert "格式不正确" in msg def test_invalid_too_long(self): """位数过多.""" ok, msg = validate_phone("138001380001") assert ok is False def test_invalid_starts_with_2(self): """开头不是1.""" ok, msg = validate_phone("23800138000") assert ok is False def test_invalid_starts_with_12(self): """第二位不在3-9.""" ok, msg = validate_phone("12800138000") assert ok is False def test_invalid_empty(self): """空字符串.""" ok, msg = validate_phone("") assert ok is False assert "不能为空" in msg def test_invalid_whitespace_only(self): """仅空白.""" ok, msg = validate_phone(" ") assert ok is False assert "不能为空" in msg def test_valid_all_prefixes_3_to_9(self): """第二位3-9都有效.""" for n in range(3, 10): ok, _ = validate_phone(f"1{n}800138000") assert ok is True, f"1{n} prefix should be valid" def test_invalid_contains_letters(self): """包含字母.""" ok, msg = validate_phone("13800abc000") assert ok is False def test_strips_whitespace(self): """前后空格会被清理.""" ok, msg = validate_phone(" 13800138000 ") assert ok is True # ── normalize_phone 测试 ──────────────────────────────────────────────────── class TestNormalizePhone: """手机号标准化测试""" def test_strip_plus_86(self): """去掉+86前缀.""" assert normalize_phone("+8613800138000") == "13800138000" def test_no_prefix_stays_same(self): """无前缀保持不变.""" assert normalize_phone("13800138000") == "13800138000" def test_strips_whitespace(self): """清理前后空格.""" assert normalize_phone(" 13800138000 ") == "13800138000" def test_plus_86_with_spaces(self): """带空格的+86.""" assert normalize_phone(" +8613800138000 ") == "13800138000" # ── validate_email 测试 ───────────────────────────────────────────────────── class TestValidateEmail: """邮箱格式校验测试""" def test_valid_simple(self): """标准邮箱.""" ok, msg = validate_email("user@example.com") assert ok is True assert msg == "" def test_valid_with_dots(self): """带点号的用户名.""" ok, _ = validate_email("user.name@example.com") assert ok is True def test_valid_with_plus(self): """带加号的邮箱.""" ok, _ = validate_email("user+tag@example.com") assert ok is True def test_valid_with_underscore(self): """带下划线.""" ok, _ = validate_email("user_name@example.com") assert ok is True def test_valid_subdomain(self): """多级域名.""" ok, _ = validate_email("user@mail.example.com") assert ok is True def test_invalid_no_at(self): """没有@.""" ok, msg = validate_email("userexample.com") assert ok is False assert "格式不正确" in msg def test_invalid_empty_local(self): """@前为空.""" ok, _ = validate_email("@example.com") assert ok is False def test_invalid_empty_domain(self): """@后为空.""" ok, _ = validate_email("user@") assert ok is False def test_invalid_no_tld(self): """没有顶级域名.""" ok, _ = validate_email("user@example") assert ok is False def test_invalid_empty(self): """空字符串.""" ok, msg = validate_email("") assert ok is False assert "不能为空" in msg def test_invalid_spaces_only(self): """仅空白.""" ok, msg = validate_email(" ") assert ok is False assert "不能为空" in msg def test_strips_whitespace(self): """前后空格会被清理.""" ok, msg = validate_email(" user@e.com ") assert ok is True def test_invalid_special_chars(self): """特殊字符.""" ok, _ = validate_email("user name@e.com") assert ok is False def test_valid_numbers(self): """数字邮箱.""" ok, _ = validate_email("12345@example.com") assert ok is True # ── VerificationCode 实体辅助验证 ────────────────────────────────────────── class TestVerificationCodeEntity: """VerificationCode 实体属性测试""" def test_is_expired_false_when_fresh(self): code = _make_code(ttl=300) assert code.is_expired is False def test_is_expired_true_when_past(self): code = _make_code(ttl=-1) assert code.is_expired is True def test_is_used_false_initially(self): code = _make_code() assert code.is_used is False def test_is_used_after_mark_used(self): code = _make_code() code.mark_used() assert code.is_used is True assert code.used_at is not None def test_is_valid_fresh(self): code = _make_code() assert code.is_valid is True def test_is_valid_when_expired(self): code = _make_code(ttl=-100) assert code.is_valid is False def test_is_valid_when_used(self): code = _make_code(used=True) assert code.is_valid is False def test_increment_attempts(self): code = _make_code(attempts=0) code.increment_attempts() assert code.attempts == 1 code.increment_attempts() assert code.attempts == 2 def test_create_generates_6_digit_code(self): code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND) assert len(code.code) == 6 assert code.code.isdigit() def test_create_custom_code(self): code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, custom_code="555555") assert code.code == "555555" def test_create_strips_recipient(self): code = VerificationCode.create(" u@e.com ", CODE_TYPE_EMAIL_BIND) assert code.recipient == "u@e.com" def test_create_sets_expiry(self): code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=120) delta = code.expires_at - code.created_at assert delta.total_seconds() == 120