9e0959cb85
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API 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) Successful in 34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 3m51s
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 / Integration Tests (push) Successful in 10m22s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""验证 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_502(fake_user):
|
||
"""ASR 转写异常 → 502(被 _direct_url_download_and_local_asr 包装)。"""
|
||
from app.services.script_asr_service import ASRTranscriptionError
|
||
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
||
|
||
# MediaKit 失败
|
||
fake_mk = mock.MagicMock()
|
||
fake_mk.is_available = True
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
fake_mk.asr_submit.side_effect = MediaKitError("ASR failed", code="TaskFailed")
|
||
|
||
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="语音识别失败"),
|
||
):
|
||
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
|
||
|
||
|
||
# ── 下载超时 → 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
|