"""验证 extract-from-douyin 在各种失败场景返回正确的 HTTP 状态码(绝不能 500)""" from __future__ import annotations import sys import types from unittest import mock import pytest from app.auth import AuthenticatedUser from fastapi import HTTPException, status class _FakeUser: id = "u-test" is_member = False member_type = None @pytest.fixture def fake_user(): return AuthenticatedUser(user=_FakeUser()) class _FakeYDLBase: """通用假 yt-dlp 基类(支持上下文管理器 with 语法)""" extract_info_result = None extract_info_raises = None prepare_filename_result = "/tmp/fake.mp4" def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): return False def extract_info(self, url, download=True): if self.__class__.extract_info_raises: raise self.__class__.extract_info_raises return self.__class__.extract_info_result def prepare_filename(self, info): return self.__class__.prepare_filename_result def _install_fake_ytdlp(fake_ydl_class, *, download_error_cls=None): """把假 yt-dlp 注入 sys.modules,函数内 import yt_dlp 会拿到我们的假版本""" fake_mod = types.ModuleType("yt_dlp") fake_mod.YoutubeDL = fake_ydl_class if download_error_cls is None: download_error_cls = type("DownloadError", (Exception,), {}) fake_mod.DownloadError = download_error_cls utils_mod = types.ModuleType("yt_dlp.utils") utils_mod.DownloadError = download_error_cls fake_mod.utils = utils_mod sys.modules["yt_dlp"] = fake_mod sys.modules["yt_dlp.utils"] = utils_mod return fake_mod def _import_target(): from app.api.routes import scripts_ai return scripts_ai def _fake_mk_unavailable(): """Mock MediaKitClient 不可用,强制走下载+本地 ASR 路径。""" fake_mk = mock.MagicMock() fake_mk.is_available = False return mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk) def test_download_http404_returns_400_not_500(fake_user): """无效短链 / 视频 404 → 应返回 400 业务错误,不能 500""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/") class DownloadError(Exception): pass class FailingYDL(_FakeYDLBase): extract_info_raises = DownloadError("ERROR: Unable to download webpage: HTTP Error 404: Not Found") _install_fake_ytdlp(FailingYDL, download_error_cls=DownloadError) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert ( exc.value.status_code == status.HTTP_400_BAD_REQUEST ), f"应为400,实际 {exc.value.status_code}: {exc.value.detail}" assert "无法解析" in exc.value.detail or "抖音" in exc.value.detail def test_download_network_error_returns_502_not_500(fake_user): """网络错误 / 上游异常 → 502,不能 500""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class DownloadError(Exception): pass class NetErrYDL(_FakeYDLBase): # 路径A(元信息解析)会吞异常返回 None;路径B(下载)抛网络错误 @staticmethod def _raise(): raise DownloadError("ERROR: Connection reset by peer") def extract_info(self, url, download=True): # 元信息探测返回 None(拿不到直链),下载时再抛 if not download: return None self._raise() _install_fake_ytdlp(NetErrYDL, download_error_cls=DownloadError) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY def test_info_none_returns_400(fake_user): """yt-dlp 返回 None info → 400""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class NoneInfoYDL(_FakeYDLBase): def extract_info(self, url, download=True): # 元信息探测返回 None;下载也返回 None return None _install_fake_ytdlp(NoneInfoYDL) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST def test_asr_not_configured_returns_503(fake_user): scripts_ai = _import_target() from app.services.script_asr_service import ASRNotConfiguredError body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class OkYDL(_FakeYDLBase): def extract_info(self, url, download=True): # 元信息返回 None(不走 MediaKit);下载返回正常 info if not download: return None return {"id": "x", "duration": 10, "title": "t"} _install_fake_ytdlp(OkYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRNotConfiguredError("未配置")), ): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE def test_asr_failure_returns_502(fake_user): scripts_ai = _import_target() from app.services.script_asr_service import ASRTranscriptionError body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class OkYDL(_FakeYDLBase): def extract_info(self, url, download=True): if not download: return None return {"id": "x", "duration": 10, "title": "t"} _install_fake_ytdlp(OkYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRTranscriptionError("识别失败")), ): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY def test_asr_unexpected_error_returns_502_not_500(fake_user): """ASR 抛未预期异常也应被兜住,不能 500""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class OkYDL(_FakeYDLBase): def extract_info(self, url, download=True): if not download: return None return {"id": "x", "duration": 10, "title": "t"} _install_fake_ytdlp(OkYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=RuntimeError("ffmpeg crashed")), ): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY, f"应为502,实际 {exc.value.status_code}" def test_missing_downloaded_file_returns_502_not_500(fake_user): """yt-dlp 返回 info 但文件未落地(isfile False)→ 502""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class OkYDL(_FakeYDLBase): def extract_info(self, url, download=True): if not download: return None return {"id": "x", "duration": 10, "title": "t"} _install_fake_ytdlp(OkYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai.os.path, "isfile", return_value=False), ): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code != 500 assert "下载异常" in exc.value.detail or "文件" in exc.value.detail def test_any_unexpected_error_does_not_return_500_raw(fake_user): """兜底:prepare_filename 抛未预期异常也应被捕获,返回500 code但含业务detail""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class BuggyYDL(_FakeYDLBase): def extract_info(self, url, download=True): if not download: return None return {"id": "x", "duration": "not_a_number", "title": "t"} def prepare_filename(self, info): raise RuntimeError("some internal bug") _install_fake_ytdlp(BuggyYDL) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500 # ── cookies 相关测试 ────────────────────────────────────────────── def test_cookies_error_returns_503_friendly_message(fake_user): """cookies 缺失/过期 → 返回 503 + 友好文案,不暴露原始错误""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/") class DownloadError(Exception): pass class CookiesYDL(_FakeYDLBase): def extract_info(self, url, download=True): raise DownloadError( "ERROR: [Douyin] 7623712911260650802: Fresh cookies (not necessarily logged in) are needed" ) _install_fake_ytdlp(CookiesYDL, download_error_cls=DownloadError) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE, f"应为503,实际 {exc.value.status_code}" assert ( "暂时不可用" in exc.value.detail or "稍后重试" in exc.value.detail ), f"应有友好提示,实际: {exc.value.detail}" assert "Fresh cookies" not in exc.value.detail def test_cookies_error_in_generic_except_also_returns_503(fake_user): """cookies 错误绕过 DownloadError 时,兜底异常分支也应识别并返回 503""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class CookieBugYDL(_FakeYDLBase): def extract_info(self, url, download=True): raise RuntimeError("Fresh cookies are needed to access this video") _install_fake_ytdlp(CookieBugYDL) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE def test_ydl_opts_includes_cookiefile_when_file_exists(fake_user): """cookies 文件存在时,ydl_opts 应包含 cookiefile 指向该路径(在下载分支)""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") captured_opts_download = {} class CaptureOptsYDL(_FakeYDLBase): def __init__(self, opts): # 下载分支会触发 download=True;元信息探测 download=False # 元信息也会传 cookiefile,但我们只在下载分支记录(更接近真实) super().__init__() self._opts = opts # 总是记录最后一次的 opts,方便断言 captured_opts_download.clear() captured_opts_download.update(opts) def extract_info(self, url, download=True): if not download: return None # 元信息失败,走下载分支 return {"id": "x", "duration": 5, "title": "t"} _install_fake_ytdlp(CaptureOptsYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value="/tmp/fake_cookies.txt"), mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"), ): scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert ( captured_opts_download.get("cookiefile") == "/tmp/fake_cookies.txt" ), f"cookiefile 应被设置,opts={captured_opts_download}" def test_ydl_opts_no_cookiefile_when_file_missing(fake_user): """cookies 文件不存在时,ydl_opts 不应包含 cookiefile 键""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") captured_opts_download = {} class CaptureOptsYDL(_FakeYDLBase): def __init__(self, opts): super().__init__() captured_opts_download.clear() captured_opts_download.update(opts) def extract_info(self, url, download=True): if not download: return None return {"id": "x", "duration": 5, "title": "t"} _install_fake_ytdlp(CaptureOptsYDL) with ( _fake_mk_unavailable(), mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value=None), mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"), ): scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert ( "cookiefile" not in captured_opts_download ), f"cookies 文件缺失时不应设置 cookiefile,opts={captured_opts_download}" def test_generic_download_error_hides_raw_message(fake_user): """非 cookies 非 404 的通用下载错误 → 502,且不暴露 yt-dlp 原始错误文本""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") class DownloadError(Exception): pass class GenErrYDL(_FakeYDLBase): def extract_info(self, url, download=True): raise DownloadError("ERROR: some internal yt-dlp weird failure with trace") _install_fake_ytdlp(GenErrYDL, download_error_cls=DownloadError) with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY assert "下载失败" in exc.value.detail assert "weird failure" not in exc.value.detail, "不应暴露 yt-dlp 内部错误文本" def test_share_text_input_extracts_url_correctly(fake_user): """分享文本(含前后说明文字)应能正确提取 URL""" scripts_ai = _import_target() share_text = "这个视频太搞笑了 https://v.douyin.com/abcdeFG/ 快来看看!#搞笑 #日常" body = scripts_ai.ExtractFromDouyinRequest(url=share_text) class OkYDL(_FakeYDLBase): def extract_info(self, url, download=True): if not download: return {"url": "https://example.com/direct.mp4", "duration": 5} return {"id": "x", "duration": 5, "title": "t"} _install_fake_ytdlp(OkYDL) fake_mk = mock.MagicMock() fake_mk.is_available = True fake_mk.asr_submit.return_value = "tk1" fake_mk.asr_poll.return_value = ("识别成功的文案", 5.0) with ( mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk), mock.patch.object(scripts_ai, "_ytdlp_extract_video_url", return_value=("https://example.com/direct.mp4", 5.0)), ): resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert resp.source_url == "https://v.douyin.com/abcdeFG/" assert resp.text == "识别成功的文案" assert resp.duration_seconds == 5.0 def test_non_douyin_share_text_returns_400(fake_user): """粘贴非抖音分享链接 → 400""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="看看这个 https://www.bilibili.com/video/BV1xx 哈哈哈") with _fake_mk_unavailable(): with pytest.raises(HTTPException) as exc: scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert exc.value.status_code == 400