Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot 9af146fd8b style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Check if frontend-only change (pull_request) Successful in 33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m31s
AI Code Review / AI Code Review (pull_request) Successful in 3m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m23s
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 / Integration Tests (pull_request) Successful in 7m25s
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
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 51s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
2026-07-29 10:52:27 +00:00
xiaoxia 7d1bfe74cf test(wave187): speed_config 调速配置 +77测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
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 1m15s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 46s
2026-07-29 18:43:15 +08:00
2 changed files with 453 additions and 280 deletions
+453
View File
@@ -0,0 +1,453 @@
"""speed_config 调速配置领域模型单测."""
import pytest
from domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
adjust_duration,
build_audio_filter,
build_clip_speed_filter,
build_video_filter,
resolve_clip_speed,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_speed_limits(self):
assert MIN_SPEED == 0.25
assert MAX_SPEED == 4.0
assert DEFAULT_SPEED == 1.0
# ── SpeedConfig 默认值与基础 ────────────────────────────────────────────────
class TestSpeedConfigDefaults:
"""SpeedConfig 默认值"""
def test_default_values(self):
c = SpeedConfig()
assert c.speed == 1.0
assert c.pitch_correct is True
def test_custom_values(self):
c = SpeedConfig(speed=2.0, pitch_correct=False)
assert c.speed == 2.0
assert c.pitch_correct is False
# ── SpeedConfig.parse ───────────────────────────────────────────────────────
class TestSpeedConfigParse:
"""SpeedConfig.parse 工厂方法"""
def test_none_returns_default(self):
c = SpeedConfig.parse(None)
assert c.speed == 1.0
assert c.pitch_correct is True
def test_empty_dict_returns_default(self):
c = SpeedConfig.parse({})
assert c.speed == 1.0
def test_not_dict_returns_default(self):
c = SpeedConfig.parse("not a dict")
assert c.speed == 1.0
def test_valid_speed(self):
c = SpeedConfig.parse({"speed": 2.0})
assert c.speed == 2.0
def test_valid_speed_int(self):
c = SpeedConfig.parse({"speed": 2})
assert c.speed == 2.0
assert isinstance(c.speed, float)
def test_pitch_correct_false(self):
c = SpeedConfig.parse({"pitch_correct": False})
assert c.pitch_correct is False
def test_pitch_correct_non_bool_falls_back(self):
c = SpeedConfig.parse({"pitch_correct": "true"})
assert c.pitch_correct is True
def test_invalid_speed_string_falls_back(self):
c = SpeedConfig.parse({"speed": "fast"})
assert c.speed == 1.0
def test_speed_below_min_clamped(self):
c = SpeedConfig.parse({"speed": 0.1})
assert c.speed == MIN_SPEED
def test_speed_above_max_clamped(self):
c = SpeedConfig.parse({"speed": 10.0})
assert c.speed == MAX_SPEED
def test_zero_speed_falls_back_to_default(self):
c = SpeedConfig.parse({"speed": 0})
assert c.speed == DEFAULT_SPEED
def test_negative_speed_falls_back(self):
c = SpeedConfig.parse({"speed": -1.0})
assert c.speed == DEFAULT_SPEED
def test_min_speed_boundary(self):
c = SpeedConfig.parse({"speed": 0.25})
assert c.speed == 0.25
def test_max_speed_boundary(self):
c = SpeedConfig.parse({"speed": 4.0})
assert c.speed == 4.0
# ── SpeedConfig.clamp ───────────────────────────────────────────────────────
class TestSpeedConfigClamp:
"""SpeedConfig.clamp 方法"""
def test_normal_speed_no_change(self):
c = SpeedConfig(speed=1.5)
c.clamp()
assert c.speed == 1.5
def test_zero_speed_reset_default(self):
c = SpeedConfig(speed=0.0)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_negative_speed_reset_default(self):
c = SpeedConfig(speed=-0.5)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_below_min_clamped(self):
c = SpeedConfig(speed=0.1)
c.clamp()
assert c.speed == MIN_SPEED
def test_above_max_clamped(self):
c = SpeedConfig(speed=5.0)
c.clamp()
assert c.speed == MAX_SPEED
def test_exact_min_unchanged(self):
c = SpeedConfig(speed=MIN_SPEED)
c.clamp()
assert c.speed == MIN_SPEED
def test_exact_max_unchanged(self):
c = SpeedConfig(speed=MAX_SPEED)
c.clamp()
assert c.speed == MAX_SPEED
# ── SpeedConfig 属性方法 ────────────────────────────────────────────────────
class TestSpeedConfigProperties:
"""SpeedConfig 属性方法"""
def test_is_original_true(self):
c = SpeedConfig(speed=1.0)
assert c.is_original is True
def test_is_original_very_close(self):
c = SpeedConfig(speed=1.0 + 1e-7)
assert c.is_original is True
def test_is_original_false_fast(self):
c = SpeedConfig(speed=1.5)
assert c.is_original is False
def test_is_original_false_slow(self):
c = SpeedConfig(speed=0.8)
assert c.is_original is False
def test_is_fast_true(self):
c = SpeedConfig(speed=2.0)
assert c.is_fast is True
def test_is_fast_false(self):
c = SpeedConfig(speed=0.5)
assert c.is_fast is False
def test_is_fast_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_fast is False
def test_is_slow_true(self):
c = SpeedConfig(speed=0.5)
assert c.is_slow is True
def test_is_slow_false(self):
c = SpeedConfig(speed=2.0)
assert c.is_slow is False
def test_is_slow_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_slow is False
# ── build_video_filter ───────────────────────────────────────────────────────
class TestBuildVideoFilter:
"""build_video_filter 视频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_video_filter(c) == ""
def test_double_speed(self):
c = SpeedConfig(speed=2.0)
result = build_video_filter(c)
assert "setpts=PTS/2.0" in result
def test_half_speed(self):
c = SpeedConfig(speed=0.5)
result = build_video_filter(c)
assert "setpts=PTS/0.5" in result
def test_format_precision(self):
c = SpeedConfig(speed=1.5)
result = build_video_filter(c)
# 应该是 4 位小数
assert "1.5000" in result
def test_min_speed(self):
c = SpeedConfig(speed=0.25)
result = build_video_filter(c)
assert result.startswith("setpts=PTS/")
def test_max_speed(self):
c = SpeedConfig(speed=4.0)
result = build_video_filter(c)
assert "4.0000" in result
# ── build_audio_filter / atempo 拆分 ────────────────────────────────────────
class TestBuildAudioFilter:
"""build_audio_filter 音频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_audio_filter(c) == ""
def test_within_range_single_stage(self):
c = SpeedConfig(speed=1.5)
result = build_audio_filter(c)
assert result == "atempo=1.5000"
def test_05_speed_single_stage(self):
c = SpeedConfig(speed=0.5)
result = build_audio_filter(c)
assert result == "atempo=0.5000"
def test_20_speed_single_stage(self):
c = SpeedConfig(speed=2.0)
result = build_audio_filter(c)
assert result == "atempo=2.0000"
def test_4x_speed_two_stages(self):
c = SpeedConfig(speed=4.0)
result = build_audio_filter(c)
# 2.0 * 2.0 = 4.0
assert result == "atempo=2.0000,atempo=2.0000"
def test_025_speed_two_stages(self):
c = SpeedConfig(speed=0.25)
result = build_audio_filter(c)
# 0.5 * 0.5 = 0.25
assert result == "atempo=0.5000,atempo=0.5000"
def test_3x_speed_two_stages(self):
c = SpeedConfig(speed=3.0)
result = build_audio_filter(c)
# 2.0 * 1.5 = 3.0
stages = result.split(",")
assert len(stages) == 2
assert "atempo=2.0000" in stages[0]
assert "atempo=1.5000" in stages[1]
def test_03_speed_two_stages(self):
c = SpeedConfig(speed=0.3)
result = build_audio_filter(c)
stages = result.split(",")
assert len(stages) == 2
# 0.5 * 0.6 = 0.3
assert "atempo=0.5000" in stages[0]
def test_format_each_stage(self):
c = SpeedConfig(speed=1.2345)
result = build_audio_filter(c)
assert "atempo=1.2345" in result
class TestAtempoStages:
"""atempo 多级拆分逻辑验证"""
def _extract_speeds(self, filter_str: str) -> list[float]:
"""从 atempo 滤镜字符串中提取速度值."""
import re
return [float(m) for m in re.findall(r"atempo=([\d.]+)", filter_str)]
def test_product_equals_speed_fast_3x(self):
c = SpeedConfig(speed=3.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 3.0) < 1e-4
def test_product_equals_speed_4x(self):
c = SpeedConfig(speed=4.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 4.0) < 1e-4
def test_product_equals_speed_slow_025(self):
c = SpeedConfig(speed=0.25)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.25) < 1e-4
def test_product_equals_speed_slow_03(self):
c = SpeedConfig(speed=0.3)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.3) < 1e-4
def test_each_stage_in_range_fast(self):
c = SpeedConfig(speed=3.5)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
def test_each_stage_in_range_slow(self):
c = SpeedConfig(speed=0.35)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
# ── adjust_duration ──────────────────────────────────────────────────────────
class TestAdjustDuration:
"""adjust_duration 时长计算"""
def test_original_speed_no_change(self):
assert adjust_duration(10.0, SpeedConfig(speed=1.0)) == 10.0
def test_double_speed_half_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=2.0)) == 5.0
def test_half_speed_double_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.5)) == 20.0
def test_zero_duration_unchanged(self):
assert adjust_duration(0.0, SpeedConfig(speed=2.0)) == 0.0
def test_negative_duration_unchanged(self):
assert adjust_duration(-1.0, SpeedConfig(speed=2.0)) == -1.0
def test_original_with_zero_duration(self):
assert adjust_duration(0.0, SpeedConfig(speed=1.0)) == 0.0
def test_triple_speed(self):
assert adjust_duration(30.0, SpeedConfig(speed=3.0)) == 10.0
def test_quarter_speed(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.25)) == 40.0
# ── build_clip_speed_filter ──────────────────────────────────────────────────
class TestBuildClipSpeedFilter:
"""build_clip_speed_filter 便捷方法"""
def test_returns_tuple_of_three(self):
result = build_clip_speed_filter(1.5)
assert len(result) == 3
video_filter, audio_filter, config = result
assert isinstance(video_filter, str)
assert isinstance(audio_filter, str)
assert isinstance(config, SpeedConfig)
def test_normal_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(1.0)
assert video_filter == ""
assert audio_filter == ""
assert config.speed == 1.0
def test_double_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(2.0)
assert "setpts" in video_filter
assert "atempo" in audio_filter
assert config.speed == 2.0
def test_clamps_speed(self):
_, _, config = build_clip_speed_filter(10.0)
assert config.speed == MAX_SPEED
def test_pitch_correct_false(self):
# pitch_correct=False 时仍然生成滤镜(实际使用中可能换其他算法,但接口返回不变)
video_filter, audio_filter, config = build_clip_speed_filter(2.0, pitch_correct=False)
assert config.pitch_correct is False
assert "setpts" in video_filter
# ── resolve_clip_speed ───────────────────────────────────────────────────────
class TestResolveClipSpeed:
"""resolve_clip_speed 片段速度解析"""
def test_none_config_uses_global(self):
assert resolve_clip_speed(None, 1.5) == 1.5
def test_zero_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
def test_missing_key_uses_global(self):
assert resolve_clip_speed({}, 2.0) == 2.0
def test_valid_speed(self):
assert resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
def test_negative_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": -1.0}, 1.0) == 1.0
def test_invalid_type_uses_global(self):
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
def test_default_global_is_one(self):
assert resolve_clip_speed({"playback_speed": 0}) == 1.0
def test_int_speed(self):
result = resolve_clip_speed({"playback_speed": 2})
assert result == 2.0
assert isinstance(result, float)
def test_very_small_positive_uses_it(self):
# 只要 > 0 就用
result = resolve_clip_speed({"playback_speed": 0.1})
assert result == 0.1
-280
View File
@@ -1,280 +0,0 @@
"""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