`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。 这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种 写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。 新增 ALLOW_REFERENCE,刻意收得很紧以免开后门: - 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状) - 只允许**恰好一个点且随即行尾** - 连字符不在字符集内 对抗性夹具验证(三真三假): - 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态) - 拦下 `secret_key: "AKID…"`(腾讯云形态) - 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**) - 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken` 原 5 形态泄漏夹具复验仍 8 条命中,一条不少。 另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓—— patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致 (4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""`ArkClient` 的测试——只测传输层:鉴权头、错误解包、连接生命周期。
|
||||
|
||||
**刻意不测任何视频业务**:本 client 不认识「视频」,测试也不该。视频相关的断言
|
||||
在 `test_video.py`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.domain.generation import Retry, UserFacing
|
||||
from app.providers.volcengine.client import ArkClient, ArkError
|
||||
|
||||
#: 显式的假值。防泄漏扫描会拦下 `api_key = "..."` 形态(规则正确——这正是真实
|
||||
#: 密钥最常见的泄漏形态),故夹具用 ALLOW 清单认可的占位词。
|
||||
FAKE_API_KEY = "dummy-api-key-for-tests"
|
||||
|
||||
BASE_URL = "https://ark.example.invalid/api/v3"
|
||||
|
||||
|
||||
def client_with(handler) -> ArkClient:
|
||||
return ArkClient(
|
||||
api_key=FAKE_API_KEY,
|
||||
base_url=BASE_URL,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_missing_api_key_fails_at_construction_not_at_first_call(self):
|
||||
# 缺密钥是配置错误。让它表现为一串莫名的 401 会浪费排查时间。
|
||||
with pytest.raises(ValueError, match="ARK_API_KEY"):
|
||||
ArkClient(api_key="", base_url=BASE_URL)
|
||||
|
||||
def test_trailing_slash_in_base_url_does_not_double_up(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "//contents" not in str(request.url)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
ArkClient(
|
||||
api_key=FAKE_API_KEY,
|
||||
base_url=BASE_URL + "/",
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
).get("/contents/x")
|
||||
|
||||
|
||||
class TestAuthAndTransport:
|
||||
def test_bearer_token_and_content_type_are_sent(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == f"Bearer {FAKE_API_KEY}"
|
||||
assert request.headers["Content-Type"] == "application/json"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
assert client_with(handler).post("/x", {}) == {"ok": True}
|
||||
|
||||
def test_get_and_post_hit_the_expected_method(self):
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.method)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
client = client_with(handler)
|
||||
client.post("/x", {})
|
||||
client.get("/x")
|
||||
assert seen == ["POST", "GET"]
|
||||
|
||||
def test_network_failure_is_classified_retryable(self):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with pytest.raises(ArkError) as exc:
|
||||
client_with(handler).get("/x")
|
||||
assert exc.value.failure.retry is Retry.BACKOFF
|
||||
assert exc.value.http_status is None
|
||||
|
||||
|
||||
class TestErrorUnwrapping:
|
||||
def test_vendor_error_body_is_classified(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
json={
|
||||
"error": {
|
||||
"code": "RateLimitExceeded.EndpointRPMExceeded",
|
||||
"message": "RPM limit exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ArkError) as exc:
|
||||
client_with(handler).get("/x")
|
||||
assert exc.value.failure.retry is Retry.BACKOFF
|
||||
assert exc.value.failure.user_facing is UserFacing.RATE_LIMITED
|
||||
assert (
|
||||
exc.value.failure.provider_code == "RateLimitExceeded.EndpointRPMExceeded"
|
||||
)
|
||||
assert exc.value.http_status == 429
|
||||
|
||||
def test_vendor_message_is_preserved_for_troubleshooting(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
401,
|
||||
json={
|
||||
"error": {"code": "AuthenticationError", "message": "key invalid"}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ArkError) as exc:
|
||||
client_with(handler).get("/x")
|
||||
assert "key invalid" in exc.value.failure.message
|
||||
|
||||
def test_non_json_error_body_is_not_swallowed(self):
|
||||
# 打到错误地址时常见:网关返回 HTML 错误页。静默会让排查绕远路。
|
||||
def handler(request):
|
||||
return httpx.Response(502, text="<html>bad gateway</html>")
|
||||
|
||||
with pytest.raises(ArkError) as exc:
|
||||
client_with(handler).get("/x")
|
||||
assert "bad gateway" in exc.value.failure.message
|
||||
assert exc.value.failure.retry is Retry.BACKOFF
|
||||
|
||||
def test_non_json_success_body_is_an_error_too(self):
|
||||
def handler(request):
|
||||
return httpx.Response(200, text="not json")
|
||||
|
||||
with pytest.raises(ArkError, match="不是合法 JSON"):
|
||||
client_with(handler).get("/x")
|
||||
|
||||
|
||||
class TestConnectionOwnership:
|
||||
def test_injected_client_is_not_closed_by_us(self):
|
||||
"""注入进来的连接池归调用方管理。
|
||||
|
||||
在这里关掉会让共享同一个 httpx.Client 的其他适配器一起失效——接了第二个
|
||||
火山接口后这会变成一个极难查的偶发失败。
|
||||
"""
|
||||
injected = httpx.Client(
|
||||
transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))
|
||||
)
|
||||
ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL, client=injected).close()
|
||||
assert not injected.is_closed
|
||||
|
||||
def test_own_client_is_closed(self):
|
||||
client = ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL)
|
||||
client.close()
|
||||
assert client._client.is_closed
|
||||
|
||||
def test_usable_as_context_manager(self):
|
||||
with ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL) as client:
|
||||
inner = client._client
|
||||
assert inner.is_closed
|
||||
@@ -5,7 +5,8 @@
|
||||
可重试而不是直接判死。
|
||||
"""
|
||||
|
||||
from app.providers.errors import Retry, UserFacing, classify
|
||||
from app.domain.generation import Retry, UserFacing
|
||||
from app.providers.volcengine.errors import classify
|
||||
|
||||
|
||||
class TestQuotaExceededIsTwoDifferentThings:
|
||||
@@ -0,0 +1,398 @@
|
||||
"""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 = dict(
|
||||
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 TestPixelDimensionsAreLeftBlankOnPurpose:
|
||||
def test_no_invented_dimensions(self):
|
||||
"""火山只返回 ratio 与 resolution,不返回像素值。
|
||||
|
||||
换算需要一张随模型族变化的对照表;在 V7 目录种子定下模型族之前,
|
||||
留空比编一个可能错的尺寸好。
|
||||
"""
|
||||
result = provider_with(
|
||||
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||||
).fetch("x")
|
||||
assert result.width_px is None
|
||||
assert result.height_px is None
|
||||
# 但原始信息要透出,供上层或后续实现换算。
|
||||
assert (result.ratio, result.resolution) == ("16:9", "720p")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""装配根与注册表的测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.assembly import build_registry
|
||||
from app.config import Settings, VolcengineSettings
|
||||
from app.domain.generation import (
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
MediaKind,
|
||||
TaskState,
|
||||
)
|
||||
from app.providers.base import GenerationProvider
|
||||
from app.providers.registry import ProviderRegistry
|
||||
from app.providers.volcengine.video import PROVIDER_CODE
|
||||
|
||||
FAKE_API_KEY = "dummy-api-key-for-tests"
|
||||
|
||||
|
||||
class StubProvider(GenerationProvider):
|
||||
def __init__(self, code: str, kind: MediaKind = MediaKind.VIDEO):
|
||||
self._code = code
|
||||
self._kind = kind
|
||||
|
||||
@property
|
||||
def provider_code(self) -> str:
|
||||
return self._code
|
||||
|
||||
@property
|
||||
def media_kind(self) -> MediaKind:
|
||||
return self._kind
|
||||
|
||||
def submit(self, request: GenerationRequest) -> str:
|
||||
return "stub-1"
|
||||
|
||||
def fetch(self, provider_request_id: str) -> GenerationResult:
|
||||
return GenerationResult(provider_request_id, TaskState.RUNNING)
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_finds_by_code(self):
|
||||
registry = ProviderRegistry([StubProvider("a"), StubProvider("b")])
|
||||
assert registry.find("a").provider_code == "a"
|
||||
assert registry.codes == frozenset({"a", "b"})
|
||||
|
||||
def test_unknown_code_returns_none(self):
|
||||
"""对已持久化的任务,未知 code 意味着当初提交它的适配器已被移除。
|
||||
|
||||
调用方应当作不可重试的失败,而不是循环重试到预算耗尽。
|
||||
"""
|
||||
assert ProviderRegistry([StubProvider("a")]).find("gone") is None
|
||||
|
||||
def test_duplicate_registration_is_rejected(self):
|
||||
# 静默覆盖会让「哪个实例在处理任务」取决于注册顺序,是最难查的一类问题。
|
||||
with pytest.raises(ValueError, match="重复注册"):
|
||||
ProviderRegistry([StubProvider("same"), StubProvider("same")])
|
||||
|
||||
|
||||
class TestAssembly:
|
||||
def test_seedance_is_registered_when_credentials_exist(self):
|
||||
registry = build_registry(
|
||||
Settings(volcengine=VolcengineSettings(api_key=FAKE_API_KEY))
|
||||
)
|
||||
provider = registry.find(PROVIDER_CODE)
|
||||
assert provider is not None
|
||||
assert provider.media_kind is MediaKind.VIDEO
|
||||
|
||||
def test_missing_credentials_skip_the_provider_instead_of_failing_startup(self):
|
||||
"""本地只配了一家凭证时,服务仍应起得来。
|
||||
|
||||
真正的失败点在调用时:find() 返回 None,调用方按「适配器不可用」处理,
|
||||
而不是拿到一个半死的实例。
|
||||
"""
|
||||
registry = build_registry(Settings(volcengine=VolcengineSettings(api_key="")))
|
||||
assert registry.find(PROVIDER_CODE) is None
|
||||
assert len(registry) == 0
|
||||
|
||||
def test_registered_code_matches_the_catalog_column(self):
|
||||
"""注册表的键必须与 creation.generation_models.provider_code 同值。
|
||||
|
||||
对不上时,Java 侧按任务行的快照来查会一直查不到适配器——而症状是任务
|
||||
永远卡在排队,离根因很远。
|
||||
"""
|
||||
registry = build_registry(
|
||||
Settings(volcengine=VolcengineSettings(api_key=FAKE_API_KEY))
|
||||
)
|
||||
assert PROVIDER_CODE in registry.codes
|
||||
@@ -0,0 +1,98 @@
|
||||
"""配置加载的测试。
|
||||
|
||||
重点在两处:非法值必须**响亮失败而不是静默回落**(一次配置笔误变成难查的行为
|
||||
偏差是最糟的结果),以及密钥**绝不能有默认值**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
|
||||
|
||||
|
||||
class TestCredentialsHaveNoDefaults:
|
||||
def test_api_key_defaults_to_empty_not_a_placeholder(self, monkeypatch):
|
||||
monkeypatch.delenv("ARK_API_KEY", raising=False)
|
||||
assert VolcengineSettings.from_env().api_key == ""
|
||||
|
||||
def test_service_jwt_public_key_defaults_to_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("PATBOND_SERVICE_JWT_PUBLIC_KEY", raising=False)
|
||||
assert ServiceAuthSettings.from_env().public_key == ""
|
||||
|
||||
def test_missing_credentials_do_not_prevent_loading(self, monkeypatch):
|
||||
"""缺密钥时配置仍能加载。
|
||||
|
||||
服务要能起来并提供健康检查;缺配置在**调用点**响亮失败,而不是让整个
|
||||
进程起不来——与 Java 侧 JwtVerifier/ServiceTokenSigner 的既有约定一致。
|
||||
"""
|
||||
for name in ("ARK_API_KEY", "PATBOND_SERVICE_JWT_PUBLIC_KEY"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
settings = Settings.from_env()
|
||||
assert settings.volcengine.api_key == ""
|
||||
assert settings.service_auth.public_key == ""
|
||||
|
||||
|
||||
class TestEnvParsing:
|
||||
def test_values_are_trimmed(self, monkeypatch):
|
||||
# 从 .env 或 compose 里复制粘贴时尾随空格极常见。
|
||||
monkeypatch.setenv("ARK_API_KEY", " dummy-value ")
|
||||
assert VolcengineSettings.from_env().api_key == "dummy-value"
|
||||
|
||||
def test_int_override(self, monkeypatch):
|
||||
monkeypatch.setenv("ARK_TASK_EXPIRY_SEC", "7200")
|
||||
assert VolcengineSettings.from_env().task_expiry_sec == 7200
|
||||
|
||||
def test_float_override(self, monkeypatch):
|
||||
monkeypatch.setenv("ARK_TIMEOUT_SEC", "2.5")
|
||||
assert VolcengineSettings.from_env().timeout_sec == 2.5
|
||||
|
||||
def test_malformed_int_fails_loudly_instead_of_falling_back(self, monkeypatch):
|
||||
# 静默回落到默认值会让一次笔误变成难查的行为偏差。
|
||||
monkeypatch.setenv("ARK_TASK_EXPIRY_SEC", "一小时")
|
||||
with pytest.raises(ValueError, match="不是合法整数"):
|
||||
VolcengineSettings.from_env()
|
||||
|
||||
def test_malformed_float_fails_loudly(self, monkeypatch):
|
||||
monkeypatch.setenv("ARK_TIMEOUT_SEC", "fast")
|
||||
with pytest.raises(ValueError, match="不是合法数字"):
|
||||
VolcengineSettings.from_env()
|
||||
|
||||
def test_empty_string_uses_the_default(self, monkeypatch):
|
||||
# compose 里 `ARK_TIMEOUT_SEC: ${FOO:-}` 展开成空串是常见情形。
|
||||
monkeypatch.setenv("ARK_TIMEOUT_SEC", "")
|
||||
assert VolcengineSettings.from_env().timeout_sec == 10.0
|
||||
|
||||
|
||||
class TestVendorConstraintsAreEnforcedAtStartup:
|
||||
"""厂商的硬性区间在启动时校验,而不是等提交时被拒。"""
|
||||
|
||||
def test_expiry_below_the_vendor_minimum_is_rejected(self):
|
||||
with pytest.raises(ValueError, match=r"\[3600, 259200\]"):
|
||||
VolcengineSettings(task_expiry_sec=60)
|
||||
|
||||
def test_expiry_above_the_vendor_maximum_is_rejected(self):
|
||||
with pytest.raises(ValueError, match=r"\[3600, 259200\]"):
|
||||
VolcengineSettings(task_expiry_sec=300000)
|
||||
|
||||
def test_the_boundaries_themselves_are_allowed(self):
|
||||
assert VolcengineSettings(task_expiry_sec=3600).task_expiry_sec == 3600
|
||||
assert VolcengineSettings(task_expiry_sec=259200).task_expiry_sec == 259200
|
||||
|
||||
def test_default_is_the_minimum_not_the_vendor_default(self):
|
||||
# 厂商默认 48 小时,被放弃的任务会在厂商侧挂两天。
|
||||
assert VolcengineSettings().task_expiry_sec == 3600
|
||||
|
||||
|
||||
class TestServiceAuthDefaults:
|
||||
def test_audience_and_type_have_sensible_defaults(self, monkeypatch):
|
||||
for name in (
|
||||
"PATBOND_SERVICE_JWT_AUDIENCE",
|
||||
"PATBOND_SERVICE_JWT_ISSUER",
|
||||
"PATBOND_SERVICE_JWT_TYPE",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
auth = ServiceAuthSettings.from_env()
|
||||
assert auth.audience == "patbond-ai"
|
||||
assert auth.issuer == "patbond-api"
|
||||
assert auth.required_type == "service"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""分层边界的测试。
|
||||
|
||||
这些断言不测业务,测的是**架构约束本身**。分层的价值全在「换供应商不动上层」,
|
||||
而这个性质只要有人无意加一行 import 就会悄悄失效——没有测试守着,它迟早会失效。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
|
||||
def imports_of(path: pathlib.Path) -> set[str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
names.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module)
|
||||
return names
|
||||
|
||||
|
||||
def modules_under(*parts: str) -> list[pathlib.Path]:
|
||||
return sorted((APP.joinpath(*parts)).rglob("*.py"))
|
||||
|
||||
|
||||
class TestDomainIsIndependent:
|
||||
"""领域层是「换供应商不动上层」的支点。它一旦依赖厂商或 HTTP,分层就名存实亡。"""
|
||||
|
||||
def test_domain_imports_no_provider_code(self):
|
||||
for module in modules_under("domain"):
|
||||
offenders = {name for name in imports_of(module) if "providers" in name}
|
||||
assert not offenders, f"{module.name} 不应依赖服务商代码:{offenders}"
|
||||
|
||||
def test_domain_imports_no_http_library(self):
|
||||
for module in modules_under("domain"):
|
||||
offenders = {
|
||||
name
|
||||
for name in imports_of(module)
|
||||
if name.split(".")[0] in {"httpx", "requests"}
|
||||
}
|
||||
assert not offenders, f"{module.name} 不应依赖 HTTP 库:{offenders}"
|
||||
|
||||
def test_domain_does_not_read_configuration(self):
|
||||
# 领域对象应当是纯数据;让它自己读配置会使单测必须摆弄环境变量。
|
||||
for module in modules_under("domain"):
|
||||
offenders = {
|
||||
name for name in imports_of(module) if name in {"os", "app.config"}
|
||||
}
|
||||
assert not offenders, f"{module.name} 不应读取配置:{offenders}"
|
||||
|
||||
|
||||
class TestAbstractionDoesNotLeak:
|
||||
def test_provider_base_knows_no_specific_vendor(self):
|
||||
"""抽象接口若 import 了火山,新增第二家供应商就得改抽象层。"""
|
||||
offenders = {
|
||||
name
|
||||
for name in imports_of(APP / "providers" / "base.py")
|
||||
if "volcengine" in name
|
||||
}
|
||||
assert not offenders, f"抽象层不应依赖具体服务商:{offenders}"
|
||||
|
||||
def test_registry_knows_no_specific_vendor(self):
|
||||
offenders = {
|
||||
name
|
||||
for name in imports_of(APP / "providers" / "registry.py")
|
||||
if "volcengine" in name
|
||||
}
|
||||
assert not offenders, f"注册表不应依赖具体服务商:{offenders}"
|
||||
|
||||
|
||||
class TestTransportStaysBusinessAgnostic:
|
||||
def test_ark_client_does_not_import_video_business(self):
|
||||
"""传输层复用的前提是它不认识「视频」。
|
||||
|
||||
一旦 client.py 依赖了 video.py,接火山的图片接口时就没法复用它,
|
||||
只能再抄一遍鉴权与错误解包——那正是拆分要避免的。
|
||||
"""
|
||||
offenders = {
|
||||
name
|
||||
for name in imports_of(APP / "providers" / "volcengine" / "client.py")
|
||||
if name.endswith((".video", ".image")) or name.endswith("models")
|
||||
}
|
||||
assert not offenders, f"传输层不应依赖具体业务模块:{offenders}"
|
||||
|
||||
|
||||
class TestVendorVocabularyIsContained:
|
||||
def test_only_the_volcengine_package_mentions_ark_paths(self):
|
||||
"""厂商特有的路径、字段名只应出现在其适配包内。
|
||||
|
||||
`framespersecond` 这种厂商拼法漏到上层,就意味着上层已经知道自己在跟
|
||||
火山说话——换供应商时会牵连到它。
|
||||
"""
|
||||
vendor_markers = ("framespersecond", "contents/generations", "ark.cn-beijing")
|
||||
for module in sorted(APP.rglob("*.py")):
|
||||
if "volcengine" in module.parts:
|
||||
continue
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for marker in vendor_markers:
|
||||
assert marker not in text, (
|
||||
f"{module.relative_to(APP)} 出现了厂商专有词汇 {marker!r},"
|
||||
f"应收拢到 providers/volcengine/ 内"
|
||||
)
|
||||
|
||||
|
||||
class TestDependencyDirectionIsOneWay:
|
||||
"""配置 → 适配层,绝不反向。
|
||||
|
||||
`config.py` 作为装配根知道有哪家供应商是合理的;反过来,适配层一旦自己去读
|
||||
配置,依赖注入就失效了——单测又得摆弄环境变量,而且同一进程内没法跑两套
|
||||
不同配置的适配器实例。
|
||||
"""
|
||||
|
||||
def test_providers_do_not_read_config(self):
|
||||
for module in modules_under("providers"):
|
||||
offenders = {
|
||||
name
|
||||
for name in imports_of(module)
|
||||
if name == "app.config" or name == "os"
|
||||
}
|
||||
assert not offenders, (
|
||||
f"{module.relative_to(APP)} 不应自行读取配置或环境变量:"
|
||||
f"{offenders}。请通过构造参数注入。"
|
||||
)
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Seedance 客户端的测试。全部离线——用 httpx 的 MockTransport 拦请求,不打真实厂商。
|
||||
|
||||
夹具里的响应体逐字取自官方文档的「返回格式」示例,避免测试通过而真实字段名对不上。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.providers.errors import Retry, UserFacing
|
||||
from app.providers.seedance import (
|
||||
SeedanceClient,
|
||||
SeedanceError,
|
||||
SeedanceSubmission,
|
||||
TaskStatus,
|
||||
)
|
||||
|
||||
#: 官方查询文档「返回格式」一节的原样示例。
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
#: 显式的假值。防泄漏扫描会拦下 `api_key = "..."` 形态的赋值(规则正确——这正是
|
||||
#: 真实密钥最常见的泄漏形态),故测试夹具用 ALLOW 清单认可的占位词而非随手起名。
|
||||
FAKE_API_KEY = "dummy-api-key-for-tests"
|
||||
|
||||
|
||||
def client_with(handler) -> SeedanceClient:
|
||||
return SeedanceClient(
|
||||
api_key=FAKE_API_KEY,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
|
||||
class TestSubmissionBody:
|
||||
"""请求体必须符合官方 content 数组的形状。"""
|
||||
|
||||
def test_text_to_video_sends_only_text(self):
|
||||
body = SeedanceSubmission(model="m", prompt="一只猫").to_body()
|
||||
assert body["content"] == [{"type": "text", "text": "一只猫"}]
|
||||
assert body["model"] == "m"
|
||||
|
||||
def test_image_to_video_adds_first_frame_with_explicit_role(self):
|
||||
body = SeedanceSubmission(
|
||||
model="m", prompt="动起来", first_frame_url="https://x/a.png"
|
||||
).to_body()
|
||||
assert body["content"][0]["type"] == "text"
|
||||
image = body["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_image_only_without_prompt_is_allowed(self):
|
||||
body = SeedanceSubmission(
|
||||
model="m", first_frame_url="https://x/a.png"
|
||||
).to_body()
|
||||
assert len(body["content"]) == 1
|
||||
assert body["content"][0]["type"] == "image_url"
|
||||
|
||||
def test_empty_content_is_rejected_before_hitting_the_vendor(self):
|
||||
# 提交空内容必然被厂商拒,但那要花一次往返。本地先拦。
|
||||
with pytest.raises(ValueError, match="至少要有一个"):
|
||||
SeedanceSubmission(model="m").to_body()
|
||||
|
||||
def test_expiry_defaults_to_the_minimum_not_the_vendor_default(self):
|
||||
# 厂商默认 48 小时,对交互式应用过长——被放弃的任务会在厂商侧挂两天。
|
||||
# 取值范围 [3600, 259200],故取下限。
|
||||
body = SeedanceSubmission(model="m", prompt="x").to_body()
|
||||
assert body["execution_expires_after"] == 3600
|
||||
|
||||
def test_last_frame_requested_by_default_for_free_cover_image(self):
|
||||
body = SeedanceSubmission(model="m", prompt="x").to_body()
|
||||
assert 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
|
||||
|
||||
def test_safety_identifier_is_passed_through_when_given(self):
|
||||
body = SeedanceSubmission(
|
||||
model="m", prompt="x", safety_identifier="sha256hash"
|
||||
).to_body()
|
||||
assert body["safety_identifier"] == "sha256hash"
|
||||
|
||||
|
||||
class TestSubmit:
|
||||
def test_returns_vendor_task_id(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == f"Bearer {FAKE_API_KEY}"
|
||||
assert request.url.path.endswith("/contents/generations/tasks")
|
||||
return httpx.Response(200, json={"id": "cgt-123"})
|
||||
|
||||
assert (
|
||||
client_with(handler).submit(SeedanceSubmission(model="m", prompt="x"))
|
||||
== "cgt-123"
|
||||
)
|
||||
|
||||
def test_missing_id_in_success_response_is_an_error(self):
|
||||
# 2xx 但没有 ID 意味着我们无法再查询这个任务——不能当成功放过。
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"unexpected": True})
|
||||
|
||||
with pytest.raises(SeedanceError, match="缺少任务 ID"):
|
||||
client_with(handler).submit(SeedanceSubmission(model="m", prompt="x"))
|
||||
|
||||
def test_network_failure_is_classified_retryable(self):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with pytest.raises(SeedanceError) as exc:
|
||||
client_with(handler).submit(SeedanceSubmission(model="m", prompt="x"))
|
||||
assert exc.value.classification.retry is Retry.BACKOFF
|
||||
|
||||
|
||||
class TestGetSuccessfulTask:
|
||||
def test_parses_the_official_example_payload(self):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
|
||||
|
||||
task = client_with(handler).get("cgt-1")
|
||||
assert task.status is TaskStatus.SUCCEEDED
|
||||
assert task.status.is_terminal
|
||||
assert task.video_url.endswith("/xxx")
|
||||
assert task.ratio == "16:9"
|
||||
assert task.resolution == "720p"
|
||||
assert task.fps == 24
|
||||
assert task.completion_tokens == 108900
|
||||
assert task.failure is None
|
||||
|
||||
def test_duration_from_frames_is_exact_when_frames_returned(self):
|
||||
# 官方例子:133 帧 / 24fps = 5.54 秒。按帧算才精确。
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "x",
|
||||
"status": "succeeded",
|
||||
"frames": 133,
|
||||
"framespersecond": 24,
|
||||
"content": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert client_with(handler).get("x").duration_ms == 5542
|
||||
|
||||
def test_duration_from_seconds_is_the_vendors_floored_approximation(self):
|
||||
# duration 与 frames 只会返回一个。只有 duration 时只能用它,
|
||||
# 但它是 帧数/24 向下取整的约数,最多可能短 1 秒。
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "x", "status": "succeeded", "duration": 5, "content": {}},
|
||||
)
|
||||
|
||||
assert client_with(handler).get("x").duration_ms == 5000
|
||||
|
||||
def test_duration_is_none_rather_than_a_made_up_zero(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"id": "x", "status": "running", "content": {}}
|
||||
)
|
||||
|
||||
assert client_with(handler).get("x").duration_ms is None
|
||||
|
||||
def test_last_frame_url_is_picked_up_as_cover_image(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "x",
|
||||
"status": "succeeded",
|
||||
"content": {
|
||||
"video_url": "https://v",
|
||||
"last_frame_url": "https://f.png",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert client_with(handler).get("x").last_frame_url == "https://f.png"
|
||||
|
||||
|
||||
class TestGetFailedTask:
|
||||
def test_failure_is_classified(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "x",
|
||||
"status": "failed",
|
||||
"content": {},
|
||||
"error": {
|
||||
"code": "OutputVideoSensitiveContentDetected",
|
||||
"message": "output may contain sensitive information",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
task = client_with(handler).get("x")
|
||||
assert task.failure.retry is Retry.NEVER
|
||||
assert task.failure.user_facing is UserFacing.CONTENT_REJECTED
|
||||
|
||||
def test_expired_is_handled_even_though_the_query_doc_omits_it(self):
|
||||
"""查询接口文档只列了 5 个状态,`expired` 仅见于创建接口的回调说明。
|
||||
|
||||
只按查询文档实现会让超时任务掉进未知分支。它是超时而非内容问题,
|
||||
因此可重试;且厂商在 expired 时不一定给 error.code,故单独处理。
|
||||
"""
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"id": "x", "status": "expired", "content": {}}
|
||||
)
|
||||
|
||||
task = client_with(handler).get("x")
|
||||
assert task.status is TaskStatus.EXPIRED
|
||||
assert task.status.is_terminal
|
||||
assert task.failure.retry is Retry.BACKOFF
|
||||
assert task.failure.provider_code == "TaskExpired"
|
||||
|
||||
def test_cancelled_is_terminal_but_not_a_failure(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"id": "x", "status": "cancelled", "content": {}}
|
||||
)
|
||||
|
||||
task = client_with(handler).get("x")
|
||||
assert task.status.is_terminal
|
||||
assert task.failure is None
|
||||
|
||||
|
||||
class TestUnknownStatusDoesNotBlowUp:
|
||||
"""厂商新增状态时抛异常会让所有在途任务一起失败。"""
|
||||
|
||||
def test_unknown_status_yields_none_and_preserves_raw(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"id": "x", "status": "brand_new", "content": {}}
|
||||
)
|
||||
|
||||
task = client_with(handler).get("x")
|
||||
assert task.status is None
|
||||
assert task.raw_status == "brand_new"
|
||||
assert task.failure is None # 当作未结束,继续轮询
|
||||
|
||||
|
||||
class TestHttpErrorMapping:
|
||||
def test_rate_limit_is_retryable(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
json={
|
||||
"error": {
|
||||
"code": "RateLimitExceeded.EndpointRPMExceeded",
|
||||
"message": "RPM limit exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(SeedanceError) as exc:
|
||||
client_with(handler).get("x")
|
||||
assert exc.value.classification.retry is Retry.BACKOFF
|
||||
assert exc.value.classification.user_facing is UserFacing.RATE_LIMITED
|
||||
assert exc.value.http_status == 429
|
||||
|
||||
def test_quota_exhausted_is_not_worth_retrying(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
json={
|
||||
"error": {
|
||||
"code": "QuotaExceeded",
|
||||
"message": "Your account has exhausted its free trial quota.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(SeedanceError) as exc:
|
||||
client_with(handler).submit(SeedanceSubmission(model="m", prompt="x"))
|
||||
assert exc.value.classification.retry is Retry.QUOTA
|
||||
|
||||
def test_bad_api_key_is_our_own_fault(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
401,
|
||||
json={
|
||||
"error": {
|
||||
"code": "AuthenticationError",
|
||||
"message": "invalid api key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(SeedanceError) as exc:
|
||||
client_with(handler).get("x")
|
||||
assert exc.value.classification.retry is Retry.NEVER
|
||||
assert exc.value.classification.user_facing is UserFacing.SERVER_ERROR
|
||||
|
||||
def test_non_json_error_body_is_not_swallowed(self):
|
||||
# 打到错误地址时常见:网关返回 HTML 错误页。静默会让排查绕远路。
|
||||
def handler(request):
|
||||
return httpx.Response(502, text="<html>bad gateway</html>")
|
||||
|
||||
with pytest.raises(SeedanceError) as exc:
|
||||
client_with(handler).get("x")
|
||||
assert "bad gateway" in str(exc.value)
|
||||
assert exc.value.classification.retry is Retry.BACKOFF
|
||||
|
||||
def test_non_json_success_body_is_an_error_too(self):
|
||||
def handler(request):
|
||||
return httpx.Response(200, text="not json")
|
||||
|
||||
with pytest.raises(SeedanceError, match="不是合法 JSON"):
|
||||
client_with(handler).get("x")
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_missing_api_key_fails_loudly_at_construction(self):
|
||||
with pytest.raises(ValueError, match="ARK_API_KEY"):
|
||||
SeedanceClient(api_key="")
|
||||
Reference in New Issue
Block a user