15142e3168
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
580 lines
20 KiB
Python
Executable File
580 lines
20 KiB
Python
Executable File
"""验证码服务单元测试."""
|
||
|
||
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
|