Files
xiaoxia-saas/tests/unit/test_verification_code_service.py
T
xiaoxia c884863ad5
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m56s
AI Code Review / AI Code Review (pull_request) Successful in 8m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14m40s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m8s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
style: auto-fix black formatting
2026-07-22 18:23:45 +08:00

450 lines
15 KiB
Python
Executable File

"""
验证码服务单元测试(第十七波)
覆盖:
- VerificationCodeService.generate
- VerificationCodeService.verify
- 频控逻辑(冷却 + 每日上限)
"""
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,
DEFAULT_TTL_SECONDS,
MAX_ATTEMPTS,
RESEND_COOLDOWN_SECONDS,
VerificationCodeService,
)
from packages.domain.verification_code import VerificationCode
@pytest.fixture
def mock_repo():
"""mock 验证码仓储"""
return MagicMock()
@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)
return 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,
)
# ============================================================
# generate - 参数校验
# ============================================================
class TestGenerateParamValidation:
"""generate 参数校验"""
def test_empty_recipient(self, service):
"""空接收方"""
code, err = service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in err
def test_whitespace_recipient_stripped(self, service, mock_repo):
"""前后空格会被 strip 掉,正常生成"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
assert code.recipient == "test@example.com"
def test_invalid_code_type(self, service):
"""无效验证码类型"""
code, err = service.generate("test@example.com", "invalid_type")
assert code is None
assert "无效的验证码类型" in err
# ============================================================
# generate - 正常生成
# ============================================================
class TestGenerateNormal:
"""generate 正常生成场景"""
def test_generate_success(self, service, mock_repo):
"""正常生成验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
assert code.recipient == "test@example.com"
assert code.code_type == CODE_TYPE_EMAIL_BIND
assert len(code.code) == 6
assert code.code.isdigit()
assert not code.is_used
assert not code.is_expired
mock_repo.save.assert_called_once()
def test_custom_code(self, service, mock_repo):
"""自定义验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="888888")
assert err is None
assert code.code == "888888"
def test_custom_ttl(self, service, mock_repo):
"""自定义有效期"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=60)
assert err is None
# 过期时间 - 创建时间 ≈ 60 秒
delta = (code.expires_at - code.created_at).total_seconds()
assert delta == 60
def test_default_ttl_used_when_not_specified(self, service, mock_repo):
"""未指定 ttl 时使用默认值"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
delta = (code.expires_at - code.created_at).total_seconds()
assert delta == DEFAULT_TTL_SECONDS
def test_phone_bind_type(self, service, mock_repo):
"""手机号绑定类型也支持"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
assert err is None
assert code.code_type == CODE_TYPE_PHONE_BIND
# ============================================================
# generate - 频控
# ============================================================
class TestGenerateRateLimit:
"""generate 频控逻辑"""
def test_resend_cooldown_blocked(self, service, mock_repo):
"""冷却期内发送被拒绝"""
# 10 秒前刚发过一条
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
mock_repo.count_today.return_value = 1
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in err
assert "秒后再试" in err
# 等待时间应接近 50 秒(60-10)
# 提取数字验证范围
import re
match = re.search(r"(\d+)\s*秒", err)
assert match
wait = int(match.group(1))
assert 45 <= wait <= 55
def test_resend_after_cooldown_ok(self, service, mock_repo):
"""超过冷却期可以重发"""
# 2 分钟前发的,已过冷却
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, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
def test_daily_limit_reached(self, service, mock_repo):
"""达到每日上限"""
# 没有最近的(过了冷却),但今日已达上限
old = make_code(created_at=datetime.now(timezone.utc) - timedelta(hours=2))
mock_repo.find_latest.return_value = old
mock_repo.count_today.return_value = DAILY_LIMIT
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in err
def test_daily_limit_not_reached(self, service, mock_repo):
"""未达每日上限可以发"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT - 1
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
def test_no_history_first_time_ok(self, service, mock_repo):
"""首次发送,无历史记录"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
mock_repo.save.assert_called_once()
# ============================================================
# generate - 自定义频控参数
# ============================================================
class TestGenerateCustomRateLimitParams:
"""自定义频控参数"""
def test_custom_cooldown(self, mock_repo):
"""自定义冷却时间"""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=300, daily_limit=5)
# 60 秒前发的,默认冷却 60 秒就够了,但这里设了 300 秒
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=60))
mock_repo.find_latest.return_value = recent
mock_repo.count_today.return_value = 1
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in err
def test_custom_daily_limit(self, mock_repo):
"""自定义每日上限"""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=60, daily_limit=3)
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 3
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in err
# ============================================================
# verify - 参数校验
# ============================================================
class TestVerifyParamValidation:
"""verify 参数校验"""
def test_empty_recipient(self, service):
"""空接收方"""
ok, err = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "参数不完整" in err
def test_empty_code(self, service):
"""空验证码"""
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
assert not ok
assert "参数不完整" in err
def test_whitespace_stripped(self, service, mock_repo):
"""前后空格会被 strip"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
mock_repo.count_today.return_value = 0
ok, err = service.verify(" test@example.com ", CODE_TYPE_EMAIL_BIND, " 123456 ")
assert ok
assert err is None
# ============================================================
# verify - 正常验证
# ============================================================
class TestVerifyNormal:
"""verify 正常验证场景"""
def test_verify_success_consume(self, service, mock_repo):
"""验证成功并消耗"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=True)
assert ok
assert err is None
assert code.is_used # 被标记为已使用
# save 被调用了两次:一次 increment_attempts 后,一次 mark_used 后
assert mock_repo.save.call_count >= 2
def test_verify_success_no_consume(self, service, mock_repo):
"""验证成功但不消耗"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=False)
assert ok
assert err is None
assert not code.is_used # 未被标记
def test_verify_code_not_found(self, service, mock_repo):
"""验证码不存在"""
mock_repo.find_latest.return_value = None
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "不存在或已过期" in err
def test_verify_wrong_code(self, service, mock_repo):
"""验证码错误"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "999999")
assert not ok
assert "验证码错误" in err
# 尝试次数增加了
assert code.attempts == 1
def test_verify_already_used(self, service, mock_repo):
"""验证码已使用"""
code = make_code(code="123456", used=True)
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "已使用" in err
def test_verify_expired(self, service, mock_repo):
"""验证码已过期"""
code = make_code(code="123456", ttl=-60) # 已过期 60 秒
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "已过期" in err
def test_verify_attempts_exceeded(self, service, mock_repo):
"""超过最大尝试次数"""
code = make_code(code="123456", attempts=MAX_ATTEMPTS)
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "验证次数过多" in err
# verify 里先 increment_attempts 再判断,所以这里 attempts 应该是 MAX_ATTEMPTS + 1
assert code.attempts == MAX_ATTEMPTS + 1
def test_attempts_increment_on_wrong_code(self, service, mock_repo):
"""错误验证码会增加尝试次数"""
code = make_code(code="123456", attempts=0)
mock_repo.find_latest.return_value = code
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000000")
assert code.attempts == 1
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000001")
assert code.attempts == 2
# ============================================================
# verify - 不同 code_type 互不干扰
# ============================================================
class TestVerifyCodeTypeIsolation:
"""不同验证码类型互不干扰"""
def test_email_bind_vs_email_login(self, service, mock_repo):
"""用 email_login 类型的验证码去验证 email_bind 应该失败"""
code = make_code(code_type=CODE_TYPE_EMAIL_LOGIN, code="123456")
mock_repo.find_latest.return_value = None # 按 email_bind 查不到
# find_latest 按 code_type 查询,传 email_bind 返回 None
def side_effect(recipient, ct):
if ct == CODE_TYPE_EMAIL_LOGIN:
return code
return None
mock_repo.find_latest.side_effect = side_effect
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "不存在或已过期" in err
# ============================================================
# 常量值检查
# ============================================================
class TestConstants:
"""常量默认值校验"""
def test_default_cooldown_60(self):
assert RESEND_COOLDOWN_SECONDS == 60
def test_default_daily_limit_10(self):
assert DAILY_LIMIT == 10
def test_default_max_attempts_5(self):
assert MAX_ATTEMPTS == 5
def test_default_ttl_300(self):
assert DEFAULT_TTL_SECONDS == 300
def test_valid_code_types_count(self):
"""5 种验证码类型"""
from packages.application.auth.verification_code_service import VALID_CODE_TYPES
assert len(VALID_CODE_TYPES) == 5