| 1 | import os |
| 2 | import sys |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | from pathlib import Path |
| 6 | from types import SimpleNamespace |
| 7 | from unittest.mock import patch |
| 8 | |
| 9 | import requests |
| 10 | |
| 11 | sys.path.insert(0, str(Path(__file__).parent.parent.parent)) |
| 12 | |
| 13 | from app.config import config |
| 14 | from app.services import material |
| 15 | |
| 16 | |
| 17 | class TestMaterialTlsVerification(unittest.TestCase): |
| 18 | def setUp(self): |
| 19 | self.original_app_config = dict(config.app) |
| 20 | self.original_proxy_config = dict(config.proxy) |
| 21 | |
| 22 | def tearDown(self): |
| 23 | config.app.clear() |
| 24 | config.app.update(self.original_app_config) |
| 25 | config.proxy.clear() |
| 26 | config.proxy.update(self.original_proxy_config) |
| 27 | |
| 28 | def test_search_pexels_uses_tls_verification_by_default(self): |
| 29 | """ |
| 30 | 默认路径必须开启 TLS 校验,避免素材 API key 和返回的素材 URL |
| 31 | 在公共网络或不可信代理环境中被中间人攻击截获或篡改。 |
| 32 | """ |
| 33 | config.app["pexels_api_keys"] = ["pexels-key"] |
| 34 | config.app.pop("tls_verify", None) |
| 35 | config.proxy.clear() |
| 36 | |
| 37 | fake_response = SimpleNamespace( |
| 38 | json=lambda: { |
| 39 | "videos": [ |
| 40 | { |
| 41 | "duration": 8, |
| 42 | "video_files": [ |
| 43 | { |
| 44 | "width": 1080, |
| 45 | "height": 1920, |
| 46 | "link": "https://example.com/video.mp4", |
| 47 | } |
| 48 | ], |
| 49 | } |
| 50 | ] |
| 51 | } |
| 52 | ) |
| 53 | |
| 54 | with patch("app.services.material.requests.get", return_value=fake_response) as get: |
| 55 | results = material.search_videos_pexels("cat", minimum_duration=1) |
| 56 | |
| 57 | self.assertEqual(len(results), 1) |
| 58 | self.assertTrue(get.call_args.kwargs["verify"]) |
| 59 | |
| 60 | def test_search_pixabay_allows_explicit_tls_disable_for_proxy(self): |
| 61 | """ |
| 62 | 少数企业代理会使用自签证书。该场景必须显式配置关闭 TLS 校验, |
| 63 | 不能再由代码硬编码默认关闭。 |
| 64 | """ |
| 65 | config.app["pixabay_api_keys"] = ["pixabay-key"] |
| 66 | config.app["tls_verify"] = False |
| 67 | config.proxy.clear() |
| 68 | |
| 69 | fake_response = SimpleNamespace( |
| 70 | json=lambda: { |
| 71 | "hits": [ |
| 72 | { |
| 73 | "duration": 8, |
| 74 | "videos": { |
| 75 | "large": { |
| 76 | "width": 1920, |
| 77 | "url": "https://example.com/video.mp4", |
| 78 | } |
| 79 | }, |
| 80 | } |
| 81 | ] |
| 82 | } |
| 83 | ) |
| 84 | |
| 85 | with patch("app.services.material.requests.get", return_value=fake_response) as get: |
| 86 | results = material.search_videos_pixabay("cat", minimum_duration=1) |
| 87 | |
| 88 | self.assertEqual(len(results), 1) |
| 89 | self.assertFalse(get.call_args.kwargs["verify"]) |
| 90 | |
| 91 | def test_save_video_uses_tls_verification_by_default(self): |
| 92 | config.app.pop("tls_verify", None) |
| 93 | config.proxy.clear() |
| 94 | |
| 95 | fake_response = SimpleNamespace(content=b"fake-video") |
| 96 | |
| 97 | class FakeVideoFileClip: |
| 98 | duration = 1 |
| 99 | fps = 24 |
| 100 | |
| 101 | def __init__(self, path): |
| 102 | self.path = path |
| 103 | |
| 104 | def close(self): |
| 105 | return None |
| 106 | |
| 107 | with tempfile.TemporaryDirectory() as temp_dir: |
| 108 | with patch( |
| 109 | "app.services.material.requests.get", return_value=fake_response |
| 110 | ) as get, patch("app.services.material.VideoFileClip", FakeVideoFileClip): |
| 111 | video_path = material.save_video( |
| 112 | "https://example.com/video.mp4?token=abc", save_dir=temp_dir |
| 113 | ) |
| 114 | |
| 115 | self.assertTrue(os.path.exists(video_path)) |
| 116 | self.assertTrue(get.call_args.kwargs["verify"]) |
| 117 | |
| 118 | def test_download_videos_accepts_plain_string_concat_mode(self): |
| 119 | """ |
| 120 | download_videos 可能被服务层或测试直接传入字符串模式,而不是 |
| 121 | VideoConcatMode 枚举。这里用空搜索词避免真实网络请求,只验证 |
| 122 | 字符串 "random" 不会再因为访问 `.value` 抛 AttributeError。 |
| 123 | """ |
| 124 | result = material.download_videos( |
| 125 | task_id="string-concat-mode", |
| 126 | search_terms=[], |
| 127 | video_concat_mode="random", |
| 128 | ) |
| 129 | |
| 130 | self.assertEqual(result, []) |
| 131 | |
| 132 | def test_download_videos_can_round_robin_terms_in_script_order(self): |
| 133 | """ |
| 134 | 开启按文案顺序匹配素材后,不能让第一个关键词的多个候选先把 |
| 135 | 音频时长填满。这里模拟两个关键词各有多个候选,验证下载顺序是 |
| 136 | term1-第1个、term2-第1个、term1-第2个,贴近脚本叙事顺序。 |
| 137 | """ |
| 138 | search_results = { |
| 139 | "opening city": [ |
| 140 | material.MaterialInfo(provider="pexels", url="https://v.example/a1.mp4", duration=3), |
| 141 | material.MaterialInfo(provider="pexels", url="https://v.example/a2.mp4", duration=3), |
| 142 | ], |
| 143 | "middle office": [ |
| 144 | material.MaterialInfo(provider="pexels", url="https://v.example/b1.mp4", duration=3), |
| 145 | material.MaterialInfo(provider="pexels", url="https://v.example/b2.mp4", duration=3), |
| 146 | ], |
| 147 | } |
| 148 | downloaded_urls = [] |
| 149 | |
| 150 | def fake_search(search_term, minimum_duration, video_aspect): |
| 151 | return search_results[search_term] |
| 152 | |
| 153 | def fake_save_video(video_url, save_dir=""): |
| 154 | downloaded_urls.append(video_url) |
| 155 | return f"/tmp/{video_url.rsplit('/', 1)[-1]}" |
| 156 | |
| 157 | with ( |
| 158 | patch.dict(config.app, {"material_directory": ""}), |
| 159 | patch.object(material, "search_videos_pexels", side_effect=fake_search), |
| 160 | patch.object(material, "save_video", side_effect=fake_save_video), |
| 161 | ): |
| 162 | result = material.download_videos( |
| 163 | task_id="ordered-materials", |
| 164 | search_terms=["opening city", "middle office"], |
| 165 | source="pexels", |
| 166 | audio_duration=7, |
| 167 | max_clip_duration=3, |
| 168 | match_script_order=True, |
| 169 | ) |
| 170 | |
| 171 | self.assertEqual( |
| 172 | downloaded_urls, |
| 173 | [ |
| 174 | "https://v.example/a1.mp4", |
| 175 | "https://v.example/b1.mp4", |
| 176 | "https://v.example/a2.mp4", |
| 177 | ], |
| 178 | ) |
| 179 | self.assertEqual(result, ["/tmp/a1.mp4", "/tmp/b1.mp4", "/tmp/a2.mp4"]) |
| 180 | |
| 181 | |
| 182 | class TestCoverrProvider(unittest.TestCase): |
| 183 | """ |
| 184 | Coverr 视频素材源(spec: 2026-06-09-coverr-video-provider-design.md)。 |
| 185 | 全部用 unittest.mock 替换 requests,确保 CI 不依赖真实网络和真实 API key。 |
| 186 | """ |
| 187 | |
| 188 | def setUp(self): |
| 189 | self.original_app_config = dict(config.app) |
| 190 | self.original_proxy_config = dict(config.proxy) |
| 191 | |
| 192 | def tearDown(self): |
| 193 | config.app.clear() |
| 194 | config.app.update(self.original_app_config) |
| 195 | config.proxy.clear() |
| 196 | config.proxy.update(self.original_proxy_config) |
| 197 | |
| 198 | # ---------------- Tests for search_videos_coverr ---------------- |
| 199 | |
| 200 | def test_search_coverr_uses_mp4_download_url(self): |
| 201 | """ |
| 202 | search_videos_coverr 应把每个 hit 转成 MaterialInfo,并把 urls.mp4_download |
| 203 | 直接作为 MaterialInfo.url。 |
| 204 | 按 Coverr 官方文档 (api.coverr.co/docs/videos/#download-a-video), |
| 205 | GET mp4_download 本身就被 Coverr 计入下载统计,无需额外 PATCH ping。 |
| 206 | 同时验证 Authorization header 使用 Bearer scheme。 |
| 207 | """ |
| 208 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 209 | config.app.pop("tls_verify", None) |
| 210 | config.proxy.clear() |
| 211 | |
| 212 | fake_response = SimpleNamespace( |
| 213 | json=lambda: { |
| 214 | "page": 0, |
| 215 | "pages": 50, |
| 216 | "page_size": 20, |
| 217 | "total": 1, |
| 218 | "hits": [ |
| 219 | { |
| 220 | "id": "S1YbPl1NfI", |
| 221 | "duration": 11.625, |
| 222 | "aspect_ratio": "16:9", |
| 223 | "urls": { |
| 224 | "mp4": "https://storage.coverr.co/videos/abc?token=xyz", |
| 225 | "mp4_preview": "https://storage.coverr.co/videos/abc/preview?token=xyz", |
| 226 | "mp4_download": "https://storage.coverr.co/videos/abc/download?token=xyz", |
| 227 | }, |
| 228 | } |
| 229 | ], |
| 230 | } |
| 231 | ) |
| 232 | |
| 233 | with patch( |
| 234 | "app.services.material.requests.get", return_value=fake_response |
| 235 | ) as get: |
| 236 | results = material.search_videos_coverr("nature", minimum_duration=5) |
| 237 | |
| 238 | self.assertEqual(len(results), 1) |
| 239 | item = results[0] |
| 240 | self.assertEqual(item.provider, "coverr") |
| 241 | self.assertEqual(item.duration, 11) |
| 242 | # url 字段就是 mp4_download URL,不再做 coverr://id|url 编码 |
| 243 | self.assertEqual( |
| 244 | item.url, "https://storage.coverr.co/videos/abc/download?token=xyz" |
| 245 | ) |
| 246 | # Bearer auth + TLS verify on by default |
| 247 | self.assertEqual( |
| 248 | get.call_args.kwargs["headers"]["Authorization"], "Bearer coverr-key" |
| 249 | ) |
| 250 | self.assertTrue(get.call_args.kwargs["verify"]) |
| 251 | |
| 252 | def test_search_coverr_uses_tls_verification_by_default(self): |
| 253 | """与 pexels/pixabay 一致:未显式配置时 TLS 校验默认开启。""" |
| 254 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 255 | config.app.pop("tls_verify", None) |
| 256 | config.proxy.clear() |
| 257 | |
| 258 | fake_response = SimpleNamespace(json=lambda: {"hits": []}) |
| 259 | |
| 260 | with patch( |
| 261 | "app.services.material.requests.get", return_value=fake_response |
| 262 | ) as get: |
| 263 | material.search_videos_coverr("nature", minimum_duration=1) |
| 264 | |
| 265 | self.assertTrue(get.call_args.kwargs["verify"]) |
| 266 | |
| 267 | def test_search_coverr_allows_explicit_tls_disable_for_proxy(self): |
| 268 | """企业自签证书代理场景必须能显式关闭 TLS 校验。""" |
| 269 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 270 | config.app["tls_verify"] = False |
| 271 | config.proxy.clear() |
| 272 | |
| 273 | fake_response = SimpleNamespace(json=lambda: {"hits": []}) |
| 274 | |
| 275 | with patch( |
| 276 | "app.services.material.requests.get", return_value=fake_response |
| 277 | ) as get: |
| 278 | material.search_videos_coverr("nature", minimum_duration=1) |
| 279 | |
| 280 | self.assertFalse(get.call_args.kwargs["verify"]) |
| 281 | |
| 282 | def test_search_coverr_filters_by_min_duration_and_accepts_string(self): |
| 283 | """ |
| 284 | Coverr duration 字段在不同响应里可能是 number 或 string, |
| 285 | 两种格式都要接受;低于 minimum_duration 的应被过滤。 |
| 286 | """ |
| 287 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 288 | config.app.pop("tls_verify", None) |
| 289 | config.proxy.clear() |
| 290 | |
| 291 | fake_response = SimpleNamespace( |
| 292 | json=lambda: { |
| 293 | "hits": [ |
| 294 | { |
| 295 | "id": "shortvid", |
| 296 | "duration": 3, # below minimum |
| 297 | "urls": {"mp4_download": "https://example.com/a.mp4"}, |
| 298 | }, |
| 299 | { |
| 300 | "id": "stringdur", |
| 301 | "duration": "10.500000", # string accepted |
| 302 | "urls": {"mp4_download": "https://example.com/b.mp4"}, |
| 303 | }, |
| 304 | ] |
| 305 | } |
| 306 | ) |
| 307 | |
| 308 | with patch( |
| 309 | "app.services.material.requests.get", return_value=fake_response |
| 310 | ): |
| 311 | results = material.search_videos_coverr("x", minimum_duration=5) |
| 312 | |
| 313 | self.assertEqual(len(results), 1) |
| 314 | self.assertEqual(results[0].duration, 10) |
| 315 | self.assertEqual(results[0].url, "https://example.com/b.mp4") |
| 316 | |
| 317 | def test_search_coverr_skips_invalid_items(self): |
| 318 | """缺 id 或缺 urls.mp4_download 的条目应被跳过,不应抛异常。""" |
| 319 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 320 | config.app.pop("tls_verify", None) |
| 321 | config.proxy.clear() |
| 322 | |
| 323 | fake_response = SimpleNamespace( |
| 324 | json=lambda: { |
| 325 | "hits": [ |
| 326 | { # missing urls.mp4_download |
| 327 | "id": "no-download", |
| 328 | "duration": 10, |
| 329 | "urls": {"mp4_preview": "https://example.com/preview.mp4"}, |
| 330 | }, |
| 331 | { # missing id |
| 332 | "duration": 10, |
| 333 | "urls": {"mp4_download": "https://example.com/x.mp4"}, |
| 334 | }, |
| 335 | { # valid baseline |
| 336 | "id": "good", |
| 337 | "duration": 10, |
| 338 | "urls": {"mp4_download": "https://example.com/good.mp4"}, |
| 339 | }, |
| 340 | ] |
| 341 | } |
| 342 | ) |
| 343 | |
| 344 | with patch( |
| 345 | "app.services.material.requests.get", return_value=fake_response |
| 346 | ): |
| 347 | results = material.search_videos_coverr("x", minimum_duration=1) |
| 348 | |
| 349 | self.assertEqual(len(results), 1) |
| 350 | self.assertEqual(results[0].url, "https://example.com/good.mp4") |
| 351 | |
| 352 | def test_search_coverr_returns_empty_on_failure(self): |
| 353 | """ |
| 354 | 响应结构异常 / 网络异常时,函数必须返回 [] 而不是抛异常, |
| 355 | 与 pexels/pixabay 行为保持一致。 |
| 356 | """ |
| 357 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 358 | config.app.pop("tls_verify", None) |
| 359 | config.proxy.clear() |
| 360 | |
| 361 | # Subtest A: malformed response (no "hits" key) |
| 362 | with self.subTest("malformed response"): |
| 363 | fake_response = SimpleNamespace( |
| 364 | json=lambda: {"error": "rate limited"} |
| 365 | ) |
| 366 | with patch( |
| 367 | "app.services.material.requests.get", return_value=fake_response |
| 368 | ): |
| 369 | results = material.search_videos_coverr("x", minimum_duration=1) |
| 370 | self.assertEqual(results, []) |
| 371 | |
| 372 | # Subtest B: network exception bubbles up from requests.get |
| 373 | with self.subTest("network exception"): |
| 374 | with patch( |
| 375 | "app.services.material.requests.get", |
| 376 | side_effect=requests.ConnectionError("boom"), |
| 377 | ): |
| 378 | results = material.search_videos_coverr("x", minimum_duration=1) |
| 379 | self.assertEqual(results, []) |
| 380 | |
| 381 | # ---------------- Tests for download_videos coverr branch ---------------- |
| 382 | |
| 383 | def test_download_videos_passes_mp4_download_url_to_save_video(self): |
| 384 | """ |
| 385 | 在 source="coverr" 时: |
| 386 | 1. dispatch 到 search_videos_coverr |
| 387 | 2. coverr item 走通用下载路径:save_video 收到的就是 mp4_download URL |
| 388 | (不再有 coverr://id|url 编码,也不再调用 PATCH ping) |
| 389 | 3. 返回保存路径 |
| 390 | """ |
| 391 | config.app["coverr_api_keys"] = ["coverr-key"] |
| 392 | config.app.pop("tls_verify", None) |
| 393 | config.app.pop("material_directory", None) |
| 394 | config.proxy.clear() |
| 395 | |
| 396 | fake_item = material.MaterialInfo() |
| 397 | fake_item.provider = "coverr" |
| 398 | fake_item.url = "https://storage.coverr.co/videos/abc/download?token=xyz" |
| 399 | fake_item.duration = 10 |
| 400 | |
| 401 | with patch( |
| 402 | "app.services.material.search_videos_coverr", |
| 403 | return_value=[fake_item], |
| 404 | ) as search, patch( |
| 405 | "app.services.material.save_video", |
| 406 | return_value="/tmp/coverr-saved.mp4", |
| 407 | ) as save: |
| 408 | result = material.download_videos( |
| 409 | task_id="t-coverr", |
| 410 | search_terms=["nature"], |
| 411 | source="coverr", |
| 412 | audio_duration=5, |
| 413 | max_clip_duration=5, |
| 414 | ) |
| 415 | |
| 416 | # 1. dispatch |
| 417 | self.assertEqual(search.call_count, 1) |
| 418 | |
| 419 | # 2. save_video 收到的就是 mp4_download URL,原样传入 |
| 420 | save_url = save.call_args.kwargs.get("video_url") or save.call_args.args[0] |
| 421 | self.assertEqual( |
| 422 | save_url, "https://storage.coverr.co/videos/abc/download?token=xyz" |
| 423 | ) |
| 424 | |
| 425 | # 3. 返回值正确 |
| 426 | self.assertEqual(result, ["/tmp/coverr-saved.mp4"]) |
| 427 | |
| 428 | |
| 429 | if __name__ == "__main__": |
| 430 | unittest.main() |
| 431 |