Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

431 lines
16 KiB
Python

import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import requests
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.config import config
from app.services import material
class TestMaterialTlsVerification(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
self.original_proxy_config = dict(config.proxy)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
config.proxy.clear()
config.proxy.update(self.original_proxy_config)
def test_search_pexels_uses_tls_verification_by_default(self):
"""
默认路径必须开启 TLS 校验,避免素材 API key 和返回的素材 URL
在公共网络或不可信代理环境中被中间人攻击截获或篡改。
"""
config.app["pexels_api_keys"] = ["pexels-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"videos": [
{
"duration": 8,
"video_files": [
{
"width": 1080,
"height": 1920,
"link": "https://example.com/video.mp4",
}
],
}
]
}
)
with patch("app.services.material.requests.get", return_value=fake_response) as get:
results = material.search_videos_pexels("cat", minimum_duration=1)
self.assertEqual(len(results), 1)
self.assertTrue(get.call_args.kwargs["verify"])
def test_search_pixabay_allows_explicit_tls_disable_for_proxy(self):
"""
少数企业代理会使用自签证书。该场景必须显式配置关闭 TLS 校验,
不能再由代码硬编码默认关闭。
"""
config.app["pixabay_api_keys"] = ["pixabay-key"]
config.app["tls_verify"] = False
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"hits": [
{
"duration": 8,
"videos": {
"large": {
"width": 1920,
"url": "https://example.com/video.mp4",
}
},
}
]
}
)
with patch("app.services.material.requests.get", return_value=fake_response) as get:
results = material.search_videos_pixabay("cat", minimum_duration=1)
self.assertEqual(len(results), 1)
self.assertFalse(get.call_args.kwargs["verify"])
def test_save_video_uses_tls_verification_by_default(self):
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(content=b"fake-video")
class FakeVideoFileClip:
duration = 1
fps = 24
def __init__(self, path):
self.path = path
def close(self):
return None
with tempfile.TemporaryDirectory() as temp_dir:
with patch(
"app.services.material.requests.get", return_value=fake_response
) as get, patch("app.services.material.VideoFileClip", FakeVideoFileClip):
video_path = material.save_video(
"https://example.com/video.mp4?token=abc", save_dir=temp_dir
)
self.assertTrue(os.path.exists(video_path))
self.assertTrue(get.call_args.kwargs["verify"])
def test_download_videos_accepts_plain_string_concat_mode(self):
"""
download_videos 可能被服务层或测试直接传入字符串模式,而不是
VideoConcatMode 枚举。这里用空搜索词避免真实网络请求,只验证
字符串 "random" 不会再因为访问 `.value` 抛 AttributeError。
"""
result = material.download_videos(
task_id="string-concat-mode",
search_terms=[],
video_concat_mode="random",
)
self.assertEqual(result, [])
def test_download_videos_can_round_robin_terms_in_script_order(self):
"""
开启按文案顺序匹配素材后,不能让第一个关键词的多个候选先把
音频时长填满。这里模拟两个关键词各有多个候选,验证下载顺序是
term1-第1个、term2-第1个、term1-第2个,贴近脚本叙事顺序。
"""
search_results = {
"opening city": [
material.MaterialInfo(provider="pexels", url="https://v.example/a1.mp4", duration=3),
material.MaterialInfo(provider="pexels", url="https://v.example/a2.mp4", duration=3),
],
"middle office": [
material.MaterialInfo(provider="pexels", url="https://v.example/b1.mp4", duration=3),
material.MaterialInfo(provider="pexels", url="https://v.example/b2.mp4", duration=3),
],
}
downloaded_urls = []
def fake_search(search_term, minimum_duration, video_aspect):
return search_results[search_term]
def fake_save_video(video_url, save_dir=""):
downloaded_urls.append(video_url)
return f"/tmp/{video_url.rsplit('/', 1)[-1]}"
with (
patch.dict(config.app, {"material_directory": ""}),
patch.object(material, "search_videos_pexels", side_effect=fake_search),
patch.object(material, "save_video", side_effect=fake_save_video),
):
result = material.download_videos(
task_id="ordered-materials",
search_terms=["opening city", "middle office"],
source="pexels",
audio_duration=7,
max_clip_duration=3,
match_script_order=True,
)
self.assertEqual(
downloaded_urls,
[
"https://v.example/a1.mp4",
"https://v.example/b1.mp4",
"https://v.example/a2.mp4",
],
)
self.assertEqual(result, ["/tmp/a1.mp4", "/tmp/b1.mp4", "/tmp/a2.mp4"])
class TestCoverrProvider(unittest.TestCase):
"""
Coverr 视频素材源(spec: 2026-06-09-coverr-video-provider-design.md)。
全部用 unittest.mock 替换 requests,确保 CI 不依赖真实网络和真实 API key。
"""
def setUp(self):
self.original_app_config = dict(config.app)
self.original_proxy_config = dict(config.proxy)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
config.proxy.clear()
config.proxy.update(self.original_proxy_config)
# ---------------- Tests for search_videos_coverr ----------------
def test_search_coverr_uses_mp4_download_url(self):
"""
search_videos_coverr 应把每个 hit 转成 MaterialInfo,并把 urls.mp4_download
直接作为 MaterialInfo.url。
按 Coverr 官方文档 (api.coverr.co/docs/videos/#download-a-video),
GET mp4_download 本身就被 Coverr 计入下载统计,无需额外 PATCH ping。
同时验证 Authorization header 使用 Bearer scheme。
"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"page": 0,
"pages": 50,
"page_size": 20,
"total": 1,
"hits": [
{
"id": "S1YbPl1NfI",
"duration": 11.625,
"aspect_ratio": "16:9",
"urls": {
"mp4": "https://storage.coverr.co/videos/abc?token=xyz",
"mp4_preview": "https://storage.coverr.co/videos/abc/preview?token=xyz",
"mp4_download": "https://storage.coverr.co/videos/abc/download?token=xyz",
},
}
],
}
)
with patch(
"app.services.material.requests.get", return_value=fake_response
) as get:
results = material.search_videos_coverr("nature", minimum_duration=5)
self.assertEqual(len(results), 1)
item = results[0]
self.assertEqual(item.provider, "coverr")
self.assertEqual(item.duration, 11)
# url 字段就是 mp4_download URL,不再做 coverr://id|url 编码
self.assertEqual(
item.url, "https://storage.coverr.co/videos/abc/download?token=xyz"
)
# Bearer auth + TLS verify on by default
self.assertEqual(
get.call_args.kwargs["headers"]["Authorization"], "Bearer coverr-key"
)
self.assertTrue(get.call_args.kwargs["verify"])
def test_search_coverr_uses_tls_verification_by_default(self):
"""与 pexels/pixabay 一致:未显式配置时 TLS 校验默认开启。"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(json=lambda: {"hits": []})
with patch(
"app.services.material.requests.get", return_value=fake_response
) as get:
material.search_videos_coverr("nature", minimum_duration=1)
self.assertTrue(get.call_args.kwargs["verify"])
def test_search_coverr_allows_explicit_tls_disable_for_proxy(self):
"""企业自签证书代理场景必须能显式关闭 TLS 校验。"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app["tls_verify"] = False
config.proxy.clear()
fake_response = SimpleNamespace(json=lambda: {"hits": []})
with patch(
"app.services.material.requests.get", return_value=fake_response
) as get:
material.search_videos_coverr("nature", minimum_duration=1)
self.assertFalse(get.call_args.kwargs["verify"])
def test_search_coverr_filters_by_min_duration_and_accepts_string(self):
"""
Coverr duration 字段在不同响应里可能是 number 或 string,
两种格式都要接受;低于 minimum_duration 的应被过滤。
"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"hits": [
{
"id": "shortvid",
"duration": 3, # below minimum
"urls": {"mp4_download": "https://example.com/a.mp4"},
},
{
"id": "stringdur",
"duration": "10.500000", # string accepted
"urls": {"mp4_download": "https://example.com/b.mp4"},
},
]
}
)
with patch(
"app.services.material.requests.get", return_value=fake_response
):
results = material.search_videos_coverr("x", minimum_duration=5)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].duration, 10)
self.assertEqual(results[0].url, "https://example.com/b.mp4")
def test_search_coverr_skips_invalid_items(self):
"""缺 id 或缺 urls.mp4_download 的条目应被跳过,不应抛异常。"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"hits": [
{ # missing urls.mp4_download
"id": "no-download",
"duration": 10,
"urls": {"mp4_preview": "https://example.com/preview.mp4"},
},
{ # missing id
"duration": 10,
"urls": {"mp4_download": "https://example.com/x.mp4"},
},
{ # valid baseline
"id": "good",
"duration": 10,
"urls": {"mp4_download": "https://example.com/good.mp4"},
},
]
}
)
with patch(
"app.services.material.requests.get", return_value=fake_response
):
results = material.search_videos_coverr("x", minimum_duration=1)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].url, "https://example.com/good.mp4")
def test_search_coverr_returns_empty_on_failure(self):
"""
响应结构异常 / 网络异常时,函数必须返回 [] 而不是抛异常,
与 pexels/pixabay 行为保持一致。
"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
# Subtest A: malformed response (no "hits" key)
with self.subTest("malformed response"):
fake_response = SimpleNamespace(
json=lambda: {"error": "rate limited"}
)
with patch(
"app.services.material.requests.get", return_value=fake_response
):
results = material.search_videos_coverr("x", minimum_duration=1)
self.assertEqual(results, [])
# Subtest B: network exception bubbles up from requests.get
with self.subTest("network exception"):
with patch(
"app.services.material.requests.get",
side_effect=requests.ConnectionError("boom"),
):
results = material.search_videos_coverr("x", minimum_duration=1)
self.assertEqual(results, [])
# ---------------- Tests for download_videos coverr branch ----------------
def test_download_videos_passes_mp4_download_url_to_save_video(self):
"""
在 source="coverr" 时:
1. dispatch 到 search_videos_coverr
2. coverr item 走通用下载路径:save_video 收到的就是 mp4_download URL
(不再有 coverr://id|url 编码,也不再调用 PATCH ping)
3. 返回保存路径
"""
config.app["coverr_api_keys"] = ["coverr-key"]
config.app.pop("tls_verify", None)
config.app.pop("material_directory", None)
config.proxy.clear()
fake_item = material.MaterialInfo()
fake_item.provider = "coverr"
fake_item.url = "https://storage.coverr.co/videos/abc/download?token=xyz"
fake_item.duration = 10
with patch(
"app.services.material.search_videos_coverr",
return_value=[fake_item],
) as search, patch(
"app.services.material.save_video",
return_value="/tmp/coverr-saved.mp4",
) as save:
result = material.download_videos(
task_id="t-coverr",
search_terms=["nature"],
source="coverr",
audio_duration=5,
max_clip_duration=5,
)
# 1. dispatch
self.assertEqual(search.call_count, 1)
# 2. save_video 收到的就是 mp4_download URL,原样传入
save_url = save.call_args.kwargs.get("video_url") or save.call_args.args[0]
self.assertEqual(
save_url, "https://storage.coverr.co/videos/abc/download?token=xyz"
)
# 3. 返回值正确
self.assertEqual(result, ["/tmp/coverr-saved.mp4"])
if __name__ == "__main__":
unittest.main()