返回 MoneyPrinterTurbo
test_material.py
根目录 / test / services / test_material.py
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
431 lines PYTHON