Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot a1f42404a0 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 45s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m37s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
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 / Integration Tests (pull_request) Successful in 3m51s
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 9s
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 48s
2026-07-29 13:48:14 +00:00
xiaoxia 3a6f765159 test(domain): add wave193 video_share unit tests (+43)
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m41s
CI/CD Pipeline / Validate - Code Quality (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 / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Add comprehensive unit tests for VideoShare domain entity:
- Basic construction, slots, equality
- create() factory with validation (video_id/user_id, expiry)
- Property checks: has_password, is_expired, is_accessible
- Methods: verify_password, increment_view/download_count, revoke
- generate_share_token helper and password hashing
2026-07-29 21:45:15 +08:00
2 changed files with 290 additions and 280 deletions
-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
+290
View File
@@ -0,0 +1,290 @@
"""video_share 视频分享领域实体单测."""
from datetime import datetime, timedelta, timezone
import pytest
from domain.video_share import (
VideoShare,
_hash_password,
generate_share_token,
)
# ── _hash_password ───────────────────────────────────────────────────────────
class TestHashPassword:
"""_hash_password 函数"""
def test_empty_password_returns_empty(self):
assert _hash_password("") == ""
def test_none_password_returns_empty(self):
assert _hash_password(None) == ""
def test_same_password_same_hash(self):
h1 = _hash_password("mypassword")
h2 = _hash_password("mypassword")
assert h1 == h2
def test_different_passwords_different_hashes(self):
h1 = _hash_password("password1")
h2 = _hash_password("password2")
assert h1 != h2
def test_hash_is_hex_string(self):
h = _hash_password("test")
assert isinstance(h, str)
assert len(h) == 64 # SHA-256 hex
int(h, 16) # 应该能被解析为16进制
def test_hash_contains_salt(self):
# 直接的 SHA-256(password) 应该不等于加盐后的
from hashlib import sha256
raw = sha256("mypass".encode()).hexdigest()
salted = _hash_password("mypass")
assert raw != salted
# ── generate_share_token ─────────────────────────────────────────────────────
class TestGenerateShareToken:
"""generate_share_token 函数"""
def test_default_length(self):
token = generate_share_token()
assert len(token) == 12
def test_custom_length(self):
token = generate_share_token(20)
assert len(token) == 20
def test_short_token(self):
token = generate_share_token(6)
assert len(token) == 6
def test_url_friendly_chars(self):
token = generate_share_token(100)
# 不应该有容易混淆的字符 i,l,o,0,1
assert "i" not in token
assert "l" not in token
assert "o" not in token
assert "0" not in token
assert "1" not in token
def test_unique_tokens(self):
tokens = {generate_share_token() for _ in range(100)}
assert len(tokens) == 100 # 应该都是唯一的
def test_alphanumeric(self):
token = generate_share_token(50)
assert token.isalnum()
# ── VideoShare.create ───────────────────────────────────────────────────────
class TestVideoShareCreate:
"""VideoShare.create 工厂方法"""
def test_minimal_create(self):
s = VideoShare.create(video_id="vid_001", user_id="user_001")
assert s.id is not None
assert len(s.id) == 32 # uuid4 hex
assert s.video_id == "vid_001"
assert s.user_id == "user_001"
assert s.share_token is not None
assert len(s.share_token) == 12
assert s.password_hash is None
assert s.expires_at is None
assert s.view_count == 0
assert s.download_count == 0
assert s.is_active is True
def test_with_password(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
assert s.password_hash is not None
assert s.password_hash != "secret123" # 不是明文
assert len(s.password_hash) == 64 # SHA-256
def test_with_expiry(self):
future = datetime.now(timezone.utc) + timedelta(days=7)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.expires_at == future
def test_empty_video_id_raises(self):
with pytest.raises(ValueError, match="video_id"):
VideoShare.create(video_id="", user_id="u1")
def test_whitespace_video_id_raises(self):
with pytest.raises(ValueError):
VideoShare.create(video_id=" ", user_id="u1")
def test_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
VideoShare.create(video_id="v1", user_id="")
def test_past_expiry_raises(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
with pytest.raises(ValueError, match="past"):
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
def test_video_id_stripped(self):
s = VideoShare.create(video_id=" vid_123 ", user_id="u1")
assert s.video_id == "vid_123"
def test_user_id_stripped(self):
s = VideoShare.create(video_id="v1", user_id=" user_456 ")
assert s.user_id == "user_456"
def test_unique_ids(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.id != s2.id
def test_unique_tokens(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.share_token != s2.share_token
def test_timestamps_set(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.created_at.tzinfo is not None
assert s.updated_at.tzinfo is not None
# ── VideoShare 属性方法 ─────────────────────────────────────────────────────
class TestVideoShareProperties:
"""VideoShare 属性方法"""
def test_has_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="pass")
assert s.has_password is True
def test_has_password_false(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.has_password is False
def test_is_expired_false_no_expiry(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_expired is False
def test_is_expired_false_future_expiry(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.is_expired is False
def test_is_expired_true_past_expiry(self):
# 直接构造一个已过期的
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_expired is True
def test_is_accessible_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
def test_is_accessible_false_inactive(self):
s = VideoShare.create(video_id="v1", user_id="u1")
s.is_active = False
assert s.is_accessible is False
def test_is_accessible_false_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_accessible is False
# ── VideoShare 方法 ─────────────────────────────────────────────────────────
class TestVideoShareMethods:
"""VideoShare 方法"""
def test_verify_password_no_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.verify_password("anything") is True
assert s.verify_password("") is True
def test_verify_password_correct(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("mypass") is True
def test_verify_password_wrong(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("wrongpass") is False
def test_verify_password_empty_false(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("") is False
def test_increment_view_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.view_count == 0
s.increment_view_count()
assert s.view_count == 1
s.increment_view_count()
assert s.view_count == 2
def test_increment_download_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.download_count == 0
s.increment_download_count()
assert s.download_count == 1
s.increment_download_count()
assert s.download_count == 2
def test_revoke(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_active is True
s.revoke()
assert s.is_active is False
def test_revoke_makes_inaccessible(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
s.revoke()
assert s.is_accessible is False
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
class TestVideoShareBasics:
"""VideoShare 基础特性"""
def test_slots_no_extra_attrs(self):
s = VideoShare.create(video_id="v1", user_id="u1")
with pytest.raises(AttributeError):
s.nonexistent = "value"
def test_direct_construction(self):
s = VideoShare(
id="custom_id",
video_id="v1",
user_id="u1",
share_token="abc123",
)
assert s.id == "custom_id"
assert s.share_token == "abc123"
def test_equality_same_id(self):
now = datetime.now(timezone.utc)
s1 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
s2 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
assert s1 == s2