Files
xiaoxia-saas/tests/unit/test_extract_from_douyin_errors.py
T
CI Bot 4da3eae11a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m28s
CI/CD Pipeline / Integration Tests (push) Successful in 5m10s
CI/CD Pipeline / Validate - Style (push) Successful in 5m51s
CI/CD Pipeline / Unit Tests (push) Successful in 11m37s
CI/CD Pipeline / Validate - Security (push) Successful in 15m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 25m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m41s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-16 16:50:47 +00:00

437 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""验证 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