from types import SimpleNamespace from unittest.mock import patch from app.modules.filemanager.storages.u115 import U115Pan from app.schemas import FileItem def _target_dir() -> FileItem: """ 构造 115 上传目标目录。 """ return FileItem( storage="u115", path="/library/Test Show (2026)/Season 1", type="dir", name="Season 1", fileid="100", ) def _fake_sha1(*_args, **_kwargs) -> str: """ 返回固定 SHA1,避免单测重复计算文件哈希。 """ return "sha1" def _fake_request_api(_method, endpoint, *_args, **_kwargs): """ 模拟 115 初始化、凭证和断点续传接口。 """ if endpoint == "/open/upload/init": return { "state": True, "data": { "bucket": "bucket", "object": "object", "callback": {"callback": "callback", "callback_var": "var"}, "pick_code": "pickcode", "status": 1, }, } if endpoint == "/open/upload/get_token": return { "endpoint": "endpoint", "AccessKeyId": "access_key_id", "AccessKeySecret": "access_key_secret", "SecurityToken": "security_token", } if endpoint == "/open/upload/resume": return None return None class _FakeBucket: """ 模拟 OSS 分片上传客户端。 """ complete_payload = {"state": True} def __init__(self, *_args, **_kwargs): """ 初始化伪造 Bucket。 """ pass def init_multipart_upload(self, *_args, **_kwargs): """ 返回固定 upload_id。 """ return SimpleNamespace(upload_id="upload_id") def upload_part(self, *_args, **_kwargs): """ 返回固定分片 etag。 """ return SimpleNamespace(etag="etag") def complete_multipart_upload(self, *_args, **_kwargs): """ 返回可配置的 115 回调结果。 """ response = SimpleNamespace(json=lambda: self.complete_payload) return SimpleNamespace(status=200, resp=SimpleNamespace(response=response)) def _build_storage() -> U115Pan: """ 构造跳过初始化流程的 115 存储实例。 """ storage = object.__new__(U115Pan) storage._calc_sha1 = _fake_sha1 storage.get_item = lambda _path: None storage._request_api = _fake_request_api return storage def _upload_with_fakes(storage: U115Pan, target_dir: FileItem, local_file): """ 使用伪造 OSS 和进度回调执行上传。 """ with patch( "app.modules.filemanager.storages.u115.oss2.StsAuth", return_value=object(), ), patch( "app.modules.filemanager.storages.u115.oss2.Bucket", _FakeBucket, ), patch( "app.modules.filemanager.storages.u115.transfer_process", return_value=lambda _progress: None, ): return storage.upload(target_dir, local_file) def test_upload_returns_target_fileitem_when_uploaded_metadata_is_delayed(tmp_path): """ 115 上传完成后目录索引暂不可见时,应返回可落库的目标文件项。 """ local_file = tmp_path / "Test.Show.S01E01.mkv" local_file.write_bytes(b"movie") storage = _build_storage() uploaded_item = _upload_with_fakes(storage, _target_dir(), local_file) assert uploaded_item is not None assert uploaded_item.storage == "u115" assert uploaded_item.path == "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv" assert uploaded_item.type == "file" assert uploaded_item.size == local_file.stat().st_size def test_upload_returns_none_when_complete_callback_reports_failure(tmp_path): """ 115 完成回调失败时,不应把文件视为上传成功。 """ local_file = tmp_path / "Test.Show.S01E02.mkv" local_file.write_bytes(b"movie") storage = _build_storage() with patch.object( _FakeBucket, "complete_payload", {"state": False, "message": "callback failed"}, ): uploaded_item = _upload_with_fakes(storage, _target_dir(), local_file) assert uploaded_item is None def test_upload_uses_dynamic_part_size(tmp_path): """ 115 上传应根据文件大小动态控制 OSS 分片大小。 """ local_file = tmp_path / "Test.Show.S01E03.mkv" local_file.write_bytes(b"movie") storage = _build_storage() with patch( "app.modules.filemanager.storages.u115.determine_part_size", return_value=local_file.stat().st_size, ) as determine_part_size_mock: uploaded_item = _upload_with_fakes(storage, _target_dir(), local_file) assert uploaded_item is not None determine_part_size_mock.assert_called_once_with( local_file.stat().st_size, preferred_size=10 * 1024 * 1024 ) def test_upload_part_size_grows_for_large_files(): """ 大文件上传应自动放大分片大小,避免产生过多分片。 """ file_size = int(5.6 * 1024 * 1024 * 1024) part_size = U115Pan._U115Pan__get_upload_part_size(file_size) assert part_size == 64 * 1024 * 1024