""" SharedStorageService 单元测试 重点覆盖纯逻辑部分: - _normalize_storage_key: URL提取 + URL解码 - _is_local_generated_url: 本地生成URL判断 - get_url: 公共URL拼接 - create_direct_upload_post: policy + HMAC签名 - get_download_url: bucket=None时的fallback - 未配置OSS时的错误处理 - 单例模式 """ import base64 import hashlib import hmac import json import os from unittest.mock import MagicMock, patch import pytest from packages.shared.storage import ( SharedStorageService, get_shared_storage_service, get_storage_service, ) # ============================================================ # Fixtures # ============================================================ def _make_service( bucket_name="test-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", access_key_id="test-key-id", access_key_secret="test-key-secret", local_url_prefix="/generated-files", with_bucket=True, ): """创建一个 SharedStorageService 实例,mock 掉 oss2 和 settings。""" mock_settings = MagicMock() mock_settings.oss_bucket_name = bucket_name mock_settings.oss_endpoint = endpoint mock_settings.oss_access_key_id = access_key_id mock_settings.oss_access_key_secret = access_key_secret mock_bucket = MagicMock() if with_bucket else None with ( patch("packages.shared.storage.get_shared_settings", return_value=mock_settings), patch.dict(os.environ, {"GENERATED_FILES_URL_PREFIX": local_url_prefix}, clear=False), ): if with_bucket: with patch("packages.shared.storage.oss2") as mock_oss2: mock_oss2.Auth.return_value = MagicMock() mock_oss2.Bucket.return_value = mock_bucket service = SharedStorageService() service.bucket = mock_bucket return service, mock_bucket, mock_settings else: service = SharedStorageService() service.bucket = None return service, None, mock_settings # ============================================================ # _normalize_storage_key # ============================================================ class TestNormalizeStorageKey: """_normalize_storage_key URL 提取与解码""" def test_plain_key_returns_as_is(self): service, _, _ = _make_service() result = service._normalize_storage_key("videos/clip.mp4") assert result == "videos/clip.mp4" def test_key_with_leading_slash_stripped(self): service, _, _ = _make_service() result = service._normalize_storage_key("/videos/clip.mp4") assert result == "videos/clip.mp4" def test_https_url_extracts_path(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/clip.mp4") assert result == "videos/clip.mp4" def test_http_url_extracts_path(self): service, _, _ = _make_service() result = service._normalize_storage_key("http://test-bucket.oss-cn-hangzhou.aliyuncs.com/audio/voice.mp3") assert result == "audio/voice.mp3" def test_url_with_query_strips_query(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss-cn.com/file.mp4?signature=abc&expires=123") assert result == "file.mp4" def test_url_with_leading_slash_in_path(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com//double/slash.jpg") assert result == "double/slash.jpg" def test_url_decodes_percent_encoded_spaces(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com/my%20video.mp4") assert result == "my video.mp4" def test_url_decodes_percent_encoded_chinese(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com/%E4%B8%AD%E6%96%87.mp4") assert result == "中文.mp4" def test_url_with_special_chars_decoded(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com/file%281%29.jpg") assert result == "file(1).jpg" def test_plain_key_with_percent_not_decoded(self): """原始 key 不以 http 开头,不做 URL 解码,直接 lstrip('/')""" service, _, _ = _make_service() result = service._normalize_storage_key("file%20name.mp4") # 不是 URL,直接返回(去掉前导/) assert result == "file%20name.mp4" def test_empty_string(self): service, _, _ = _make_service() result = service._normalize_storage_key("") assert result == "" def test_root_slash_url(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com/") assert result == "" def test_nested_path_url(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com/a/b/c/d/file.txt") assert result == "a/b/c/d/file.txt" def test_url_with_port(self): service, _, _ = _make_service() result = service._normalize_storage_key("https://bucket.oss.com:443/file.txt") assert result == "file.txt" # ============================================================ # _is_local_generated_url # ============================================================ class TestIsLocalGeneratedUrl: """_is_local_generated_url 本地URL判断""" def test_local_prefix_returns_true(self): service, _, _ = _make_service() assert service._is_local_generated_url("/generated-files/abc.mp4") is True def test_relative_local_returns_true(self): service, _, _ = _make_service() # 没有 scheme,直接用原字符串匹配 assert service._is_local_generated_url("/generated-files/out.mp4") is True def test_full_url_with_local_path_returns_true(self): service, _, _ = _make_service() assert service._is_local_generated_url("https://example.com/generated-files/abc.mp4") is True def test_other_path_returns_false(self): service, _, _ = _make_service() assert service._is_local_generated_url("/videos/abc.mp4") is False def test_empty_string_returns_false(self): service, _, _ = _make_service() assert service._is_local_generated_url("") is False def test_custom_prefix(self): service, _, _ = _make_service(local_url_prefix="/custom-prefix") assert service._is_local_generated_url("/custom-prefix/file.mp4") is True assert service._is_local_generated_url("/generated-files/file.mp4") is False # ============================================================ # get_url # ============================================================ class TestGetUrl: """get_url 公共URL拼接""" def test_returns_public_url_plus_key(self): service, _, _ = _make_service() result = service.get_url("videos/test.mp4") assert result == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/test.mp4" def test_empty_key(self): service, _, _ = _make_service() result = service.get_url("") assert result == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/" def test_custom_bucket_and_endpoint(self): service, _, _ = _make_service( bucket_name="my-bucket", endpoint="oss-us-east-1.aliyuncs.com", ) result = service.get_url("file.txt") assert result == "https://my-bucket.oss-us-east-1.aliyuncs.com/file.txt" # ============================================================ # create_direct_upload_post # ============================================================ class TestCreateDirectUploadPost: """create_direct_upload_post 直传表单生成""" def test_returns_dict_with_expected_keys(self): service, _, _ = _make_service() result = service.create_direct_upload_post( storage_key="uploads/test.jpg", content_type="image/jpeg", max_size_bytes=10 * 1024 * 1024, expires_seconds=3600, ) assert "url" in result assert "method" in result assert "storage_key" in result assert "expires_at" in result assert "fields" in result def test_method_is_post(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) assert result["method"] == "POST" def test_url_is_public_url(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) assert result["url"] == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com" def test_storage_key_normalized(self): service, _, _ = _make_service() result = service.create_direct_upload_post("/uploads/test.jpg", "image/jpeg", 1024, 3600) assert result["storage_key"] == "uploads/test.jpg" assert result["fields"]["key"] == "uploads/test.jpg" def test_fields_contain_required_keys(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) fields = result["fields"] assert fields["key"] == "uploads/a.jpg" assert fields["OSSAccessKeyId"] == "test-key-id" assert fields["success_action_status"] == "201" assert fields["Content-Type"] == "image/jpeg" assert "policy" in fields assert "Signature" in fields def test_policy_signature_is_valid_hmac_sha1(self): """验证 HMAC-SHA1 签名是否正确""" secret = "my-secret-key-123" service, _, _ = _make_service(access_key_secret=secret) result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) policy = result["fields"]["policy"] signature = result["fields"]["Signature"] # 手动计算签名验证 expected = base64.b64encode( hmac.new(secret.encode("utf-8"), policy.encode("utf-8"), hashlib.sha1).digest() ).decode("ascii") assert signature == expected def test_policy_contains_bucket_and_key(self): service, _, _ = _make_service(bucket_name="my-bucket") result = service.create_direct_upload_post("uploads/photo.png", "image/png", 2048, 1800) policy = json.loads(base64.b64decode(result["fields"]["policy"])) conditions = policy["conditions"] assert {"bucket": "my-bucket"} in conditions assert {"key": "uploads/photo.png"} in conditions def test_policy_contains_content_length_range(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 5242880, 3600) policy = json.loads(base64.b64decode(result["fields"]["policy"])) conditions = policy["conditions"] size_condition = [c for c in conditions if isinstance(c, list) and c[0] == "content-length-range"] assert len(size_condition) == 1 assert size_condition[0][1] == 1 assert size_condition[0][2] == 5242880 def test_policy_content_type_starts_with(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) policy = json.loads(base64.b64decode(result["fields"]["policy"])) conditions = policy["conditions"] ct_condition = [c for c in conditions if isinstance(c, list) and c[0] == "starts-with"] assert len(ct_condition) == 1 assert ct_condition[0][1] == "$Content-Type" assert ct_condition[0][2] == "image/" def test_policy_has_expiration(self): service, _, _ = _make_service() result = service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) policy = json.loads(base64.b64decode(result["fields"]["policy"])) assert "expiration" in policy # ISO 8601 格式 assert policy["expiration"].endswith("Z") def test_non_uploads_key_raises_value_error(self): service, _, _ = _make_service() with pytest.raises(ValueError, match="uploads/"): service.create_direct_upload_post("videos/a.mp4", "video/mp4", 1024, 3600) def test_no_credentials_raises_runtime_error(self): service, _, _ = _make_service(access_key_id="", access_key_secret="", with_bucket=False) with pytest.raises(RuntimeError, match="not configured"): service.create_direct_upload_post("uploads/a.jpg", "image/jpeg", 1024, 3600) def test_url_normalized_key_in_uploads(self): service, _, _ = _make_service() # URL 形式的 key 被 normalize 后如果在 uploads/ 下应该可以 result = service.create_direct_upload_post( "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/uploads/from_url.jpg", "image/jpeg", 1024, 3600, ) assert result["storage_key"] == "uploads/from_url.jpg" # ============================================================ # get_download_url (bucket=None 时的 fallback) # ============================================================ class TestGetDownloadUrlFallback: """get_download_url 在 bucket 未配置时的 fallback 逻辑""" def test_no_bucket_local_url_returns_as_is(self): service, _, _ = _make_service(with_bucket=False) result = service.get_download_url("/generated-files/test.mp4") assert result == "/generated-files/test.mp4" def test_no_bucket_regular_key_returns_public_url(self): service, _, _ = _make_service(with_bucket=False) result = service.get_download_url("videos/test.mp4") assert result == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/test.mp4" def test_no_bucket_url_input_normalized(self): service, _, _ = _make_service(with_bucket=False) result = service.get_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/test.mp4") assert result == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/test.mp4" def test_with_bucket_calls_sign_url(self): service, mock_bucket, _ = _make_service(with_bucket=True) mock_bucket.sign_url.return_value = "https://signed-url.com/file?sig=abc" result = service.get_download_url("videos/test.mp4", expires_seconds=7200) mock_bucket.sign_url.assert_called_once_with("GET", "videos/test.mp4", 7200) assert result == "https://signed-url.com/file?sig=abc" def test_sign_url_exception_falls_back_to_public_url(self): service, mock_bucket, _ = _make_service(with_bucket=True) mock_bucket.sign_url.side_effect = Exception("sign error") result = service.get_download_url("videos/test.mp4") assert result == "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/videos/test.mp4" # ============================================================ # 未配置 OSS 时的错误处理 # ============================================================ class TestNoBucketErrorHandling: """bucket=None 时的错误处理""" def test_upload_file_raises(self): service, _, _ = _make_service(with_bucket=False) with pytest.raises(RuntimeError, match="not configured"): service.upload_file("/tmp/test.txt", "uploads/test.txt") def test_download_file_raises(self): service, _, _ = _make_service(with_bucket=False) with pytest.raises(RuntimeError, match="not configured"): service.download_file("uploads/test.txt", "/tmp/test.txt") def test_delete_file_silent_noop(self): service, _, _ = _make_service(with_bucket=False) # 不抛异常 result = service.delete_file("uploads/test.txt") assert result is None def test_file_exists_returns_false(self): service, _, _ = _make_service(with_bucket=False) assert service.file_exists("uploads/test.txt") is False # ============================================================ # upload_file / delete_file / file_exists 正常路径 # ============================================================ class TestBucketOperations: """有 bucket 时的操作调用验证""" def test_upload_file_with_path_string(self): service, mock_bucket, _ = _make_service() result = service.upload_file("/tmp/file.txt", "uploads/file.txt", "text/plain") mock_bucket.put_object_from_file.assert_called_once() args = mock_bucket.put_object_from_file.call_args assert args[0][0] == "uploads/file.txt" assert args[0][1] == "/tmp/file.txt" assert result.startswith("https://test-bucket.") def test_upload_file_with_file_object(self): service, mock_bucket, _ = _make_service() mock_file = MagicMock() result = service.upload_file(mock_file, "uploads/file.bin", "application/octet-stream") mock_file.seek.assert_called_once_with(0) mock_bucket.put_object.assert_called_once() assert result.startswith("https://test-bucket.") def test_delete_file_calls_bucket(self): service, mock_bucket, _ = _make_service() service.delete_file("uploads/test.txt") mock_bucket.delete_object.assert_called_once_with("uploads/test.txt") def test_delete_file_exception_logged_not_raised(self): service, mock_bucket, _ = _make_service() mock_bucket.delete_object.side_effect = Exception("delete error") # 不抛异常 service.delete_file("uploads/test.txt") def test_file_exists_delegates_to_bucket(self): service, mock_bucket, _ = _make_service() mock_bucket.object_exists.return_value = True assert service.file_exists("some/key") is True mock_bucket.object_exists.assert_called_once_with("some/key") def test_file_exists_false(self): service, mock_bucket, _ = _make_service() mock_bucket.object_exists.return_value = False assert service.file_exists("some/key") is False # ============================================================ # 单例 & 兼容别名 # ============================================================ class TestSingleton: """get_shared_storage_service 单例模式""" def test_get_storage_service_is_alias(self): # 两个函数返回同一个实例 with patch("packages.shared.storage._storage_service", None): with patch("packages.shared.storage.SharedStorageService") as mock_cls: mock_instance = MagicMock() mock_cls.return_value = mock_instance svc1 = get_shared_storage_service() svc2 = get_storage_service() assert svc1 is svc2 # 因为是同一个单例,类只实例化一次 assert mock_cls.call_count == 1 # ============================================================ # __init__ endpoint 处理 # ============================================================ class TestInitEndpointHandling: """初始化时 endpoint https 前缀处理""" def test_endpoint_without_https_gets_prefix(self): mock_settings = MagicMock() mock_settings.oss_bucket_name = "test-bucket" mock_settings.oss_endpoint = "oss-cn-hangzhou.aliyuncs.com" mock_settings.oss_access_key_id = "key-id" mock_settings.oss_access_key_secret = "key-secret" with ( patch("packages.shared.storage.get_shared_settings", return_value=mock_settings), patch("packages.shared.storage.oss2") as mock_oss2, ): mock_oss2.Bucket.return_value = MagicMock() service = SharedStorageService() # 验证 Bucket 构造时 endpoint 带了 https:// call_args = mock_oss2.Bucket.call_args assert call_args[0][1] == "https://oss-cn-hangzhou.aliyuncs.com" def test_endpoint_with_https_keeps_as_is(self): mock_settings = MagicMock() mock_settings.oss_bucket_name = "test-bucket" mock_settings.oss_endpoint = "https://oss-cn-hangzhou.aliyuncs.com" mock_settings.oss_access_key_id = "key-id" mock_settings.oss_access_key_secret = "key-secret" with ( patch("packages.shared.storage.get_shared_settings", return_value=mock_settings), patch("packages.shared.storage.oss2") as mock_oss2, ): mock_oss2.Bucket.return_value = MagicMock() service = SharedStorageService() call_args = mock_oss2.Bucket.call_args assert call_args[0][1] == "https://oss-cn-hangzhou.aliyuncs.com" def test_endpoint_with_http_keeps_as_is(self): mock_settings = MagicMock() mock_settings.oss_bucket_name = "test-bucket" mock_settings.oss_endpoint = "http://oss-cn-hangzhou.aliyuncs.com" mock_settings.oss_access_key_id = "key-id" mock_settings.oss_access_key_secret = "key-secret" with ( patch("packages.shared.storage.get_shared_settings", return_value=mock_settings), patch("packages.shared.storage.oss2") as mock_oss2, ): mock_oss2.Bucket.return_value = MagicMock() service = SharedStorageService() call_args = mock_oss2.Bucket.call_args assert call_args[0][1] == "http://oss-cn-hangzhou.aliyuncs.com"