"""验证 extract-from-douyin 在各种失败场景返回正确的 HTTP 状态码(绝不能 500) 新版架构:douyin_resolver 多源轮询 → MediaKit ASR → 本地下载+ASR → desc 兜底。 所有外部依赖(resolver、MediaKit、transcribe_to_text)均通过 mock 隔离。 """ from __future__ import annotations from unittest import mock import pytest from app.auth import AuthenticatedUser from app.services.douyin_resolver import ResolveResult 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()) def _import_target(): from app.api.routes import scripts_ai return scripts_ai def _fake_mk_available(text="识别成功的文案", duration=5.0): """Mock MediaKitClient 可用并返回指定 ASR 结果。""" fake_mk = mock.MagicMock() fake_mk.is_available = True fake_mk.asr_submit.return_value = "tk1" fake_mk.asr_poll.return_value = (text, duration) return mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk) 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 _fake_resolver_success(video_url="https://example.com/direct.mp4", desc="", source="app_feed"): """Mock resolver 返回成功。""" result = ResolveResult(video_url=video_url, desc=desc, source=source) return mock.patch("app.api.routes.scripts_ai.resolve_douyin_video", return_value=result) def _fake_resolver_image(desc="图文文案内容", source="app_feed_image"): """Mock resolver 返回图文视频(video_url 为空)。""" result = ResolveResult(video_url="", desc=desc, source=source) return mock.patch("app.api.routes.scripts_ai.resolve_douyin_video", return_value=result) def _fake_resolver_failure(): """Mock resolver 所有源均失败,返回 None。""" return mock.patch("app.api.routes.scripts_ai.resolve_douyin_video", return_value=None) # ── 解析阶段失败 ────────────────────────────────────────────────── def test_resolver_all_fail_returns_503_parse(fake_user): """所有解析源均失败 → 503 解析失败。""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/") with _fake_resolver_failure(): 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 assert "解析失败" in exc.value.detail or "链接" in exc.value.detail # ── 图文视频(无需ASR) ───────────────────────────────────────── def test_image_post_returns_desc_directly(fake_user): """图文视频(resolver返回空video_url有desc)→ 直接返回 desc,不走 ASR。""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abcdeFG/") with _fake_resolver_image(desc="这是图文文案"): resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert resp.text == "这是图文文案" assert resp.duration_seconds == 0.0 # ── MediaKit ASR 成功路径 ──────────────────────────────────────── def test_mediakit_asr_success(fake_user): """正常流程:resolver 成功 + MediaKit ASR 成功 → 返回文本。""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/ 快来看看!") with _fake_resolver_success(desc="Feed标题"): with _fake_mk_available(text="这是MediaKit识别的文案", duration=12.5): result = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert result.text == "这是MediaKit识别的文案" assert result.duration_seconds == 12.5 # ── 分享文本含前后文字 ────────────────────────────────────────── 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) with _fake_resolver_success(): with _fake_mk_available(text="识别成功的文案", duration=5.0) as mk_mock: 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 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 # ── ASR 空结果 → desc 兜底 ──────────────────────────────────── def test_asr_empty_falls_back_to_desc(fake_user): """MediaKit 和本地 ASR 都返回空文本(无旁白视频)→ 使用 desc 兜底。""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/") # MediaKit 返回空文本 fake_mk = mock.MagicMock() fake_mk.is_available = True fake_mk.asr_submit.return_value = "tk1" fake_mk.asr_poll.return_value = ("", 4.5) # 本地下载+ASR 也返回空(通过mock _direct_url_download_and_local_asr) with _fake_resolver_success(desc="Feed描述文案"): with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk): with mock.patch( "app.api.routes.scripts_ai._direct_url_download_and_local_asr", return_value=("", 0.0), ): resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert resp.text == "Feed描述文案" # ── ASR 未配置 → 503 ───────────────────────────────────────── def test_asr_not_configured_returns_503(fake_user): """本地 ASR 未配置 → 503。""" from app.services.script_asr_service import ASRNotConfiguredError scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/") with _fake_resolver_success(): with _fake_mk_unavailable(): with mock.patch( "app.api.routes.scripts_ai._direct_url_download_and_local_asr", side_effect=HTTPException(status_code=503, detail="ASR未配置"), ): 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 # ── ASR 转写失败 → 502 ──────────────────────────────────────── def test_asr_transcription_failure_returns_503_with_desc_fallback(fake_user): """ASR 转写异常(MediaKit+本地都失败)→ desc 兜底;desc 也空则 503。""" from app.api.routes import scripts_ai from app.services.mediakit_client import MediaKitError from fastapi import HTTPException body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/") fake_mk = mock.MagicMock() fake_mk.is_available = True fake_mk.asr_submit.side_effect = MediaKitError("ASR failed", code="TaskFailed") # desc 为空 → 最终 503(stage=asr) with _fake_resolver_success(desc=""): with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk): with mock.patch( "app.api.routes.scripts_ai._direct_url_download_and_local_asr", side_effect=HTTPException(status_code=502, detail="语音识别失败: No module named 'apps.worker'"), ): 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 # desc 非空 → desc 兜底成功,返回 200 with _fake_resolver_success(desc="这是视频文案描述"): with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk): with mock.patch( "app.api.routes.scripts_ai._direct_url_download_and_local_asr", side_effect=HTTPException(status_code=502, detail="语音识别失败"), ): resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock()) assert resp.text == "这是视频文案描述" assert resp.duration_seconds == 0.0 # ── 下载超时 → 504 ──────────────────────────────────────────── def test_download_timeout_returns_504(fake_user): """视频下载超时 → 504。""" scripts_ai = _import_target() body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/") with _fake_resolver_success(): with _fake_mk_unavailable(): with mock.patch( "app.api.routes.scripts_ai._direct_url_download_and_local_asr", side_effect=HTTPException(status_code=504, detail="视频下载超时"), ): 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_504_GATEWAY_TIMEOUT