diff --git a/tests/unit/domain/test_video_share.py b/tests/unit/domain/test_video_share.py new file mode 100755 index 000000000..3105d10c1 --- /dev/null +++ b/tests/unit/domain/test_video_share.py @@ -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