ebe68429bc
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
285 lines
11 KiB
Python
Executable File
285 lines
11 KiB
Python
Executable File
"""视频分享领域模型单元测试 — wave215"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from packages.domain.video_share import (
|
|
VideoShare,
|
|
_hash_password,
|
|
generate_share_token,
|
|
)
|
|
|
|
# ── 密码哈希 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestHashPassword:
|
|
def test_empty_password_returns_empty(self):
|
|
assert _hash_password("") == ""
|
|
|
|
def test_same_password_same_hash(self):
|
|
h1 = _hash_password("secret123")
|
|
h2 = _hash_password("secret123")
|
|
assert h1 == h2
|
|
assert h1 != ""
|
|
|
|
def test_different_password_different_hash(self):
|
|
h1 = _hash_password("pass1")
|
|
h2 = _hash_password("pass2")
|
|
assert h1 != h2
|
|
|
|
def test_hash_is_sha256_hex(self):
|
|
h = _hash_password("test")
|
|
assert len(h) == 64
|
|
assert re.match(r"^[0-9a-f]{64}$", h)
|
|
|
|
def test_hash_contains_salt(self):
|
|
# 直接SHA-256("test") vs 加盐后的结果应该不同
|
|
import hashlib
|
|
|
|
direct = hashlib.sha256(b"test").hexdigest()
|
|
salted = _hash_password("test")
|
|
assert direct != salted
|
|
|
|
|
|
# ── Token 生成 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGenerateShareToken:
|
|
def test_default_length_12(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_url_friendly_no_ambiguous_chars(self):
|
|
# 不应包含容易混淆的字符:i, l, o, I, L, O, 0, 1
|
|
token = generate_share_token(100)
|
|
for ch in "ilO01":
|
|
assert ch not in token
|
|
|
|
def test_alphanumeric_only(self):
|
|
token = generate_share_token(50)
|
|
assert token.isalnum()
|
|
|
|
def test_two_tokens_different(self):
|
|
# 随机生成的两个token应该不同
|
|
t1 = generate_share_token()
|
|
t2 = generate_share_token()
|
|
assert t1 != t2
|
|
|
|
|
|
# ── VideoShare.create ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareCreate:
|
|
def test_basic_create(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.id is not None
|
|
assert share.video_id == "v1"
|
|
assert share.user_id == "u1"
|
|
assert share.share_token is not None
|
|
assert len(share.share_token) == 12
|
|
assert share.password_hash is None
|
|
assert share.expires_at is None
|
|
assert share.view_count == 0
|
|
assert share.download_count == 0
|
|
assert share.is_active is True
|
|
assert share.created_at is not None
|
|
assert share.updated_at is not None
|
|
|
|
def test_create_with_password(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="secret")
|
|
assert share.password_hash is not None
|
|
assert share.password_hash != "secret"
|
|
assert len(share.password_hash) == 64
|
|
|
|
def test_create_with_empty_password_no_hash(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="")
|
|
assert share.password_hash is None
|
|
|
|
def test_create_with_expires_at(self):
|
|
future = datetime.now(timezone.utc) + timedelta(days=7)
|
|
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
|
assert share.expires_at == future
|
|
|
|
def test_create_past_expires_at_raises(self):
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
with pytest.raises(ValueError, match="expires_at cannot be in the past"):
|
|
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
|
|
|
|
def test_create_empty_video_id_raises(self):
|
|
with pytest.raises(ValueError, match="video_id cannot be empty"):
|
|
VideoShare.create(video_id="", user_id="u1")
|
|
|
|
def test_create_whitespace_video_id_raises(self):
|
|
with pytest.raises(ValueError, match="video_id cannot be empty"):
|
|
VideoShare.create(video_id=" ", user_id="u1")
|
|
|
|
def test_create_empty_user_id_raises(self):
|
|
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
|
VideoShare.create(video_id="v1", user_id="")
|
|
|
|
def test_create_strips_whitespace(self):
|
|
share = VideoShare.create(video_id=" v1 ", user_id=" u1 ")
|
|
assert share.video_id == "v1"
|
|
assert share.user_id == "u1"
|
|
|
|
def test_create_unique_id_each_time(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_create_unique_token_each_time(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
|
|
|
|
|
|
# ── has_password ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareHasPassword:
|
|
def test_no_password(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.has_password is False
|
|
|
|
def test_with_password(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="pass")
|
|
assert share.has_password is True
|
|
|
|
def test_empty_password_none(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="")
|
|
assert share.has_password is False
|
|
|
|
|
|
# ── is_expired ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareIsExpired:
|
|
def test_no_expiry_never_expired(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.is_expired is False
|
|
|
|
def test_future_expiry_not_expired(self):
|
|
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
share = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
|
|
assert share.is_expired is False
|
|
|
|
def test_past_expiry_is_expired(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
assert share.is_expired is True
|
|
|
|
|
|
# ── is_accessible ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareIsAccessible:
|
|
def test_active_no_expiry_accessible(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.is_accessible is True
|
|
|
|
def test_revoked_not_accessible(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.is_active = False
|
|
assert share.is_accessible is False
|
|
|
|
def test_expired_not_accessible(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
|
assert share.is_accessible is False
|
|
|
|
def test_revoked_and_expired_not_accessible(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.is_active = False
|
|
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
|
assert share.is_accessible is False
|
|
|
|
|
|
# ── verify_password ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareVerifyPassword:
|
|
def test_no_password_any_pass_ok(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.verify_password("anything") is True
|
|
assert share.verify_password("") is True
|
|
|
|
def test_no_password_none_ok(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.verify_password("") is True
|
|
|
|
def test_correct_password(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
|
assert share.verify_password("mysecret") is True
|
|
|
|
def test_wrong_password(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
|
assert share.verify_password("wrong") is False
|
|
|
|
def test_empty_password_with_protection(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="mysecret")
|
|
assert share.verify_password("") is False
|
|
|
|
def test_password_case_sensitive(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1", password="Secret")
|
|
assert share.verify_password("secret") is False
|
|
assert share.verify_password("Secret") is True
|
|
|
|
|
|
# ── 计数方法 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareCounters:
|
|
def test_increment_view(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.view_count == 0
|
|
share.increment_view_count()
|
|
assert share.view_count == 1
|
|
share.increment_view_count()
|
|
assert share.view_count == 2
|
|
|
|
def test_increment_download(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.download_count == 0
|
|
share.increment_download_count()
|
|
assert share.download_count == 1
|
|
share.increment_download_count()
|
|
assert share.download_count == 2
|
|
|
|
def test_counters_independent(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.increment_view_count()
|
|
share.increment_view_count()
|
|
share.increment_download_count()
|
|
assert share.view_count == 2
|
|
assert share.download_count == 1
|
|
|
|
|
|
# ── revoke ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestVideoShareRevoke:
|
|
def test_revoke_sets_inactive(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
assert share.is_active is True
|
|
share.revoke()
|
|
assert share.is_active is False
|
|
|
|
def test_revoke_makes_inaccessible(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.revoke()
|
|
assert share.is_accessible is False
|
|
|
|
def test_revoke_idempotent(self):
|
|
share = VideoShare.create(video_id="v1", user_id="u1")
|
|
share.revoke()
|
|
share.revoke() # 第二次也不报错
|
|
assert share.is_active is False
|