470 lines
17 KiB
Rust
470 lines
17 KiB
Rust
use crate::app::{AppConfig, AppState};
|
||||
|
|
use crate::context::RequestContext;
|
|||
|
|
use crate::error::WebError;
|
|||
|
|
use crate::page_aggregate::PageAggregate;
|
|||
|
|
use crate::routes::documents::{
|
|||
|
|
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
|||
|
|
DocumentMetaQuery,
|
|||
|
|
};
|
|||
|
|
use crate::routes::snapshot_support::{
|
|||
|
|
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
|||
|
|
};
|
|||
|
|
use crate::routes::tree::collect_page_tree_render_rows;
|
|||
|
|
use crate::tree_shell::page_renderer::{
|
|||
|
|
render_initial_page_tree_html, PageTreeInitialRenderInput,
|
|||
|
|
};
|
|||
|
|
use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection};
|
|||
|
|
use axum::extract::{Extension, Path, Query, State};
|
|||
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|||
|
|
use axum::response::{Html, IntoResponse, Response};
|
|||
|
|
use axum::Json;
|
|||
|
|
use core_protocol::KernelProjectionKind;
|
|||
|
|
use serde::Deserialize;
|
|||
|
|
use crate::ssr::pages::document::DocumentPage;
|
|||
|
|
use serde_json::{json, Value};
|
|||
|
|
|
|||
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|||
|
|
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
|||
|
|
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
|||
|
|
|
|||
|
|
#[derive(Debug, Deserialize)]
|
|||
|
|
#[serde(rename_all = "camelCase")]
|
|||
|
|
pub struct DocumentShellQuery {
|
|||
|
|
pub workspace_id: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub async fn document_page_shell(
|
|||
|
|
State(state): State<AppState>,
|
|||
|
|
Extension(context): Extension<RequestContext>,
|
|||
|
|
Path(document_id): Path<String>,
|
|||
|
|
Query(query): Query<DocumentShellQuery>,
|
|||
|
|
) -> Result<Response, WebError> {
|
|||
|
|
let aggregate = build_page_aggregate_snapshot(
|
|||
|
|
&state,
|
|||
|
|
&context,
|
|||
|
|
&document_id,
|
|||
|
|
query.workspace_id.as_deref(),
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
let title = aggregate.head_title();
|
|||
|
|
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, Some(&document_id))
|
|||
|
|
.await
|
|||
|
|
.unwrap_or_default();
|
|||
|
|
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
|||
|
|
let body_content = crate::ssr::render_view(leptos::view! {
|
|||
|
|
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
|
|||
|
|
});
|
|||
|
|
let html = format!(
|
|||
|
|
r#"<!doctype html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<title>{}</title>
|
|||
|
|
<style>{}</style>
|
|||
|
|
</head>
|
|||
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
|
|||
|
|
{}
|
|||
|
|
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
|||
|
|
</body>
|
|||
|
|
</html>"#,
|
|||
|
|
escape_html(title),
|
|||
|
|
crate::ssr::MNOTE_CSS,
|
|||
|
|
escape_html(&document_id),
|
|||
|
|
body_content,
|
|||
|
|
escape_script_json(&snapshot_json),
|
|||
|
|
);
|
|||
|
|
let mut response = Html(html).into_response();
|
|||
|
|
stamp_shell_headers(response.headers_mut(), "document");
|
|||
|
|
stamp_recent_page_cookie(response.headers_mut(), &document_id);
|
|||
|
|
Ok(response)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub async fn page_aggregate(
|
|||
|
|
State(state): State<AppState>,
|
|||
|
|
Extension(context): Extension<RequestContext>,
|
|||
|
|
Path(document_id): Path<String>,
|
|||
|
|
Query(query): Query<DocumentShellQuery>,
|
|||
|
|
) -> Result<Response, WebError> {
|
|||
|
|
let aggregate = build_page_aggregate_snapshot(
|
|||
|
|
&state,
|
|||
|
|
&context,
|
|||
|
|
&document_id,
|
|||
|
|
query.workspace_id.as_deref(),
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
let mut response = (
|
|||
|
|
StatusCode::OK,
|
|||
|
|
Json(json!({
|
|||
|
|
"ok": true,
|
|||
|
|
"owner": "mnote-web",
|
|||
|
|
"schema": "mnote.page_aggregate.v1",
|
|||
|
|
"result": aggregate,
|
|||
|
|
"requestId": context.trace.request_id,
|
|||
|
|
"traceId": context.trace.trace_id,
|
|||
|
|
})),
|
|||
|
|
)
|
|||
|
|
.into_response();
|
|||
|
|
stamp_shell_headers(response.headers_mut(), "page-aggregate");
|
|||
|
|
Ok(response)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async fn build_page_aggregate_snapshot(
|
|||
|
|
state: &AppState,
|
|||
|
|
context: &RequestContext,
|
|||
|
|
document_id: &str,
|
|||
|
|
workspace_id: Option<&str>,
|
|||
|
|
) -> Result<PageAggregate, WebError> {
|
|||
|
|
let meta = load_document_meta_result(
|
|||
|
|
state,
|
|||
|
|
context,
|
|||
|
|
DocumentMetaQuery {
|
|||
|
|
document_id: document_id.to_string(),
|
|||
|
|
workspace_id: workspace_id.map(str::to_string),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
let content = load_document_content_result(
|
|||
|
|
state,
|
|||
|
|
context,
|
|||
|
|
DocumentContentQuery {
|
|||
|
|
document_id: document_id.to_string(),
|
|||
|
|
workspace_id: workspace_id.map(str::to_string),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
|
|||
|
|
let conflict_detection_key = content
|
|||
|
|
.get("conflictDetectionKey")
|
|||
|
|
.or_else(|| content.get("conflict_detection_key"))
|
|||
|
|
.cloned()
|
|||
|
|
.unwrap_or(Value::Null);
|
|||
|
|
let page_subtree = content
|
|||
|
|
.get("pageSubtree")
|
|||
|
|
.or_else(|| content.get("page_subtree"))
|
|||
|
|
.cloned()
|
|||
|
|
.unwrap_or(Value::Null);
|
|||
|
|
let todo_total = meta
|
|||
|
|
.get("todo_total")
|
|||
|
|
.or_else(|| meta.get("todo_total_count"))
|
|||
|
|
.and_then(Value::as_u64)
|
|||
|
|
.unwrap_or(0);
|
|||
|
|
let todo_done = meta
|
|||
|
|
.get("todo_done")
|
|||
|
|
.or_else(|| meta.get("todo_done_count"))
|
|||
|
|
.and_then(Value::as_u64)
|
|||
|
|
.unwrap_or(0);
|
|||
|
|
|
|||
|
|
Ok(PageAggregate::builder()
|
|||
|
|
// identity
|
|||
|
|
.document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id))
|
|||
|
|
.workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default"))
|
|||
|
|
// head
|
|||
|
|
.title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题"))
|
|||
|
|
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
|||
|
|
.read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false))
|
|||
|
|
.disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
// layout
|
|||
|
|
.wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true))
|
|||
|
|
.show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true))
|
|||
|
|
.collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default"))
|
|||
|
|
.layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal"))
|
|||
|
|
.hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false))
|
|||
|
|
.embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null))
|
|||
|
|
// body
|
|||
|
|
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
|||
|
|
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
|||
|
|
.conflict_detection_key(conflict_detection_key)
|
|||
|
|
// tree
|
|||
|
|
.page_subtree(page_subtree)
|
|||
|
|
// stats
|
|||
|
|
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
|||
|
|
.character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0))
|
|||
|
|
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
|||
|
|
.todo_total(todo_total)
|
|||
|
|
.todo_done(todo_done)
|
|||
|
|
.build())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
|||
|
|
let value = document_id
|
|||
|
|
.chars()
|
|||
|
|
.map(|ch| {
|
|||
|
|
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
|
|||
|
|
ch
|
|||
|
|
} else {
|
|||
|
|
'_'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.collect::<String>();
|
|||
|
|
if value.is_empty() {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
|
|||
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|||
|
|
headers.append(header::SET_COOKIE, value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
|||
|
|
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_MNOTE_WEB_SHELL.as_bytes()) {
|
|||
|
|
headers.insert(name, HeaderValue::from_static(shell));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn escape_html(value: &str) -> String {
|
|||
|
|
value
|
|||
|
|
.replace('&', "&")
|
|||
|
|
.replace('<', "<")
|
|||
|
|
.replace('>', ">")
|
|||
|
|
.replace('"', """)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn escape_script_json(value: &str) -> String {
|
|||
|
|
value.replace("</script", "<\\/script")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) async fn load_workspace_shell_projection(
|
|||
|
|
config: &AppConfig,
|
|||
|
|
context: &RequestContext,
|
|||
|
|
workspace_id: &str,
|
|||
|
|
active_document_id: Option<&str>,
|
|||
|
|
default_workspace_name: &str,
|
|||
|
|
) -> WorkspaceShellProjection {
|
|||
|
|
let spec = ProjectionSnapshotSpec {
|
|||
|
|
workspace_id,
|
|||
|
|
root_node_id: None,
|
|||
|
|
depth: Some(99),
|
|||
|
|
projection: KernelProjectionKind::SidebarTree,
|
|||
|
|
query: None,
|
|||
|
|
max_results: None,
|
|||
|
|
};
|
|||
|
|
let dataset = load_projection_snapshot(config, context, &spec)
|
|||
|
|
.await
|
|||
|
|
.map(|snapshot| snapshot.dataset)
|
|||
|
|
.unwrap_or_else(|_| {
|
|||
|
|
let documents = active_document_id
|
|||
|
|
.map(|document_id| {
|
|||
|
|
json!([{
|
|||
|
|
"id": document_id,
|
|||
|
|
"workspace_id": workspace_id,
|
|||
|
|
"title": "个人",
|
|||
|
|
"parent_id": null,
|
|||
|
|
"sort_order": 0,
|
|||
|
|
"is_starred": false
|
|||
|
|
}])
|
|||
|
|
})
|
|||
|
|
.unwrap_or_else(|| json!([]));
|
|||
|
|
json!({
|
|||
|
|
"active_workspace_id": workspace_id,
|
|||
|
|
"active_page_id": active_document_id,
|
|||
|
|
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
|||
|
|
"documents": documents
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
build_workspace_shell_projection(
|
|||
|
|
&dataset,
|
|||
|
|
workspace_id,
|
|||
|
|
active_document_id,
|
|||
|
|
default_workspace_name,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 加载侧栏页面树 HTML(SSR)
|
|||
|
|
///
|
|||
|
|
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
|||
|
|
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
|
|||
|
|
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
|
|||
|
|
pub(crate) async fn load_sidebar_tree_html(
|
|||
|
|
config: &AppConfig,
|
|||
|
|
context: &RequestContext,
|
|||
|
|
workspace_id: &str,
|
|||
|
|
active_document_id: Option<&str>,
|
|||
|
|
) -> Option<String> {
|
|||
|
|
let spec = ProjectionSnapshotSpec {
|
|||
|
|
workspace_id,
|
|||
|
|
root_node_id: None,
|
|||
|
|
depth: Some(99),
|
|||
|
|
projection: KernelProjectionKind::SidebarTree,
|
|||
|
|
query: None,
|
|||
|
|
max_results: None,
|
|||
|
|
};
|
|||
|
|
let result = match load_projection_snapshot(config, context, &spec).await {
|
|||
|
|
Ok(snapshot) => Some(snapshot.projection),
|
|||
|
|
Err(_) if config.allow_dev_fixtures => {
|
|||
|
|
// Dev 模式降级:使用内建示例页面树数据集
|
|||
|
|
let dev_dataset = serde_json::json!({
|
|||
|
|
"active_workspace_id": workspace_id,
|
|||
|
|
"documents": [
|
|||
|
|
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
|||
|
|
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
|
|||
|
|
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 },
|
|||
|
|
],
|
|||
|
|
"trashed_documents": [],
|
|||
|
|
"media_assets": [],
|
|||
|
|
"trashed_media_assets": [],
|
|||
|
|
"mindmap_assets": [],
|
|||
|
|
"trashed_mindmap_assets": [],
|
|||
|
|
"table_assets": [],
|
|||
|
|
"trashed_table_assets": [],
|
|||
|
|
"mindmap_docs": [],
|
|||
|
|
"mindmap_asset_children": {}
|
|||
|
|
});
|
|||
|
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
|||
|
|
}
|
|||
|
|
Err(_) => None,
|
|||
|
|
};
|
|||
|
|
result.map(|projection| {
|
|||
|
|
let rows = collect_page_tree_render_rows(&projection);
|
|||
|
|
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
|||
|
|
rows,
|
|||
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|||
|
|
focused_node_id: None,
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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::Value;
|
|||
|
|
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,
|
|||
|
|
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#"{
|
|||
|
|
"documents:getMeta": {
|
|||
|
|
"id": "doc_1",
|
|||
|
|
"workspace_id": "ws_demo",
|
|||
|
|
"title": "服务端页面",
|
|||
|
|
"updated_at": "2026-04-18T09:30:00Z",
|
|||
|
|
"can_edit": true,
|
|||
|
|
"word_count": 42,
|
|||
|
|
"character_count": 128,
|
|||
|
|
"block_count": 3
|
|||
|
|
},
|
|||
|
|
"documents:getContent": {
|
|||
|
|
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
|||
|
|
"revision": 7,
|
|||
|
|
"conflict_detection_key": "doc_1:7",
|
|||
|
|
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
|||
|
|
}
|
|||
|
|
}"#
|
|||
|
|
.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 document_shell_returns_page_aggregate_snapshot() {
|
|||
|
|
let response = app()
|
|||
|
|
.oneshot(
|
|||
|
|
Request::builder()
|
|||
|
|
.uri("/documents/doc_1?workspaceId=ws_demo")
|
|||
|
|
.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("document")
|
|||
|
|
);
|
|||
|
|
assert!(response
|
|||
|
|
.headers()
|
|||
|
|
.get_all("set-cookie")
|
|||
|
|
.iter()
|
|||
|
|
.any(|value| value
|
|||
|
|
.to_str()
|
|||
|
|
.unwrap_or_default()
|
|||
|
|
.contains("mnote_recent_page_id=doc_1")));
|
|||
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|||
|
|
.await
|
|||
|
|
.expect("body");
|
|||
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|||
|
|
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
|||
|
|
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
|||
|
|
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
|||
|
|
assert!(html.contains("星标置顶"));
|
|||
|
|
assert!(html.contains("我的页面"));
|
|||
|
|
assert!(html.contains("垃圾箱"));
|
|||
|
|
assert!(html.contains("模板中心"));
|
|||
|
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
|||
|
|
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
|||
|
|
assert!(!html.contains("mnote-web-document-shell"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[tokio::test]
|
|||
|
|
async fn page_aggregate_endpoint_returns_snapshot_contract() {
|
|||
|
|
let response = app()
|
|||
|
|
.oneshot(
|
|||
|
|
Request::builder()
|
|||
|
|
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
|||
|
|
.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["owner"], "mnote-web");
|
|||
|
|
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
|||
|
|
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
|||
|
|
assert_eq!(payload["result"]["body"]["revision"], 7);
|
|||
|
|
}
|
|||
|
|
}
|