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
- 删除即梦 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
679 lines
29 KiB
Python
679 lines
29 KiB
Python
"""#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
|