use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_against_data, execute_runtime_query_via_convex, resolve_effective_workspace_id, }; use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::{local_folder_source, local_search_index}; use crate::ssr::pages::search::SearchPage; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; use serde_json::{json, Value}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_QUERY_NAME: &str = "x-query-name"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsRequest { pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub query: Option, pub document_id: Option, pub limit: Option, pub filters: Option, } #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsFilters { pub title_only: Option, pub exact: Option, pub include_ocr: Option, pub only_current_page: Option, pub time_range: Option, pub time_field: Option, pub custom_range: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchCustomRange { pub from: Option, pub to: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchShellQuery { pub workspace_id: Option, pub q: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSearchIndexRefreshRequest { pub workspace_id: Option, pub root_uri: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSearchIndexQuery { pub workspace_id: Option, pub root_uri: String, pub document_id: Option, } pub async fn shell( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let workspace_id = query .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("default"); let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None) .await .unwrap_or_default(); let search_query = query.q.as_deref().map(str::trim).unwrap_or(""); let initial_results = load_search_results( state.config(), &context, workspace_id, search_query, None, 20, ) .await .unwrap_or_else(|_| empty_search_projection()); let contract = json!({ "schema": "mnote.search_shell.v1", "owner": "mnote-web", "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "shell": "search", "workspaceId": workspace_id, "query": search_query, "initialResults": { "queryName": "search.documents.query", "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![])) }, "island": { "kind": "search_interaction_island", "mountId": "mnote-search-island", "runtime": "SearchPaletteHost" }, "requestId": context.trace.request_id, "traceId": context.trace.trace_id }); let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()); let body_content = crate::ssr::render_view(leptos::view! { }); let html = format!( r#" 搜索 {} "#, crate::ssr::MNOTE_CSS, body_content, escape_script_json(&contract_json), ); let mut response = Html(html).into_response(); stamp_search_headers(response.headers_mut()); if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") { response .headers_mut() .insert(name, HeaderValue::from_static("search")); } Ok(response) } pub async fn documents( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let filters = body.filters.unwrap_or_default(); let normalized_query = body.query.unwrap_or_default().trim().to_string(); let page_id = if filters.only_current_page.unwrap_or(false) { body.document_id.filter(|value| !value.trim().is_empty()) } else { None }; let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") { let root_uri = body .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri") .with_context(&context) })?; let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; local_search_index::query_local_search_index( &root_path, root_uri, &effective_workspace_id, &normalized_query, page_id.as_deref(), body.limit.unwrap_or(30), filters.title_only.unwrap_or(false), filters.exact.unwrap_or(false), )? } else { load_search_results_with_filters( state.config(), &context, &effective_workspace_id, &normalized_query, page_id, body.limit.unwrap_or(30), filters, ) .await? }; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])), "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])), "meta": { "owner": "mnote-web", "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "queryName": "search.documents.query", "degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)), "degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null), "requestId": context.trace.request_id, "traceId": context.trace.trace_id, }, })), )) } pub async fn refresh_local_index( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = body.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地索引刷新缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let refreshed = local_search_index::refresh_local_search_index( &root_path, root_uri, &effective_workspace_id, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "index": refreshed, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.refresh", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } pub async fn local_index_backlinks( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = query.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地反链查询缺少 rootUri", ) .with_context(&context)); } let document_id = query .document_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "local_search_document_required", "本地反链查询缺少 documentId", ) .with_context(&context) })?; let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let backlinks = local_search_index::query_local_backlinks( &root_path, root_uri, &effective_workspace_id, document_id, )?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "result": backlinks, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.backlinks", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } pub async fn local_index_tags( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let root_uri = query.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_search_root_required", "本地标签查询缺少 rootUri", ) .with_context(&context)); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( &state, &context, root_uri, ) .map_err(|error| error.with_context(&context))?; let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "result": tags, "meta": { "owner": "mnote-web", "projectionOwner": "rust-kernel", "queryName": "search.local_index.tags", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, } })), )) } async fn load_search_results( config: &crate::app::AppConfig, context: &RequestContext, workspace_id: &str, query: &str, page_id: Option, limit: u32, ) -> Result { load_search_results_with_filters( config, context, workspace_id, query, page_id, limit, SearchDocumentsFilters::default(), ) .await } async fn load_search_results_with_filters( config: &crate::app::AppConfig, context: &RequestContext, workspace_id: &str, query: &str, page_id: Option, limit: u32, filters: SearchDocumentsFilters, ) -> Result { if query.trim().is_empty() { return Ok(empty_search_projection()); } let runtime_query = RuntimeQueryEnvelopeWire { name: "search.documents.query".into(), payload: json!({ "query": query, "workspaceId": workspace_id, "pageId": page_id, "limit": limit, "titleOnly": filters.title_only.unwrap_or(false), "exact": filters.exact.unwrap_or(false), "includeOcr": filters.include_ocr.unwrap_or(false), "timeRange": filters.time_range.unwrap_or_else(|| "any".into()), "timeField": filters.time_field.unwrap_or_else(|| "updated".into()), "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()), "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), }), }; match execute_runtime_query_via_convex( config, context, Some(workspace_id), runtime_query.clone(), ) .await { Ok(value) => Ok(value), Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data( context, Some(workspace_id), runtime_query, fallback_search_dataset(workspace_id), ), Err(error) => Ok(degraded_empty_search_projection(error.message())), } } fn empty_search_projection() -> Value { json!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", "results": [], }) } fn degraded_empty_search_projection(reason: &str) -> Value { json!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", "results": [], "degraded": true, "degradedReason": reason, }) } fn fallback_search_dataset(workspace_id: &str) -> Value { json!({ "documents": [ { "id": "doc_1", "workspaceId": workspace_id, "title": "Rust Web 搜索结果", "rawText": "mnote-web search documents transport", "createdAt": "2026-04-28T00:00:00Z", "updatedAt": "2026-04-28T00:00:00Z" }, { "id": "doc_hermes", "workspaceId": workspace_id, "title": "Hermes", "rawText": "Hermes 技能知识图谱开发 Wolai aline fixture", "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" }, { "id": "doc_hermes_skill", "workspaceId": workspace_id, "title": "技能知识图谱开发", "rawText": "Hermes 页面路径 个人 软件开发", "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" } ], "mindmaps": [], "tables": [], "tableRows": [], "assets": [] }) } fn render_initial_results_html(results: Option<&Vec>) -> String { let Some(results) = results else { return r#"
暂无结果
"#.into(); }; if results.is_empty() { return r#"
暂无结果
"#.into(); } let items = results .iter() .map(|item| { let title = item .get("title") .and_then(Value::as_str) .unwrap_or("无标题"); let snippet = item .get("snippet") .and_then(Value::as_str) .unwrap_or_default(); let href = item .get("publicPath") .and_then(Value::as_str) .unwrap_or("#"); format!( r#"{}{}"#, escape_html(href), escape_html(title), escape_html(snippet) ) }) .collect::>() .join(""); format!(r#"
{items}
"#) } fn escape_script_json(value: &str) -> String { value.replace(" String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn stamp_search_headers(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if let Ok(name) = HeaderName::from_lowercase(HEADER_QUERY_NAME.as_bytes()) { headers.insert(name, HeaderValue::from_static("search.documents")); } } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::{json, Value}; use std::fs; 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: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } fn query_escape(value: &str) -> String { value .bytes() .map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (byte as char).to_string() } _ => format!("%{byte:02X}"), }) .collect() } #[tokio::test] async fn search_shell_returns_server_first_island_contract() { let response = app() .oneshot( Request::builder() .uri("/search?workspaceId=ws_demo&q=Rust") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); assert_eq!( response .headers() .get("x-mnote-web-shell") .and_then(|value| value.to_str().ok()), Some("search") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("data-mnote-shell=\"search\"")); assert!(html.contains("mnote.search_shell.v1")); assert!(html.contains("search_interaction_island")); assert!(html.contains("search.documents.query")); assert!(html.contains("search-result")); assert!(html.contains("Rust Web 搜索结果")); assert!(html.contains("data-mnote-dev-fixture=\"true\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); } #[tokio::test] async fn search_documents_route_is_owned_by_mnote_web() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .body(Body::from( json!({ "workspaceId": "ws_demo", "query": "Rust Web", "filters": { "titleOnly": false, "exact": false, "includeOcr": false, "onlyCurrentPage": false, "timeRange": "any", "timeField": "updated" } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); assert_eq!( response .headers() .get("x-query-name") .and_then(|value| value.to_str().ok()), Some("search.documents") ); 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["meta"]["owner"], "mnote-web"); assert_eq!(payload["meta"]["queryName"], "search.documents.query"); assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel"); assert_eq!(payload["projectionOwner"], "rust-kernel"); } #[tokio::test] async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() { let app = 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: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .body(Body::from( json!({ "workspaceId": "ws_demo", "query": "Hermes" }) .to_string(), )) .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["results"], json!([])); assert_eq!(payload["meta"]["degraded"], true); assert!(!payload.to_string().contains("Hermes 技能知识图谱开发")); } #[tokio::test] async fn search_documents_local_folder_uses_authorized_root_index() { let root = std::env::temp_dir().join(format!("mnote-local-search-route-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-search","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "---\ntitle: Search Home\ntags: [alpha]\n---\n# Search Home\nAlpha body links [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n", ) .expect("readme"); fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/documents") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-search", "sourceKind": "local_folder", "rootUri": root_uri, "query": "alpha", "limit": 10 }) .to_string(), )) .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["meta"]["degraded"], false); let results = payload["results"].as_array().expect("results"); let home = results .iter() .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .expect("home result"); assert_eq!(home["sourceKind"].as_str(), Some("local_folder")); assert_eq!(home["resourceType"].as_str(), Some("markdown")); assert!(home["tags"] .as_array() .unwrap() .iter() .any(|tag| tag.as_str() == Some("alpha"))); assert!(home["backlinks"] .as_array() .unwrap() .iter() .any(|link| link.as_str() == Some("Daily"))); assert!(home["resourceRefs"] .as_array() .unwrap() .iter() .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); assert!(root .join(".mnote") .join("index") .join("search-index.json") .exists()); assert!(payload["recent"] .as_array() .unwrap() .iter() .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn search_local_index_refresh_rebuilds_authorized_root() { let root = std::env::temp_dir().join(format!( "mnote-local-search-refresh-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-refresh","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme"); let root_uri = format!("file://{}", root.display()); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/search/local-index/refresh") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "workspaceId": "local-ws-refresh", "rootUri": root_uri }) .to_string(), )) .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["index"]["documentCount"], 1); assert_eq!( payload["meta"]["queryName"].as_str(), Some("search.local_index.refresh") ); let index = fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) .expect("index"); assert!(index.contains("refresh-token")); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn search_local_index_backlinks_and_tags_read_authorized_root() { let root = std::env::temp_dir().join(format!( "mnote-local-search-backlinks-route-{}", std::process::id() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join("docs")).expect("docs"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-backlinks","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "---\ntitle: Home\ntags: [alpha]\n---\n# Home\nSee [Child](docs/child.md).\n", ) .expect("home"); fs::write( root.join("docs").join("child.md"), "---\ntitle: Child\ntags: [alpha, beta]\n---\n# Child\n", ) .expect("child"); let root_uri = format!("file://{}", root.display()); let encoded_root = query_escape(&root_uri); let backlinks_response = app() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/search/local-index/backlinks?workspaceId=local-ws-backlinks&rootUri={encoded_root}&documentId=local-md:docs~2Fchild.md" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("backlinks response"); assert_eq!(backlinks_response.status(), StatusCode::OK); let backlinks_body = to_bytes(backlinks_response.into_body(), usize::MAX) .await .expect("backlinks body"); let backlinks_payload: Value = serde_json::from_slice(&backlinks_body).expect("json"); assert_eq!( backlinks_payload["meta"]["queryName"].as_str(), Some("search.local_index.backlinks") ); assert!(backlinks_payload["result"]["backlinks"] .as_array() .unwrap() .iter() .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let tags_response = app() .oneshot( Request::builder() .method("GET") .uri(format!( "/api/search/local-index/tags?workspaceId=local-ws-backlinks&rootUri={encoded_root}" )) .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("tags response"); assert_eq!(tags_response.status(), StatusCode::OK); let tags_body = to_bytes(tags_response.into_body(), usize::MAX) .await .expect("tags body"); let tags_payload: Value = serde_json::from_slice(&tags_body).expect("json"); assert!(tags_payload["result"]["tags"] .as_array() .unwrap() .iter() .any(|tag| tag["tag"].as_str() == Some("alpha") && tag["count"].as_u64() == Some(2))); let _ = fs::remove_dir_all(&root); } }