feat: complete tree shell cutover and regression coverage
This commit is contained in:
@@ -78,7 +78,11 @@ pub fn build_tree_target(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> {
|
||||
pub fn ensure_non_empty(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
context: &RequestContext,
|
||||
) -> Result<String, WebError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -99,12 +103,11 @@ pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
|
||||
|
||||
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
|
||||
if sort_order < 0 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"sortOrder 不能小于 0",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
return Err(
|
||||
WebError::bad_request_code("tree_command_validation", "sortOrder 不能小于 0")
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"),
|
||||
);
|
||||
}
|
||||
Ok(sort_order)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use axum::extract::Query;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -77,10 +75,7 @@ pub async fn next_sidebar(
|
||||
.with_header("x-error-phase", "compat_sidebar_shape")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
dataset_object.insert(
|
||||
"kernel_sidebar_projection".into(),
|
||||
snapshot.projection,
|
||||
);
|
||||
dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
@@ -59,10 +57,11 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn project_sidebar(
|
||||
async fn project_projection(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<KernelProjectionQuery>,
|
||||
projection: KernelProjectionKind,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
@@ -74,7 +73,7 @@ pub async fn project_sidebar(
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: query.root_node_id.as_deref(),
|
||||
depth: query.depth,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
projection,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -82,6 +81,38 @@ pub async fn project_sidebar(
|
||||
Ok(ok_response(&context, snapshot.projection))
|
||||
}
|
||||
|
||||
pub async fn project_tree_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::SidebarTree).await
|
||||
}
|
||||
|
||||
pub async fn project_tree_page(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::PageTree).await
|
||||
}
|
||||
|
||||
pub async fn project_tree_file(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_projection(state, context, query, KernelProjectionKind::FileTree).await
|
||||
}
|
||||
|
||||
pub async fn project_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
query: Query<KernelProjectionQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
project_tree_sidebar(state, context, query).await
|
||||
}
|
||||
|
||||
pub async fn subtree(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -154,8 +185,9 @@ pub async fn graph(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -168,7 +200,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -190,4 +222,169 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_contract_route_keeps_shared_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let first = &payload["result"]["items"][0];
|
||||
assert_eq!(first["projectionKind"], "sidebar_tree");
|
||||
assert_eq!(first["resourceMeta"]["resourceKind"], "document");
|
||||
assert_eq!(first["iconHint"], "page");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_return_sidebar_page_and_file_projection_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
"/api/tree/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"sidebar_tree",
|
||||
),
|
||||
(
|
||||
"/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"page_tree",
|
||||
),
|
||||
(
|
||||
"/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root",
|
||||
"file_tree",
|
||||
),
|
||||
];
|
||||
|
||||
for (uri, expected_projection_kind) in cases {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["projection"], expected_projection_kind);
|
||||
assert_eq!(
|
||||
payload["result"]["items"][0]["projectionKind"],
|
||||
expected_projection_kind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["result"]["projection"], "page_tree");
|
||||
assert_eq!(payload["result"]["rootNodeId"], "page_root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_includes_index_and_asset_rows() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let items = payload["result"]["items"].as_array().expect("items");
|
||||
let row_kinds = items
|
||||
.iter()
|
||||
.filter_map(|item| item["rowKind"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let resource_kinds = items
|
||||
.iter()
|
||||
.filter_map(|item| item["resourceMeta"]["resourceKind"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(row_kinds.contains(&"document"));
|
||||
assert!(row_kinds.contains(&"index"));
|
||||
assert!(row_kinds.contains(&"asset"));
|
||||
assert!(row_kinds.contains(&"asset_folder"));
|
||||
assert!(resource_kinds.contains(&"document"));
|
||||
assert!(resource_kinds.contains(&"index"));
|
||||
assert!(resource_kinds.contains(&"asset"));
|
||||
assert!(resource_kinds.contains(&"mindmap"));
|
||||
assert!(resource_kinds.contains(&"table"));
|
||||
assert_eq!(items[0]["nodeId"], "page_root");
|
||||
assert_eq!(items[1]["nodeId"], "index:page_root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_resource_variants_keep_icon_hint_and_capabilities() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let item_by_row_id = payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.filter_map(|item| item["rowId"].as_str().map(|row_id| (row_id, item)))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(item_by_row_id["asset:asset_pdf_1"]["iconHint"], "pdf");
|
||||
assert_eq!(item_by_row_id["asset:asset_book_1"]["iconHint"], "book");
|
||||
assert_eq!(item_by_row_id["asset:table_1"]["iconHint"], "table");
|
||||
assert_eq!(item_by_row_id["asset-folder:mind_1"]["iconHint"], "mindmap");
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
||||
"mindmap"
|
||||
);
|
||||
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/tree", get(tree::tree_shell))
|
||||
.route(
|
||||
"/api/tree/projections/sidebar",
|
||||
get(kernel::project_tree_sidebar),
|
||||
)
|
||||
.route("/api/tree/projections/page", get(kernel::project_tree_page))
|
||||
.route("/api/tree/projections/file", get(kernel::project_tree_file))
|
||||
.route(
|
||||
"/api/kernel/projections/sidebar",
|
||||
get(kernel::project_sidebar),
|
||||
|
||||
@@ -126,6 +126,7 @@ pub async fn execute_runtime_query_via_convex(
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
let data =
|
||||
fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,8 @@ use crate::routes::command_support::{
|
||||
build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex,
|
||||
read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
@@ -118,7 +114,12 @@ fn normalize_tree_mode(value: Option<&str>) -> &'static str {
|
||||
}
|
||||
|
||||
fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
|
||||
match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() {
|
||||
match value
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => default,
|
||||
@@ -812,6 +813,7 @@ fn build_tree_shell_html(
|
||||
nodeId: normalizeText(item?.nodeId),
|
||||
parentNodeId: normalizeParent(item?.parentNodeId),
|
||||
title: normalizeText(item?.title, "无标题"),
|
||||
depth: normalizeNumber(item?.depth, 0),
|
||||
childCount: normalizeNumber(item?.childCount, 0),
|
||||
position: normalizeNumber(item?.position),
|
||||
expandedByDefault: item?.expandedByDefault !== false,
|
||||
@@ -971,6 +973,17 @@ fn build_tree_shell_html(
|
||||
<circle cx="12" cy="8" r="1.2"/>
|
||||
</svg>
|
||||
`,
|
||||
edit: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`,
|
||||
up: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`,
|
||||
page: `
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
@@ -1422,6 +1435,8 @@ fn build_tree_shell_html(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
const siblingRows = getSiblings(item.parentNodeId);
|
||||
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.add,
|
||||
@@ -1432,6 +1447,24 @@ fn build_tree_shell_html(
|
||||
),
|
||||
);
|
||||
if (mode === "page") {
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"tree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => void handleRename(item.nodeId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.up,
|
||||
"tree-action-move-up",
|
||||
`上移 ${item.title}`,
|
||||
() => void handleMove(item.nodeId, -1),
|
||||
siblingIndex <= 0,
|
||||
),
|
||||
);
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.more,
|
||||
@@ -1916,9 +1949,10 @@ pub async fn tree_shell(
|
||||
&snapshot.dataset,
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1938,9 +1972,10 @@ fn create_command_wire(
|
||||
} => {
|
||||
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
||||
let parent_id = read_optional_non_empty(parent_id);
|
||||
let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
||||
let access_scope =
|
||||
read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.create".into(),
|
||||
name: "tree.node.create".into(),
|
||||
command_id: format!("tree_create_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -1952,7 +1987,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
@@ -1974,7 +2013,7 @@ fn create_command_wire(
|
||||
} => {
|
||||
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.title.update".into(),
|
||||
name: "tree.node.rename".into(),
|
||||
command_id: format!("tree_rename_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -1986,7 +2025,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"title": title,
|
||||
@@ -2007,7 +2050,7 @@ fn create_command_wire(
|
||||
let sort_order = ensure_sort_order(sort_order, context)?;
|
||||
let parent_id = read_optional_non_empty(parent_id);
|
||||
Ok(RuntimeCommandEnvelopeWire {
|
||||
name: "documents.move".into(),
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: format!("tree_move_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: bridge_runtime::RuntimeActorWire {
|
||||
@@ -2019,7 +2062,11 @@ fn create_command_wire(
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
||||
target: Some(build_tree_target(
|
||||
workspace_id,
|
||||
Some(document_id.as_str()),
|
||||
None,
|
||||
)),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"parentId": parent_id,
|
||||
@@ -2097,9 +2144,7 @@ pub async fn tree_command(
|
||||
None,
|
||||
),
|
||||
TreeCommandRequest::Rename {
|
||||
document_id,
|
||||
title,
|
||||
..
|
||||
document_id, title, ..
|
||||
} => (
|
||||
"rename",
|
||||
document_id.clone(),
|
||||
@@ -2154,9 +2199,12 @@ pub async fn tree_command(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_command_wire, TreeCommandRequest};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2277,7 +2325,10 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
||||
assert_eq!(payload["result"]["documentId"], Value::String("page_new".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_new".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2345,6 +2396,199 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["action"], Value::String("move".into()));
|
||||
assert_eq!(payload["result"]["documentId"], Value::String("page_child".into()));
|
||||
assert_eq!(
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", "Bearer demo-token")
|
||||
.body(Body::from(
|
||||
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert!(payload["requestId"].as_str().is_some());
|
||||
assert!(payload["traceId"].as_str().is_some());
|
||||
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
|
||||
.header("Authorization", "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["boundary"], "next_sidebar_compat");
|
||||
assert_eq!(payload["workspaceId"], "ws_demo");
|
||||
assert!(payload["requestId"].as_str().is_some());
|
||||
assert!(payload["traceId"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
|
||||
let create_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Create {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_new".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
title: "新页面".into(),
|
||||
access_scope: Some("private".into()),
|
||||
content: Some(Value::Array(Vec::new())),
|
||||
},
|
||||
)
|
||||
.expect("create wire");
|
||||
assert_eq!(create_wire.name, "tree.node.create");
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Rename {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
title: "重命名".into(),
|
||||
},
|
||||
)
|
||||
.expect("rename wire");
|
||||
assert_eq!(rename_wire.name, "tree.node.rename");
|
||||
|
||||
let move_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Move {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
sort_order: 1,
|
||||
},
|
||||
)
|
||||
.expect("move wire");
|
||||
assert_eq!(move_wire.name, "tree.subtree.move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
|
||||
let tree_create_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Create {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_new".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
title: "新页面".into(),
|
||||
access_scope: Some("private".into()),
|
||||
content: Some(Value::Array(Vec::new())),
|
||||
},
|
||||
)
|
||||
.expect("tree create wire");
|
||||
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.create".into(),
|
||||
..tree_create_wire.clone()
|
||||
};
|
||||
let tree_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
|
||||
.expect("tree create plan");
|
||||
let compat_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
|
||||
.expect("compat create plan");
|
||||
assert_eq!(
|
||||
tree_create_plan.function_name,
|
||||
"documents:createWithParentReference"
|
||||
);
|
||||
assert_eq!(
|
||||
tree_create_plan.function_name,
|
||||
compat_create_plan.function_name
|
||||
);
|
||||
|
||||
let tree_rename_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Rename {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
title: "重命名".into(),
|
||||
},
|
||||
)
|
||||
.expect("tree rename wire");
|
||||
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.title.update".into(),
|
||||
..tree_rename_wire.clone()
|
||||
};
|
||||
let tree_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
|
||||
.expect("tree rename plan");
|
||||
let compat_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
|
||||
.expect("compat rename plan");
|
||||
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
|
||||
assert_eq!(
|
||||
tree_rename_plan.function_name,
|
||||
compat_rename_plan.function_name
|
||||
);
|
||||
|
||||
let tree_move_wire = create_command_wire(
|
||||
&context,
|
||||
"ws_demo",
|
||||
TreeCommandRequest::Move {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
document_id: "page_child".into(),
|
||||
parent_id: Some("page_root".into()),
|
||||
sort_order: 1,
|
||||
},
|
||||
)
|
||||
.expect("tree move wire");
|
||||
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.move".into(),
|
||||
..tree_move_wire.clone()
|
||||
};
|
||||
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
|
||||
.expect("tree move plan");
|
||||
let compat_move_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
|
||||
.expect("compat move plan");
|
||||
assert_eq!(tree_move_plan.function_name, "documents:move");
|
||||
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ async fn handle_socket(
|
||||
query: StreamSnapshotQuery,
|
||||
payload: Value,
|
||||
) {
|
||||
let _ = socket
|
||||
.send(serialize_snapshot_message(&payload))
|
||||
.await;
|
||||
let _ = socket.send(serialize_snapshot_message(&payload)).await;
|
||||
|
||||
while let Some(message) = socket.next().await {
|
||||
let Ok(message) = message else {
|
||||
@@ -43,7 +41,11 @@ async fn handle_socket(
|
||||
if is_resync_request(&text) {
|
||||
match load_stream_snapshot(state.config(), &context, &query).await {
|
||||
Ok(snapshot) => {
|
||||
if socket.send(serialize_resync_message(&snapshot)).await.is_err() {
|
||||
if socket
|
||||
.send(serialize_resync_message(&snapshot))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +74,11 @@ async fn handle_socket(
|
||||
"accepted": false,
|
||||
"reason": "unsupported_message",
|
||||
});
|
||||
if socket.send(Message::Text(ack.to_string().into())).await.is_err() {
|
||||
if socket
|
||||
.send(Message::Text(ack.to_string().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user