use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, }; 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, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use serde::Deserialize; use serde_json::{json, Value}; #[cfg(test)] static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionQuery { pub workspace_id: Option, pub root_node_id: Option, pub depth: Option, pub query: Option, pub max_results: Option, pub source_kind: Option, pub root_uri: Option, pub parent_relative_path: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelSubtreeQuery { pub workspace_id: Option, pub root_node_id: String, pub depth: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelEdgesQuery { pub workspace_id: Option, pub node_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelGraphQuery { pub workspace_id: Option, pub start_node_id: String, pub max_depth: Option, } fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { ( StatusCode::OK, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "result": result, })), ) } async fn project_projection( State(state): State, Extension(context): Extension, Query(query): Query, projection: KernelProjectionKind, ) -> Result<(StatusCode, Json), WebError> { let source_kind = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if source_kind == Some("local_folder") { let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let root_uri = root_uri.to_string(); let parent_relative_path = query .parent_relative_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let root_node_id = query .root_node_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let snapshot = tokio::task::spawn_blocking(move || { #[cfg(test)] block_local_folder_projection_for_test(); if projection == KernelProjectionKind::FileTree { if let Some(parent_relative_path) = parent_relative_path.as_deref() { load_local_folder_file_tree_children_snapshot(&root_uri, parent_relative_path) } else if root_node_id.is_some() { load_local_folder_file_tree_snapshot_with_reveal( &root_uri, root_node_id.as_deref(), ) } else { load_local_folder_file_tree_snapshot(&root_uri) } } else if let Some(parent_relative_path) = parent_relative_path.as_deref() { load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path) } else { load_local_folder_page_tree_snapshot(&root_uri) } }) .await .map_err(|error| { WebError::internal(format!("本地树 projection 构建任务失败: {error}")) })??; return Ok(ok_response(&context, snapshot.projection)); } let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let snapshot = load_projection_snapshot( state.config(), &context, &ProjectionSnapshotSpec { workspace_id: &effective_workspace_id, root_node_id: query.root_node_id.as_deref(), depth: query.depth, query: query.query.as_deref(), max_results: query.max_results, projection, }, ) .await?; Ok(ok_response(&context, snapshot.projection)) } #[cfg(test)] fn block_local_folder_projection_for_test() { let delay_ms = LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst); if delay_ms > 0 { std::thread::sleep(std::time::Duration::from_millis(delay_ms)); } } pub async fn project_tree_sidebar( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::SidebarTree).await } pub async fn project_tree_page( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::PageTree).await } pub async fn project_tree_file( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::FileTree).await } pub async fn project_sidebar( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_tree_sidebar(state, context, query).await } pub async fn subtree( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, subtree_query(&effective_workspace_id, &query.root_node_id, query.depth), dataset, )?; Ok(ok_response(&context, result)) } pub async fn edges( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.edges.list".into(), payload: json!({ "workspaceId": effective_workspace_id, "nodeId": query.node_id, "direction": KernelGraphDirection::Both, }), }, dataset, )?; Ok(ok_response(&context, result)) } pub async fn graph( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.graph.traverse".into(), payload: json!({ "workspaceId": effective_workspace_id, "startNodeId": query.start_node_id, "maxDepth": query.max_depth.unwrap_or(2), "edgeTypes": [], }), }, dataset, )?; Ok(ok_response(&context, result)) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use crate::routes::local_folder_source::initialize_local_workspace_for_actor; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::Value; use std::time::{Duration, Instant}; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), 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"}],"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":"table","file_name":"预算.table","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(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn kernel_projection_route_returns_sidebar_projection() { 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); } #[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_short_circuit_local_folder_without_convex_dataset() { let root = std::env::temp_dir().join(format!( "mnote-local-kernel-projection-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("本地页面")).expect("create local page bundle"); std::fs::write(root.join("本地页面").join("本地页面.md"), "") .expect("write local markdown"); let root_uri = format!("file://{}", root.display()); initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); let response = app() .oneshot( Request::builder() .uri(format!( "/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&rootNodeId=local-md:%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2~2F%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2.md" )) .header("x-mnote-actor-id", "dev-user") .header("x-mnote-actor-type", "user") .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"], "file_tree"); assert_eq!(payload["result"]["sourceKind"], "local_folder"); assert!(payload["result"]["items"] .as_array() .expect("items") .iter() .any(|item| item["title"] == "本地页面" && item["rowKind"] == "folder" && item["documentId"] .as_str() .unwrap_or("") .starts_with("local-md:") && item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_folder_page_projection_can_scope_by_parent_path() { let root = std::env::temp_dir().join(format!( "mnote-local-kernel-page-scope-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("design")).expect("create design"); std::fs::write(root.join("Home.md"), "# Home\n").expect("write home"); std::fs::write(root.join("Other.md"), "# Other\n").expect("write other"); std::fs::write(root.join("design").join("Brief.md"), "# Brief\n").expect("write brief"); let root_uri = format!("file://{}", root.display()); initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); let response = app() .oneshot( Request::builder() .uri(format!( "/api/tree/projections/sidebar?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=design" )) .header("x-mnote-actor-id", "dev-user") .header("x-mnote-actor-type", "user") .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 titles = items .iter() .filter_map(|item| item["title"].as_str()) .collect::>(); assert_eq!(payload["result"]["parentRelativePath"], "design"); assert!(titles.contains(&"Brief")); assert!(!titles.contains(&"Home")); assert!(!titles.contains(&"Other")); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_folder_file_projection_can_load_direct_children_by_parent_path() { let root = std::env::temp_dir().join(format!( "mnote-local-kernel-children-projection-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs"); std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n") .expect("write deep"); let root_uri = format!("file://{}", root.display()); initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); let response = app() .oneshot( Request::builder() .uri(format!( "/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs" )) .header("x-mnote-actor-id", "dev-user") .header("x-mnote-actor-type", "user") .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 titles = items .iter() .filter_map(|item| item["title"].as_str()) .collect::>(); assert_eq!(payload["result"]["parentRelativePath"], "docs"); assert!(titles.contains(&"README.md")); assert!(titles.contains(&"nested")); assert!(!titles.contains(&"deep.md")); assert!(items .iter() .all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_folder_file_projection_does_not_block_leptos_runtime_asset_request() { let root = std::env::temp_dir().join(format!( "mnote-local-kernel-projection-runtime-asset-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); let root_uri = format!("file://{}", root.display()); initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); let app = app(); let projection_app = app.clone(); let projection_uri = format!( "/api/tree/projections/file/children?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs" ); super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS .store(400, std::sync::atomic::Ordering::SeqCst); struct ResetLocalProjectionBlock; impl Drop for ResetLocalProjectionBlock { fn drop(&mut self) { super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS .store(0, std::sync::atomic::Ordering::SeqCst); } } let _reset_block = ResetLocalProjectionBlock; let projection_task = tokio::spawn(async move { projection_app .oneshot( Request::builder() .uri(projection_uri) .header("x-mnote-actor-id", "dev-user") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("projection request"), ) .await .expect("projection response") }); let started = Instant::now(); tokio::task::yield_now().await; let asset_response = app .oneshot( Request::builder() .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") .body(Body::empty()) .expect("asset request"), ) .await .expect("asset response"); let asset_elapsed = started.elapsed(); let projection_response = projection_task.await.expect("projection task"); let _ = std::fs::remove_dir_all(&root); assert_eq!(asset_response.status(), StatusCode::OK); assert_eq!(projection_response.status(), StatusCode::OK); assert!( asset_elapsed < Duration::from_millis(150), "leptos-tiptap runtime asset 不应被本地 FileTree projection 扫描阻塞,实际等待 {asset_elapsed:?}" ); } #[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_page_markdown_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::>(); let resource_kinds = items .iter() .filter_map(|item| item["resourceMeta"]["resourceKind"].as_str()) .collect::>(); assert!(row_kinds.contains(&"document")); assert!(row_kinds.contains(&"asset")); assert!(row_kinds.contains(&"asset_folder")); assert!(resource_kinds.contains(&"document")); 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[0]["title"], "工作区首页.md"); } #[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::>(); 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")); } #[tokio::test] async fn file_tree_projection_query_returns_matches_and_ancestors() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=%E9%A2%84%E7%AE%97") .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_ids = items .iter() .filter_map(|item| item["rowId"].as_str()) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["schema"], "mnote.file_tree.indexing_visibility" ); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["status"], "visible" ); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["visibleResourceKinds"] .as_array() .expect("visible resource kinds") .iter() .any(|value| value == "asset"), true ); assert_eq!(items[0]["expandedByDefault"], true); assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table"); let edges = payload["result"]["edges"].as_array().expect("edges"); assert_eq!(edges.len(), 1); assert_eq!(edges[0]["fromNodeId"], "page_root"); assert_eq!(edges[0]["toNodeId"], "asset:table_1"); } #[tokio::test] async fn file_tree_projection_query_honors_max_results_and_keeps_ancestors() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=png&maxResults=1") .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_ids = items .iter() .filter_map(|item| item["rowId"].as_str()) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root", "asset:asset_file_1"]); assert_eq!(items[0]["expandedByDefault"], true); assert_eq!(items[1]["resourceMeta"]["assetKind"], "image"); } }