为Tripo提交路由补应用级验收用例
- 走真实 build_router 断言两个端点未鉴权一律 401,路径写错或漏 merge 会退化成 404 - 断言缺 Idempotency-Key 在解析之后、组合校验与定价之前返回 400 - 断言贴图档位冲突与定价必填字段缺失都在定价与 provider 副作用之前返回 400 并定位字段 - 实施计划进展补记该组用例与当前全量测试通过数
This commit is contained in:
@@ -71,9 +71,10 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执
|
||||
步骤 1 至 6 已落地:配置与路由、组合校验、定价、入队、图片输入解析、产物 OSS 写入与 worker 执行链路均已提交;步骤 7 的接线(`app.rs` merge、worker 分支、长任务超时)与文档同步也已完成。尚未完成的是需要真实环境的项:
|
||||
|
||||
- 图片输入在提交时已补元数据预检(`tripo3d::image_source::preflight_image_source`):按 `source.kind` 分支配对记录类型并复用既有定点引用查询 `resolve_editor_reference_record_by_id_for_owner`,取代原先“列工程 / 列素材库再筛”的宽查询;跨 owner、未登记、已删除与 `kind` 不符都在扣费与入队前返回 400,worker 执行时再重新确认同一事实。
|
||||
- 提交路由已补应用级验收用例:走真实 `build_router` 断言两个端点未鉴权 401、缺 `Idempotency-Key` 400、组合校验与定价必填字段冲突在定价与 provider 之前 400,因此路由 merge、鉴权中间件与校验顺序都有可执行证据,不依赖 provider 与 SpacetimeDB 环境。
|
||||
|
||||
- 默认配置里的 `model3d` 泥点定价数值仍未确认,缺段时提交按 fail closed 拒绝。
|
||||
- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到 `cargo test -p api-server` 全绿(1085 通过)。
|
||||
- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到 `cargo test -p api-server` 全绿(1089 通过)与应用级路由验收用例。
|
||||
- 新增的 `tripo3d/` 文件里,`storage.rs` 的完整字节写入、`artifacts.rs` 的完整字节读取都留着流式 TODO。
|
||||
|
||||
## 验证命令
|
||||
|
||||
@@ -200,7 +200,18 @@ fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, Response> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderValue, Request},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[test]
|
||||
fn idempotency_key_is_required_and_validated() {
|
||||
@@ -222,4 +233,185 @@ mod tests {
|
||||
spaced.insert("idempotency-key", HeaderValue::from_static("a b"));
|
||||
assert!(require_idempotency_key(&spaced).is_err());
|
||||
}
|
||||
|
||||
/// 两个路由必须真的挂在应用路由上并走统一鉴权:路径写错或漏 merge 会退化成 404。
|
||||
#[tokio::test]
|
||||
async fn tripo_submit_routes_require_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
for route in [TEXT_TO_MODEL_ROUTE, IMAGE_TO_MODEL_ROUTE] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(route)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{route} 未鉴权时必须 401"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缺 `Idempotency-Key` 必须在任何查询与扣费之前拦下,且不产生 operation。
|
||||
#[tokio::test]
|
||||
async fn image_to_model_requires_idempotency_key_before_any_work() {
|
||||
let (state, token) = authenticated_state().await;
|
||||
let response = post_json(
|
||||
&state,
|
||||
&token,
|
||||
IMAGE_TO_MODEL_ROUTE,
|
||||
None,
|
||||
valid_image_body(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
response.1.contains("Idempotency-Key"),
|
||||
"错误文案必须点名缺失的请求头:{}",
|
||||
response.1
|
||||
);
|
||||
}
|
||||
|
||||
/// 组合校验前置于定价与 provider:贴图档位与 `texture=false` 冲突时直接 400。
|
||||
#[tokio::test]
|
||||
async fn text_to_model_rejects_param_composition_before_pricing() {
|
||||
let (state, token) = authenticated_state().await;
|
||||
let mut body = valid_body();
|
||||
body["generation"]["texture"] = json!(false);
|
||||
body["generation"]["textureQuality"] = json!("standard");
|
||||
|
||||
let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-1"), body).await;
|
||||
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
response.1.contains("generation.textureQuality"),
|
||||
"错误必须定位到冲突字段:{}",
|
||||
response.1
|
||||
);
|
||||
}
|
||||
|
||||
/// 组合校验前置于定价与 provider:定价参数缺省时报错并定位到缺失字段。
|
||||
#[tokio::test]
|
||||
async fn text_to_model_rejects_missing_pricing_params_before_pricing() {
|
||||
let (state, token) = authenticated_state().await;
|
||||
let mut body = valid_body();
|
||||
body["generation"]
|
||||
.as_object_mut()
|
||||
.expect("generation 应为对象")
|
||||
.remove("quad");
|
||||
|
||||
let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-2"), body).await;
|
||||
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
response.1.contains("generation.quad"),
|
||||
"错误必须定位到缺失字段:{}",
|
||||
response.1
|
||||
);
|
||||
}
|
||||
|
||||
/// 可解析、可校验的 spec 形状请求体;只在进入组合校验之前使用。
|
||||
fn valid_body() -> serde_json::Value {
|
||||
json!({
|
||||
"generation": {
|
||||
"prompt": "一只木箱",
|
||||
"model": "v3.1-20260211",
|
||||
"texture": true,
|
||||
"textureQuality": "standard",
|
||||
"pbr": true,
|
||||
"geometryQuality": "standard",
|
||||
"quad": false,
|
||||
"smartLowPoly": false,
|
||||
"generateParts": false
|
||||
},
|
||||
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" }
|
||||
})
|
||||
}
|
||||
|
||||
/// 图生 3D 的参数集与文生不同:没有 `prompt`,多了图片对齐与输入朝向。
|
||||
fn valid_image_body() -> serde_json::Value {
|
||||
json!({
|
||||
"source": { "kind": "resource", "resourceId": "resource-1" },
|
||||
"generation": {
|
||||
"model": "v3.1-20260211",
|
||||
"texture": true,
|
||||
"textureQuality": "standard",
|
||||
"pbr": true,
|
||||
"geometryQuality": "standard",
|
||||
"quad": false,
|
||||
"smartLowPoly": false,
|
||||
"generateParts": false
|
||||
},
|
||||
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" }
|
||||
})
|
||||
}
|
||||
|
||||
async fn authenticated_state() -> (AppState, String) {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let user_id = state
|
||||
.seed_test_phone_user_with_password("13800138121", "secret123")
|
||||
.await
|
||||
.id;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user_id.clone(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(&user_id, "sess_tripo"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("3D 用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
||||
(state, token)
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
state: &AppState,
|
||||
token: &str,
|
||||
route: &str,
|
||||
idempotency_key: Option<&str>,
|
||||
body: serde_json::Value,
|
||||
) -> (StatusCode, String) {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(route)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1");
|
||||
if let Some(key) = idempotency_key {
|
||||
request = request.header("idempotency-key", key);
|
||||
}
|
||||
let response = build_router(state.clone())
|
||||
.oneshot(
|
||||
request
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
(status, String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user