fd23dfc0b9
CI / ai-gates (push) Successful in 33s
按用户拍板的四点定型 Java↔Python 的 JSON 契约。 **① 字段命名 snake_case**(与对客户端的 camelCase 契约不同,有意为之):本服务是 Python,snake_case 使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需 在这一个 Feign 客户端上配置命名策略。用 camelCase 反而要在两端都加转换。 **② 两类失败分开**,判别字段是 `error.kind` 而非 HTTP 状态码: - `service`(Java↔Python):unauthenticated / invalid_request / task_not_found / provider_unavailable / internal_error。属我方 bug 或配置问题。 - `provider`(Python↔服务商):非 2xx + 完整分类(retry / user_facing / provider_code / message),字段与 Failure 一致,故 Java 侧可复用同一段处置 逻辑,不必为「提交时被限流」和「任务失败于限流」写两套分支。 - **任务失败是第三类,走 200 + state=failed + failure**:查询一个失败的任务, HTTP 调用本身是成功的。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。 **③ 像素对照表已实现**——此处需更正一个理解:该表**不需要查询任何东西、也不依赖 存储方案**。它是纯静态数据(官方文档「不同宽高比对应的宽高像素值」一节),厂商 查询响应已返回 ratio 与 resolution,查表即可,无需下载产物或解码。故 widthPx/heightPx 在 AI 视频产物上可零成本填真值(ADR-023 §11)。 实现上一个关键观察:**2.5 与 2.0 的 24 格里只有两格不同**(480p 的 16:9 与 9:16),其余完全一致。故写成「共享表 + 两格覆盖」,而非抄两份 24 行——后者每次 改动要改两处,迟早只改一处。另 4k 六格仅 2.0 有。 查不到时返回 None 而不猜(ratio=adaptive、未知模型族、该模型不支持的组合三种 情形):一个错的尺寸会让客户端按错误比例渲染,且看不出是错的。 连带给 SeedanceTask 补解析响应里的 model 字段——同一适配器实例可能同时处理两个 模型族的任务,按响应里的实际模型定族才对。 **④ 路径参数用服务商任务 ID**,并在契约里写明原因:本服务不持有状态,唯一能用来 问服务商的就是这个 ID;用 job_id 就得存一份映射,「无状态」不再成立,且会多出 一个「Java 有 ID 但查不到任何东西」的中间态。你指出的那点完全对——submit 会阻塞 一次到服务商的短往返才能拿到 ID,但服务商的提交接口本身是异步的(只入队即返回), 所以这一跳很快。契约里也如实登记了那个**无法消除的窗口**:该调用超时时 Java 拿 不到 ID 而服务商可能已创建任务,无幂等键可用,只能由 max_attempts 兜住。 测试 123 → 127:旧的「像素刻意留空」断言已作废,换成断言真实换算,并覆盖两族 差异的那两格、4k 仅 2.0、adaptive 与未知模型族的留空回退。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
543 lines
20 KiB
Python
543 lines
20 KiB
Python
"""Seedance 视频适配器的测试:领域请求 ↔ 厂商请求体的翻译,以及状态映射。
|
||
|
||
夹具里的响应体逐字取自官方文档的「返回格式」示例,避免测试通过而真实字段名对不上。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.domain.generation import (
|
||
GenerationRequest,
|
||
MediaKind,
|
||
Retry,
|
||
TaskState,
|
||
UserFacing,
|
||
)
|
||
from app.providers.volcengine.client import ArkClient
|
||
from app.providers.volcengine.models import SeedanceSubmission
|
||
from app.providers.volcengine.video import PROVIDER_CODE, SeedanceVideoProvider
|
||
|
||
FAKE_API_KEY = "dummy-api-key-for-tests"
|
||
|
||
#: 官方查询文档「返回格式」一节的原样示例。
|
||
OFFICIAL_SUCCESS_PAYLOAD = {
|
||
"id": "cgt-2026****-****",
|
||
"model": "doubao-seedance-2-0-260128",
|
||
"status": "succeeded",
|
||
"content": {
|
||
"video_url": "https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com/xxx"
|
||
},
|
||
"usage": {"completion_tokens": 108900, "total_tokens": 108900},
|
||
"created_at": 1779348818,
|
||
"updated_at": 1779348874,
|
||
"seed": 78674,
|
||
"resolution": "720p",
|
||
"ratio": "16:9",
|
||
"duration": 5,
|
||
"framespersecond": 24,
|
||
"service_tier": "default",
|
||
"execution_expires_after": 172800,
|
||
"generate_audio": True,
|
||
"draft": False,
|
||
"priority": 0,
|
||
}
|
||
|
||
|
||
def provider_with(handler, **kwargs) -> SeedanceVideoProvider:
|
||
client = ArkClient(
|
||
api_key=FAKE_API_KEY,
|
||
base_url="https://ark.example.invalid/api/v3",
|
||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||
)
|
||
return SeedanceVideoProvider(client, **kwargs)
|
||
|
||
|
||
def video_request(**kwargs) -> GenerationRequest:
|
||
defaults = {
|
||
"job_id": "job-1",
|
||
"media_kind": MediaKind.VIDEO,
|
||
"model": "doubao-seedance-x",
|
||
"prompt": "一只猫",
|
||
}
|
||
return GenerationRequest(**{**defaults, **kwargs})
|
||
|
||
|
||
class TestIdentity:
|
||
def test_provider_code_matches_the_catalog_column(self):
|
||
assert (
|
||
provider_with(lambda r: httpx.Response(200, json={})).provider_code
|
||
== PROVIDER_CODE
|
||
)
|
||
|
||
def test_declares_itself_as_a_video_provider(self):
|
||
assert (
|
||
provider_with(lambda r: httpx.Response(200, json={})).media_kind
|
||
is MediaKind.VIDEO
|
||
)
|
||
|
||
|
||
class TestSubmitTranslation:
|
||
def test_text_to_video_sends_only_text(self):
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.content))
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
provider_with(handler).submit(video_request())
|
||
assert captured["content"] == [{"type": "text", "text": "一只猫"}]
|
||
|
||
def test_input_url_becomes_first_frame_with_explicit_role(self):
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.content))
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
provider_with(handler).submit(video_request(input_url="https://x/a.png"))
|
||
image = captured["content"][1]
|
||
assert image["type"] == "image_url"
|
||
assert image["image_url"]["url"] == "https://x/a.png"
|
||
# role 省略时厂商按 first_frame 处理,但显式写出以免依赖默认行为。
|
||
assert image["role"] == "first_frame"
|
||
|
||
def test_model_from_request_wins_over_the_configured_default(self):
|
||
"""请求里的模型来自任务行冻结的快照,必须优先。
|
||
|
||
否则「任务记录里写的模型」与「实际调用的模型」会不一致,事后无法对账。
|
||
"""
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.content))
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
provider_with(handler, default_model="fallback-model").submit(
|
||
video_request(model="snapshot-model")
|
||
)
|
||
assert captured["model"] == "snapshot-model"
|
||
|
||
def test_configured_default_is_only_a_fallback(self):
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.content))
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
provider_with(handler, default_model="fallback-model").submit(
|
||
video_request(model="")
|
||
)
|
||
assert captured["model"] == "fallback-model"
|
||
|
||
def test_no_model_anywhere_fails_before_calling_the_vendor(self):
|
||
with pytest.raises(ValueError, match="未指定模型"):
|
||
provider_with(lambda r: httpx.Response(200, json={})).submit(
|
||
video_request(model="")
|
||
)
|
||
|
||
def test_task_expiry_comes_from_config_not_the_vendor_default(self):
|
||
# 厂商默认 48 小时,对交互式应用过长——被放弃的任务会在厂商侧挂两天。
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.content))
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
provider_with(handler, task_expiry_sec=7200).submit(video_request())
|
||
assert captured["execution_expires_after"] == 7200
|
||
|
||
def test_rejects_a_misrouted_image_request(self):
|
||
with pytest.raises(ValueError, match="只处理视频生成"):
|
||
provider_with(lambda r: httpx.Response(200, json={})).submit(
|
||
video_request(media_kind=MediaKind.IMAGE)
|
||
)
|
||
|
||
def test_returns_vendor_task_id(self):
|
||
def handler(request):
|
||
return httpx.Response(200, json={"id": "cgt-123"})
|
||
|
||
assert provider_with(handler).submit(video_request()) == "cgt-123"
|
||
|
||
def test_success_without_task_id_is_an_error(self):
|
||
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
|
||
def handler(request):
|
||
return httpx.Response(200, json={"unexpected": True})
|
||
|
||
with pytest.raises(ValueError, match="缺少任务 ID"):
|
||
provider_with(handler).submit(video_request())
|
||
|
||
|
||
class TestSubmissionBody:
|
||
"""`SeedanceSubmission` 自身的行为,不经过 HTTP。"""
|
||
|
||
def test_empty_content_is_rejected_locally(self):
|
||
# 空内容必然被厂商拒,但那要花一次往返(还可能计入 RPM)。
|
||
with pytest.raises(ValueError, match="至少要有一个"):
|
||
SeedanceSubmission(model="m").to_body()
|
||
|
||
def test_last_frame_requested_by_default_for_a_free_cover_image(self):
|
||
assert (
|
||
SeedanceSubmission(model="m", prompt="x").to_body()["return_last_frame"]
|
||
is True
|
||
)
|
||
|
||
def test_optional_fields_are_omitted_when_unset(self):
|
||
body = SeedanceSubmission(model="m", prompt="x").to_body()
|
||
assert "safety_identifier" not in body
|
||
assert "callback_url" not in body
|
||
|
||
|
||
class TestFetchStateMapping:
|
||
def test_succeeded(self):
|
||
result = provider_with(
|
||
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||
).fetch("cgt-1")
|
||
assert result.state is TaskState.SUCCEEDED
|
||
assert result.progress == 100
|
||
assert result.asset_url.endswith("/xxx")
|
||
assert result.ratio == "16:9"
|
||
assert result.resolution == "720p"
|
||
assert result.billing_tokens == 108900
|
||
assert result.failure is None
|
||
|
||
def test_asset_url_carries_its_expiry_so_callers_cannot_treat_it_as_permanent(self):
|
||
"""厂商 URL 只有 24 小时有效期,存成 external_url 会变死链。
|
||
|
||
把时效随 URL 一起给出,上层就没有理由把它当永久地址。
|
||
"""
|
||
result = provider_with(
|
||
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||
).fetch("cgt-1")
|
||
assert result.asset_url_expires_in_sec == 24 * 3600
|
||
|
||
def test_queued_maps_to_pending(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "queued", "content": {}}
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").state is TaskState.PENDING
|
||
|
||
def test_running_has_zero_progress_because_the_vendor_gives_none(self):
|
||
# 不是缺陷:客户端契约本就不承诺平滑进度(ADR-023 §10)。
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "running", "content": {}}
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert result.state is TaskState.RUNNING
|
||
assert result.progress == 0
|
||
|
||
def test_cancelled_is_terminal_without_a_failure(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "cancelled", "content": {}}
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert result.state is TaskState.CANCELLED
|
||
assert result.state.is_terminal
|
||
assert result.failure is None
|
||
|
||
def test_expired_maps_to_failed_and_stays_retryable(self):
|
||
"""`expired` 只见于创建接口的回调说明,查询接口文档没列。
|
||
|
||
只按查询文档实现会让超时任务掉进未知分支。它是超时而非内容问题,故可重试;
|
||
且厂商 expired 时不一定给 error.code,故单独分类而非走通用路径。
|
||
"""
|
||
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "expired", "content": {}}
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert result.state is TaskState.FAILED
|
||
assert result.failure.retry is Retry.BACKOFF
|
||
assert result.failure.provider_code == "TaskExpired"
|
||
|
||
def test_unknown_status_keeps_the_task_alive(self):
|
||
# 厂商新增状态时把在途任务判死更糟,最坏是等到我们自己的超时。
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "brand_new", "content": {}}
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").state is TaskState.RUNNING
|
||
|
||
def test_succeeded_without_a_url_is_treated_as_a_retryable_failure(self):
|
||
"""厂商说成功却没给 URL——那不是成功。
|
||
|
||
放一个没有产物的「成功」给上层,只会把空指针推到更远的地方。
|
||
"""
|
||
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "succeeded", "content": {}}
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert result.state is TaskState.FAILED
|
||
assert result.failure.retry is Retry.BACKOFF
|
||
assert result.failure.provider_code == "MissingVideoUrl"
|
||
|
||
|
||
class TestFetchFailureClassification:
|
||
def test_content_rejection_is_not_retried(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"status": "failed",
|
||
"content": {},
|
||
"error": {
|
||
"code": "OutputVideoSensitiveContentDetected",
|
||
"message": "output may contain sensitive information",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert result.failure.retry is Retry.NEVER
|
||
assert result.failure.user_facing is UserFacing.CONTENT_REJECTED
|
||
# 原始码与 message 都要带到上层——排查时它们是唯一线索。
|
||
assert result.failure.provider_code == "OutputVideoSensitiveContentDetected"
|
||
assert "sensitive" in result.failure.message
|
||
|
||
|
||
class TestDurationPrecision:
|
||
def test_frames_over_fps_is_exact(self):
|
||
# 官方例子:133 帧 / 24fps = 5.54 秒。
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"status": "succeeded",
|
||
"frames": 133,
|
||
"framespersecond": 24,
|
||
"content": {"video_url": "https://v"},
|
||
},
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").duration_ms == 5542
|
||
|
||
def test_falls_back_to_the_vendors_floored_seconds(self):
|
||
# duration 与 frames 只返回其一。只有 duration 时它是「帧数/24 向下取整」
|
||
# 的约数,最多可能短 1 秒——可用,但不可当精确值。
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"status": "succeeded",
|
||
"duration": 5,
|
||
"content": {"video_url": "https://v"},
|
||
},
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").duration_ms == 5000
|
||
|
||
def test_none_rather_than_a_made_up_zero(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200, json={"id": "x", "status": "running", "content": {}}
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").duration_ms is None
|
||
|
||
|
||
class TestCoverImage:
|
||
def test_last_frame_becomes_the_cover_url(self):
|
||
"""尾帧图可免费充当视频封面——iteration-4/03 R13 把缩略图列为视频成本项,
|
||
实际上厂商白给。"""
|
||
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"status": "succeeded",
|
||
"content": {
|
||
"video_url": "https://v",
|
||
"last_frame_url": "https://f.png",
|
||
},
|
||
},
|
||
)
|
||
|
||
assert provider_with(handler).fetch("x").cover_url == "https://f.png"
|
||
|
||
|
||
class TestPixelDimensions:
|
||
"""厂商只返回 ratio 与 resolution,像素值由静态对照表换算。
|
||
|
||
不需要下载产物、不需要解码、不依赖存储方案——这是 widthPx/heightPx 在 AI
|
||
视频产物上能零成本填真值的依据(ADR-023 §11)。
|
||
"""
|
||
|
||
def test_derived_from_ratio_and_resolution(self):
|
||
# 官方表:2.0 系列 720p + 16:9 = 1280×720。
|
||
result = provider_with(
|
||
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||
).fetch("x")
|
||
assert (result.width_px, result.height_px) == (1280, 720)
|
||
# 原始信息同时透出,便于排查与将来复核。
|
||
assert (result.ratio, result.resolution) == ("16:9", "720p")
|
||
|
||
def test_the_two_cells_where_the_families_differ(self):
|
||
"""2.5 与 2.0 的 24 格里只有两格不同:480p 的 16:9 与 9:16。
|
||
|
||
这两格若搞错,客户端会按错误比例渲染 480p 视频且看不出是错的。
|
||
"""
|
||
|
||
def payload(model: str) -> dict:
|
||
return {
|
||
"id": "x",
|
||
"model": model,
|
||
"status": "succeeded",
|
||
"resolution": "480p",
|
||
"ratio": "16:9",
|
||
"content": {"video_url": "https://v"},
|
||
}
|
||
|
||
result_2_5 = provider_with(
|
||
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-5-260628"))
|
||
).fetch("x")
|
||
result_2_0 = provider_with(
|
||
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-0-260128"))
|
||
).fetch("x")
|
||
assert (result_2_5.width_px, result_2_5.height_px) == (854, 480)
|
||
assert (result_2_0.width_px, result_2_0.height_px) == (864, 496)
|
||
|
||
def test_4k_resolves_only_on_2_0(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"model": "doubao-seedance-2-0-260128",
|
||
"status": "succeeded",
|
||
"resolution": "4k",
|
||
"ratio": "16:9",
|
||
"content": {"video_url": "https://v"},
|
||
},
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert (result.width_px, result.height_px) == (3840, 2160)
|
||
|
||
def test_adaptive_ratio_yields_none_rather_than_a_guess(self):
|
||
"""`adaptive` 时厂商自动适配首帧比例,本表无从查起。
|
||
|
||
留空而不猜:一个错的尺寸会让客户端按错误比例渲染,而且看不出是错的。
|
||
"""
|
||
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"model": "doubao-seedance-2-5-260628",
|
||
"status": "succeeded",
|
||
"resolution": "720p",
|
||
"ratio": "adaptive",
|
||
"content": {"video_url": "https://v"},
|
||
},
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert (result.width_px, result.height_px) == (None, None)
|
||
# 但原始值仍透出,上层至少知道厂商说了什么。
|
||
assert result.ratio == "adaptive"
|
||
|
||
def test_unknown_model_family_yields_none(self):
|
||
def handler(request):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"id": "x",
|
||
"model": "some-future-model",
|
||
"status": "succeeded",
|
||
"resolution": "720p",
|
||
"ratio": "16:9",
|
||
"content": {"video_url": "https://v"},
|
||
},
|
||
)
|
||
|
||
result = provider_with(handler).fetch("x")
|
||
assert (result.width_px, result.height_px) == (None, None)
|
||
|
||
|
||
class TestPerModelLimitsAreEnforcedBeforeCallingTheVendor:
|
||
"""超范围的请求本地就拦下,不花一次往返去换一个厂商 400。
|
||
|
||
两个已接入模型的限制不同,同一组参数在一个上合法、在另一个上不合法——
|
||
这正是需要本地预校验的理由。
|
||
"""
|
||
|
||
def test_duration_beyond_the_2_0_cap_is_rejected_locally(self):
|
||
def handler(request):
|
||
raise AssertionError("不应发出请求:本地预校验就该拦下")
|
||
|
||
with pytest.raises(ValueError, match=r"\[4, 15\]"):
|
||
provider_with(handler).submit(
|
||
video_request(model="doubao-seedance-2-0-260128", duration_sec=20)
|
||
)
|
||
|
||
def test_the_same_duration_passes_on_2_5(self):
|
||
def handler(request):
|
||
return httpx.Response(200, json={"id": "cgt-1"})
|
||
|
||
assert (
|
||
provider_with(handler).submit(
|
||
video_request(model="doubao-seedance-2-5-260628", duration_sec=20)
|
||
)
|
||
== "cgt-1"
|
||
)
|
||
|
||
def test_4k_is_rejected_on_2_5_but_allowed_on_2_0(self):
|
||
def rejecting(request):
|
||
raise AssertionError("不应发出请求")
|
||
|
||
with pytest.raises(ValueError, match="不支持分辨率 4k"):
|
||
provider_with(rejecting).submit(
|
||
video_request(model="doubao-seedance-2-5-260628", resolution="4k")
|
||
)
|
||
|
||
def accepting(request):
|
||
return httpx.Response(200, json={"id": "cgt-2"})
|
||
|
||
assert (
|
||
provider_with(accepting).submit(
|
||
video_request(model="doubao-seedance-2-0-260128", resolution="4k")
|
||
)
|
||
== "cgt-2"
|
||
)
|
||
|
||
def test_2_5_image_to_video_rejects_a_specific_ratio(self):
|
||
def handler(request):
|
||
raise AssertionError("不应发出请求")
|
||
|
||
with pytest.raises(ValueError, match="只支持 ratio=adaptive"):
|
||
provider_with(handler).submit(
|
||
video_request(
|
||
model="doubao-seedance-2-5-260628",
|
||
input_url="https://x/a.png",
|
||
ratio="16:9",
|
||
)
|
||
)
|