833c604fd7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m39s
604 lines
20 KiB
Python
Executable File
604 lines
20 KiB
Python
Executable File
"""视频分享 - 领域实体 + Use cases 单元测试."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from packages.application.video_share.commands import (
|
|
CreateShareCommand,
|
|
UpdateShareCommand,
|
|
)
|
|
from packages.application.video_share.use_cases import (
|
|
AccessShareUseCase,
|
|
CreateShareUseCase,
|
|
GetShareByTokenUseCase,
|
|
InvalidPasswordError,
|
|
ListSharesByUserUseCase,
|
|
ListSharesByVideoUseCase,
|
|
NotFoundError,
|
|
PasswordRequiredError,
|
|
RecordShareDownloadUseCase,
|
|
RevokeShareUseCase,
|
|
ShareAccessResult,
|
|
ShareExpiredError,
|
|
UpdateShareUseCase,
|
|
VideoNotFoundError,
|
|
)
|
|
from packages.domain.generated_video import GeneratedVideo
|
|
from packages.domain.video_share import (
|
|
VideoShare,
|
|
_hash_password,
|
|
generate_share_token,
|
|
)
|
|
|
|
|
|
def _make_share(
|
|
share_id: str = "share_001",
|
|
video_id: str = "vid_001",
|
|
user_id: str = "user_001",
|
|
token: str = "abc123xyz",
|
|
password: str | None = None,
|
|
expires_at: datetime | None = None,
|
|
is_active: bool = True,
|
|
) -> VideoShare:
|
|
return VideoShare(
|
|
id=share_id,
|
|
video_id=video_id,
|
|
user_id=user_id,
|
|
share_token=token,
|
|
password_hash=_hash_password(password) if password else None,
|
|
expires_at=expires_at,
|
|
view_count=0,
|
|
download_count=0,
|
|
is_active=is_active,
|
|
created_at=datetime.now(timezone.utc),
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
|
|
def _make_video(video_id: str = "vid_001", user_id: str = "user_001") -> GeneratedVideo:
|
|
return GeneratedVideo(
|
|
id=video_id,
|
|
project_id="proj_001",
|
|
generation_task_id="task_001",
|
|
name="测试视频",
|
|
file_url="oss://bucket/video.mp4",
|
|
file_size=1024000,
|
|
duration=30.5,
|
|
width=1080,
|
|
height=1920,
|
|
fps=30.0,
|
|
user_id=user_id,
|
|
)
|
|
|
|
|
|
class TestVideoShareDomain:
|
|
def test_generate_token_length(self) -> None:
|
|
token = generate_share_token(12)
|
|
assert len(token) == 12
|
|
|
|
def test_generate_token_url_safe(self) -> None:
|
|
token = generate_share_token(16)
|
|
# 只包含字母数字,没有特殊字符
|
|
assert token.isalnum()
|
|
|
|
def test_hash_password_consistent(self) -> None:
|
|
h1 = _hash_password("mypassword")
|
|
h2 = _hash_password("mypassword")
|
|
assert h1 == h2
|
|
assert len(h1) == 64 # sha256 hex
|
|
|
|
def test_hash_password_different_for_different_passwords(self) -> None:
|
|
h1 = _hash_password("password1")
|
|
h2 = _hash_password("password2")
|
|
assert h1 != h2
|
|
|
|
def test_hash_empty_password(self) -> None:
|
|
assert _hash_password("") == ""
|
|
|
|
def test_create_share_success(self) -> None:
|
|
share = VideoShare.create(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
)
|
|
assert share.video_id == "vid_001"
|
|
assert share.user_id == "user_001"
|
|
assert len(share.id) == 32
|
|
assert len(share.share_token) == 12
|
|
assert share.password_hash is None
|
|
assert share.expires_at is None
|
|
assert share.is_active is True
|
|
assert share.view_count == 0
|
|
assert share.download_count == 0
|
|
|
|
def test_create_share_with_password(self) -> None:
|
|
share = VideoShare.create(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
password="secret123",
|
|
)
|
|
assert share.has_password is True
|
|
assert share.verify_password("secret123") is True
|
|
assert share.verify_password("wrong") is False
|
|
|
|
def test_create_share_with_expiry(self) -> None:
|
|
future = datetime.now(timezone.utc) + timedelta(days=7)
|
|
share = VideoShare.create(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
expires_at=future,
|
|
)
|
|
assert share.expires_at == future
|
|
assert share.is_expired is False
|
|
|
|
def test_create_share_past_expiry_raises(self) -> None:
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
with pytest.raises(ValueError, match="past"):
|
|
VideoShare.create(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
expires_at=past,
|
|
)
|
|
|
|
def test_create_share_empty_video_id_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="video_id"):
|
|
VideoShare.create(video_id="", user_id="user_001")
|
|
|
|
def test_create_share_empty_user_id_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="user_id"):
|
|
VideoShare.create(video_id="vid_001", user_id=" ")
|
|
|
|
def test_is_expired_false_when_no_expiry(self) -> None:
|
|
share = _make_share()
|
|
assert share.is_expired is False
|
|
|
|
def test_is_expired_true_when_past(self) -> None:
|
|
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
share = _make_share(expires_at=past)
|
|
assert share.is_expired is True
|
|
|
|
def test_is_accessible_active_not_expired(self) -> None:
|
|
share = _make_share()
|
|
assert share.is_accessible is True
|
|
|
|
def test_is_accessible_inactive(self) -> None:
|
|
share = _make_share(is_active=False)
|
|
assert share.is_accessible is False
|
|
|
|
def test_is_accessible_expired(self) -> None:
|
|
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
share = _make_share(expires_at=past)
|
|
assert share.is_accessible is False
|
|
|
|
def test_has_password_false_when_no_password(self) -> None:
|
|
share = _make_share()
|
|
assert share.has_password is False
|
|
|
|
def test_has_password_true_when_password_set(self) -> None:
|
|
share = _make_share(password="pass123")
|
|
assert share.has_password is True
|
|
|
|
def test_verify_no_password_always_true(self) -> None:
|
|
share = _make_share() # 没有密码
|
|
assert share.verify_password("") is True
|
|
assert share.verify_password("anything") is True
|
|
|
|
def test_verify_correct_password(self) -> None:
|
|
share = _make_share(password="mysecret")
|
|
assert share.verify_password("mysecret") is True
|
|
|
|
def test_verify_wrong_password(self) -> None:
|
|
share = _make_share(password="mysecret")
|
|
assert share.verify_password("wrong") is False
|
|
|
|
def test_verify_empty_password_with_password_set(self) -> None:
|
|
share = _make_share(password="mysecret")
|
|
assert share.verify_password("") is False
|
|
|
|
def test_increment_view_count(self) -> None:
|
|
share = _make_share()
|
|
share.increment_view_count()
|
|
assert share.view_count == 1
|
|
share.increment_view_count()
|
|
assert share.view_count == 2
|
|
|
|
def test_increment_download_count(self) -> None:
|
|
share = _make_share()
|
|
share.increment_download_count()
|
|
assert share.download_count == 1
|
|
|
|
def test_revoke_sets_inactive(self) -> None:
|
|
share = _make_share()
|
|
share.revoke()
|
|
assert share.is_active is False
|
|
assert share.is_accessible is False
|
|
|
|
|
|
class TestCreateShareUseCase:
|
|
def test_create_success(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
video_repo.get.return_value = _make_video()
|
|
share_repo.create.side_effect = lambda s: s
|
|
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.video_id == "vid_001"
|
|
assert result.user_id == "user_001"
|
|
share_repo.create.assert_called_once()
|
|
|
|
def test_create_with_password(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
video_repo.get.return_value = _make_video()
|
|
share_repo.create.side_effect = lambda s: s
|
|
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
cmd = CreateShareCommand(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
password="secret",
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.has_password is True
|
|
|
|
def test_create_with_expiry(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
video_repo.get.return_value = _make_video()
|
|
share_repo.create.side_effect = lambda s: s
|
|
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
future = datetime.now(timezone.utc) + timedelta(days=1)
|
|
cmd = CreateShareCommand(
|
|
video_id="vid_001",
|
|
user_id="user_001",
|
|
expires_at=future,
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.expires_at == future
|
|
|
|
def test_video_not_found_raises(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
video_repo.get.return_value = None
|
|
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
cmd = CreateShareCommand(video_id="nonexistent", user_id="user_001")
|
|
|
|
with pytest.raises(VideoNotFoundError):
|
|
use_case.execute(cmd)
|
|
|
|
def test_wrong_user_cannot_share(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
video_repo.get.return_value = _make_video(user_id="other_user")
|
|
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
|
|
|
|
with pytest.raises(VideoNotFoundError):
|
|
use_case.execute(cmd)
|
|
|
|
|
|
class TestGetShareByTokenUseCase:
|
|
def test_found_active_share(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = _make_share()
|
|
|
|
use_case = GetShareByTokenUseCase(repo)
|
|
result = use_case.execute("abc123xyz")
|
|
assert result.share_token == "abc123xyz"
|
|
|
|
def test_not_found_raises(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = None
|
|
|
|
use_case = GetShareByTokenUseCase(repo)
|
|
with pytest.raises(NotFoundError):
|
|
use_case.execute("nonexistent")
|
|
|
|
def test_inactive_share_raises_expired(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = _make_share(is_active=False)
|
|
|
|
use_case = GetShareByTokenUseCase(repo)
|
|
with pytest.raises(ShareExpiredError):
|
|
use_case.execute("token")
|
|
|
|
def test_expired_share_raises(self) -> None:
|
|
repo = MagicMock()
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
repo.get_by_token.return_value = _make_share(expires_at=past)
|
|
|
|
use_case = GetShareByTokenUseCase(repo)
|
|
with pytest.raises(ShareExpiredError):
|
|
use_case.execute("token")
|
|
|
|
|
|
class TestAccessShareUseCase:
|
|
def test_access_no_password(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = _make_share()
|
|
video_repo.get.return_value = _make_video()
|
|
share_repo.increment_view.return_value = None
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
result = use_case.execute("abc123xyz")
|
|
|
|
assert isinstance(result, ShareAccessResult)
|
|
assert result.video.id == "vid_001"
|
|
assert result.password_verified is True
|
|
assert result.share.view_count == 1 # 浏览量+1
|
|
share_repo.increment_view.assert_called_once()
|
|
|
|
def test_access_with_correct_password(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = _make_share(password="mypass")
|
|
video_repo.get.return_value = _make_video()
|
|
share_repo.increment_view.return_value = None
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
result = use_case.execute("token", password="mypass")
|
|
|
|
assert result.password_verified is True
|
|
|
|
def test_access_password_required_but_not_provided(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = _make_share(password="secret")
|
|
video_repo.get.return_value = _make_video()
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
|
|
with pytest.raises(PasswordRequiredError):
|
|
use_case.execute("token", password=None)
|
|
|
|
def test_access_wrong_password(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = _make_share(password="correct")
|
|
video_repo.get.return_value = _make_video()
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
|
|
with pytest.raises(InvalidPasswordError):
|
|
use_case.execute("token", password="wrong")
|
|
|
|
def test_access_share_not_found(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = None
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
use_case.execute("nonexistent")
|
|
|
|
def test_access_share_expired(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
share_repo.get_by_token.return_value = _make_share(expires_at=past)
|
|
video_repo.get.return_value = _make_video()
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
|
|
with pytest.raises(ShareExpiredError):
|
|
use_case.execute("token")
|
|
|
|
def test_access_video_not_found(self) -> None:
|
|
share_repo = MagicMock()
|
|
video_repo = MagicMock()
|
|
share_repo.get_by_token.return_value = _make_share()
|
|
video_repo.get.return_value = None
|
|
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
|
|
with pytest.raises(VideoNotFoundError):
|
|
use_case.execute("token")
|
|
|
|
|
|
class TestListSharesByVideoUseCase:
|
|
def test_lists_shares(self) -> None:
|
|
repo = MagicMock()
|
|
expected = [_make_share(), _make_share(share_id="share_002", token="tok2")]
|
|
repo.list_by_video.return_value = expected
|
|
|
|
use_case = ListSharesByVideoUseCase(repo)
|
|
result = use_case.execute("vid_001", "user_001")
|
|
|
|
assert len(result) == 2
|
|
repo.list_by_video.assert_called_once_with("vid_001", "user_001")
|
|
|
|
|
|
class TestListSharesByUserUseCase:
|
|
def test_lists_with_total(self) -> None:
|
|
repo = MagicMock()
|
|
items = [_make_share(), _make_share(share_id="s2", token="t2")]
|
|
repo.list_by_user.return_value = items
|
|
repo.count_by_user.return_value = 10
|
|
|
|
use_case = ListSharesByUserUseCase(repo)
|
|
result_items, total = use_case.execute("user_001", skip=0, limit=2)
|
|
|
|
assert len(result_items) == 2
|
|
assert total == 10
|
|
repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=2)
|
|
|
|
|
|
class TestUpdateShareUseCase:
|
|
def test_update_password(self) -> None:
|
|
repo = MagicMock()
|
|
share = _make_share()
|
|
repo.get_by_id.return_value = share
|
|
repo.update.side_effect = lambda s: s
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
cmd = UpdateShareCommand(
|
|
share_id="share_001",
|
|
user_id="user_001",
|
|
password="newpass",
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.has_password is True
|
|
assert result.verify_password("newpass") is True
|
|
repo.update.assert_called_once()
|
|
|
|
def test_clear_password(self) -> None:
|
|
repo = MagicMock()
|
|
share = _make_share(password="oldpass")
|
|
repo.get_by_id.return_value = share
|
|
repo.update.side_effect = lambda s: s
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
cmd = UpdateShareCommand(
|
|
share_id="share_001",
|
|
user_id="user_001",
|
|
password="", # 空字符串=清除密码
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.has_password is False
|
|
assert result.password_hash is None
|
|
|
|
def test_password_none_does_not_change(self) -> None:
|
|
repo = MagicMock()
|
|
share = _make_share(password="existing")
|
|
repo.get_by_id.return_value = share
|
|
repo.update.side_effect = lambda s: s
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
cmd = UpdateShareCommand(
|
|
share_id="share_001",
|
|
user_id="user_001",
|
|
password=None, # None=不修改
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.verify_password("existing") is True
|
|
|
|
def test_update_expires_at(self) -> None:
|
|
repo = MagicMock()
|
|
share = _make_share()
|
|
repo.get_by_id.return_value = share
|
|
repo.update.side_effect = lambda s: s
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
future = datetime.now(timezone.utc) + timedelta(days=3)
|
|
cmd = UpdateShareCommand(
|
|
share_id="share_001",
|
|
user_id="user_001",
|
|
expires_at=future,
|
|
)
|
|
result = use_case.execute(cmd)
|
|
|
|
assert result.expires_at == future
|
|
|
|
def test_not_found_raises(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_id.return_value = None
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
cmd = UpdateShareCommand(share_id="no", user_id="u1", password="x")
|
|
|
|
with pytest.raises(NotFoundError):
|
|
use_case.execute(cmd)
|
|
|
|
def test_past_expiry_raises(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_id.return_value = _make_share()
|
|
|
|
use_case = UpdateShareUseCase(repo)
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
cmd = UpdateShareCommand(
|
|
share_id="share_001",
|
|
user_id="user_001",
|
|
expires_at=past,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="past"):
|
|
use_case.execute(cmd)
|
|
|
|
|
|
class TestRevokeShareUseCase:
|
|
def test_revoke_success(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_id.return_value = _make_share()
|
|
repo.delete.return_value = True
|
|
|
|
use_case = RevokeShareUseCase(repo)
|
|
result = use_case.execute("share_001", "user_001")
|
|
|
|
assert result is True
|
|
repo.delete.assert_called_once_with("share_001", "user_001")
|
|
|
|
def test_revoke_not_found_raises(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_id.return_value = None
|
|
|
|
use_case = RevokeShareUseCase(repo)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
use_case.execute("nonexistent", "user_001")
|
|
|
|
|
|
class TestRecordShareDownloadUseCase:
|
|
def test_record_success(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = _make_share()
|
|
repo.increment_download.return_value = None
|
|
|
|
use_case = RecordShareDownloadUseCase(repo)
|
|
use_case.execute("token")
|
|
|
|
repo.increment_download.assert_called_once()
|
|
|
|
def test_record_with_password(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = _make_share(password="pass")
|
|
repo.increment_download.return_value = None
|
|
|
|
use_case = RecordShareDownloadUseCase(repo)
|
|
use_case.execute("token", password="pass")
|
|
|
|
repo.increment_download.assert_called_once()
|
|
|
|
def test_record_wrong_password_raises(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = _make_share(password="correct")
|
|
|
|
use_case = RecordShareDownloadUseCase(repo)
|
|
|
|
with pytest.raises(InvalidPasswordError):
|
|
use_case.execute("token", password="wrong")
|
|
|
|
def test_record_share_not_found(self) -> None:
|
|
repo = MagicMock()
|
|
repo.get_by_token.return_value = None
|
|
|
|
use_case = RecordShareDownloadUseCase(repo)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
use_case.execute("nonexistent")
|
|
|
|
def test_record_expired_share(self) -> None:
|
|
repo = MagicMock()
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
repo.get_by_token.return_value = _make_share(expires_at=past)
|
|
|
|
use_case = RecordShareDownloadUseCase(repo)
|
|
|
|
with pytest.raises(ShareExpiredError):
|
|
use_case.execute("token")
|