"""视频分享 - 领域实体 + 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")