Files
xiaoxia-saas/tests/unit/test_ai_client_video.py
T
saas-backend 8e19f24984
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m2s
AI Code Review / AI Code Review (pull_request) Successful in 5m36s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
feat(#2170): 方舟信任链方案——真人照片经 Seedream AI 化后走 Seedance reference_image
- 删除即梦 cvtob 接入代码(jimeng_client.py / JIMENG_* 配置 / _call_jimeng_video_generation)
- 新增 DoubaoClient.image_generation:调用 Seedream 5.0 Pro (doubao-seedream-5-0-pro-260628) 文生图/图生图
- 信任链:有参考图时先经 Seedream 图生图 AI 化(保持五官特征),AI 产物作为 reference_image 传 Seedance
- 纯文字直传 Seedance 2.5;信任链强制走 reference_image 模式(非 first_frame),保留用户指定 ratio
- Seedream 失败自动回退原图直传;图片/视频错误分类统一,支持 last_image_error
- 删除 #2166 t2v 自动降级、#2169 jimeng 兜底逻辑
- config/base.py 新增 doubao_image_model/doubao_image_timeout,豆包模型升级到 seed-2-1 系列
- .env.example 清理 JIMENG_*,补全 VIDEO_*/IMAGE_* 配置
- points_rules.py 删除 jimeng-3.0 定价与模型配置
- 新增 10 个单测覆盖 Seedream 主路径 + 信任链 3 种场景;更新现有 mock
2026-10-04 02:44:08 +08:00

679 lines
29 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.
"""#2106 DoubaoClient.video_generation 单测,覆盖 submit/poll/download 主路径和失败分支。"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from packages.shared.ai_client import DoubaoClient
def _make_client(**overrides):
client = DoubaoClient.__new__(DoubaoClient)
client.api_key = overrides.get("api_key", "test-key")
client.base_url = overrides.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
client.model = "doubao-model"
client.vision_model = "doubao-vision"
client.embedding_model = "doubao-embedding"
client.image_model = overrides.get("image_model", "doubao-seedream-5-0-pro-260628")
client.image_timeout = overrides.get("image_timeout", 120)
client.timeout = overrides.get("timeout", 30)
client.max_retries = overrides.get("max_retries", 0)
client.last_video_error = {}
client.last_image_error = {}
return client
def _fake_time_factory(base=1000.0, jump_after=2, jump=1e9):
"""返回一个 time.time() 替身:前 jump_after 次返回 base+offset,之后返回巨大值让 deadline 立即触发。
避免 Python logging 内部也调 time.time() 导致 StopIteration。
"""
state = {"n": 0}
def _t():
n = state["n"]
state["n"] += 1
if n < jump_after:
return base + n
return base + jump + n
return _t
class TestVideoGenerationHappyPath:
def test_happy_path_generates_and_downloads(self, tmp_path):
client = _make_client()
fake_task_resp = MagicMock()
fake_task_resp.json.return_value = {"id": "task-001"}
fake_task_resp.raise_for_status = MagicMock()
fake_task_resp.status_code = 200
fake_task_resp.text = ""
fake_poll_resp = MagicMock()
fake_poll_resp.json.return_value = {
"status": "succeeded",
"content": {"video_url": "https://cdn.example.com/v.mp4"},
}
fake_poll_resp.raise_for_status = MagicMock()
fake_poll_resp.status_code = 200
fake_poll_resp.text = ""
class FakeStreamResponse:
def __init__(self):
self._chunks = [b"FAKE", b"MP4", b"DATA"]
self._it = iter(self._chunks)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
return None
def iter_bytes(self, chunk_size=None):
return self._it
calls = {"post": 0, "get": 0}
def fake_post(url, **kwargs):
calls["post"] += 1
return fake_task_resp
def fake_get(url, **kwargs):
calls["get"] += 1
if "/tasks/task-001" in url:
return fake_poll_resp
raise AssertionError(f"unexpected GET (not stream): {url}")
fake_uuid = MagicMock()
fake_uuid.hex = "abcd1234"
with (
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
patch("packages.shared.ai_client.httpx.get", side_effect=fake_get),
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStreamResponse()),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory(jump_after=2)),
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
patch("packages.shared.ai_client.get_shared_settings") as mock_settings,
):
mock_settings.return_value = MagicMock(
doubao_video_poll_interval=0,
doubao_video_timeout=60,
doubao_video_model="doubao-seedance-2-5-260628",
)
out = client.video_generation(
prompt=" 镜头一 ",
# 不传 image_url:纯文生视频,不触发信任链,post 调用数为 1(创建任务)
duration=5,
ratio="9:16",
resolution="720p",
output_dir=str(tmp_path),
)
assert out is not None and isinstance(out, dict)
assert Path(out["video_path"]).exists()
assert Path(out["video_path"]).name == "seedance_task-001_abcd1234.mp4"
assert Path(out["video_path"]).read_bytes() == b"FAKEMP4DATA"
assert calls["post"] == 1
assert calls["get"] == 1
class TestVideoGenerationFailures:
def test_returns_none_when_unavailable(self, tmp_path):
client = _make_client(api_key="")
assert client.video_generation("p", output_dir=str(tmp_path)) is None
def test_returns_none_on_empty_prompt(self, tmp_path):
client = _make_client()
assert client.video_generation(" ", output_dir=str(tmp_path)) is None
def test_returns_none_when_create_returns_no_id(self, tmp_path):
client = _make_client(max_retries=0)
fake_resp = MagicMock()
fake_resp.json.return_value = {"error": "bad"}
fake_resp.raise_for_status = MagicMock()
with (
patch("packages.shared.ai_client.httpx.post", return_value=fake_resp),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=1, doubao_video_timeout=60, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
def test_returns_none_when_poll_returns_failed(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.json.return_value = {"id": "t2"}
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock()
poll_resp.json.return_value = {"status": "failed", "error": {"code": "C1", "message": "bad"}}
poll_resp.raise_for_status = MagicMock()
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
def test_returns_none_when_download_raises(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.json.return_value = {"id": "t3"}
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock()
poll_resp.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn/v.mp4"}}
poll_resp.raise_for_status = MagicMock()
class BadStream:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
raise RuntimeError("network down")
def iter_bytes(self, **kw):
return iter([])
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
patch("packages.shared.ai_client.httpx.stream", return_value=BadStream()),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
class TestVideoGenerationRetryAndPoll:
def test_create_retries_then_succeeds(self, tmp_path):
client = _make_client(max_retries=1)
ok_resp = MagicMock()
ok_resp.json.return_value = {"id": "t-retry"}
ok_resp.raise_for_status = MagicMock()
poll_resp = MagicMock()
poll_resp.json.return_value = {"status": "expired"}
poll_resp.raise_for_status = MagicMock()
calls = {"post": 0}
def fake_post(url, **kwargs):
calls["post"] += 1
if calls["post"] == 1:
raise httpx.HTTPError("network")
return ok_resp
with (
patch("packages.shared.ai_client.httpx") as mock_httpx,
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory(jump_after=3)),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
)
mock_httpx.HTTPError = httpx.HTTPError
mock_httpx.post.side_effect = fake_post
mock_httpx.get.return_value = poll_resp
assert client.video_generation("p", output_dir=str(tmp_path)) is None
assert calls["post"] == 2
def test_succeeded_but_no_video_url_returns_none(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.json.return_value = {"id": "t-nourl"}
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock()
poll_resp.json.return_value = {"status": "succeeded", "content": {}}
poll_resp.raise_for_status = MagicMock()
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
class TestAiServiceCallVideoGeneration:
def test_returns_none_on_exception(self):
from packages.shared import ai_service
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.side_effect = RuntimeError("boom")
mock_get.return_value = mock_client
assert ai_service.call_video_generation("p") is None
class TestVideoGenerationPollLoop:
def test_poll_queued_then_running_then_succeeded(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.json.return_value = {"id": "t-wait"}
create_resp.raise_for_status = MagicMock()
queued = MagicMock(json=MagicMock(return_value={"status": "queued"}))
queued.raise_for_status = MagicMock()
running = MagicMock(json=MagicMock(return_value={"status": "running"}))
running.raise_for_status = MagicMock()
ok = MagicMock(
json=MagicMock(return_value={"status": "succeeded", "content": {"video_url": "https://cdn/x.mp4"}})
)
ok.raise_for_status = MagicMock()
poll_seq = [queued, running, ok]
class EmptyChunkStream:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
return None
def iter_bytes(self, chunk_size=None):
yield b""
yield b"D"
yield b""
yield b"ATA"
get_calls = {"n": 0}
def fake_get(url, **kw):
if "/tasks/t-wait" in url:
resp = poll_seq[min(get_calls["n"], len(poll_seq) - 1)]
get_calls["n"] += 1
return resp
raise AssertionError(url)
sleeps = []
# jump_after 要足够大:deadline 计算一次 + 3次 while 条件判断 = 4 次
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", side_effect=fake_get),
patch("packages.shared.ai_client.httpx.stream", return_value=EmptyChunkStream()),
patch("packages.shared.ai_client.time.sleep", side_effect=lambda s: sleeps.append(s)),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory(jump_after=5, jump=1)),
patch("packages.shared.ai_client.uuid.uuid4", return_value=MagicMock(hex="ef012345")),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=200, doubao_video_model="seedance"
)
out = client.video_generation("p", output_dir=str(tmp_path))
assert out is not None and isinstance(out, dict)
assert Path(out["video_path"]).read_bytes() == b"DATA"
# queued 和 running 各 sleep 一次
assert len(sleeps) >= 2
def test_poll_exception_does_not_crash(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock(json=MagicMock(return_value={"id": "t-err"}))
create_resp.raise_for_status = MagicMock()
ok = MagicMock(
json=MagicMock(return_value={"status": "succeeded", "content": {"video_url": "https://cdn/e.mp4"}})
)
ok.raise_for_status = MagicMock()
class OkStream:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
return None
def iter_bytes(self, chunk_size=None):
yield b"OK"
poll_calls = {"n": 0}
def fake_get(url, **kw):
poll_calls["n"] += 1
if poll_calls["n"] == 1:
raise httpx.HTTPError("transient")
return ok
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", side_effect=fake_get),
patch("packages.shared.ai_client.httpx.stream", return_value=OkStream()),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory(jump_after=3)),
patch("packages.shared.ai_client.uuid.uuid4", return_value=MagicMock(hex="11111111")),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=100, doubao_video_model="seedance"
)
out = client.video_generation("p", output_dir=str(tmp_path))
assert out is not None and isinstance(out, dict)
assert Path(out["video_path"]).exists()
assert poll_calls["n"] == 2
def test_default_output_dir_and_audio_watermark(self, tmp_path, monkeypatch):
"""不传 output_dir 时落到 /tmp;generate_audio/watermark=True 也能正常提交。"""
client = _make_client()
create_resp = MagicMock(json=MagicMock(return_value={"id": "t-default"}))
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock(
json=MagicMock(return_value={"status": "succeeded", "content": {"video_url": "https://cdn/d.mp4"}})
)
poll_resp.raise_for_status = MagicMock()
# 用 tmp_path 伪造 /tmp 避免污染真 /tmp
monkeypatch.setattr("packages.shared.ai_client.os.makedirs", lambda d, exist_ok=True: None)
class S:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
return None
def iter_bytes(self, chunk_size=None):
yield b"D"
# 捕获 POST payload 断言
captured = {}
def fake_post(url, **kw):
captured["json"] = kw.get("json")
return create_resp
def fake_get(url, **kw):
return poll_resp
def fake_open(path, mode):
# 返回一个 MagicMock file,模拟写入
f = MagicMock()
f.__enter__ = MagicMock(return_value=f)
f.__exit__ = MagicMock(return_value=False)
captured["path"] = path
return f
monkeypatch.setattr("packages.shared.ai_client.httpx.post", fake_post)
monkeypatch.setattr("packages.shared.ai_client.httpx.get", fake_get)
monkeypatch.setattr("packages.shared.ai_client.httpx.stream", lambda *a, **kw: S())
monkeypatch.setattr("builtins.open", fake_open)
monkeypatch.setattr("packages.shared.ai_client.os.path.getsize", lambda p: 99)
with (
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
patch("packages.shared.ai_client.uuid.uuid4", return_value=MagicMock(hex="00000001")),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0,
doubao_video_timeout=60,
doubao_video_model="seedance",
)
out = client.video_generation(
"p", duration=3, ratio="1:1", resolution="480p", generate_audio=True, watermark=True
)
assert out is not None and isinstance(out, dict)
assert out["video_path"] == "/tmp/seedance_t-default_00000001.mp4"
assert captured["json"]["generate_audio"] is True
assert captured["json"]["watermark"] is True
assert captured["json"]["ratio"] == "1:1"
assert captured["json"]["resolution"] == "480p"
class TestGetDoubaoClientSingleton:
def test_singleton_lazy_init(self):
from packages.shared import ai_client
prev = ai_client._client
try:
ai_client._client = None
c1 = ai_client.get_doubao_client()
c2 = ai_client.get_doubao_client()
assert c1 is c2
assert isinstance(c1, ai_client.DoubaoClient)
finally:
ai_client._client = prev
class TestVideoGenerationCancelled:
def test_poll_cancelled_returns_none(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock(json=MagicMock(return_value={"id": "t-can"}))
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock(json=MagicMock(return_value={"status": "cancelled"}))
poll_resp.raise_for_status = MagicMock()
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
# ============ #2157 _resolve_video_model_id 模型ID映射单测 ============
class TestResolveVideoModelId:
"""覆盖 _resolve_video_model_id 各分支(#2157 P0 修复)。"""
def _import_target(self):
from packages.shared.ai_client import _resolve_video_model_id
return _resolve_video_model_id
def test_none_uses_default(self):
fn = self._import_target()
with patch("packages.shared.ai_client.get_shared_settings") as ms:
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
assert fn(None) == "doubao-seedance-2-5-260628"
def test_empty_uses_default(self):
fn = self._import_target()
with patch("packages.shared.ai_client.get_shared_settings") as ms:
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
assert fn(" ") == "doubao-seedance-2-5-260628"
def test_doubao_prefix_passthrough(self):
fn = self._import_target()
assert fn("doubao-seedance-2-5-260628") == "doubao-seedance-2-5-260628"
def test_ep_prefix_passthrough(self):
fn = self._import_target()
assert fn("ep-20260721114705-b568m") == "ep-20260721114705-b568m"
def test_seedance_2_5_alias(self):
fn = self._import_target()
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
def test_seedance_2_0_alias(self):
fn = self._import_target()
assert fn("seedance-2.0") == "doubao-seedance-2-0-260128"
def test_seedance_2_0_fast_alias(self):
fn = self._import_target()
assert fn("seedance-2.0-fast") == "doubao-seedance-2-0-fast-260128"
def test_seedance_2_0_mini_alias(self):
fn = self._import_target()
assert fn("seedance-2.0-mini") == "doubao-seedance-2-0-mini-260615"
def test_wan_3_0_returns_dashscope_provider(self):
from packages.shared.ai_client import _resolve_video_provider_and_id
prov, mid, cfg = _resolve_video_provider_and_id("wan-3.0")
assert prov == "dashscope"
assert mid == "wan3.0-video"
assert cfg.get("billing_mode") == "per_second"
def test_seedance_2_5_uppercase(self):
fn = self._import_target()
assert fn("Seedance-2.5") == "doubao-seedance-2-5-260628"
def test_seedance_dot_normalize(self):
fn = self._import_target()
# dot 形式 "seedance-2.5" 直接命中 domain config 的 key(与 2-5 同等)
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
def test_unknown_model_falls_back_to_default_seedance_2_5(self, caplog):
fn = self._import_target()
import logging
# 未知 model key 会通过 get_viral_video_model_config 回落到 seedance-2.5
with caplog.at_level(logging.WARNING, logger="shared.ai_client"):
assert fn("some-random-model") == "doubao-seedance-2-5-260628"
# ── #2165 详细错误信息和 last_video_error ─────────────────────────
class TestVideoGenerationLastError:
def test_create_400_portrait_returns_user_message(self, tmp_path):
"""#2169: HTTP 400 + 真人拦截关键词 → 自动尝试即梦兜底;即梦未配时返回 portrait_intercept。"""
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.status_code = 400
create_resp.text = '{"error":{"code":"ContentRisk","message":"Real person face detected in reference image, portrait blocked"}}'
create_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "..."}}
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"bad", request=MagicMock(), response=create_resp
)
# 信任链:Seedream 会先被调用来 AI 化;这里 mock Seedream 也失败,回退原图直传,
# 原图直传被 400 portrait 拦截,最终返回 portrait_intercept。
seedream_resp = MagicMock()
seedream_resp.status_code = 400
seedream_resp.text = '{"error":{"code":"ContentRisk","message":"sensitive"}}'
seedream_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "sensitive"}}
seedream_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"bad", request=MagicMock(), response=seedream_resp
)
def fake_post(url, **kwargs):
# 第一次 POST 是 Seedream(/images/generations),返回 portrait 拦截
# 回退原图直传后第二次 POST 是 Seedance(/contents/generations/tasks),也返回 portrait 拦截
return create_resp
with (
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
)
result = client.video_generation("p", output_dir=str(tmp_path), image_url="https://img/x.jpg")
assert result is None
err = client.get_last_video_error()
assert err["error_code"] == "portrait_intercept"
assert "真人" in err["user_message"] or "肖像" in err["user_message"] or "审核" in err["user_message"]
assert err["status_code"] in (0, 400)
def test_create_401_returns_auth_error(self, tmp_path):
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.status_code = 401
create_resp.text = '{"error":{"message":"Unauthorized"}}'
create_resp.json.return_value = {"error": {"message": "Unauthorized"}}
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"auth", request=MagicMock(), response=create_resp
)
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
)
result = client.video_generation("p", output_dir=str(tmp_path))
assert result is None
err = client.get_last_video_error()
assert err["error_code"] == "auth_error"
assert err["status_code"] == 401
def test_poll_failed_returns_task_failed_error(self, tmp_path):
"""轮询 status=failed 时应记录 task_failed 错误并含 detail。"""
client = _make_client(max_retries=0)
create_resp = MagicMock()
create_resp.status_code = 200
create_resp.json.return_value = {"id": "t-fail"}
create_resp.raise_for_status = MagicMock()
poll_resp = MagicMock()
poll_resp.status_code = 200
poll_resp.json.return_value = {
"status": "failed",
"error": {"code": "InvalidParam", "message": "resolution invalid"},
}
poll_resp.raise_for_status = MagicMock()
with (
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
patch("packages.shared.ai_client.time.sleep", return_value=None),
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
):
mock_s.return_value = MagicMock(
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
result = client.video_generation("p", output_dir=str(tmp_path))
assert result is None
err = client.get_last_video_error()
assert err["error_code"] == "task_failed"
assert "InvalidParam" in err.get("detail", "") or err["status_code"] == 200
class TestAiServiceLastVideoError:
def test_call_video_generation_returns_none_sets_error(self):
"""失败后 get_last_video_error 应返回结构化错误信息。"""
from packages.shared import ai_service
mock_client = MagicMock()
mock_client.is_available = True
mock_client.last_video_error = {"error_code": "unknown", "user_message": "test"}
mock_client.get_last_video_error.return_value = {"error_code": "unknown", "user_message": "test"}
mock_client.video_generation.return_value = None
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
assert ai_service.call_video_generation("p") is None
err = ai_service.get_last_video_error()
assert err["error_code"] == "unknown"
assert "user_message" in err