为Tripo提交路由补应用级验收用例

- 走真实 build_router 断言两个端点未鉴权一律 401,路径写错或漏 merge 会退化成 404

- 断言缺 Idempotency-Key 在解析之后、组合校验与定价之前返回 400

- 断言贴图档位冲突与定价必填字段缺失都在定价与 provider 副作用之前返回 400 并定位字段

- 实施计划进展补记该组用例与当前全量测试通过数
This commit is contained in:
2026-09-21 15:16:22 +08:00
parent d4af6802fb
commit f732555a44
2 changed files with 195 additions and 2 deletions
@@ -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())
}
}