Files
xiaoxia-saas/tests/unit/test_ai_client_video.py
T
CI Bot 1afda93b1a
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
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 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 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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m9s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m16s
AI Code Review / AI Code Review (pull_request) Successful in 10m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m46s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 19m59s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m35s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-30 09:54:33 +00:00

421 lines
18 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.timeout = overrides.get("timeout", 30)
client.max_retries = overrides.get("max_retries", 0) # 默认关闭重试以便断言
return client
class TestVideoGenerationHappyPath:
def test_happy_path_generates_and_downloads(self, tmp_path):
"""模拟 submit 返回 task_id → poll succeeded → 下载到本地 → 返回路径。"""
client = _make_client()
fake_task_resp = MagicMock()
fake_task_resp.json.return_value = {"id": "task-001"}
fake_task_resp.raise_for_status = MagicMock()
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()
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
assert "/contents/generations/tasks" in url
payload = kwargs.get("json", {})
assert payload["model"]
assert payload["duration"] == 5
assert payload["content"][0]["text"].strip() == "镜头一"
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}")
# time.time 在代码里:第一次在 deadline 计算 < while 入口,第二次 while 判断时要 < deadline
# 然后 succeeded 立即 break,不会再 sleep
times = iter([1000.0, 1001.0])
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=lambda: next(times)),
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="https://img/x.jpg",
duration=5,
ratio="9:16",
resolution="720p",
output_dir=str(tmp_path),
)
assert out is not None
assert Path(out).exists()
assert Path(out).name == "seedance_task-001_abcd1234.mp4"
assert Path(out).read_bytes() == b"FAKEMP4DATA"
assert calls["post"] == 1
assert calls["get"] == 1 # 只 poll 一次就 succeeded
class TestVideoGenerationFailures:
def test_returns_none_when_unavailable(self, tmp_path):
client = _make_client(api_key="")
# is_available 由 self.api_key 判断
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()
t0 = [1000.0]
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),
):
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()
t0 = [1000.0]
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=lambda: t0.__setitem__(0, t0[0] + 1) or t0[0]),
):
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=[1000.0, 1001.0, 1002.0]),
):
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):
"""创建任务第一次 httpx 异常,重试后成功拿到 id。"""
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"} # 走 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
times = iter([1000.0, 1001.0, 1002.0])
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=lambda: next(times)),
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 # 第一次失败,第二次成功;poll 返回 expired → None
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": {}} # 无 video_url
poll_resp.raise_for_status = MagicMock()
times = iter([1000.0, 1001.0])
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=lambda: next(times)),
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:
"""ai_service.call_video_generation 的 except 分支(client 方法抛异常时返回 None)。"""
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):
"""轮询两次后才 succeeded,覆盖 time.sleep 和 chunk None 分支。"""
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()
queued.json.return_value = {"status": "queued"}
queued.raise_for_status = MagicMock()
running = MagicMock()
running.json.return_value = {"status": "running"}
running.raise_for_status = MagicMock()
ok = MagicMock()
ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn/x.mp4"}}
ok.raise_for_status = MagicMock()
poll_seq = [queued, running, ok]
class EmptyChunkStream:
"""模拟一次返回空 chunk + 一次返回数据,覆盖 if chunk 分支。"""
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"" # 空 chunk
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)
# time.time 序列:t0=进入前算deadline, 然后3次while判断都 < deadline+200
times = iter([1000.0, 1001.0, 1002.0, 1003.0, 1004.0])
sleeps = []
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=lambda: next(times)),
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
assert Path(out).read_bytes() == b"DATA"
assert len(sleeps) >= 2 # queued/running 各 sleep 一次
def test_poll_exception_does_not_crash(self, tmp_path):
"""轮询 GET 抛异常时被 except 吞掉并继续 sleep,后续成功返回。"""
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
times = iter([1000.0, 1001.0, 1002.0])
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=lambda: next(times)),
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
assert Path(out).exists()
assert poll_calls["n"] == 2
class TestGetDoubaoClientSingleton:
def test_singleton_lazy_init(self):
"""覆盖 get_doubao_client 懒加载分支。"""
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
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()
times = iter([1000.0, 1001.0])
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=lambda: next(times)),
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