"""视频分享 UseCase 单元测试.""" 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, ShareExpiredError, UpdateShareUseCase, VideoNotFoundError, ) from packages.domain.generated_video import GeneratedVideo from packages.domain.video_share import VideoShare @pytest.fixture def mock_share_repo(): return MagicMock() @pytest.fixture def mock_video_repo(): return MagicMock() @pytest.fixture def sample_video(): video = MagicMock(spec=GeneratedVideo) video.id = "video_001" video.user_id = "user_001" return video @pytest.fixture def sample_share(): share = VideoShare.create( video_id="video_001", user_id="user_001", ) return share @pytest.fixture def sample_share_with_password(): share = VideoShare.create( video_id="video_001", user_id="user_001", password="secret123", ) return share @pytest.fixture def sample_share_expired(): # 直接构造已过期的分享(不经过create方法的校验) share = VideoShare( id="share_expired_001", video_id="video_001", user_id="user_001", share_token="expiredtoken123", expires_at=datetime.now(timezone.utc) - timedelta(hours=1), ) return share class TestCreateShareUseCase: """CreateShareUseCase 测试""" def test_create_share_success(self, mock_share_repo, mock_video_repo, sample_video): """正常创建分享链接""" mock_video_repo.get.return_value = sample_video mock_share_repo.create.side_effect = lambda s: s use_case = CreateShareUseCase(mock_share_repo, mock_video_repo) command = CreateShareCommand(video_id="video_001", user_id="user_001") result = use_case.execute(command) assert result.video_id == "video_001" assert result.user_id == "user_001" assert result.share_token is not None assert result.has_password is False mock_share_repo.create.assert_called_once() def test_create_share_with_password(self, mock_share_repo, mock_video_repo, sample_video): """创建带密码的分享""" mock_video_repo.get.return_value = sample_video mock_share_repo.create.side_effect = lambda s: s use_case = CreateShareUseCase(mock_share_repo, mock_video_repo) command = CreateShareCommand( video_id="video_001", user_id="user_001", password="mypassword", ) result = use_case.execute(command) assert result.has_password is True assert result.password_hash is not None def test_create_share_with_expiry(self, mock_share_repo, mock_video_repo, sample_video): """创建带有效期的分享""" mock_video_repo.get.return_value = sample_video mock_share_repo.create.side_effect = lambda s: s future = datetime.now(timezone.utc) + timedelta(days=7) use_case = CreateShareUseCase(mock_share_repo, mock_video_repo) command = CreateShareCommand( video_id="video_001", user_id="user_001", expires_at=future, ) result = use_case.execute(command) assert result.expires_at == future def test_create_share_video_not_found(self, mock_share_repo, mock_video_repo): """视频不存在时抛出 VideoNotFoundError""" mock_video_repo.get.return_value = None use_case = CreateShareUseCase(mock_share_repo, mock_video_repo) command = CreateShareCommand(video_id="nonexistent", user_id="user_001") with pytest.raises(VideoNotFoundError): use_case.execute(command) mock_share_repo.create.assert_not_called() def test_create_share_wrong_user(self, mock_share_repo, mock_video_repo, sample_video): """非视频所有者创建分享失败""" sample_video.user_id = "user_other" mock_video_repo.get.return_value = sample_video use_case = CreateShareUseCase(mock_share_repo, mock_video_repo) command = CreateShareCommand(video_id="video_001", user_id="user_001") with pytest.raises(VideoNotFoundError): use_case.execute(command) mock_share_repo.create.assert_not_called() class TestGetShareByTokenUseCase: """GetShareByTokenUseCase 测试""" def test_get_share_success(self, mock_share_repo, sample_share): """通过 token 正常获取分享信息""" mock_share_repo.get_by_token.return_value = sample_share use_case = GetShareByTokenUseCase(mock_share_repo) result = use_case.execute(sample_share.share_token) assert result.id == sample_share.id mock_share_repo.get_by_token.assert_called_once_with(sample_share.share_token) def test_get_share_not_found(self, mock_share_repo): """token 不存在时抛出 NotFoundError""" mock_share_repo.get_by_token.return_value = None use_case = GetShareByTokenUseCase(mock_share_repo) with pytest.raises(NotFoundError): use_case.execute("invalid_token") def test_get_share_expired_raises(self, mock_share_repo, sample_share_expired): """已过期的分享不可访问""" mock_share_repo.get_by_token.return_value = sample_share_expired use_case = GetShareByTokenUseCase(mock_share_repo) with pytest.raises(ShareExpiredError): use_case.execute(sample_share_expired.share_token) class TestAccessShareUseCase: """AccessShareUseCase 测试""" def test_access_without_password(self, mock_share_repo, mock_video_repo, sample_share, sample_video): """无密码分享直接访问成功""" mock_share_repo.get_by_token.return_value = sample_share mock_video_repo.get.return_value = sample_video use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) result = use_case.execute(sample_share.share_token) assert result.share.id == sample_share.id assert result.video.id == "video_001" assert result.password_verified is True mock_share_repo.increment_view.assert_called_once_with(sample_share.id) assert sample_share.view_count == 1 def test_access_with_correct_password( self, mock_share_repo, mock_video_repo, sample_share_with_password, sample_video ): """带密码分享输入正确密码访问成功""" mock_share_repo.get_by_token.return_value = sample_share_with_password mock_video_repo.get.return_value = sample_video use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) result = use_case.execute(sample_share_with_password.share_token, password="secret123") assert result.password_verified is True mock_share_repo.increment_view.assert_called_once() def test_access_password_required_but_not_provided( self, mock_share_repo, mock_video_repo, sample_share_with_password ): """带密码分享不输入密码抛出 PasswordRequiredError""" mock_share_repo.get_by_token.return_value = sample_share_with_password use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) with pytest.raises(PasswordRequiredError): use_case.execute(sample_share_with_password.share_token) mock_share_repo.increment_view.assert_not_called() def test_access_wrong_password(self, mock_share_repo, mock_video_repo, sample_share_with_password): """密码错误抛出 InvalidPasswordError""" mock_share_repo.get_by_token.return_value = sample_share_with_password use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) with pytest.raises(InvalidPasswordError): use_case.execute(sample_share_with_password.share_token, password="wrongpass") mock_share_repo.increment_view.assert_not_called() def test_access_share_not_found(self, mock_share_repo, mock_video_repo): """分享不存在抛出 NotFoundError""" mock_share_repo.get_by_token.return_value = None use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) with pytest.raises(NotFoundError): use_case.execute("invalid_token") def test_access_expired_share(self, mock_share_repo, mock_video_repo, sample_share_expired): """已过期分享不可访问""" mock_share_repo.get_by_token.return_value = sample_share_expired use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) with pytest.raises(ShareExpiredError): use_case.execute(sample_share_expired.share_token) mock_share_repo.increment_view.assert_not_called() def test_access_video_not_found(self, mock_share_repo, mock_video_repo, sample_share): """分享存在但视频不存在""" mock_share_repo.get_by_token.return_value = sample_share mock_video_repo.get.return_value = None use_case = AccessShareUseCase(mock_share_repo, mock_video_repo) with pytest.raises(VideoNotFoundError): use_case.execute(sample_share.share_token) class TestListSharesByVideoUseCase: """ListSharesByVideoUseCase 测试""" def test_list_by_video(self, mock_share_repo, sample_share): """列出某个视频的所有分享""" mock_share_repo.list_by_video.return_value = [sample_share] use_case = ListSharesByVideoUseCase(mock_share_repo) result = use_case.execute("video_001", "user_001") assert len(result) == 1 mock_share_repo.list_by_video.assert_called_once_with("video_001", "user_001") def test_list_by_video_empty(self, mock_share_repo): """视频没有分享记录时返回空列表""" mock_share_repo.list_by_video.return_value = [] use_case = ListSharesByVideoUseCase(mock_share_repo) result = use_case.execute("video_001", "user_001") assert result == [] class TestListSharesByUserUseCase: """ListSharesByUserUseCase 测试""" def test_list_by_user(self, mock_share_repo, sample_share): """列出用户的所有分享""" mock_share_repo.list_by_user.return_value = [sample_share] mock_share_repo.count_by_user.return_value = 1 use_case = ListSharesByUserUseCase(mock_share_repo) items, total = use_case.execute("user_001") assert len(items) == 1 assert total == 1 mock_share_repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=20) def test_list_by_user_with_pagination(self, mock_share_repo): """带分页参数查询""" mock_share_repo.list_by_user.return_value = [] mock_share_repo.count_by_user.return_value = 50 use_case = ListSharesByUserUseCase(mock_share_repo) items, total = use_case.execute("user_001", skip=10, limit=5) assert total == 50 mock_share_repo.list_by_user.assert_called_once_with("user_001", skip=10, limit=5) def test_list_by_user_empty(self, mock_share_repo): """用户没有分享记录""" mock_share_repo.list_by_user.return_value = [] mock_share_repo.count_by_user.return_value = 0 use_case = ListSharesByUserUseCase(mock_share_repo) items, total = use_case.execute("user_001") assert items == [] assert total == 0 class TestUpdateShareUseCase: """UpdateShareUseCase 测试""" def test_update_password(self, mock_share_repo, sample_share): """更新分享密码""" mock_share_repo.get_by_id.return_value = sample_share mock_share_repo.update.side_effect = lambda s: s use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id=sample_share.id, user_id="user_001", password="newpassword", ) result = use_case.execute(command) assert result.has_password is True mock_share_repo.update.assert_called_once() def test_clear_password(self, mock_share_repo, sample_share_with_password): """清除分享密码(空字符串)""" mock_share_repo.get_by_id.return_value = sample_share_with_password mock_share_repo.update.side_effect = lambda s: s use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id=sample_share_with_password.id, user_id="user_001", password="", # 空字符串表示清除 ) result = use_case.execute(command) assert result.has_password is False assert result.password_hash is None def test_update_password_none_no_change(self, mock_share_repo, sample_share_with_password): """password=None 不修改密码""" original_hash = sample_share_with_password.password_hash mock_share_repo.get_by_id.return_value = sample_share_with_password mock_share_repo.update.side_effect = lambda s: s use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id=sample_share_with_password.id, user_id="user_001", password=None, # None表示不修改 ) result = use_case.execute(command) assert result.password_hash == original_hash def test_update_expires_at(self, mock_share_repo, sample_share): """更新有效期""" mock_share_repo.get_by_id.return_value = sample_share mock_share_repo.update.side_effect = lambda s: s future = datetime.now(timezone.utc) + timedelta(days=3) use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id=sample_share.id, user_id="user_001", expires_at=future, ) result = use_case.execute(command) assert result.expires_at == future def test_update_expires_at_past_raises(self, mock_share_repo, sample_share): """设置过去的有效期抛出 ValueError""" mock_share_repo.get_by_id.return_value = sample_share past = datetime.now(timezone.utc) - timedelta(hours=1) use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id=sample_share.id, user_id="user_001", expires_at=past, ) with pytest.raises(ValueError, match="expires_at cannot be in the past"): use_case.execute(command) mock_share_repo.update.assert_not_called() def test_update_share_not_found(self, mock_share_repo): """分享不存在抛出 NotFoundError""" mock_share_repo.get_by_id.return_value = None use_case = UpdateShareUseCase(mock_share_repo) command = UpdateShareCommand( share_id="nonexistent", user_id="user_001", password="newpass", ) with pytest.raises(NotFoundError): use_case.execute(command) mock_share_repo.update.assert_not_called() class TestRevokeShareUseCase: """RevokeShareUseCase 测试""" def test_revoke_success(self, mock_share_repo, sample_share): """撤销分享成功""" mock_share_repo.get_by_id.return_value = sample_share mock_share_repo.delete.return_value = True use_case = RevokeShareUseCase(mock_share_repo) result = use_case.execute(sample_share.id, "user_001") assert result is True mock_share_repo.delete.assert_called_once_with(sample_share.id, "user_001") def test_revoke_not_found(self, mock_share_repo): """分享不存在抛出 NotFoundError""" mock_share_repo.get_by_id.return_value = None use_case = RevokeShareUseCase(mock_share_repo) with pytest.raises(NotFoundError): use_case.execute("nonexistent", "user_001") mock_share_repo.delete.assert_not_called() class TestRecordShareDownloadUseCase: """RecordShareDownloadUseCase 测试""" def test_record_download_no_password(self, mock_share_repo, sample_share): """无密码分享记录下载""" mock_share_repo.get_by_token.return_value = sample_share use_case = RecordShareDownloadUseCase(mock_share_repo) use_case.execute(sample_share.share_token) mock_share_repo.increment_download.assert_called_once_with(sample_share.id) def test_record_download_with_password(self, mock_share_repo, sample_share_with_password): """带密码分享正确密码记录下载""" mock_share_repo.get_by_token.return_value = sample_share_with_password use_case = RecordShareDownloadUseCase(mock_share_repo) use_case.execute(sample_share_with_password.share_token, password="secret123") mock_share_repo.increment_download.assert_called_once() def test_record_download_wrong_password(self, mock_share_repo, sample_share_with_password): """密码错误不记录下载""" mock_share_repo.get_by_token.return_value = sample_share_with_password use_case = RecordShareDownloadUseCase(mock_share_repo) with pytest.raises(InvalidPasswordError): use_case.execute(sample_share_with_password.share_token, password="wrong") mock_share_repo.increment_download.assert_not_called() def test_record_download_not_found(self, mock_share_repo): """分享不存在抛出 NotFoundError""" mock_share_repo.get_by_token.return_value = None use_case = RecordShareDownloadUseCase(mock_share_repo) with pytest.raises(NotFoundError): use_case.execute("invalid_token") def test_record_download_expired(self, mock_share_repo, sample_share_expired): """已过期分享不能下载""" mock_share_repo.get_by_token.return_value = sample_share_expired use_case = RecordShareDownloadUseCase(mock_share_repo) with pytest.raises(ShareExpiredError): use_case.execute(sample_share_expired.share_token) mock_share_repo.increment_download.assert_not_called()